事の発端
先日こんな話がありました。
徳丸 浩 @ockeghem
『ユーザー名に<h2>admin</h2>と入力しても、パスワードが正しければ、ログイン可能 <script>admin</script>だと、ログインできません』<なん…だと? / “wp-login.php へのアタックが多く…” htn.to/rhgKKM
2014年6月25日 - 3:33pm
ユーザー名は照合時にサニタイズされていて、でもscriptタグはサニタイズされないような何とも言えない感じがありますが、実際のところどうなのでしょうか。
WordPressのソースコードはgithubにあるのでソースコードから調べてみます。バージョンは3.9.1です。
ログインから問題の処理まで
まずログイン処理はwp-login.phpのretrieve_password()関数の中にあります。
if ( empty( $_POST['user_login'] ) ) {
$errors->add('empty_username', __('<strong>ERROR</strong>: Enter a username or e-mail address.'));
} else if ( strpos( $_POST['user_login'], '@' ) ) {
$user_data = get_user_by( 'email', trim( $_POST['user_login'] ) );
if ( empty( $user_data ) )
$errors->add('invalid_email', __('<strong>ERROR</strong>: There is no user registered with that email address.'));
} else {
$login = trim($_POST['user_login']);
$user_data = get_user_by('login', $login);
}
ここでget_user_by()にメールアドレスかユーザー名を渡してユーザーオブジェクトを取得しています。
get_user_by()は最終的な取得処理であるWP_User::get_data_by()を呼び出しています。
WP_User::get_data_by()はID、スラグ、メールアドレス、ユーザー名のいずれかからユーザーIDを取得して、それを使用してDBから情報を取り出すということを行っています。サニタイズに関連のある処理は以下の部分です。
switch ( $field ) {
case 'id':
$user_id = $value;
$db_field = 'ID';
break;
case 'slug':
$user_id = wp_cache_get($value, 'userslugs');
$db_field = 'user_nicename';
break;
case 'email':
$user_id = wp_cache_get($value, 'useremail');
$db_field = 'user_email';
break;
case 'login':
$value = sanitize_user( $value );
$user_id = wp_cache_get($value, 'userlogins');
$db_field = 'user_login';
break;
default:
return false;
}
case 'login' がログイン名での処理で、まさにそのまんまなsanitize_user()という関数が呼ばれているのが分かります。ユーザー名をサニタイズしているということは間違いないようです。
ちなみにメールアドレスの場合は何もサニタイズされません。wp-login.phpの処理を見るとわかりますが、アットマークを含む文字列の場合はメールアドレスとして扱われ、サニタイズなしの値がほぼ同じ経路を通過していきます。ユーザー名だけサニタイズする意味はあまりないように感じます。
サニタイズ関数
サニタイズ関数sanitize_user()はどのようなことを行っているのでしょうか。
処理は以下のようになっています。
function sanitize_user( $username, $strict = false ) {
$raw_username = $username;
$username = wp_strip_all_tags( $username );
$username = remove_accents( $username );
// Kill octets
$username = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', $username );
$username = preg_replace( '/&.+?;/', '', $username ); // Kill entities
// If strict, reduce to ASCII for max portability.
if ( $strict )
$username = preg_replace( '|[^a-z0-9 _.\-@]|i', '', $username );
$username = trim( $username );
// Consolidate contiguous whitespace
$username = preg_replace( '|\s+|', ' ', $username );
/**
* Filter a sanitized username string.
*
* @since 2.0.1
*
* @param string $username Sanitized username.
* @param string $raw_username The username prior to sanitization.
* @param bool $strict Whether to limit the sanitization to specific characters. Default false.
*/
return apply_filters( 'sanitize_user', $username, $raw_username, $strict );
}
おおむね次のことを行っているようです。
- HTMLのタグの削除
- アクセント記号を削除
- パーセントエンコードを削除
- HTMLの実体参照を削除
ここまで削るなら普通に英数字と一部の記号だけにすればよさそうですが、きっと過去との互換性のためなのでしょう。
HTMLのタグの削除はPHPのstrip_all_tags()ではなく、独自のwp_strip_all_tags()が使われています。
function wp_strip_all_tags($string, $remove_breaks = false) {
$string = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $string );
$string = strip_tags($string);
if ( $remove_breaks )
$string = preg_replace('/[\r\n\t ]+/', ' ', $string);
return trim( $string );
}
ここでscript要素とstyle要素が特別扱いされていて、要素の中身ごと消されるようになっています。つまり、サニタイズされずに残るのではなく、中身ごと消されるのでユーザー名が空になってログインできなかったということだったようです。
わざわざ中身ごと消すということはユーザー名がscript要素やstyle要素などJSやCSSが解釈されるような部分に埋め込まれることがあるということなのでしょうか。
まとめ
- WordPressのユーザー名はサニタイズされる
- script要素とstyle要素は要素の中身ごと消される