Три вызова Math.random() при старте процесса. Результат – строка из 30 символов, которой подписывается каждая сессионная кука файлового сервера Rejetto HFS. Разработчик оставил в коде комментарий: «randomness at start gives some extra security». Пять слов, в которых сжата суть уязвимости CVE-2026-61500 с оценкой CVSS 9.3: Math.random() в V8 – не случайность, а xorshift128+, генератор с полностью обратимым состоянием.
Исследователи кибербезопасности из Horizon3 годами натыкались на этот класс – нешифровой PRNG на месте криптографического – и каждый раз проходили мимо. Не потому что баг невидим, а потому что доказательство эксплуатируемости означает садиться за математику: обращать генератор, сцеплять утечку с ключом подписи, собирать солвер. ИИ-модель Anthropic Mythos, работающая в исследовательском пайплайне Horizon3 с июля 2026, за один проход нашла и утечку, и цепочку – и написала рабочий Z3-солвер. 12 HTTP-запросов – и ключ подписи у атакующего. Horizon3 прямо говорит: они не помнят случая, когда SMT-солвер применялся к криптографическому дефекту реального приложения для обхода аутентификации.
Rejetto HFS – проект для раздачи файлов по HTTP с минимальной настройкой. Один исполняемый файл, веб-интерфейс, запуск без конфигурации. Предыдущее поколение (HFS 2.x на Delphi) попало в CISA KEV с CVE-2024-23692 – неаутентифицированная инъекция шаблонов с исполнением произвольного кода. Проект полностью переписали на TypeScript: Node.js, фреймворк Koa, архитектура с нуля. Версии 3.0.0–3.2.0 – те, в которых живёт новая уязвимость.
Horizon3 участвует в Anthropic Project Glasswing – инициативе по поиску уязвимостей в критически важном ПО с помощью ИИ-моделей. Собственная задача Horizon3 в рамках проекта – находить уязвимости, которые злоумышленники с высокой вероятностью найдут и будут массово эксплуатировать. Пайплайн компании запускает несколько специализированных агентов параллельно, каждый ищет свой класс уязвимостей в кодовой базе. Агент криптографического анализа, управляемый моделью Mythos, нашёл в HFS 3.x не одну уязвимость – цепочку.
Вся аутентификация HFS стоит на подписи сессионных кук. Фреймворк Koa при старте получает массив ключей для библиотеки keygrip, которая вычисляет HMAC каждой куки. Откуда берётся ключ:
const keys = process.env.COOKIE_SIGN_KEYS?.split(',')
|| [randomId(30)] // randomness at start gives some extra security
export const app = new Koa({ keys })
Переменная COOKIE_SIGN_KEYS в кодовой базе HFS не устанавливается нигде – поиск по репозиторию подтверждает это. Фолбэк срабатывает всегда: ключом подписи становится randomId(30).
Функция randomId берёт Math.random(), конвертирует в base36 через toString(36), вырезает подстроку из 10 символов и рекурсивно склеивает фрагменты до нужной длины. randomId(30) потребляет ровно 3 последовательных вызова Math.random() при старте процесса – и ключ, которым сервер заверяет каждую сессию на всё время жизни процесса, готов.
Нешифровой генератор на месте криптографического – ошибка, которая дорого обходилась и раньше: предсказуемый RNG стоил Coldcard 1 367 биткоинов. Здесь он стоит admin-доступа и исполнения произвольного кода. crypto.randomBytes() – штатный модуль Node.js для криптографической случайности – давал бы на этом месте необратимый результат при нулевой дополнительной сложности: замена одного вызова на другой, одна строка, ноль новых зависимостей. Комментарий разработчика – «randomness at start gives some extra security» – доказывает, что намерение было защитным. Инструмент – нет.
Ключ подписи собран из Math.random(). Чтобы его восстановить, атакующему нужны наблюдения – другие выходные значения того же генератора. HFS их раздаёт.
SRP (Secure Remote Password) – протокол аутентификации, встроенный в HFS. Первый шаг хендшейка, обработчик loginSrp1, генерирует идентификатор сессии:
const sid = Math.random()
ctx.session.loggingIn = { username, pubKey, sid }
Значение sid – число с плавающей точкой IEEE-754 double precision, 52 бита мантиссы. Оно записывается в объект сессии, а сессии в HFS хранятся не на сервере – в куках клиента. koa-session по умолчанию держит сессию целиком в куке без серверного хранилища: сериализация JSON, кодирование base64, целостность через HMAC от keygrip. Шифрования нет: содержимое куки – открытый текст для любого, у кого она есть. Клиент декодирует base64, читает JSON и получает точное значение Math.random(), вышедшее из генератора.
Для вызова loginSrp1 достаточно знать имя пользователя – пароль на первом шаге SRP не требуется. Учётная запись admin существует по умолчанию, а её наличие подтверждается через user enumeration oracle – отдельную уязвимость, найденную и раскрытую вместе с основной. Переменная admin_net в конфигурации по умолчанию – пустая строка: сетевых ограничений на admin-доступ нет.
Каждый запрос loginSrp1 – одно наблюдение Math.random(). Привилегии не нужны, доступ к серверу не нужен, пароль не нужен.
Math.random() для идентификатора сессии – вторая проектная ошибка в той же цепочке. crypto.randomUUID() на этом месте давал бы непрозрачный токен, не связанный с состоянием генератора: ответ сервера не сообщал бы о внутренней механике процесса ничего. Math.random() полной точностью IEEE-754 отдаёт 52 бита состояния в каждом ответе, без аутентификации. Первая ошибка без второй не эксплуатируема: ключ предсказуем, но наблюдения взять неоткуда. Вторая без первой безвредна: генератор утекает, но утечка ни к чему не ведёт. Обе вместе – полная цепочка от анонимного запроса до root.
V8 – JavaScript-движок Node.js – реализует Math.random() алгоритмом xorshift128+. Внутреннее состояние – два 64-битных целых числа (state0 и state1). На каждом шаге алгоритм выполняет три побитовых операции – два XOR со сдвигом и сложение обоих слов – и выдаёт результат. Все операции линейны над GF(2), полем из двух элементов, где каждый бит рассматривается независимо. Отсюда ключевое свойство: каждый шаг алгебраически обратим. Зная достаточно выходных значений, состояние восстанавливается аналитически.
xorshift128+ – хороший генератор, если от него не требуют того, чего он не обещал: непредсказуемости. V8 выбрал его за скорость и длину периода, не за криптографическую стойкость. У генератора нет претензий на криптографическую случайность – это претензия разработчика HFS.
Нюанс первый – буфер. V8 генерирует числа не по одному, а пачками по 64. Заполненный кэш потребляется в обратном порядке: первый вызов Math.random() после заполнения отдаёт 64-е число, второй – 63-е. Когда буфер заканчивается, генератор выполняет 64 детерминированных шага вперёд и заполняет его заново. Для атакующего это означает, что значения, наблюдённые подряд, не обязательно сгенерированы подряд: они могут принадлежать одному буферу (в обратном порядке относительно генерации) или соседним (с разрывом между ними). Каждый шаг и переход детерминирован и обратим – это вопрос правильного окна.
Нюанс второй – потеря бит. Когда V8 преобразует 64-битный выход xorshift128+ в IEEE-754 double (V8 ToDouble), он берёт старшие 53 бита и масштабирует в диапазон [0, 1). 11 младших бит отбрасываются – они ненаблюдаемы. На каждом наблюдении атакующему приходится перебирать 2 048 вариантов этих бит. Но перебор не полный: кандидатное состояние из первого числа проверяется вторым наблюдением мгновенно, и ложные ветки отсекаются на первом же расхождении.
Z3 – SMT-солвер (Satisfiability Modulo Theories) от Microsoft Research. Механизм, который принимает систему ограничений и находит значения переменных, при которых все ограничения выполняются одновременно. Алгебраическая формула, логическое условие, битовый вектор – Z3 работает со всеми. В контексте xorshift128+: state0 и state1 – неизвестные 64-битные векторы, уравнения генератора – ограничения, наблюдённые doubles – выходные значения. Солвер находит решение или доказывает его отсутствие.
Mythos указал на Z3 как на стандартный инструмент для этой задачи. Horizon3 отмечает, что без модели их первым подходом к атаке на генератор был бы перебор – а модель сразу выбрала алгебраический путь, которого исследователи не припомнят ни в одном прецеденте атаки на криптографический дефект реального приложения.
Публичный PoC для CVE-2026-61500 идёт алгебраическим путём: берёт 5 последовательных doubles из 6 запросов loginSrp1, перебирает 11 отброшенных младших бит и обращает рекуррентное соотношение xorshift128+ напрямую. PoC перебирает смещения наблюдений внутри 64-элементного буфера V8 и проверяет варианты ToDouble (shift11 или shift12), пока все наблюдения не воспроизведутся бит-в-бит. Оба подхода – Z3 и алгебраическое обращение – решают одну задачу; первый выразительнее, второй быстрее.
Восстановленное состояние прокручивается назад – через границы буферов, через шаги заполнения – к тем трём вызовам Math.random() при старте процесса. Из трёх doubles детерминированно вычисляются три base36-фрагмента: toString(36), обрезка до 10 символов, замена строчной l на прописную L – и склеиваются в 30-символьный ключ подписи. В кодовой базе HFS единственные потребители Math.random() на стороне сервера – ключ подписи (3 вызова при старте) и sid – пространство поиска стартового смещения минимально.
Верификация бесплатна. Атакующий берёт свою легитимно полученную куку, вычисляет HMAC кандидатным ключом и сравнивает с подписью сервера. Совпадение – ключ верный. В PoC на всё уходит 2.4 секунды.
Horizon3 описывает свой опыт прямо: их исследователи неоднократно обнаруживали нешифровой PRNG на месте криптографического и отступали. Первая причина – отсутствие глубокой математической подготовки в криптографии. Вторая – экономика: уязвимость, требующая значительного времени на понимание и разработку эксплойта, не попадает в приоритет.
Mythos снял оба ограничения. Horizon3 выделяет три области, в которых модель превосходит ожидания: научные задачи в области информатики и внутренней механики ОС, математические дистилляции и задачи с длинным горизонтом. Агент криптографического анализа получил исходный код HFS и задачу. Без наводящих промптов, без указания направления. Вывод модели в переводе из отчёта Horizon3:
Когда COOKIE_SIGN_KEYS не установлена, ключ Koa keygrip – randomId(30) (index.ts:41-42), а randomId (cross.ts:334-341) построен на Math.random().toString(36) – V8 xorshift128+, нешифровой и обратимый. Процесс утекает сырые Math.random() удалённым клиентам: loginSrp1 записывает sid = Math.random() (api.auth.ts:78) в ctx.session.loggingIn, а куки koa-session – base64 JSON, клиент получает точный 52-битный double в Set-Cookie. Последовательные doubles позволяют восстановить состояние xorshift128+ (стандартный Z3/алгебраический тулинг); состояние прокручивается назад к выходам randomId(30) при старте, кандидатный ключ верифицируется HMAC-подписью собственной легитимно полученной куки. С восстановленным ключом – подделать {username:'admin', ts:now, allow_session_ip_change:true}, пройдя prepareState, invalidateSessionBefore и проверку IP.
Верифицирующий агент перепроверил каждый тезис по коду. COOKIE_SIGN_KEYS не устанавливается нигде в репозитории. randomId(30) потребляет ровно 3 последовательных Math.random() при старте. Комментарий разработчика подтверждает защитное намерение – исключение «not for security purposes» не работает. sid полной точностью записывается в сессию. Сессии хранятся в куках, не на сервере. Куки подписаны, но не зашифрованы. Единственные серверные потребители Math.random() – ключ и sid – пространство поиска минимально.
Horizon3 подчёркивает отдельно: модель не просто нашла нешифровой PRNG. Mythos одновременно обнаружил утечку через отдельный путь кода, опознал оба факта как одну эксплуатируемую цепочку и определил, что утечка даёт ровно те наблюдения, которые нужны для восстановления состояния генератора. Дополнительных промптов не потребовалось. После этого модель написала полный proof of concept – Z3-солвер с ограничениями, ковку куки и демонстрацию исполнения кода.
Сами исследователи формулируют впечатление так: Mythos с ходу определил, что восстановление криптографического сида жизнеспособно, и понял, как практически атаковать его. Люди, которые редко берутся за вепонизацию криптографических дефектов, увидели, как модель делает это за один проход.
Полная цепочка эксплуатации – от первого запроса до исполнения кода.
Атакующий проверяет наличие admin через user enumeration oracle. Дальше собирает наблюдения Math.random(): разбор Horizon3 закладывает 12 обращений к утечке, публичный PoC обходится шестью запросами loginSrp1. Каждый запрос даёт один sid полной точностью IEEE-754. Извлекает doubles из кук: base64, JSON, поле sid. Подаёт наблюдения в солвер и за секунды получает внутреннее состояние xorshift128+. Прокручивает его назад к трём вызовам Math.random() при старте. Реконструирует ключ подписи – 30 символов base36. Верифицирует кандидат HMAC-подписью собственной куки.
Ключ восстановлен. Атакующий формирует сессионный объект:
{
"username": "admin",
"ts": 1790000000,
"allow_session_ip_change": true
}
Поле allow_session_ip_change обходит привязку сессии к IP: при ковке атакующий подписывает куку сам и включает любой флаг. Метка ts – текущее время. invalidateSessionBefore – пустая Map по умолчанию: для admin нет метки принудительного сброса. ctxAdminAccess предоставляет административный доступ по username в состоянии контекста – подпись совпадает, в сессии стоит admin, доступ получен.
Последний шаг – server_code. Административный API HFS позволяет задать произвольный JavaScript через конфигурацию, который сервер исполняет. Атакующий вызывает set_config, записывает свой код – и тот исполняется с привилегиями процесса. В демонстрации PoC – uid=0(root), полный захват.
HFS 3.2.1 – первый исправленный релиз. Ключ подписи больше не собирается из Math.random(): вместо randomId(30) используется непрозрачный UUID. Ответ loginSrp1 в исправленной версии тоже содержит UUID вместо числа Math.random() – PoC, запущенный против 3.2.1, завершается с кодом 3, не добравшись до ковки. Обходная мера – явная установка COOKIE_SIGN_KEYS через переменную окружения: достаточно длинный случайный ключ, и фолбэк на randomId(30) не сработает.
CVE-2026-61500 получила оценку CVSS 9.3 (Critical) по версии 4.0: сетевой вектор, низкая сложность, без привилегий и без взаимодействия пользователя, максимальное воздействие на конфиденциальность, целостность и доступность. CWE-338 – использование криптографически слабого PRNG.
Обе проектные ошибки в цепочке – тривиальные. crypto.randomBytes() вместо Math.random() для ключа подписи. crypto.randomUUID() вместо Math.random() для sid. Ноль дополнительных строк, ноль новых зависимостей, идентичная семантика, необратимый результат. Цена ошибки: полный захват сервера из 12 HTTP-запросов без аутентификации.
Но тривиальность ошибки – не причина, по которой она жила в коде. Причина – математический барьер между наблюдением «здесь стоит нешифровой PRNG» и рабочим эксплойтом. Барьер из xorshift128+, обращения состояния, SMT-солверов и алгебры над GF(2). Для исследователей Horizon3 – людей с обширным опытом – этот барьер каждый раз оказывался достаточной причиной развернуться.
Mythos прошёл его за один проход. Модель той же линейки, что вскрывала 7 раундов AES, здесь нашла ключ от сервера. Не пометила баг как «потенциально опасный» – построила полную цепочку, написала солвер и продемонстрировала исполнение кода.
Horizon3 формулирует следствие прямо: классы уязвимостей, которые злоумышленники сочтут пригодными для вепонизации, изменятся. Раньше типичной уязвимостью, эксплуатируемой массово, был простой обход аутентификации с готовым исполнением кода. Теперь более сложные и менее надёжные классы багов станут доступны – автоматизация превращает каждый из них в эксплойт с предсказуемыми затратами. Модели 2025 года находили уязвимости, но требовали серьёзной инженерии пайплайна: контекстно-зависимый промптинг, фильтрация релевантных файлов и функций, специализированные системы. Текущее поколение моделей сделало многое из этого ненужным – стандартный пайплайн с промптом «найди уязвимости» даёт результат для проектов, не проходивших аудит.
Для проектов, которые аудит проходили, Horizon3 ставит вопросы следующего порядка: насколько легко злоумышленник автоматизирует построение ROP-цепочек между архитектурами, и насколько элегантное решение для вепонизации бага работы с памятью способен создать агент. Исследователи видят в себе ту часть процесса, которую модель пока не заменяет: инженерное суждение о том, куда вложить усилие – какой проект стоит специализированного пайплайна, а какой достаточно прогнать за ночь.
Разбор уязвимости и proof of concept опубликованы Horizon3.
Автор: hacker@shifry.local