在 WordCamp Europe 2022 上奪旗

已發表: 2022-06-13

在 WordCamp Europe 2022 期間,我們舉辦了一場 WordPress 奪旗 (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路由訪問的 REST API 端點。 它被設計為接收有效負載和散列請求參數,將request['payload']連接到標誌和 32 個加密安全隨機字節的字符串,並將生成的散列與request['hash']進行比較。

在閱讀 crypt() 函數的文檔後,可以發現該函數不是二進制安全的(還沒有!),這意味著可以使用空字節 (%00) 在標誌之前截斷要散列的字符串,並32 個隨機字節。 這是因為該函數在 PHP 中的當前實現基本上只是同名底層 C 函數的別名,並且 C 字符串以空字節結尾。

要獲得您的標誌,您所要做的就是使用您控制的消息和插件代碼中使用的加密鹽計算哈希,在“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訪問的 REST API 端點。

目標很簡單:嘗試洩露“hackismet_flag_1”選項。

不幸的是,該端點的權限回調也做了一些事情來阻止您簡單地獲取站點上的任何選項:

  • 它驗證了選項鍵以“hackismet_”開頭。
  • 它還確保您要檢索的任何選項都不是標誌所在的 hackismet_flag_1。
  • 為了讓事情看起來更困難,API 路由限制了哪些字符可以在 option_key 路由參數中出現,只允許匹配\w+正則表達式的字符串。

“hackismet_validate_option”回調函數還使用了“strtolower”和“trim”函數,試圖規範化“option_key”參數。 這是為了阻止使用 MySQL 的“utf8mb4_unicode_ci”排序規則中記錄良好的行為的嘗試,例如字符串比較不區分大小寫,並且它也不關心 VARCHAR 列中的尾隨空格。

其他整理技巧

為了解決這一挑戰,必須找到“utf8mb4_unicode_ci”進行字符串搜索的方式以繞過現有檢查的其他特性,並且至少有兩種方法可以做到這一點。

口音敏感度

如 MySQL 官方文檔中所述:

對於未指定區分重音的非二進制排序規則名稱,它由區分大小寫決定。

簡而言之:口音敏感是一回事。 WordPress 的默認排序規則使用“_ci”組件(表示“不區分大小寫”),這意味著排序規則也是不區分重音的。

因此,傳遞“hackismet_flag_1”將繞過hackismet_validate_option中的檢查。

可忽略的權重

MySQL 的 utf8mb4_unicode_ci 排序規則用於比較和排序 Unicode 字符串的 Unicode 排序算法描述了“可忽略權重”的概念,如下所示:


可忽略的權重由從排序元素序列構造排序鍵的規則傳遞。 因此,它們在排序規則元素中的存在不會影響使用生成的排序鍵比較字符串。 在整理元素中明智地分配可忽略的權重是 UCA 的一個重要概念。

簡而言之,該算法計算每個排序規則元素(字符)的權重,其中一些被定義為默認權重為零,這實際上使算法在進行字符串比較時忽略它們。

多種方法可以(ab)使用這種行為來應對挑戰,包括:

  • 在字符串中的某處添加空字節(例如hackismet_fl%00ag_1
  • 在字符串中插入無效的 UTF-8 序列(例如hackismet_fl%c2%80ag_1

您可以在 MySQL 的 UCA 實現中找到許多其他組合。

繞過“option_key”參數字符限制

“option_key”路由變量被定義為不讓 \w+ 以外的任何東西通過。 那是個問題。 PHP 將每個字符串視為一系列字節,而不是像 MySQL 那樣的 unicode 字符,因此向“/wp-json/hackismet/get-option/hackismet_flag_1”或“/wp-json/hackismet/get-option/hackismet_fla”發送請求%00g_1” 不起作用。

為了繞過這一點,WordPress 關於編寫 REST API 端點的官方文檔有所幫助,特別是它說的那一行:

默認情況下,路由接收從請求中傳入的所有參數。 這些被合併為一組參數,然後添加到請求對像中,該對像作為第一個參數傳遞給您的端點

這實際上意味著在訪問/wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1時,option_key 參數將包含“hackismet_fla%00g_1”,而不是“test”,這也會強制插件給你的標誌。

挑戰 #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' ) );
 }

如何解決?

這個挑戰背後的想法是模擬一個(非常)損壞的許可證管理和驗證系統。

雖然這個挑戰旨在讓參與者利用一個非常深奧的競爭條件漏洞,但挑戰設計者的一個微妙疏忽使得它可以使用非預期的、不那麼奇特的解決方案來解決。

挑戰提出了三個端點,即使只需要兩個端點即可獲得標誌:

  • /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;
    }

一種方法是讓$request['key']包含一個布爾值“true”,而$request['rounds']一個值為零。 通過這樣做,您確保$request['key']沒有被多次調用str_rot13修改,並且由於許可證驗證是使用 PHP 的鬆散比較運算符完成的,因此比較將始終返回 true。

但是,您不能使用常規的GETPOST參數來做到這一點,因為它們只包含字符串或數組。 幸運的是,WordPress REST API 允許您發送 JSON 請求正文,即使在僅註冊為使用 GET HTTP 方法的端點上也是如此。 因此,發送以下請求將為您提供挑戰標誌:

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! )

如何解決?

這個挑戰提出了三個端點(實際上需要使用所有三個端點來解決!):

  • /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 ,一次用於驗證許可證密鑰,另一次用於實際將其與我們提供的進行比較。 兩個調用都由一個 str_rot13 循環分隔,該循環運行的輪次與$request['rounds']路由變量中定義的輪次一樣多。

這使得通過在 rounds 變量中發送一個大數字來延遲請求足夠長的時間以使我們能夠點擊/hackismet/delete-license端點,從而在與我們自己的許可證進行比較之前有效地刪除許可證,從而有可能發生競爭條件。

如果get_option()沒有找到給定的選項,它默認返回布爾值 false 的事實是蛋糕上的櫻桃。 由於該函數從不檢查$request['key']是否為空,並且當在 PHP 中鬆散地比較不同類型時,false == “”,這將允許我們完全繞過安全檢查。

但這只是理論上的!

緩存來拯救!

從函數的源代碼中可以看出, get_option緩存它正在檢索的任何選項的結果,因此在同一 HTTP 請求上對該選項的任何進一步請求都不會發送額外的單獨 SQL 查詢。 僅此一項就可以防止我們的競爭條件攻擊起作用。 即使在我們循環所有這些str_rot13調用時另一個請求刪除了許可選項,get_option 也不會知道,因為結果已經為該請求緩存了!

再一次,查看源代碼,看起來防止這種情況發生的唯一方法是 wp_installing 返回......真的嗎? 事實證明,我們可以做到這一點。

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();
}

是什麼使它特別適合我們的目的,它首先要做的事情之一就是在 wp-blog-header.php 上運行require()

長話短說:實際啟動 REST API 服務器的代碼與parse_request操作掛鉤,因此只有在 WordPress 內部設置了 The Loop 執行其工作所需的查詢變量時,它才可用。

僅當 wp() 函數像在 wp-blog-header.php 中一樣被調用時才會發生這種情況。

由於在內部,WordPress 使用 rest_route 參數來知道要加載哪個路由,因此只需將該參數添加到 URL 即可在訪問 /wp-activate.php 時啟動 API。

因此,最後的攻擊看起來像這樣:

  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

特別感謝 Harald Eilertsen 在 WordCamp Europe 親自宣傳,並感謝 Jetpack Scan 團隊的反饋、幫助和更正。