Логотип ШифрыШифры
ГлавнаяЖурналЛотыУзел ИИВойти
ГлавнаяЖурналЛотыУзел ИИ
Войти
Логотип ШифрыШифры2К26Версияъ1
EmailMAX
Назад к логу

Один баг в GoBalance отдал мастер-ключ любого .onion тому, кто скачал дескриптор

24 октября 2026 г.•Репа
#атаки#сети#шифрование

Чтобы увести самый крупный форум даркнета, в октябре не понадобилось трогать его серверы. Атакующий запросил публичный дескриптор скрытого сервиса – документ, который Tor по определению раздаёт любому желающему, – прочитал из него подпись, решил одно линейное уравнение и получил мастер-ключ .onion-адреса. С этого момента адрес принадлежал ему: сайт переехал на чужой сервер, посетители – следом.

Это не взлом инфраструктуры. База данных Dread осталась на месте, SSH никто не подбирал, нулевого дня в самом Tor нет. Сломалась математика: GoBalance, Go-реимплементация балансировщика OnionBalance, при подписи дескриптора отдавала криптоядру половину ключа вместо целого. Отброшенная половина – ровно та, что держит nonce подписи в секрете. Без неё nonce превращается в публичную константу, а подпись ed25519 – в уравнение с единственным неизвестным, и это неизвестное – секретный скаляр мастер-ключа. Searchlight Cyber разобрала дефект построчно; разбор ниже идёт по их отчёту и по коду самого GoBalance.

Зачем даркнету балансировщик и откуда взялся GoBalance

Форумы и маркетплейсы даркнета живут под постоянным DDoS – чаще всего от конкурентов, которым проще залить чужой сайт мусорным трафиком, чем переманить аудиторию. Один .onion-адрес, обслуживаемый одной backend-машиной, валится первым же серьёзным флудом. OnionBalance решает это штатно: он позволяет одному v3-адресу обслуживаться сразу несколькими инстансами Tor, разводя нагрузку по бэкендам и делая адрес живучим.

Темп, с которым Tor добавлял собственные DDoS-защиты, операторов не устраивал, и они собрали свой тулкит – EndGame, набор для удержания скрытого сервиса на плаву под атакой. В его состав вошла GoBalance – переписанная на Go версия OnionBalance. Вот здесь и была посажена уязвимость: оригинальный OnionBalance на Python не затронут, сам Tor не затронут, дыра живёт только в реимплементации.

Разница между оригиналом и реимплементацией – не в алгоритме, а в том, через какой аксессор код добывает ключ. Python-версия работает с развёрнутым ключом явно: она держит оба 32-байтных поля формата Tor и подаёт в подпись все 64 байта, не пропуская их через метод, который по контракту отдаёт только seed. У Go такой метод есть, он называется Seed(), он выглядит как естественный способ «достать ключ» – и именно эта естественность стала ловушкой. Переписывая стек, автор потянулся за идиоматичным аксессором stdlib, не заметив, что контракт Seed() расходится с тем, что лежит в формате Tor. Оригинал этой ловушки избежал не потому, что умнее, а потому, что не имел под рукой слишком удобного неправильного инструмента.

Это первое инженерное решение, которое стоит взвесить. Переписать криптографический стек на Go ради скорости и единого бинаря – соблазнительно, но оригинальный OnionBalance прошёл годы эксплуатации в проде скрытых сервисов, и его обращение с форматами ключа выверено. Форк с оптимизациями поверх проверенной криптореализации обошёлся бы в часть производительности Go и необходимость тащить Python-рантайм – зато не перенёс бы на себя ответственность за ed25519 заново. GoBalance эту ответственность взяла и уронила на первом же нетривиальном месте: на разнице двух форматов секретного ключа. Цена переписывания здесь – не абстрактный техдолг, а весь адресный простор сервисов, хранивших ключ в формате Tor.

Два формата одного ключа, и уязвим ровно один

Подпись дескриптора требует мастер-ключа идентичности сервиса, и этот ключ приходит в одном из двух форматов – разница между ними и есть эпицентр всей истории.

