Back to Blog

Análise de Ataque | Como um Mapeamento Sem Verificação Causou Perdas de $200.000.000 na Nomad Bridge

Code Auditing
August 2, 2022
7 min read

Resumo

Em 2 de agosto de 2022, uma ponte cross-chain chamada Nomad Bridge foi atacada, resultando na perda de quase $200M. A causa raiz é a verificação incorreta na versão atualizada do contrato inteligente on-chain.

O Contexto

A Nomad Bridge é uma ponte de ativos cross-chain emergente que utiliza um design baseado em prova de fraude. Ela funciona da seguinte forma:

  1. A Nomad implanta um contrato principal chamado Replica em cada blockchain suportada como caixa de correio para quaisquer mensagens cross-chain.
  2. Agentes off-chain retransmitem e organizam mensagens cross-chain em uma árvore de Merkle e atualizam a raiz da árvore publicando o novo hash de raiz assinado neste contrato.
  3. Novas mensagens que precisam ser confirmadas on-chain devem passar pelos procedimentos prove() e process().
    1. O procedimento prove() verifica a mensagem e a prova na árvore de Merkle e, em seguida, marca a mensagem como provada.
    2. O procedimento process() verifica e executa a mensagem se ela foi previamente provada e a raiz da árvore associada foi confirmada.

O Código

No Ethereum, a Replica é um proxy Beacon implantado em 0x5d94309e5a0090b165fa4181519701637b6daeba. Existem duas versões do contrato lógico: a primeira versão implantada em 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b, e a segunda versão implantada em 0xb92336759618f55bd0f8313bd843604592e27bd8.

Primeiro verificamos a versão anterior do contrato lógico, especificamente a função process():

function process(bytes memory _message) public returns (bool _success) {
    bytes29 _m = _message.ref(0);
    // ensure message was meant for this domain
    require(_m.destination() == localDomain, "!destination");
    // ensure message has been proven
    bytes32 _messageHash = _m.keccak();
    require(messages[_messageHash] == MessageStatus.Proven, "!proven");
    // check re-entrancy guard
    require(entered == 1, "!reentrant");
    entered = 0;
    // update message status as processed
    messages[_messageHash] = MessageStatus.Processed;

Mostramos apenas uma parte desta função. Neste trecho de código, o hash da mensagem é calculado e verificado no mapeamento messages para garantir que a mensagem foi previamente provada, em seguida a verificação de reentrância e, por fim, a atualização do status da mensagem.

Também revisamos brevemente a função prove() antiga:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // ensure that message has not been proven or processed
    require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
    // calculate the expected root based on the proof
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // if the root is valid, change status to Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = MessageStatus.Proven;
        return true;
    }
    return false;
}

Nada de especial aqui: verificação de duplicidade, cálculo da raiz da árvore e, se aceitável, marcação como provada. Portanto, na versão antiga do contrato Replica, há uma marcação especial (MessageStatus.Proven = 1) para todas as mensagens que foram provadas.

Então vamos verificar a segunda versão do contrato lógico. Para a nova versão, primeiro verificamos a função prove():

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // ensure that message has not been processed
    // Note that this allows re-proving under a new root.
    require(
        messages[_leaf] != LEGACY_STATUS_PROCESSED,
        "already processed"
    );
    // calculate the expected root based on the proof
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // if the root is valid, change status to Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = _calculatedRoot;
        return true;
    }
    return false;
}

Imediatamente notamos uma mudança importante: por algum motivo, os desenvolvedores decidiram registrar a raiz calculada como o status de provado em vez de uma marcação especial. Para esta função está tudo bem, pois o hash da raiz da árvore de Merkle é garantido como não sendo zero. Também é razoável porque, assim que a raiz da árvore é confirmada, qualquer nova mensagem provada com essa raiz está pronta para ser executada.

Em seguida, verificamos a função process() na nova versão:

function process(bytes memory _message) public returns (bool _success) {
    // ensure message was meant for this domain
    bytes29 _m = _message.ref(0);
    require(_m.destination() == localDomain, "!destination");
    // ensure message has been proven
    bytes32 _messageHash = _m.keccak();
    require(acceptableRoot(messages[_messageHash]), "!proven");
    // check re-entrancy guard
    require(entered == 1, "!reentrant");
    entered = 0;
    // update message status as processed
    messages[_messageHash] = LEGACY_STATUS_PROCESSED;

Notamos a linha messages[_messageHash]. É uma armadilha comum que a recuperação de uma entrada inexistente em um mapeamento retorne zero. Neste contexto, isso significa que a raiz da árvore de Merkle associada a este hash de mensagem é zero. Precisamos verificar mais detalhadamente o resultado desse zero. Portanto, devemos analisar cuidadosamente a nova função acceptableRoot().

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // this is backwards-compatibility for messages proven/processed
    // under previous versions
    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;
}

Basicamente, esta função verifica o mapeamento confirmAt para verificar se a raiz da árvore de Merkle foi confirmada.

Infelizmente, em AMBAS as versões do contrato Replica, o hash zero é definido como 1 no inicializador:

function initialize(
    uint32 _remoteDomain,
    address _updater,
    bytes32 _committedRoot, // this is zero at initialization
    uint256 _optimisticSeconds
) public initializer {
    __NomadBase_initialize(_updater);
    // set storage variables
    entered = 1;
    remoteDomain = _remoteDomain;
    committedRoot = _committedRoot;
    // pre-approve the committed root.
    confirmAt[_committedRoot] = 1;
    _setOptimisticTimeout(_optimisticSeconds);
}

Na versão antiga do contrato Replica isso é totalmente correto: em prove() nenhum hash de raiz de árvore pode ser zero, portanto é seguro definir a entrada do hash zero como 1 no mapeamento confirmAt.

Na nova versão, no entanto, para uma nova mensagem o messages[_messageHash] retorna zero. Em seguida, acceptableRoot acessará a entrada do hash zero no mapeamento confirmAt e retornará verdadeiro.

O Ataque

Com base na análise do código acima, sabemos que qualquer mensagem não vista anteriormente pode simplesmente passar pela lógica de validação e ser executada. Portanto, basta forjar uma mensagem e chamar process().

Curiosamente, a primeira chamada à função process() neste contrato ocorreu há apenas dois dias (no bloco 15249565) em 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.

Na figura a seguir, podemos ver que o slot de armazenamento para a variável de estado messages desta mensagem era originalmente zero, o que significa que o contrato Replica não conhecia esta mensagem anteriormente.

Em seguida, este slot foi definido como dois (ou seja, o status LEGACY_STATUS_PROCESSED, indicando que esta mensagem foi processada). Isso indica que uma mensagem inválida contornou a lógica de prove() e foi processada diretamente.

Conclusão

Este é mais um ataque clássico que explora o valor de retorno não verificado recuperado de um mapeamento. Desenvolvedores Solidity devem prestar atenção especial ao lidar com mapeamentos para evitar resultados inesperados.

Best Security Auditor for Web3

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

BlockSec Audit