WEB3DEV

Cover image for Análise de Ataque | Como o mapping não verificado causa perdas de $ 200 milhões de dólares na Nomad Bridge
Banana Labs
Banana Labs

Posted on

Análise de Ataque | Como o mapping não verificado causa perdas de $ 200 milhões de dólares na Nomad Bridge

Esse artigo é uma tradução de BlockSec Team feita por @bananlabs. Você pode encontrar o artigo original aqui

Em 2 de agosto de 2022, uma cross-chain bridge chamada Nomad Bridge foi atacada, levando à perda de quase $200 milhões de dólares. A causa raiz é a verificação incorreta na versão atualizada do contrato inteligente on-chain (dentro da cadeia).

O Pano de Fundo

A Nomad Bridge é uma emergente cross-chain bridge de ativos que usa um design à prova de fraude. Funciona da seguinte forma:

1.Nomad implanta um contrato principal chamado Replica em cada blockchain compatível como caixa de correio para qualquer mensagem cross-chain.

2.Agentes off-chain (fora da cadeia) retransmitem e organizam mensagens cross-chain em uma árvore Merkle e atualizam a raiz da árvore postando o novo hash da raiz da árvore assinado para este contrato.

3.Novas mensagens que precisam ser confirmadas on-chain (dentro da cadeia) devem passar pelo procedimento prove() e process().

1) O procedimento prove() verifica a mensagem e a prova na árvore Merkle, então marca a mensagem como comprovada.

2) O procedimento process() verifica e executa a mensagem se a mensagem for previamente comprovada e a raiz da árvore associada for 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;
Enter fullscreen mode Exit fullscreen mode

Mostramos apenas uma parte desta função. Neste segmento de código, o hash da mensagem é calculado, e o hash é verificado em relação ao messages mapping para garantir que essa mensagem tenha sido comprovada anteriormente, em seguida, a verificação de reentrância e, depois, atualizar o status da mensagem.

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

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;
}
Enter fullscreen mode Exit fullscreen mode

Nada de especial aqui: verificação de duplicação, calcular a raiz da árvore, se aceitável, então marcar como provado. Portanto, na versão antiga do contrato Replica, há uma marca especial (MessageStatus.Proven = 1) para todas as mensagens que são comprovadas.

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;
}
Enter fullscreen mode Exit fullscreen mode

Imediatamente notamos uma grande mudança aqui: por algum motivo, os desenvolvedores decidiram registrar a raiz calculada como o status comprovado em vez de uma marca especial. Para esta função está tudo bem porque o hash raiz da árvore Merkle é garantido que não é zero. Também é razoável porque assim que a raiz da árvore for confirmada, todas as novas mensagens comprovadas com essa raiz da árvore estarão prontas para serem executadas.

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;
Enter fullscreen mode Exit fullscreen mode

Notamos a linha messages[_messageHash]. É uma armadilha comum que a recuperação de uma entrada de mapping inexistente retorne zero. Neste contexto, significa que a raiz da árvore Merkle associada a este hash de mensagem é zero. Precisamos verificar ainda mais o resultado desse zero. Portanto, devemos verificar 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;
}
Enter fullscreen mode Exit fullscreen mode

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

Infelizmente, em AMBAS a versão 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);
}
Enter fullscreen mode Exit fullscreen mode

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

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

O Ataque

A partir da análise de código acima, sabemos que qualquer mensagem não vista anteriormente pode simplesmente passar pela lógica de validação e ser executada. Então apenas forje uma mensagem e chame process().

Curiosamente, a primeira chamada para a função process() neste contrato é apenas dois dias atrás (no bloco 15249565) em 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.

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

output replica contract

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

Conclusão

Este é outro ataque clássico que explora o valor de retorno não verificado recuperado de um mapping. Os desenvolvedores que utilizam Solidity devem prestar atenção especial ao lidar com mappings para evitar resultados inesperados.

Oldest comments (0)