Back to Blog

Анализ атаки: как ошибки сопоставления привели к потере 200 000 000 долларов в Nomad Bridge

Code Auditing
2 августа 2022 г.
7 min read

Резюме

2 августа 2022 года межсетевой мост Nomad Bridge подвергся атаке, что привело к потере почти 200 миллионов долларов. Первопричиной стала некорректная проверка в обновленной версии ончейн-смарт-контракта.

Предыстория

Nomad Bridge — это новый межсетевой мост для активов, использующий архитектуру на основе доказательств мошенничества (fraud-proof). Он работает следующим образом:

  1. Nomad развертывает базовый контракт под названием Replica в каждом поддерживаемом блокчейне в качестве почтового ящика для любых межсетевых сообщений.
  2. Внецепочечные агенты (off-chain agents) ретранслируют и организуют межсетевые сообщения в дерево Меркла и обновляют корень дерева, отправляя подписанный хеш нового корня дерева в этот контракт.
  3. Новые сообщения, которые должны быть подтверждены в блокчейне, должны пройти процедуры prove() и process().
    1. Процедура prove() проверяет сообщение и доказательство в дереве Меркла, а затем помечает сообщение как доказанное (proven).
    2. Процедура process() проверяет и выполняет сообщение, если оно было ранее доказано, а связанный с ним корень дерева подтвержден.

Код

В Ethereum контракт Replica представляет собой Beacon-прокси, развернутый по адресу 0x5d94309e5a0090b165fa4181519701637b6daeba. Существует две версии логического контракта: первая версия развернута по адресу 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b, а вторая — по адресу 0xb92336759618f55bd0f8313bd843604592e27bd8.

Сначала рассмотрим предыдущую версию логического контракта, в частности функцию process():

function process(bytes memory _message) public returns (bool _success) {
    bytes29 _m = _message.ref(0);
    // убедиться, что сообщение предназначалось для этого домена
    require(_m.destination() == localDomain, "!destination");
    // убедиться, что сообщение было доказано
    bytes32 _messageHash = _m.keccak();
    require(messages[_messageHash] == MessageStatus.Proven, "!proven");
    // проверка защиты от повторного входа (re-entrancy guard)
    require(entered == 1, "!reentrant");
    entered = 0;
    // обновить статус сообщения как обработанное
    messages[_messageHash] = MessageStatus.Processed;

Мы привели только часть этой функции. В этом сегменте кода вычисляется хеш сообщения, проверяется по маппингу messages, чтобы убедиться, что сообщение было ранее доказано, затем выполняется проверка от повторного входа, и в конце обновляется статус сообщения.

Также кратко рассмотрим старую функцию prove():

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // убедиться, что сообщение не было доказано или обработано
    require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
    // вычислить ожидаемый корень на основе доказательства
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // если корень валиден, изменить статус на Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = MessageStatus.Proven;
        return true;
    }
    return false;
}

Здесь нет ничего необычного: проверка дублирования, вычисление корня дерева, и если он допустим, то пометка как доказанное. Таким образом, в старой версии контракта Replica существует специальная отметка (MessageStatus.Proven = 1) для всех доказанных сообщений.

Теперь давайте проверим вторую версию логического контракта. Для новой версии сначала рассмотрим функцию prove():

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // убедиться, что сообщение не было обработано
    // Заметьте, что это позволяет повторное доказательство с новым корнем.
    require(
        messages[_leaf] != LEGACY_STATUS_PROCESSED,
        "already processed"
    );
    // вычислить ожидаемый корень на основе доказательства
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // если корень валиден, изменить статус на Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = _calculatedRoot;
        return true;
    }
    return false;
}

Мы сразу заметили здесь серьезное изменение: разработчики почему-то решили записывать вычисленный корень в качестве статуса доказательства вместо использования специальной отметки. Для функции prove() это допустимо, так как гарантируется, что хеш корня дерева Меркла не равен нулю. Это также логично, потому что как только корень дерева подтвержден, любые новые сообщения, доказанные с этим корнем, готовы к выполнению.

Затем мы проверяем функцию process() в новой версии:

function process(bytes memory _message) public returns (bool _success) {
    // убедиться, что сообщение предназначалось для этого домена
    bytes29 _m = _message.ref(0);
    require(_m.destination() == localDomain, "!destination");
    // убедиться, что сообщение было доказано
    bytes32 _messageHash = _m.keccak();
    require(acceptableRoot(messages[_messageHash]), "!proven");
    // проверка защиты от повторного входа
    require(entered == 1, "!reentrant");
    entered = 0;
    // обновить статус сообщения как обработанное
    messages[_messageHash] = LEGACY_STATUS_PROCESSED;

Обратите внимание на строку messages[_messageHash]. Распространенная ловушка заключается в том, что при обращении к несуществующему элементу маппинга возвращается ноль. В данном контексте это означает, что корень дерева Меркла, связанный с этим хешем сообщения, равен нулю. Нам нужно проверить, к чему приведет это нулевое значение. Для этого следует внимательно изучить новую функцию acceptableRoot():

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // это обратная совместимость для сообщений, доказанных/обработанных
    // в предыдущих версиях
    if (_root == LEGACY_STATUS_PROVEN) return true;
    if (_root == LEGACY_STATUS_PROCESSED) return false;

    uint256 _time = confirmAt[_root];
    if (_time == 0) {
        return false;
    }
    return block.timestamp >= _time;
}