Первый – стандартный ed25519. На диске это 32-байтный seed. Секретный скаляр a и nonce-prefix из него не хранятся, а выводятся по требованию: seed прогоняется через SHA-512, старшая половина хэша даёт скаляр, младшая – nonce-prefix. Так устроена конвенция NaCl, и так работает любая каноническая реализация ed25519.

Второй – расширенный формат Tor. Tor seed не хранит вообще. Его файл hs_ed25519_secret_key держит уже развёрнутые 64 байта: 32-байтный секретный скаляр a, за ним 32-байтный PRF-ключ h. Шага разворота здесь нет – разворачивать нечего, обе половины уже лежат на диске. Именно h – тот самый секрет, из которого на каждой подписи рождается nonce.

Так ed25519 устроен намеренно. Классический ECDSA выводит nonce из генератора случайных чисел, и слабый ГСЧ там означает катастрофу: на повторном nonce в своё время вскрыли подпись прошивок Sony PlayStation 3 и уводили биткоины с кошельков, чьи подписи разделили одно случайное значение. Чтобы убить этот класс ошибок разом, ed25519 сделали детерминированным – nonce выводится не из ГСЧ, а из секретной половины ключа h и самого сообщения, r = H(h ∥ M). Пока h секретен, r непредсказуем, и две разные подписи никогда не делят один nonce. Вся конструкция держится на одном допущении: что h у подписывающего есть и он секретен. GoBalance в ветке Tor нарушает ровно это допущение.

Разница тонкая, но решающая. Стандартный формат несёт 32 байта, из которых честная реализация добывает обе величины. Формат Tor несёт 64 байта, где обе величины уже готовы. Код, который перепутает, сколько байт он держит в руках, в одном формате просто недосчитается половины ключа – и эта половина окажется той, на которой стоит вся стойкость подписи.

Сам GoBalance, когда генерирует ключ своим setup-инструментом, пишет его в безопасном стандартном формате. Поэтому не всякий сервис на GoBalance уязвим – уязвим тот, кто принёс на балансировщик legacy-ключ из hs_ed25519_secret_key. Dread принёс.

Почему дескриптор вообще подписывают слепым ключом

Публикации скрытого сервиса не подписываются мастер-ключом напрямую – иначе мастер-ключ светился бы в каждом дескрипторе. Вместо этого Tor для каждого временно́го периода выводит из ключа идентичности слепую (blinded) пару ключей, используя публичный blinding-nonce, и подписывает дескриптор уже слепым ключом. Фокус в том, что клиент, знающий только адрес, может проверить такой дескриптор, а мастер-ключ при этом наружу не выходит.

Механика тут такая. Долгосрочный ключ идентичности в идеале лежит оффлайн; для публикации Tor выводит из него blinding-фактор t, завязанный на текущий временно́й период и публичный параметр, и получает слепую пару: слепой публичный ключ A' = t·A и слепой скаляр a' = t·a. Дескриптор подписывается слепым ключом. Клиент, набравший .onion-адрес, читает из него публичный ключ идентичности A, сам вычисляет t для текущего периода, выводит A' – и проверяет подпись дескриптора, ни разу не притронувшись к мастер-ключу. Выигрыш двойной: реле-директории (HSDir), раздающие дескрипторы, не видят долгосрочный ключ сервиса, а сторонний наблюдатель не может связать публикации разных периодов между собой, потому что A' каждый период новый.

И блиндинг, и подпись требуют полного развёрнутого ключа – и скаляра, и nonce-prefix, в любом из двух форматов. Это ключевое условие: функции ниже по стеку ждут на входе 64 байта. Что произойдёт, если им скормить 32, – зависит от того, насколько аккуратно верхний слой различает форматы. GoBalance различать их пытается, но делает это так, что различие ни на что не влияет.

Развилка, где обе ветки зовут один и тот же Seed

Подписывающая функция ветвится по формату ключа в pkg/stem/descriptor/hidden_service.go:

func blindedSign(msg []byte, identityKey gobpk.PrivateKey, blindedKey, blindingNonce []byte) []byte {
    if identityKey.IsPrivKeyInTorFormat() {
        return util.BlindedSignWithTorKey(msg, identityKey.Seed(), blindedKey, blindingNonce)
    } else {
        return util.BlindedSign(msg, identityKey.Seed(), blindedKey, blindingNonce)
    }
}

