Захватите флаг на WordCamp Europe 2022

Опубликовано: 2022-06-13

Во время WordCamp Europe 2022 мы провели соревнование 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);
 }

Как это можно решить?

Эта задача представила конечную точку REST API, к которой можно было получить доступ через маршрут /wp-json/hackismet/am-i-lucky . Он был разработан для получения полезной нагрузки и параметра запроса хэша, объединения request['payload'] с флагом и строкой из 32 криптографически безопасных случайных байтов и сравнения полученного хэша с request['hash'] .

Прочитав документацию по функции crypt(), можно обнаружить, что эта функция не является двоично-безопасной (пока!), а это означает, что нулевой байт (%00) может использоваться для усечения строки, подлежащей хешированию, прямо перед флагом и 32 случайных байта. Это связано с тем, что текущая реализация этой функции в PHP в основном является просто псевдонимом базовой функции C с тем же именем, а строки C заканчиваются нулевыми байтами.

Чтобы получить свой флаг, все, что вам нужно было сделать, это вычислить хеш с сообщением, которым вы управляете, и криптографической солью, используемой в коде плагина, использовать полученный хеш в параметре «хэш» и поместить свое сообщение в «полезную нагрузку». параметр, объединенный с нулевым байтом (%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 ) );
 }

Как это можно решить?

Эта задача представила конечную точку REST API, к которой можно было получить доступ через /wp-json/hackismet/get-option/option_key_you_want .

Цель была довольно проста: попытаться слить опцию «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 .

Игнорируемые веса

Алгоритм сортировки Unicode, который используется сортировкой MySQL utf8mb4_unicode_ci для сравнения и сортировки строк Unicode, описывает концепцию «игнорируемых весов» следующим образом:


Игнорируемые веса игнорируются правилами, которые создают ключи сортировки из последовательностей элементов сопоставления. Таким образом, их присутствие в элементах сортировки не влияет на сравнение строк по полученным ключам сортировки . Разумное присвоение игнорируемых весов элементам сопоставления является важной концепцией для UCA.

Короче говоря, алгоритм вычисляет вес для каждого элемента сопоставления (символов), и некоторые из них определены как имеющие вес по умолчанию, равный нулю, что эффективно заставляет алгоритм игнорировать их при сравнении строк.

Существовало несколько способов (аб)использования такого поведения для решения задачи, в том числе:

  • Добавление нулевых байтов где-то внутри строки (например hackismet_fl%00ag_1 )
  • Вставка недопустимых последовательностей UTF-8 внутри строки (например hackismet_fl%c2%80ag_1 )

Вы можете найти множество других комбинаций в реализации UCA в MySQL.

Обход ограничения символов параметра «option_key»

Переменная маршрута «option_key» была определена так, чтобы не пропускать ничего, кроме \w+. Это было проблемой. PHP обрабатывает каждую строку как серию байтов, а не символы Юникода, как это делает MySQL, поэтому отправка запроса на «/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\-]+)/(?<раунды>\d+)
  • /hackismet/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?<раунды>\d+)
  • /hackismet/удалить-лицензию/(?P<session_id>[0-9a-f\-]+)

Конечная точка generate-license заполнила лицензионный ключ для конкретного сеанса, который затем будет проверен с помощью обратного вызова разрешений hackismet_validate_license конечной точки access-flag-3 . К сожалению, поскольку вы так и не увидели, каким был фактически сгенерированный лицензионный ключ, вам пришлось найти способ полностью обойти проверку лицензии, чтобы получить флаг.

    $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.

Однако вы не можете сделать это с обычными параметрами GET или POST , так как они всегда содержат только строки или массивы. К счастью, 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<раунды>\d+)
  • /hackismet/удалить-лицензию/(?P<session_id>[0-9a-f\-]+)
  • /hackismet/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<раунды>\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'] пустым, и false == ““ при свободном сравнении разных типов в PHP, это позволило бы нам полностью обойти проверки безопасности.

Но это только в теории!

Кэширование в помощь!

Как видно из исходного кода функции, 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();
}

Что делает его особенно подходящим для нашей цели, так это то, что одним из первых действий, которое он делает, является запуск require() на wp-blog-header.php.

Короче говоря: код, который фактически запускает сервер REST API, привязан к действию parse_request , поэтому он будет доступен только тогда, когда WordPress внутренне установит переменные запроса, необходимые для работы цикла.

Это происходит только в том случае, если функция wp() вызывается так же, как в wp-blog-header.php.

Поскольку внутри WordPress использует параметр rest_route, чтобы узнать, какой маршрут загружать, добавление этого параметра в URL — это все, что нужно для запуска API при посещении /wp-activate.php.

Таким образом, финальная атака выглядела примерно так:

  1. Отправьте запрос на /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds где $rounds rounds — довольно большое число, чтобы этот запрос выполнялся достаточно долго, чтобы вы могли выполнить шаг № 2.
  2. Отправьте запрос на /wp-json/hackismet/delete-license/$session_id , пока ваш первый запрос заблокирован в цикле str_rot13 .
  3. Подождите, пока ваш первый запрос завершится, и получите свой флаг.

Вывод

Мы надеемся, что вам было так же весело участвовать в этом первом конкурсе Jetpack Capture The Flag, как и нам. Мы надеемся сделать это снова когда-нибудь в будущем. Чтобы узнать больше о CTF, посетите сайт CTF101.org.

Кредиты

Дизайнер челленджа: Марк Монпас

Особая благодарность Harald Eilertsen за личное распространение информации на WordCamp Europe, а также команде Jetpack Scan за отзывы, помощь и исправления.