По сути, эта функция проверяет маппинг confirmAt, чтобы убедиться, что корень дерева Меркла был подтвержден.

К сожалению, в ОБЕИХ версиях контракта Replica нулевой хеш устанавливается в 1 в функции инициализации:

function initialize(
    uint32 _remoteDomain,
    address _updater,
    bytes32 _committedRoot, // при инициализации он равен нулю
    uint256 _optimisticSeconds
) public initializer {
    __NomadBase_initialize(_updater);
    // установка переменных состояния
    entered = 1;
    remoteDomain = _remoteDomain;
    committedRoot = _committedRoot;
    // предварительное одобрение зафиксированного корня
    confirmAt[_committedRoot] = 1;
    _setOptimisticTimeout(_optimisticSeconds);
}

В старой версии контракта Replica это вполне безопасно: в prove() хеш корня дерева не может быть равен нулю, поэтому безопасно устанавливать значение для нулевого хеша в маппинге confirmAt равным 1.

Однако в новой версии для любого нового сообщения messages[_messageHash] возвращает ноль. Затем acceptableRoot обращается к записи для нулевого хеша в маппинге confirmAt и возвращает true.

Атака

Исходя из анализа кода выше, мы видим, что любое ранее не виденное сообщение может просто пройти через логику валидации и быть выполнено. Таким образом, достаточно подделать сообщение и вызвать process().

Интересно, что первый вызов функции process() в этом контракте произошел всего два дня назад (на блоке 15249565) в транзакции 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.

На рисунке ниже мы видим, что слот хранения (storage slot) для переменной состояния messages для этого сообщения изначально был равен нулю, что означает, что контракт Replica ранее не «знал» об этом сообщении.

Затем этот слот был установлен в значение два (т.е. статус LEGACY_STATUS_PROCESSED), что означает, что это сообщение было успешно обработано. Это указывает на то, что невалидное сообщение обошло логику prove() и было обработано напрямую.

Заключение

Это еще одна классическая атака, использующая непроверенное возвращаемое значение из маппинга. Разработчикам на Solidity следует уделять особое внимание при работе с маппингами, чтобы избежать подобных неожиданных результатов.

Sign up for the latest updates
Потери на сумму около 3,87 млн USD: взломы Autheo Network и UNCX | Еженедельный обзор BlockSec
Security Insights

Потери на сумму около 3,87 млн USD: взломы Autheo Network и UNCX | Еженедельный обзор BlockSec

В этом еженедельном отчёте рассматриваются 3 инцидента безопасности за период 12.10.2026–18.10.2026. Общий оценочный ущерб составил около 3,87 млн USD. Ущерб Autheo Network от компрометации закрытого ключа составил примерно 1,76 млн USD, Qubic Wallet потерял примерно 1,30 млн USD из-за ненадёжной генерации seed, а ошибка учёта в контрактах Smart Token Vesting V2 сети UNCX Network на Ethereum, BNB Chain, Base и Robinhood Chain привела к ущербу примерно в 807 тыс. USD. В подробном анализе инцидента UNCX объясняется, как некорректный расчёт совокупного количества долей смешивал транши вестинга, созданные при разных соотношениях токенов и долей, и позволял выводить из общих пулов больше положенного.

Потеряно около $542 тыс.: эксплуатация уязвимости MakerDaoBot | Еженедельный обзор BlockSec
Security Insights

Потеряно около $542 тыс.: эксплуатация уязвимости MakerDaoBot | Еженедельный обзор BlockSec

В Ethereum за 05–11.10.2026 произошёл 1 инцидент; ущерб — около $542 тыс. Атакующий передал вредоносный адаптер функции MakerDaoBot без контроля доступа. Получив права на балансы MakerDAO, адаптер закрыл 4 аукциона ETH-A, конвертировал 200 ETH-A в WETH; атакующий вывел 200 ETH.

~418 млн $ потеряно: эксплойты Bitget и NEAR Intents | BlockSec Weekly
Security Insights

~418 млн $ потеряно: эксплойты Bitget и NEAR Intents | BlockSec Weekly

Этот отчёт за две недели фиксирует 8 инцидентов с 21 сентября по 4 октября 2026 г., убытки ≈$418,4М в Ethereum, BNB Chain, Solana, Bitcoin, TRON, XRP Ledger, Zcash, Avalanche, NEAR. Разбор: уязвимость продукта безопасности Bitget ($387,5М) и ошибка NEAR Intents ($3,87М).

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit