WordPressの権限昇格を封印する

WordPressの権限昇格を封印する

WordPressにおける脆弱性で最も多いのは、脆弱なプラグインやテーマを使用した場合に権限を昇格(乗っ取られる)ことです。最近で有名なのは、「My WP Translate」に権限昇格の脆弱性が発見され、一時的に公開が停止される事態になりました。まず、WordPressの権限昇格の仕組みから考えて封鎖してみましょう。

権限昇格の手口

WordPressの権限昇格には大きく分けてこのような手口があります。

(A) 任意 update_option() 実行型

プラグインやテーマのAJAXやRESTエンドポイントにて update_option() を利用しているが、current_user_can('manage_options') のチェックを省略しているケース。これにより、低権限ユーザーが「WordPress全体の設定値」を書き換え可能。

典型的な悪用例:

update_option( 'default_role', 'administrator' );
update_option( 'users_can_register', 1 );

→ 新規登録をONにし、登録時の初期権限を管理者に設定。
以後、自分で新規ユーザー登録を行うと、即adminアカウントが作成される。

(B) set_user_role() / wp_update_user() の悪用

プラグイン内のコードが「権限変更API」を直接呼び出しており、呼び出し前に十分な権限チェックをしていない。

典型的な悪用例:

set_user_role( $target_user_id, 'administrator' );

被害:
投稿者・編集者が管理者へ昇格。
管理画面にアクセスすれば全操作が可能になる。

(C) map_meta_cap() を利用した投稿編集バイパス

投稿操作の内部権限マッピング(edit_postedit_others_posts など)を誤って制御しているプラグイン。例えば、RESTエンドポイントやGutenberg経由で他者投稿が編集可能になる。

悪用例:

  • 攻撃者が自分の投稿IDを別ユーザーの投稿IDに差し替えて送信。
  • map_meta_cap で誤って「edit_post」を通してしまう。

(D) REST API 経由の昇格

概要:
WordPress REST API の /wp/v2/users, /wp/v2/settings, /wp/v2/plugins 等は高リスク。
権限検証の不備があるプラグインでは、これらルートが悪用される。

悪用例:

POST /wp-json/wp/v2/users
{
  "username": "evil",
  "email": "evil@example.com",
  "roles": ["administrator"]
}

(E) AJAX (admin-ajax.php) 経由の昇格

wp_ajax_* フックで update_option()set_user_role() を呼ぶ処理があり、current_user_can() を省略している場合に発生。

悪用例:

POST /wp-admin/admin-ajax.php?action=save_settings
&option_name=default_role
&value=administrator

(F) ファイル編集・テーマ改竄

edit_themes, edit_plugins, edit_files が有効だと、攻撃者が「テーマエディタ」経由で任意コードを実行可能。

権限昇格の可能性そのものを封鎖する

これらの手口はいずれも脆弱なプラグインや未知の脆弱性が原因で発生するため、権限昇格の可能性そのものを封鎖します。作成したのはAuthenticated Privilege Guardというプラグインです。これは権限昇格の可能性を徹底的に潰します。技術的には、capabilityの強制無効化により、権限昇格の操作そのものを禁止するものです。さらにはREST APIにも介入するため、低権限ユーザーによる昇格操作ができなくなります。通常の使用であれば、寄稿者や投稿者といった低権限ユーザーを編集者や管理者に昇格させる必要性は一切存在しないと思われるため、このプラグインで強制停止するのがベストではないでしょうか。

/* =========================================================
 * ユーザー識別と権限チェック
 * ========================================================= */
function apg_current_user_id_or_zero() {
	return is_user_logged_in() ? get_current_user_id() : 0;
}

function apg_user_is_trusted( $user_id = null ) {
	if ( null === $user_id ) $user_id = apg_current_user_id_or_zero();
	$user = get_userdata( $user_id );
	if ( ! $user ) return false;
	return in_array( 'administrator', (array) $user->roles, true );
}

/* =========================================================
 * 危険cap除去
 * ========================================================= */
