Skip to content

Latest commit

 

History

History
187 lines (146 loc) · 10.5 KB

File metadata and controls

187 lines (146 loc) · 10.5 KB

D-bank

Анализ кода

Нам выдается два файла и ссылка на веб-приложение. Один из файлов - Dockerfile, который может пригодиться для отладки эксплойта. Если его открыть, можно увидеть что базовым образом является perl, а значит бэкенд этого приложения написан на perl. Это не должно стать большой проблемой, так как в коде используется достаточно современный фреймворк Mojolicious, и, в целом, код достаточно читаемый.

На что стоит обратить внимание в первую очередь?

my $secret_key = random_bytes(8);
my $jwt_secret = encode_base64(random_bytes(32), '');
my $flag = $ENV{FLAG};
post '/get-flag' => sub {}

В коде объявляются переменные secret_key, jwt_secret, flag и обработчики API-ручек, один из которых - get-flag. Секреты вырабатываются с помощью стойкого рандома из Crypt::PRNG qw(random_bytes);, поэтому будем далее считать, что нет способа их получить быстрее, чем полным перебором.

Посмотрим внимательнее на код функции, вызываемой при POST на get-flag.

my $package_data;
eval {
    $package_data = decode_jwt(token => $package, key => $jwt_secret, alg => 'none', allow_none => 1);
};

unless (verify_signature(
    join('|', @$package_data{qw(sender recipient amount timestamp)}),
    $package_data->{signature}
)) {
    return $c->render(json => { error => "Invalid signature" }, status => 400);
}
if ($package_data->{recipient} ne "1337133713371337133713371337133713371337133713371337133713371337") {
    return $c->render(json => { error => "This package is not for flag wallet address" }, status => 400);
}
if ($package_data->{amount} ne "133713371337") {
    return $c->render(json => { error => "This package contains not enough money" }, status => 400);
}

return $c->render(json => { success => 1, flag => $flag })

Здесь много условий, заканчивающихся возвратом ответа 400, но если их все пройти - мы получим флаг. В условиях участвуют поля объекта package_data, который, в свою очередь, декодируется из токена package. Нас интересуют signature (должно удовлетворять функции verify_signature), recipient (должен быть фиксированным) и amount (аналогично recipient).

Токен package генерируется с помощью функции create-package, например, через веб-интерфейс. Это обычный неподписанный JWT токен, так что мы без проблем можем положить внутрь любые данные.

verify_signature

sub verify_signature {
    my ($message, $provided_signature) = @_;
    my $md5_hash = md5_hex($message);
    my $des = Crypt::DES->new($secret_key);
    my $encrypted_hash = decode_base64($provided_signature);
    my $decrypted_hash = $des->decrypt($encrypted_hash);
    return '00'.substr($md5_hash, 0, 6) eq $decrypted_hash;
}

Эту функцию стоит читать снизу вверх, поскольку возвращается boolean от сравнения $decrypted_hash и '00'.substr($md5_hash, 0, 6). Если переписать эту функцию на псевдокод, получим примерно следующее сравнение:

('00' + md5_hex(message)[:6]) == DES(secret_key).decrypt(b64.decode(signature))

Т.е. один из параметров хешируется, а другой проворачивается через DES с неизвестным ключом secret_key. Или, проще говоря, подпись расшифровывается и должна совпасть с началом хеша от message.

Стоит обратить внимание на то, что блок для алгоритма DES - 8 байт, и именно столько байт занимает строка "00xxxxxx", в которой 6 символов - первые 6 символов hexdigest md5 от message.

Это не очень хорошо, потому что на каждый байт буфера (256 значений) приходится только 16 уникальных значений шестнадцатеричного алфавита. Из всех возможных значений байта от \x00 до \xFF там могут быть значения \x30-\x39 (ASCII цифры) и \x61-\x66 (ASCII буквы от a до f)

По сути, уникальных значений decrypted_hash может быть только $16^{6} = 2^{24}$ (два фиксированных байта и 6 из алфавита hex). Перебор этого значения уже ощутимо (на 40 порядков) проще, чем перебор ключа для DES ($2^{64}$), но всё еще не понятно - а что именно перебирать?

Быстро взглянем на функцию create_signature и перепишем ее на псевдокод.

sub generate_signature {
    my ($message) = @_;
    my $md5_hash = md5_hex($message);
    my $des = Crypt::DES->new($secret_key);
    my $encrypted_hash = $des->encrypt('00'.substr($md5_hash, 0, 6));
    return encode_base64($encrypted_hash, '');
}
return DES(secret_key).encrypt(b64.encode('00' + md5_hex(message)[:6]))

Сразу становится понятно, что за $signature мы расшифровываем в verify_signature - это зашифрованный DES на ключе $secret_key md5 хэш сообщения, таким же образом подрезанный до первых 6 hex символов.

Что такое $message?

Процедуры генерации и проверки подписи могут принимать на вход любой $message, который можно захэшировать с md5, поэтому отправимся на поиски $message выше.

join('|', @$package_data{qw(sender recipient amount timestamp)})

В ручках create-package, redeem-package и get-flag можно найти повтояющийся код, который и генерирует искомый $message. Перепишем его на псевдокод :)

'|'.join([
    package_data.sender, 
    package_data.recipient, 
    package_data.amount, 
    package_data.timestamp,
])

Т.е. $message - склееная по прямому слешу строчка из значений внутри JWT токена.

Атака

Проанализировав логику работы подписи можем сгенерировать пакет, который пройдет проверку verify_signature, даже без знания секретного ключа DES.

Алгоритм

  • Получим произвольный JWT токен с помощью вызова create-package
  • Раскодируем JWT и получим package_data
  • Посчитаем хэш от hash = md5_hex("|".join(*package_data))[:6]
  • Подменим recipient на нужный (мы не можем сразу сгенерировать здесь нужное значение, см. Вторичный вектор)
  • Подменим amount на нужный
  • Далее, перебирая значения в package_data.sender или package_data.timestamp, добьемся совпадения нового хэша badhash с hash.
  • Запакуем package_data в JWT и отправим на get-flag

Почему это сработает?

Мы точно знаем, что передается в процедуру шифрования DES - это join от полей package_data. Какой там ключ - нам всё равно, так как мы будем перебирать поля в package_data до тех пор, пока первые 6 символов от нового md5 не совпадут со старыми (полученными из create-package). Свойством любого алгоритма блочного шифрования, очевидно, является совпадение зашифрованных сообщений CT при совпадающих шифруемых PT. Соотвественно, хоть мы и не получим полную коллизию md5 (нам это не требуется), при частичной коллизии по 3-байтовому префиксу для нашего нового package_data create_signature сгенерировал бы совпадающую подпись!

Остается только обернуть это в небольшой скрипт и написать переборщик $2^{24}$ случайных значений. brek.py

Вторичный вектор

Гипотетически, можно пойти по другому пути.

При регистрации пользователя ему выдается 10 монет, которые он может потратить для создания передачки любому другому пользователю. В теории, зарегистрировав 13371337133 пользователей, можно передать все средства на одного пользователя и создать желаемую ручкой get-flag передачку.

Однако, в коде есть проверка, и создать такую передачку не получится.

post '/create-package' => sub {
    ...
    if ($recipient_address eq "1337133713371337133713371337133713371337133713371337133713371337") {
        return $c->render(json => { error => "Антифрод система заблокировала операцию"}, status => 418);
    }
    ...
}