Развилка выглядит грамотно: отдельная ветка под формат Tor, отдельная под стандартный. Но обе зовут identityKey.Seed(). В pkg/gobpk/gobpk.go этот метод проксирует в ed25519.PrivateKey.Seed() из стандартной библиотеки Go, а тот по контракту возвращает первые 32 байта приватного ключа – seed, и только seed.

Для стандартного ключа это ровно то, что нужно: 32 байта seed уходят в BlindedSign, которая внутри переразворачивает их через SHA-512 и регенерирует обе величины – a и h. Стандартная ветка не страдает ни на байт.

Для ключа Tor значение, загруженное с диска в pkg/onionbalance/tor_ed25519.go, – это полные 64 байта a ∥ h. И Seed() молча возвращает из них только a, отбрасывая h. Ошибки нет. Паники нет. Функция честно отдаёт 32 байта, как ей и положено по контракту stdlib, – просто эти 32 байта означают совсем не то, что ждёт ветка Tor.

Вот вторая точка, где решение стоило всего. Ветвление IsPrivKeyInTorFormat() декоративно: раз обе ветки добывают ключ одним и тем же Seed(), различать форматы на этом уровне бессмысленно. На том же месте просился отдельный аксессор под развёрнутый формат – что-нибудь вроде ExpandedKey(), отдающего все 64 байта, – или хотя бы проверка длины, которая уронила бы процесс с паникой при недостаче. Любая из этих альтернатив стоила бы автору нескольких строк. Выбранный же вариант не стоил ничего на вид и потому дорог по-настоящему: дефект не роняет сборку, не портит формат подписи и выдаёт внешне безупречные, верифицируемые подписи. Такую ошибку не ловит ни компилятор, ни беглый code review, ни даже функциональный тест, который проверяет, что подпись валидна, – она валидна. Поэтому баг доехал до каждой выпущенной версии.

Пустой k и один nonce на весь даркнет

Ветка Tor уходит в BlindedSignWithTorKey, которая исходит из того, что ключ ей передали уже развёрнутым:

func BlindedSignWithTorKey(msg []byte, identityKey ed25519.PrivateKey, blindedKey, blindingNonce []byte) []byte {
    esk := identityKey.Seed() // уже срезан до 32 байт; повторный Seed() ничего не меняет
    return blindedSignP2(esk, msg, blindedKey, blindingNonce)
}

Комментарий в коде не врёт: esk должен держать 64 байта (a ∥ h), а держит 32 (a). Повторный вызов Seed() поверх уже срезанного значения ничего не чинит – nonce-prefix потерян ещё до того, как подпись началась.

Дальше blindedSignP2 делит развёрнутый ключ надвое и выводит nonce-prefix из второй половины:

s := decodeInt(esk[:32])  // скаляр a — корректно
k := esk[32:]             // должен быть h, но esk всего 32 байта → k пуст
tmp := sha512.Sum512([]byte("Derive temporary signing key hash input" + string(k)))
kPrime := tmp[:32]        // теперь фиксированная публичная константа

Скаляр s считывается верно – и это отдельный тонкий момент, который делает дефект таким коварным. В расширенном формате Tor первые 32 байта и есть готовый скаляр a, поэтому decodeInt(esk[:32]) берёт именно его. В стандартном формате те же первые 32 байта – это seed, из которого скаляр ещё надо вывести через SHA-512 и склампить, и тот же код на них дал бы неверный a. Но ветка Tor получает ровно Tor-формат, скаляр оказывается правильным, подпись – валидной, и ничто не сигналит об ошибке. Половина ключа верна, половина потеряна – и теряется именно та, что держала секрет. А k := esk[32:] на 32-байтном срезе даёт пустую строку. SHA-512 берётся от строки "Derive temporary signing key hash input", склеенной с пустотой, – то есть просто от фиксированной строки. Результат kPrime – одни и те же 32 байта. Для каждого сервиса. Для каждого дескриптора. На всём даркнете, где ключ был в формате Tor.

Nonce-prefix, единственный секрет, разделявший подписи, схлопнулся в известную всем константу.

Подпись, свёрнутая в линейное уравнение