add_filter(
	'user_has_cap',
	function( $allcaps, $caps, $args, $user ) {
		if ( ! apg_get_option( 'enable_guard' ) ) return $allcaps;
		$danger_caps = array(
			'install_plugins','update_plugins','delete_plugins',
			'activate_plugins','edit_plugins','edit_themes',
			'install_themes','update_themes','edit_files',
			'edit_dashboard','create_users','delete_users',
			'promote_users','edit_users','edit_theme_options',
			'manage_options','switch_themes',
		);
		$user_id = $user instanceof WP_User ? $user->ID : (int) $user['ID'];
		if ( apg_user_is_trusted( $user_id ) ) return $allcaps;

		foreach ( $danger_caps as $dc ) {
			if ( ! empty( $allcaps[ $dc ] ) && true === $allcaps[ $dc ] ) {
				$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
				apg_log( sprintf( 'Blocked capability: user=%d cap=%s ip=%s', $user_id, $dc, $ip ) );
				$allcaps[ $dc ] = false;
			}
		}
		return $allcaps;
	},
	PHP_INT_MAX,
	4
);

/* =========================================================
 * 投稿編集制御
 * ========================================================= */
add_filter(
	'map_meta_cap',
	function( $caps, $cap, $user_id, $args ) {
		if ( ! apg_get_option( 'enable_guard' ) ) return $caps;
		if ( apg_user_is_trusted( $user_id ) ) return $caps;

		if ( in_array( $cap, array( 'edit_post','delete_post','edit_others_posts','delete_others_posts' ), true )
			&& isset( $args[0] ) && $post = get_post( $args[0] ) ) {

			$post_author = get_userdata( $post->post_author );
			if ( $post_author && in_array( 'administrator', (array) $post_author->roles, true ) ) {
				$caps[] = 'do_not_allow';
				apg_log( sprintf( 'Blocked admin post manipulation: user=%d post=%d', $user_id, $post->ID ) );
				return $caps;
			}

			if ( (int) $post->post_author === (int) $user_id ) {
				return $caps;
			}

			$allow_editor_collab = (bool) apg_get_option( 'allow_editor_collab', false );
			if ( $allow_editor_collab ) {
				apg_log( sprintf( 'Allowed editor collaboration: user=%d post=%d', $user_id, $post->ID ) );
				return $caps;
			}

			$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
			$is_editor_screen = ( is_admin() && strpos( $request_uri, 'post.php' ) !== false );

			if ( $is_editor_screen ) {
				$intent     = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
				$new_status = isset( $_REQUEST['post_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['post_status'] ) ) : '';

				if ( isset( $_REQUEST['_wpnonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'update-post_' . $post->ID ) ) {
					apg_log( sprintf( 'Nonce verification failed: user=%d post=%d', $user_id, $post->ID ) );
					$caps[] = 'do_not_allow';
					return $caps;
				}

				$allowed_ops = array( 'trash', 'draft' );
				if ( in_array( $intent, $allowed_ops, true ) || in_array( $new_status, $allowed_ops, true ) ) {
					apg_log( sprintf( 'Allowed limited modification: user=%d post=%d', $user_id, $post->ID ) );
					return $caps;
				}
			}

			$caps[] = 'do_not_allow';
			apg_log( sprintf( 'Blocked others post edit: user=%d post=%d', $user_id, $post->ID ) );
		}
		return $caps;
	},
	PHP_INT_MAX,
	4
);

/* =========================================================
 * ロール変更阻止
 * ========================================================= */
add_action(
	'set_user_role',
	function( $user_id, $role, $old_roles ) {
		if ( ! apg_get_option( 'enable_guard' ) ) return;
		$actor_id = apg_current_user_id_or_zero();
		if ( ! $actor_id || apg_user_is_trusted( $actor_id ) ) return;
		apg_log( sprintf( 'Blocked set_user_role: actor=%d target=%d newrole=%s', $actor_id, $user_id, $role ) );
		wp_die( esc_html( 'Access denied. Role changes are restricted.', 'authenticated-privilege-guard' ) );
	},
	1,
	3
);

/* =========================================================
 * RESTルート保護
 * ========================================================= */
add_filter(
	'rest_pre_dispatch',
	function( $result, $server, $request ) {
		if ( ! apg_get_option( 'enable_guard' ) ) return $result;
		$route   = $request->get_route();
		$method  = $request->get_method();
		$user_id = apg_current_user_id_or_zero();
		if ( apg_user_is_trusted( $user_id ) ) return $result;

		$exception_raw = apg_get_option( 'rest_exceptions', '' );
		$exceptions = array_filter( array_map( 'trim', explode( "\n", $exception_raw ) ) );
		foreach ( $exceptions as $allow ) {
			if ( @preg_match( $allow, $route ) ) {
				apg_log( sprintf( 'Allowed REST route: %s', $allow ) );
				return $result;
			}
		}

		$danger_patterns = array( '#^/wp/v2/users#','#^/wp/v2/plugins#','#^/wp/v2/themes#','#^/wp/v2/settings#','#^/wp/v2/options#' );
		foreach ( $danger_patterns as $pat ) {
			if ( preg_match( $pat, $route ) ) {
				apg_log( sprintf( 'Blocked REST route: user=%d route=%s method=%s', $user_id, $route, $method ) );
				return new WP_Error( 'apg_rest_block', esc_html( 'Access denied to privileged REST route.', 'authenticated-privilege-guard' ), array( 'status' => 403 ) );
			}
		}
		return $result;
	},
	PHP_INT_MAX,
	3
);

仕組みは単純です。ユーザー権限をチェックしたうえでcapを強制除去。さらにロールの変更を阻止し、設定によっては他者の編集した記事への介入も禁止するというものです。例えば編集者の編集権を停止した状態で、他のユーザーの記事を編集しようとするとこのようなエラーが発生します。

また、編集者権限であっても、管理者の投稿の介入は原則不可能になります。そのうえ、APIやプラグイン経由であっても強制介入して停止させるため、よほど脆弱なプラグインが発見されたとか、テーマそのものに脆弱性があり、スクリプトを仕込まれたなどの事態が発生しない限りはこのプラグインが最終的な防波堤となって食い止めます。

機能は至って単純です。有効化すると、ログ粒度(すべてのアクションを記録するか否か)を設定し、ログ保存先を指定でき(wp-content内にディレクトリ作ります)、かつ最優先割込みにするか、編集者に他人の編集を許可させるかを設定できます。ログは肥大化しやすいので、必要に応じてログのローテーションが必要になると思います。基本的にこれを有効にすると、明示的に許可しない限り編集者でも他人の投稿の編集はできなくなります。

組み合わせて使ってほしいもの

これ単体だと、意味がないわけではないのですが弱いです。admin-ajax-blocker(非ログインユーザーによるadmin-ajax.phpへのアクセスを禁止するプラグイン。JSONでエラー返しします)と、REST API Shield and XML RPC Blocker(REST APIエンドポイントへのアクセスそのものを防ぐ)と、API Write Blocker(POST PUT PATCH DELETEをホワイトリストでしか通さない)を入れてください。

auth-priv-guard(GitHub)
admin-ajax-blocker(GitHub)
api-write-blocker(WordPress.org)
REST API Shield and XML RPC Blocker(WordPress.org)

おまけ

若干機能として重複しますが、media-access-restrictorというプラグインも作りました。こちらは投稿者、寄稿者などは、他人のメディアにアクセスできなくするというプラグインです。上記プラグインを使うと他人の記事一覧は見えてしまいますが、一切触ることはできません。また、メディアは自分のファイル以外一切閲覧できませんので、ぜひ併用して「硬い複数人管理のWordPress」を実現してみてください!

コメントを投稿する

メールアドレスは公開されませんのでご安心ください。 * が付いている欄は必須項目となります。

内容に問題なければ、下記の「コメントを送信する」ボタンを押してください。

ページの先頭