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 (...)句に紛れ込ませることができます。

[続きを読む]