Подпись ed25519 – это пара (R, S). В норме её стойкость держится на том, что nonce r секретен: он выводится как r = H(h ∥ M), и раз h секретен, то и r секретен. В уравнении подписи тогда два неизвестных – r и скаляр a, – и по одной подписи их не разрешить.

Сломанная ветка убирает этот секрет. Nonce теперь r = H(kPrime ∥ M), где kPrime – публичная константа, а M – подписываемый сертификат, который лежит прямо в опубликованном дескрипторе. Значит, r вычислим кем угодно. В уравнении остаётся одно неизвестное.

Уравнение Смысл
R = rB, S = r + H(R ∥ A ∥ M)·a (mod ℓ) подпись ed25519
r = H(h ∥ M) нормальный секретный nonce
r = H(kPrime ∥ M), kPrime = SHA-512("Derive…") сломанный публичный nonce
a' = (S − r)·H(R ∥ A' ∥ M)⁻¹ (mod ℓ) слепой скаляр из дескриптора
a = a'·t⁻¹ (mod ℓ) снятие блиндинга → мастер-скаляр

Атакующий забирает дескриптор и читает из него R, S, M, blinding-nonce и слепой публичный ключ A'. Он вычисляет r – kPrime ему известен, M у него в руках. Остаётся выразить a' из уравнения подписи: a' = (S − r)·H(R ∥ A' ∥ M)⁻¹. Это слепой скаляр – тот, которым подписан конкретный временно́й период. Но блиндинг обратим: он умножает мастер-скаляр на множитель t, выведенный из публичного blinding-nonce, а значит, делится обратно – a = a'·t⁻¹. На выходе – мастер-скаляр идентичности, достаточный, чтобы выводить слепые ключи и подписывать валидные дескрипторы для адреса в любой временно́й период.

Стоит оценить, насколько это сильнее классической атаки на повторный nonce. Обычно, чтобы вытащить скаляр из ed25519, нужны две подписи, случайно разделившие один r: из разности их уравнений r сокращается, и a выражается через известные величины. Здесь же хватает одной подписи – потому что r не повторяется, а публично вычислим. Детерминизм, который ed25519 ввёл как щит против слабого ГСЧ, обернулся здесь худшим из возможных исходов: nonce стал не предсказуемым, а полностью известным, и не вероятностно, а строго на каждой подписи. Множитель t⁻¹ в последнем шаге – обращение blinding-фактора по модулю ℓ, порядка группы; ℓ простое, обратный элемент существует и считается мгновенно расширенным алгоритмом Евклида. Вся атака – арифметика по одному модулю, без перебора, без обращения к серверу и без единого бита, которого не было бы в публичном дескрипторе.

Схема потока данных: ключ формата Tor проходит через Seed(), теряет nonce-prefix h, nonce схлопывается в константу kPrime, и мастер-скаляр a восстанавливается из публичных полей дескриптора R, S, M, A′; параллельная ветка стандартного формата остаётся стойкой
Путь уязвимой ветки: от 64-байтного ключа Tor до мастер-скаляра, вычисленного из публичных полей дескриптора

Пустое значение, попавшее в криптопримитив, выворачивает всю стойкость наизнанку – ровно так же пустой ключ подписи однажды открыл постороннему ядерный архив Филиппин, где подпись проверялась против ничего. Здесь пустота не на стороне проверяющего, а на стороне подписывающего, но исход тот же: математика, которая должна была прятать секрет, сама его печатает.

Что держит в руках тот, кто вытащил скаляр

.onion-адрес сам по себе есть публичный ключ – адрес и есть отпечаток ключа идентичности. Кто держит соответствующий приватный ключ, тот держит адрес. GoBalance-дефект позволяет вычислить этот приватный ключ из публичных данных, без единого обращения к серверам сервиса.

С мастер-скаляром на руках атакующий может опубликовать собственный дескриптор и увести посетителей на подконтрольный сервер; может поднять копию сайта или его модифицированную версию и собирать на ней учётки, перехватывать трафик, вклиниваться посредником; и делать это для любого будущего временно́го периода, потому что скомпрометирован мастер-ключ, а не короткоживущий сессионный. Поскольку blinding детерминирован от мастер-скаляра и номера периода, владелец a выводит a' для любого грядущего периода наперёд и может заготовить валидные дескрипторы заранее – так что смена периода, на которую в норме полагаются как на естественную ротацию, жертву не спасает. При этом сам дефект не даёт доступа к серверам сервиса, его базе и хранимым данным пользователей – адрес уводится, но машины остаются у законного владельца.

