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;
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;
}
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;
}
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;
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;
}
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);
}
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.
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.
Latest comments (0)