Ошибка консенсуса KAWPOW в Ravencoin
Вторую неделю августа Ravencoin откатывал свою цепочку примерно на три дня назад. Причиной был не картель майнеров, не эксплойт кошелька и не сломанная хеш-функция. Причиной было одно поле в заголовке блока, которому нода доверяла выбор между двумя проверками доказательства работы. Каждый фрагмент кода ниже взят из исходного кода консенсуса самого Ravencoin, а не из объявлений, и там, где объявления говорят то, чего исходный код не показывает, эта статья прямо об этом говорит, а не гадает.
Что несёт заголовок KAWPOW
KAWPOW - это доказательство работы Ravencoin, вариант ProgPoW, на который сеть перешла в мае 2020 года, чтобы монету можно было и дальше майнить на обычных GPU. Этот переход изменил заголовок блока. До времени активации заголовок заканчивается обычным 32-битным nonce, который использует любая цепочка, произошедшая от Bitcoin. После него сериализатор записывает в это место три поля в таком порядке: nHeight, затем nNonce64, затем mix_hash.
Два из них ничем не примечательны. nNonce64 - это просто более широкий nonce. mix_hash - промежуточный результат той части алгоритма, которая требует много памяти. Он передаётся в заголовке, чтобы у проверяющего было значение для сверки, а не вычисление, которое пришлось бы повторять с нуля. Интересно третье поле. nHeight - это высота, на которой, по словам самого блока, он находится, записанная внутри его собственного заголовка.
Реальное место блока в цепочке - не вопрос мнения. Оно задаётся полем hashPrevBlock: блок, на котором вы строите, определяет, где вы окажетесь, и солгать об этом, оставаясь присоединённым к вершине цепочки, нельзя. nHeight - это вторая, записываемая копия того же факта, а копия факта стоит ровно столько, сколько стоит проверка, сравнивающая её с оригиналом.
Две хеш-функции, одна из которых ничего не проверяет
В файле hash.cpp Ravencoin определены две точки входа KAWPOW, и их сигнатуры рассказывают большую часть истории ещё до того, как вы прочтёте хоть строку тела любой из них. Первая: uint256 KAWPOWHash(const CBlockHeader& blockHeader, uint256& mix_hash). Вторая: uint256 KAWPOWHash_OnlyMix(const CBlockHeader& blockHeader).
Первая принимает mix_hash как выходной параметр, потому что она его вычисляет. Она выбирает набор данных через ethash::get_epoch_number(blockHeader.nHeight), строит контекст этой эпохи, если его ещё нет, и затем выполняет над ним progpow::hash. Возвращаемый mix - побочный продукт проделанной работы. Это путь, требующий много памяти, а требование к памяти и есть вся причина, по которой KAWPOW существует вместо простого хеша.
Вторая такого параметра не принимает, потому что ничего не вычисляет. Она берёт mix, уже лежащий в заголовке, через progpow::hash_no_verify(blockHeader.nHeight, header_hash, to_hash256(blockHeader.mix_hash.GetHex()), blockHeader.nNonce64). Исходная библиотека назвала эту функцию hash_no_verify, и название точное. Она выполняет последний шаг перемешивания над переданным ей значением и возвращает ответ. Она никогда не строит набор данных, а значит, никогда не устанавливает, что переданное ей значение могло появиться только в результате проделанной работы.
Ни одна из функций не ошибка. У дешёвой есть веская причина существовать: нода, синхронизирующая миллионы старых блоков, которые уже закреплены жёстко прописанной контрольной точкой, не должна заново строить набор данных размером в несколько гигабайт на каждую эпоху, чтобы ещё раз подтвердить историю, которую никто не оспаривает. Единственный вопрос, на который должна ответить такая архитектура: каким блокам разрешено идти этим путём.
Развилка и то, чему она доверяла
На этот вопрос отвечает CheckBlockHeader в validation.cpp, и комментарий прямо над ответом ясно говорит о намерении: «If we are checking a KAWPOW block below a know checkpoint height. We can validate the proof of work using the mix_hash». Намерение верное. Реализация же проверяет условие block.nHeight <= (uint32_t)pcheckpoint->nHeight, а block.nHeight - это число, которое блок записал о самом себе.
Строка ниже вызывает общую функцию CheckProofOfWork(uint256 hash, unsigned int nBits, const Consensus::Params&). Посмотрите, чего нет в этой сигнатуре. Нет ни mix, ни nonce, ни заголовка. Она получает готовый хеш и цель и сравнивает их, и у неё нет способа узнать, какая из двух функций выдала полученное ею число. Для блока KAWPOW CBlockHeader::GetHash() возвращает KAWPOWHash_OnlyMix(*this), так что получает она дешёвое число.
Если собрать всё вместе, блок, объявивший достаточно низкую nHeight, проверялся вопросом о том, лежит ли значение, полученное из его собственного mix hash, ниже цели. Нигде на этом пути ничто не спрашивало, откуда взялся mix, и нигде ничто не сравнивало объявленную высоту с местом, которое блок на самом деле занимал.
Насколько низкой должна быть высота, оказывается вовсе не тонким вопросом. Самая высокая контрольная точка в выпущенных параметрах основной сети - 2 383 550. Первый поддельный блок находился на высоте 4 487 776. Его заголовку пришлось заявить место примерно на 2,1 миллиона блоков позади реального, и ветка, которую он этим выбирал, никогда не смотрела на эту разницу.
Что дал этот обходной путь и чего не дал
Здесь стоит быть точными, потому что большинство публикаций описывали это небрежно, а небрежная версия делает ошибку одновременно проще и страннее, чем она есть.
Обходной путь не снижал сложность. nBits проверяется по тому, что требует реальный предыдущий блок, а реальный предыдущий блок выбирается через hashPrevBlock, который атакующий не может подделать и при этом оказаться на вершине цепочки. Поддельный блок должен был пройти ровно ту же цель, что и честный блок, найденный в ту же минуту.
Изменилась цена одной попытки. Честный майнер платит за каждую попытку полным проходом по набору данных размером в гигабайты, и в этом весь смысл алгоритма, требующего много памяти, и причина, по которой KAWPOW шесть лет не пускал ASIC в Ravencoin. Обходной путь стоит хеша заголовка плюс одного последнего шага перемешивания над значением, которое атакующий выбирал произвольно. Та же лотерея, те же шансы у каждого билета, но билеты дешевле на порядки. 2Miners назвал результаты блоками почти без работы, и по сравнению с тем, сколько должен стоить KAWPOW, это справедливое описание.
Что произошло в сети
Первый известный невалидный блок имеет высоту 4 487 776 и метку времени 2026-08-07 15:44:01 UTC. Эксплуатация продолжалась до экстренного выпуска 10 августа. На одном изученном отрезке из 2 089 блоков 2Miners насчитал 96 затронутых, то есть поддельные блоки разбросаны по ветке, а не идут одной непрерывной серией. Поэтому исправлением должна была стать реорганизация, а не точечное удаление.
Для операторов нод видимый симптом был хуже, чем тихо принятый невалидный блок. Ноды падали на этих блоках, а после перезапуска переставали синхронизироваться. Tron Black с тех пор описал этот механизм, и это аккуратный пример побочного ущерба. Индекс блоков хранит высоту, на которой блок реально находится, а не высоту, объявленную в его заголовке. При перезапуске нода восстанавливает каждый заголовок из индекса и заново вычисляет его хеш, так что для поддельного блока она хеширует реальную высоту там, где заголовок нёс ложную. Хеш больше не совпадает, проверка доказательства работы не проходит, и LoadBlockIndexGuts прерывается. Блок был однажды принят, а потом стал незагружаемым, поэтому второй симптом пережил первый.
Несколько бирж приостановили депозиты и вывод RVN, пока ветка не была разрешена. Это обычная и правильная реакция на цепочку, чья недавняя история вот-вот будет переписана, и её стоит ожидать, а не читать как приговор монете.
Патч и откуда он взялся
Первое исправление вышло 10 августа как версия 4.6.1.1-hf1, и выпустил его 2Miners, майнинговый пул, а не проект Ravencoin. Оно остановило ущерб для всех, кто его установил. Через день его заменил другой выпуск.
Версия 4.8.0, опубликованная 11 августа в репозитории Ravencoin, - это та, которую нужно запускать. Не останавливайтесь на чём-то более раннем. Она отклоняет блоки, начиная с 4 487 776, у которых объявленная в заголовке высота не совпадает с реальным местом, ставит контрольную точку на 4 487 775 как последний чистый блок, автоматически восстанавливает повреждённое состояние цепочки вместо аварийного завершения и включает исправление переполнения при переводе активов, которого не было в экстренном хотфиксе, вместе с другими изменениями из основной ветки.
Ремонтом является только первое из этого. Отклоните блок, чья объявленная высота не совпадает с его реальным местом, и развилка закрыта. Контрольная точка и автоматическое восстановление - это восстановление после сбоя, а контрольная точка особенно грубый инструмент: это жёстко прописанное утверждение, что один конкретный блок правильный, а таких вещей цепочке нужно как можно меньше. Она здесь потому, что честную ветку нужно было закрепить быстрее, чем это сделал бы один хешрейт.
Сама точка восстановления была вопросом суждения, а не очевидным выбором. Tron Black написал, что просил пулы рассмотреть более позднюю точку отсечения, чтобы сделать реорганизацию мельче, а поддельные блоки обработать как исключения. Они отказались и провели самую чистую возможную линию. В системе с доказательством работы такой спор решается сам: когда большинство честного хешрейта отвергает историю, эта история проигрывает, а у двух крупнейших пулов вместе было большинство.
Что на деле означает реорганизация на три дня
Поскольку уязвимые ноды приняли поддельные блоки, ветка с ними накопила поверх себя настоящую цепочку. Отбросить её означает реорганизацию, а при целевом времени блока Ravencoin в одну минуту отрезок от 4 487 776 до патча - это примерно три дня блоков.
Если вы держите RVN, на практике это значит, что транзакции, подтверждённые в отброшенной ветке, перестают быть подтверждёнными. Большинство из них возвращается в mempool и снова попадает в блоки уцелевшей цепочки, но это не гарантировано, а один класс транзакций вернуться не может вообще. Транзакция, потратившая coinbase отброшенного блока, тратит монеты, которых больше нет в уцелевшей цепочке, и то же касается всего, что построено поверх неё. Такие транзакции просто невалидны, а не ожидают подтверждения. Поэтому приостановка бирж была правильным решением, а не перестраховкой, и поэтому к активности после 4 487 775 стоило относиться осторожно, пока не установилась единая вершина цепочки.
Одну деталь полезно знать, если на той неделе вы видели, как нода отказывается двигаться. В нодах Ravencoin есть правило против длинных реорганизаций, которое не даёт внезапной атаке большинства переписать глубокую историю. Большинство давно работающих старых нод в итоге опустились ниже числа пиров, от которого зависит это правило, и тогда приняли более длинную цепочку, как и задумано. Несколько нод этого не сделали, потому что так и не потеряли достаточно пиров и потому что по правилам, как они написаны, ничего нарушено не было. Это защита, которая работает точно по спецификации на проблеме, для которой её никогда не проектировали. Раскол возник из ошибки проверки, а не из атаки на большинство, и правило не умеет их различать.
Что делать, если вы майните RVN
Если вы запускаете собственную ноду Ravencoin, запускайте 4.8.0 и именно её, а не один из более ранних выпусков, которые исправляли лишь часть проблемы. Остановите ноду, замените бинарный файл, запустите снова. Никакого reindex и никаких ручных шагов: программа сама восстанавливает всё, что ей нужно. Нода, переходящая с экстренного хотфикса, перезапускается быстро. Нода, переходящая с непропатченной версии, должна заново воспроизвести и восстановить цепочку, что на обычном оборудовании занимает несколько часов, и ошибки bad-blk-height в течение этого времени означают, что патч работает, а не что в нём неисправность.
Если вы майните через пул, с вашей стороны ничего менять не нужно. Ни нового адреса, ни нового порта, ни обновления майнера. Сам KAWPOW здесь никогда не был проблемой: алгоритм делал ровно то, для чего был создан, а дефект был в коде, который решал, когда его запускать. Хешрейт, который вы направляли на старую ветку, был настоящей работой. Он просто попал в блоки, которые были отброшены вместе с разбросанными среди них поддельными.
Есть способ в одну строку убедиться, что вы оказались на правильной цепочке, и это стоит сделать, а не предполагать. Проверьте хеш блока 4 494 143. В канонической цепочке он заканчивается на 4c67d6a4, и все независимые обозреватели Ravencoin показывали эту же цепочку, когда проект объявил сеть восстановленной.
Форма этой ошибки
Если отбросить частности, останется форма, которая постоянно повторяется, и в блокчейнах, и далеко за их пределами. Значение, пришедшее извне, использовалось для решения о том, насколько тщательно будет проверено это же самое значение. Заголовок сообщал свою высоту, а нода использовала это сообщение, чтобы выбрать, какую проверку доказательства работы этому заголовку затем придётся пройти.
Правильный вариант несложен, и патч по сути одна его строка: сравнить объявленную высоту с местом, которое блок реально занимает, прежде чем позволить ей хоть что-то решать. Интереснее другая половина: почему этого не было. Обходной путь писался для блоков, которым уже доверяют, и внутри этого допущения условие читается как констатация факта, а не как вопрос, на который кто-то может ответить нечестно. Оно перестаёт так читаться в тот момент, когда недоверенному блоку разрешают заполнить это поле, и ничто в коде не отметило момент, когда это изменилось.
О том, как ошибку нашли, стоит записать две вещи. Код Ravencoin проходил профессиональные аудиты безопасности, и они не заметили ни этой ошибки, ни переполнения при переводе активов, исправленного вместе с ней. Tron Black написал, что сильно подозревает: обе были выявлены проверкой безопасности с помощью передовой модели ИИ. Каким бы ни был инструмент, это соответствует форме ошибки: изъян невидим, пока вы читаете обходной путь в его собственной логике, и проявляется, только когда вы спрашиваете, кто может заполнить поле, которому этот путь доверяет.
Последнее, на что стоит обратить внимание: кто действовал и в каком порядке. Майнинговый пул прочитал путь проверки, разобрался в нём и выпустил экстренный бинарный файл за три дня. Затем два крупнейших пула согласились майнить цепочку, отбрасывающую всё окно атаки, вместо удобной точки отсечения, а когда их попросили взять более мелкую, отказались. Проект последовал днём позже с выпуском, который заменяет хотфикс и исправляет больше, чем он. Кто был первым, важно меньше, чем то, что произошло и то и другое, и что окончательно всё решило не одно из объявлений, а накопленный вес реальной работы на одной из сторон раскола. Так выглядит децентрализация в день, когда ей приходится держать нагрузку, а не лежать в белой книге.
Откуда эти цифры
Чтобы любой мог проверить, а не верить нам на слово. Структура заголовка, две хеш-функции и их сигнатуры, выбор эпохи и ветка проверки с её комментарием взяты из исходного кода самого Ravencoin: src/primitives/block.h, src/primitives/block.cpp, src/hash.cpp, src/hash.h, src/pow.h и src/validation.cpp. Список контрольных точек, включая 2 383 550 как самую высокую в основной сети, взят из src/chainparams.cpp. Первая невалидная высота и её метка времени взяты из собственного сетевого уведомления проекта Ravencoin. Путь падения через индекс блоков, зависящие от coinbase транзакции, которые не могут вернуться, поведение правила против длинных реорганизаций, отклонённая более мелкая точка восстановления, замечания об аудитах и проверке с помощью ИИ, содержание 4.8.0 и проверка блока 4 494 143 взяты из отчёта Tron Black для проекта Ravencoin. Версия экстренного хотфикса и число 96 затронутых блоков из 2 089 взяты из выпуска 2Miners и публикаций вокруг него. Всё, чего источники не говорят, в эту статью не включено, а не додумано.