Отсюда – третье решение под оценку, и принимал его уже не автор GoBalance, а сам оператор сервиса. Dread пришёл на балансировщик с ключом в уязвимом формате Tor, хотя GoBalance свои ключи генерирует в безопасном. Перегенерировать ключ идентичности в стандартном формате на миграции было можно – но это сменило бы .onion-адрес, а для площадки уровня Dread узнаваемый адрес и есть весь актив, нажитый репутацией и ссылками по всему даркнету. Выбор в пользу сохранения адреса означал гонять мастер-ключ в уязвимом формате – необратимо, потому что это мастер-ключ, а не сессионный токен, который можно перевыпустить. Цена этого решения вскрылась 5 октября.

Dread: признание Paris, которое не объясняет второй адрес

Все времена ниже – UTC, а картина собрана из публичных заявлений и скриншотов: проверить их независимо нельзя, стороны могли выдавать желаемое за действительное.

Около 05:31 на основном адресе Dread (внутренняя кличка dreadytofat) появилась плашка «dread²» с сообщением, что сайт взломан. К 06:02 тот же адрес показывал уже обращение администратора конкурирующей площадки Conclave, заявлявшего, что контролирует секретный onion-ключ Dread и держит некие чувствительные данные. Адрес принялся мигать между дефейсом и веткой обсуждения взлома.

Основатель Dread, HugBunter, выпустил «отчёт о прозрачности» и свалил всё на со-администратора по кличке Paris: тот якобы по ошибке залил архив с приватным ключом. Paris ответственность принял – и сделал это в фирменном стиле (здесь и далее перевод наш):

Вы когда-нибудь лажали так сильно, что заархивировали приватный ключ своего форума в апдейт gobalance и не заметили? Нет? Только я?

Версия звучала правдоподобно: оператор ошибся qube'ом, заархивировал боевую версию вместо тестовой и выложил в публичную директорию. На время простоя пользователей увели на резервный «VIP»-адрес (кличка dreadlands) – зеркало для премиум-участников, державшееся про запас как обход DDoS. 6 октября HugBunter повторил: это операционная ошибка, а не дыра в инфраструктуре Dread.

История держалась ровно до тех пор, пока не упал второй адрес.

Русский текст на резервном домене

7 октября около 02:29 на том самом резервном dreadlands появилось заявление, написанное по-русски, – и оно разносило версию администраторов в клочья. Логика автора была проста и неудобна:

А откуда же у меня оказался этот премиальный домен который они только вчера назвали безопасным?

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

К 12:37 администраторы вернули контроль над VIP-адресом и перенаправили его обратно. После этого HugBunter признал то, что отрицал двое суток: атакующий применил zero-day в GoBalance против нескольких сервисов даркнета. Он настаивал, что трафик пользователей перехвачен не был, а основной адрес всё же потерян через отдельную утечку ключа; отверг требования вымогателя и опубликовал частично затёртый IP-адрес, приписанный администратору Conclave. Dread начал продвигать свежесгенерированный адрес (кличка dreadohbles) через официальные каналы.

Повторные захваты поначалу вводили администраторов в ступор: они не понимали, как можно скомпрометировать новые адреса без единого признака взлома серверов. Ответ был в том, что серверы и не нужно было трогать. Атакующий, к слову, заявлял, что держит базу Dread, и пытался шантажировать HugBunter, но предъявить доказательства отказался – и это ровно тот профиль поведения, который выдаёт блеф: будь у него дамп, он бы им размахивал. Опсек у атакующего при этом хромал в другую сторону – громкое русскоязычное заявление с привязкой к Conclave и требованием компенсации превратило тихий захват адреса в публичный инцидент и подарило администраторам повод опубликовать его IP. Тихий MiTM на уведённом адресе собирал бы учётки ровно до тех пор, пока кто-то не заметит; громкий дефейс закрыл это окно в первые же часы, заставив Dread мигрировать. Размен славы на доступ – выбор не технический, но оплаченный доступом.

