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:
- A Nomad implanta um contrato principal chamado Replica em cada blockchain suportada como caixa de correio para quaisquer mensagens cross-chain.
- 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.
- Novas mensagens que precisam ser confirmadas on-chain devem passar pelos procedimentos
prove()eprocess().- O procedimento
prove()verifica a mensagem e a prova na árvore de Merkle e, em seguida, marca a mensagem como provada. - O procedimento
process()verifica e executa a mensagem se ela foi previamente provada e a raiz da árvore associada foi confirmada.
- O procedimento
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.



