Предисловие
Мне давно хотелось проверить на практике, насколько качественно авторы шифровальщиков реализуют криптографию. В теории схема AES+RSA(или ChaCha20+ AES) выглядит непробиваемой но это только в теории.Для эксперимента я выбрал два локера: Hakuna Matata и Chaos v4. Оба написаны на .NET, оба используют связку AES+RSA. Но подходы к криптографии у них кардинально разные — и ошибки тоже.
Hakuna Matata впервые появился в июле 2023 года. Билдер был опубликован на форумах пользователем Hakuna1, и вскоре малварь взяла на вооружение группировка Key Group. ASEC фиксировал атаки на корейские компании.
Chaos — более ранний проект, эволюционировавший из вайпера (версии 1-2) в полноценный локер (версии 3-5). Код унаследован от опенсорсного Hidden Tear. На основе Chaos построена Yashma, а от нее произошла и Hakuna Matata.
Забегая вперед: в одном случае я нашел баги реализации, которые портят данные жертвы даже при расшифровке. В другом уязвимость, позволяющую расшифровать файлы без выкупа. Но обо всем по порядку.
Теория: криптография в рансоме
Симметричное шифрование (AES)AES оперирует блоками по 16 байт. Для обработки данных произвольной длины используются режимы работы. В локерах чаще всего встречается CBC (Cipher Block Chaining): каждый блок открытого текста покрывается XOR с предыдущим зашифрованным блоком, образуя цепочку. Для первого блока используется вектор инициализации (IV) — случайное 16-байтное значение. Благодаря IV два одинаковых файла, зашифрованных одним ключом, дают разный шифротекст.
Для AES-256 ключ — 32 байта, IV — 16 байт. Пространство ключей — 2^256. Перебрать такое количество вариантов невозможно даже теоретически.
Padding. AES работает блоками по 16 байт. Если последний блок короче, то его нужно дополнить. Стандарт PKCS7 заполняет недостающие позиции числом, равным количеству добавленных байт. При расшифровке padding удаляется. Если использовать PaddingMode.None, дополнения не происходит, и при расшифровке в конце файла остаётся мусор. Для текста это незаметно, но структура бинарных форматов разрушается.
Асимметричное шифрование (RSA)
Безопасность RSA основана на сложности факторизации больших чисел. Для RSA-2048 модуль имеет длину 256 байт, и факторизовать его при нынешних технологиях опять же нереально. Однако RSA на порядки медленнее AES и ограничен по размеру данных (максимум 245 байт за одну операцию при PKCS#1 1.5). Шифровать файлы напрямую через RSA бессмысленно.
В RSA есть два стандарта padding: PKCS#1 v1.5 (уязвим к атаке Блейхенбахера) и OAEP (современный, устойчивый). Атака Блейхенбахера — это chosen-ciphertext атака, при которой злоумышленник по ответам «корректный/некорректный» постепенно восстанавливает открытый текст. OAEP эту проблему закрывает.
Гибридная схема
Современные шифровальщики комбинируют оба алгоритма:
- Для каждого файла генерируется случайный AES ключ и IV
- Файл шифруется AES
- AES ключ шифруется публичным RSA ключом
- Зашифрованный ключ сохраняется в конце или середине файла
Схема используется в LockBit, Qilin, Akira вообщем практически во всех шифровальщиках. При корректной реализации она не вскрывается. Ключевое слово — «корректной».
ГПСЧ: System.Random vs RNGCryptoServiceProvider
Этот пункт — ключ ко всему дальнейшему анализу, поэтому остановлюсь на нем подробнее.
В .NET есть два генератора случайных чисел.
System.Random — детерминированный генератор на основе алгоритма с seed (начальным значением). Если создать new Random() без аргументов, seed берется из Environment.TickCount (количества миллисекунд с момента загрузки системы). Это 32-битное целое число, максимум 2 миллиарда. Зная seed, можно воспроизвести всю последовательность «случайных» чисел.
RNGCryptoServiceProvider — криптографически стойкий генератор, использующий энтропию операционной системы (движения мыши, тайминги прерываний, аппаратный шум). Его выход непредсказуем и не воспроизводим. Именно его нужно использовать для генерации ключей.
Часть I: Hakuna Matata
IDA сразу определила .NET, так что я переключился на dnSpy и открыл оба бинарника.
Не удалось прогрузить медиа
Структура энкриптора
В декомпилированном коде видны методы, по названиям которых уже складывается картина.Шифрование: ENCRYPT_DATA (AES-256-CBC), RSA_ENCRYPT (обертка), FULL_ENCRYPT (шифрование файла целиком), TRIPLE_ENCRYPT (частичное шифрование крупных файлов).
Ключи: RANDOM_VALUE (алфавит для генерации), RSA_KEY_IV (хранилище зашифрованной связки ключ+IV), RSA_PUBLIC_KEY (публичный RSA ключ), SALT_ALL / SALT_TRIPLE (массивы), RANDOM_STRING(int) (генератор случайных строк).
Не удалось прогрузить медиа
Анализ криптографии
ENCRYPT_DATA: ядро шифрования
Функция реализует AES-256-CBC. Ключ и IV передаются через Encoding.ASCII.GetBytes() — ключ состоит из печатных ASCII-символов, а не произвольных байт. Пространство ключей сужается, хотя остается достаточно большим.Не удалось прогрузить медиа
Используется PaddingMode.None, но об этом позже.
RANDOM_STRING: генерация ключей
Генератор использует RNGCryptoServiceProvider — криптостойкий ГПСЧ. Ключ собирается посимвольно из алфавита RANDOM_VALUE (62 символа). AES ключ — 32 символа, энтропия около 190 бит. IV — 16 символов, 95 бит. Брутфорс нереален.Не удалось прогрузить медиа
Не удалось прогрузить медиа
Тут все сделано правильно. Уязвимости в генерации ключей нет.
FULL_ENCRYPT: полная схема
Что происходит при шифровании файла:- Генерируется случайный AES ключ (32 символа) и IV (16 символов)
- Ключ и IV склеиваются через |: AAAA...AAAA|BBBB...BBBB
- Связка шифруется публичным RSA ключом → 256-байтный блоб
- Результат записывается в статическое поле Program.RSA_KEY_IV
- Файл шифруется AES-256-CBC
- Блоб дописывается в конец файла как footer
Не удалось прогрузить медиа
AES_SALT: иллюзия криптографии
В Main вычисляются FOR_ALL и FOR_TRIPLE — через функцию AES_SALT, которая берет символы из захардкоженного алфавита по индексам из захардкоженных массивов. Результат — фиксированные строки, одинаковые в каждой копии одной сборки. Скорее всего это ошибка разработчиков так как настоящая соль должна быть случайной.Кстати эти значения вообще не участвуют в шифровании файлов — ключи генерируются RANDOM_STRING заново для каждого файла. Зачем соли вычисляются я так и не понял, возможно осталось с исходника из которого они писали свой.
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Маскировка расширений
После шифрования файл переименовывается с рандомным расширением из 5 символов. Каждый файл получает уникальное расширение, жертва не может загуглить семейство по расширению.Ошибки реализации
PaddingMode.None — битые файлы после расшифровки
AES-256-CBC работает с PaddingMode.None. Если размер файла не кратен 16 байтам, при расшифровке в конце остаются мусорные нули.Для текстового файла на 1001 байт — 15 нулей в конце, не критично. Но для папки которая хранит каталог в последних байтах — структура ломается. Для PE-файлов (.exe, .dll) аналогично.
Результат: жертва платит выкуп, получает декриптор, а часть расшифрованных файлов не открывается.
Race condition в статическом поле RSA_KEY_IV
Program.RSA_KEY_IV — статическое поле, которое перезаписывается при каждом файле. Между записью в поле и записью в footer файла проходит время — шифрование данных AES.В текущей однопоточной реализации баг не проявляется. Но если добавить многопоточность поток A будет генерировать ключ для файла_1, записывать в RSA_KEY_IV, и начинает шифровать. Поток B при этом генерирует ключ для файла_2, перезаписывает RSA_KEY_IV. Поток A заканчивает и записывает footer — но там уже ключ от файла_2. Файл_1 получает чужой footer и становится нерасшифровываемым навсегда.
Предсказуемый формат footer
Footer — 256 байт шифротекста. Внутри строка фиксированного формата: 32 символа из a-zA-Z0-9. Всего 49 байт с известной структурой.Это дает реверс инженерам возможность расшифровать footer, и получить строку нужного формата. В сочетании с PKCS#1 v1.5 создается теоретическая возможность для атаки Блейхенбахера. На практике она тяжело реализуема, но все же.
Статические соли
SALT_ALL и SALT_TRIPLE — детерминированные значения, вычисленные из констант. Криптографической ценности ноль. Хоть напрямую на шифрование не влияют, демонстрируют уровень понимания криптографии программистами.Проверка генератора ключей в билдере
Раз уязвимость не в энкрипторе может, она в билдере? Я открыл сам билдер в dnSpy и нашел метод генерации RSA пары:Не удалось прогрузить медиа
RSACryptoServiceProvider(2048) — стандартный криптостойкий генератор. Каждый билд получает уникальную RSA пару.
Тупик. Hakuna Matata использует правильную криптографию. Ошибки реализации есть — PaddingMode.None портит файлы, race condition грозит потерей данных, но ключи не предсказуемы. Без приватного RSA ключа расшифровка практически невозможна.
Часть II: Chaos v4
Chaos — более старый локер. Первые две версии (июнь 2021) просто перезаписывали файлы мусором и притворялись шифровальщиком, а на самом деле данные уничтожались, расшифровка была невозможна физически. С версии 3.0 (июль 2021) появилось реальное шифрование, скопированное из опенсорсного проекта Hidden Tear.ryukRans на xss, первоначально назвал билдер «Ryuk .NET Ransomware Builder» хотя к настоящему Ryuk проект не имел никакого отношения. Версия 4.0 вышла в августе 2021 и расширила лимит шифруемых файлов до 2 МБ. На базе Chaos 5.0 (Yashma) впоследствии построена Hakuna Matata, но к тому моменту генерацию ключей уже исправили.
Исправили в Hakuna Matata. А в Chaos v4 уязвимость осталась.
Первый взгляд: структура
Класс Program содержит набор методов: EncryptFile, AES_Encrypt, RSAEncrypt, encryptDirectory, CreatePassword, random_bytes. И сразу бросилось в глаза поле:random : Random @04000001CSystem.Random. Не RNGCryptoServiceProvider. Обычный детерминированный ГПСЧ.
Не удалось прогрузить медиа
CreatePassword: генерация AES ключа
Не удалось прогрузить медиа
new Random() без аргументов. Seed берется из Environment.TickCount. Это 32-битное число — максимум 2 миллиарда значений. Ключ AES, который должен иметь энтропию 256 бит, на деле имеет энтропию 31 бит. Разница в 2^225 раз.
Причем new Random() создается заново при каждом вызове CreatePassword. Это значит seed привязан к моменту вызова с точностью до миллисекунды.
random_bytes: генерация IV
Та же история. System.Random, Environment.TickCount как seed. IV предсказуем.Не удалось прогрузить медиа
AES_Encrypt: схема шифрования
byte[] array2 = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };Rfc2898DeriveBytes rfc = new Rfc2898DeriveBytes(passwordBytes, array2, 1000);
rijndaelManaged.Key = rfc.GetBytes(32);
rijndaelManaged.IV = rfc.GetBytes(16);
Соль — {1, 2, 3, 4, 5, 6, 7, 8}. Захардкожена. Одинакова во всех копиях. PBKDF2 с 1000 итерациями разворачивает 20 символьный пароль в AES ключ и IV.
Не удалось прогрузить медиа
Но вот в чем дело: PBKDF2 — детерминированная функция. Одинаковый пароль + одинаковая соль = одинаковый ключ. Соль фиксирована. Пароль генерируется через System.Random с предсказуемым seed. Значит вся цепочка детерминирована: зная seed, восстанавливаем пароль, из пароля через PBKDF2 — ключ и IV, ключом расшифровываем файл.
EncryptFile: как сохраняется шифротекст
Пароль шифруется RSA и записывается между тегами <EncryptedKey>. Потом идет закодированный шифротекст.Не удалось прогрузить медиа
RSA тут использует OAEP (параметр true в Encrypt) формально это правильнее, чем PKCS#1 v1.5 у Hakuna Matata. Но впрочем разницы нет, так как, мы обходим RSA полностью.
RSA ключ: еще один подарок
RSA ключ захардкожен в энкрипторе. И это RSA-1024, не 2048. Но это уже неважно, потому что мы его обходим через перебор seed.Не удалось прогрузить медиа
encryptDirectory:
C#:
if (fileInfo.Length < 2117152L) // < 2 МБ
{
if (Program.encryptionAesRsa)
Program.EncryptFile(files[i]);
}
else if (fileInfo.Length > 200000000L) // > 200 МБ
{
Random random = new Random();
int num = random.Next(200000000, 300000000);
string @string = Encoding.UTF8.GetString(Program.random_bytes(num));
File.WriteAllText(files[i], Program.randomEncode(@string));
}
else // 2 МБ — 200 МБ
{
string string2 = Encoding.UTF8.GetString(
Program.random_bytes(Convert.ToInt32(fileInfo.Length) / 4));
File.WriteAllText(files[i], Program.randomEncode(string2));
}
Три категории:
Файлы до 2 МБ — шифруются AES+RSA. Можно расшифровать через перебор seed.
Файлы 2–200 МБ — перезаписываются случайными данными длиной в четверть файла. Оригинальные данные уничтожены навсегда.
Файлы больше 200 МБ — перезаписываются 200-300 МБ мусора. Оригинальные данные уничтожены навсегда.
Для файлов больше 2 МБ это не шифровальщик, а вайпер. Все, что больше 2 МБ, уничтожается безвозвратно. Оператор шифровальщика физически не способен их восстановить, даже если жертва заплатит. Наследство от Chaos v1-v2, где все файлы просто перезаписывались.
Не удалось прогрузить медиа
Формат зашифрованного файла
<EncyptedKey>[RSA пароль base64]<EncyptedKey>[AES шифротекст]Обратите внимание что в теге написано EncyptedKey, а не EncryptedKey. Опечатка в коде, которая может помочь при идентификации локера.
Структура: теги <EncyptedKey>; как разделители, RSA блоб в base64, шифротекст — бинарные данные. Для парсинга достаточно split по тегу.
Полная схема атаки: перебор seed
Алгоритм дешифровки без приватного ключа:- Берем зашифрованный файл, извлекаем base64 (после второго <EncryptedKey>)
- Перебираем seed от 0 до 2^31: Создаем new Random(seed). Генерируем 20 символьный пароль тем же алгоритмом, что и CreatePassword. Через PBKDF2 с фиксированной солью {1,2,3,4,5,6,7,8} получаем AES ключ и IV и пробуем расшифровать. Проверяем результат на валидность (известные сигнатуры файлов, ASCII-текст, и т.д.)
- При совпадении — seed найден, файл расшифрован
Более того: файлы шифруются последовательно. CreatePassword вызывается для каждого файла с новым new Random(), и seed каждого следующего вызова отличается на несколько миллисекунд. При расшифровке одного файла — знаешь примерный seed и остальные подбираются за секунды.
RSA полностью обходится. Приватный ключ не нужен.
Дешифратор
На основе найденной уязвимости я написал дешифратор(если кому-то нужен, не понятно только для чего, могу скинуть в комментарии). Ключевые моменты реализации:Реимплементация System.Random. DotNetRandom полностью воспроизводит поведение System.Random из .NET. Для заданного seed последовательность «случайных» чисел побитово совпадает с оригиналом. Без этого перебор бесполезен ведь даже правильный seed даст неправильный пароль.
Воспроизведение CreatePasswrd. Для каждого кандидатного seed создается экземпляр DotNetRandom(seed), затем 20 раз вызывается next(charset_length) для получения индексов в алфавите. В результате тот же 20 символьный пароль, который сгенерировал энкриптор.
Деривация ключа. Пароль передается в PBKDF2-HMAC-SHA1 с захардкоженной солью {1,2,3,4,5,6,7,8} и 1000 итерациями. Первые 32 байта результата — AES ключ, следующие 16 — IV. Полностью идентично Rfc2898DeriveBytes из NET.
Валидация. После расшифровки проверяются сигнатуры известных форматов (PNG, JPEG, PDF, ZIP, PE...) и доля печатных ASCII-символов. Если расшифрованные данные похожи на реальный файл — seed найден.
Оптимизация для нескольких файлов. Файлы шифруются последовательно, seed отличаются на миллисекунды. Расшифровав первый файл, дешифратор сужает диапазон для остальных до 10000 от найденного ранее. Первый файл — часы (в худшем случае), остальные — секунды.
Для проверки я зашифровал на виртуальной машине все файлы. Файл получил рандомное расширение .h7e5 и стал нечитаемым.
Запуск дешифратора:
python3 chaos_decryptor.py "hueta.7z.h7e5" --seed-start 0 --seed-end 1100000
Результат:
Не удалось прогрузить медиа
Seed 8744 — это Environment.TickCount на момент вызова CreatePassword. Машина работала 9 секунд энкриптор запустился почти сразу после загрузки. Расшифрованный архив открылся без ошибок — данные полностью целы. Если в вашем коде встречается new Random() рядом с чем-либо криптографическим. Замените на RNGCryptoServiceProvider или RandomNumberGenerator.Create(). Одна строка кода, но разница между 31 битом энтропии и 256.
👍43
❤️11
🔥8
👏8
🤔8
💯6
LIMB