Ни CVE, ни патча, ни кнопки «отменить»

На 9 октября у дыры не было ни номера CVE в национальной базе NVD, ни публичного адвайзори от Tor Project или мейнтейнера GoBalance. Независимый исследователь выложил патч и рабочий proof-of-concept, восстанавливающий мастер-ключ из одного публичного дескриптора; по его словам, демонстрация шла на ключах, сделанных под тест, и ни один реальный сервис не трогали. Публичного релиза фикса от самого проекта на тот момент не было.

HugBunter вдобавок утверждал, что уязвимость нашли с помощью ИИ, и что команда Dread тем же ИИ воспроизвела находку в ходе расследования. Проверить это заявление нечем, и вес ему мы не придаём – но направление знакомое: автоматический анализатор, прогоняющий криптокод против известных классов слабостей, ловит обнулённый nonce заметно быстрее человека, как ИИ уже вытаскивал предсказуемость из генератора сессионных ключей там, где люди годами видели стену. Обнулённый k для такого инструмента – идеальная мишень: константа на входе хэша видна статически.

Главное же в последствиях – необратимость. Патч не отменяет утечку. Как только уязвимая версия опубликовала хоть один дескриптор, утёкший из него ключ уже не отозвать: он в руках у всех, кто успел прочитать дескриптор, и навсегда. Дескриптор же по устройству сети расходится по реле-директориям и кэшируется – отозвать его из Tor физически некому. Поэтому единственная рабочая ремедиация – сгенерировать новый .onion-адрес и переехать на него, что Dread и сделал. Он оказался не один: маркетплейс Omega 8 октября подписанной запиской сообщил, что снял старый адрес «из-за проблемы, вызванной багом GoBalance», и переехал. HugBunter предупреждал, что затронуть могло любой сервис, когда-либо использовавший GoBalance, включая давно закрытые площадки, чьи дескрипторы всё ещё висят в сети, – для них переезжать уже некому, а ключ всё равно вычислим. Команда Dread анонсировала патченую версию GoBalance и помощь затронутым сервисам: восстановить адреса или увести пользователей на легитимные замены, – плюс полный пересмотр собственного опсека; HugBunter признал урон доверию сообщества, но заявил, что Dread продолжит работу. Пользователям оставалось одно: сменить пароли, считать старый адрес небезопасным и доверять новому только после подписанного объявления.

Ключ, который отдал не взлом, а вызов функции

Красота этой истории – в дистанции между причиной и следствием. Причина – одна строка, identityKey.Seed(), вызванная не в той ветке: автор GoBalance принял метод stdlib за универсальный аксессор ключа, а тот по контракту отдаёт seed, которого в формате Tor попросту нет. Следствие – мастер-ключи крупнейших площадок даркнета, вычисляемые из публичных данных карандашом на салфетке. Между этими двумя точками нет ни эксплойта памяти, ни подбора, ни социалки – только арифметика по модулю ℓ.

Сложить всё это вместе – значит увидеть три решения, каждое из которых в одиночку было бы терпимо, а вместе они собрали катастрофу. Переписать выверенную криптографию на Go ради скорости. Оставить декоративное ветвление по формату там, где обе ветки добывают ключ одинаково и одна из них молча недобирает половину. И принести на такой балансировщик legacy-ключ в уязвимом формате, лишь бы сохранить узнаваемый адрес. Калибр операции атакующего при этом скромен – он не ломал стойкую систему, он подобрал ключ, который система сама выложила на асфальт; вся работа была в том, чтобы прочитать дескриптор и поделить два числа. Высший класс здесь продемонстрировал не нападающий, а тот, кто разложил дефект по байтам постфактум.

Для оператора скрытого сервиса вывод жёсткий: мастер-ключ .onion – это не конфиг, который можно перегенерировать на лету, а корень доверия, живущий ровно столько, сколько жив адрес. Любой код, который его касается, обязан относиться к разнице форматов ключа как к вопросу жизни и смерти, – потому что ею она и оказалась. Разбор уязвимости, код уязвимых функций и математику восстановления ключа опубликовала Searchlight Cyber.

Автор: hacker@shifry.local