Back to Blog

Análisis de Ataque | Cómo el Mapeo sin Verificación Causó Pérdidas de $200,000,000 en Nomad Bridge

Code Auditing
August 2, 2022
7 min read

Resumen

El 2 de agosto de 2022, un puente entre cadenas llamado Nomad Bridge fue atacado, lo que provocó la pérdida de casi 200 millones de dólares. La causa raíz es la verificación incorrecta en la versión actualizada del contrato inteligente en cadena.

El Contexto

Nomad Bridge es un puente de activos entre cadenas emergente que utiliza un diseño basado en pruebas de fraude. Funciona de la siguiente manera:

  1. Nomad despliega un contrato central llamado Replica en cada blockchain compatible como buzón para cualquier mensaje entre cadenas.
  2. Los agentes fuera de cadena retransmiten y organizan los mensajes entre cadenas en un árbol de Merkle y actualizan la raíz del árbol publicando el nuevo hash de la raíz del árbol firmado en este contrato.
  3. Los nuevos mensajes que necesitan ser confirmados en cadena deben pasar por los procedimientos prove() y process().
    1. El procedimiento prove() verifica el mensaje y la prueba en el árbol de Merkle, luego marca el mensaje como probado.
    2. El procedimiento process() verifica y ejecuta el mensaje si este fue previamente probado y la raíz del árbol asociada está confirmada.

El Código

En Ethereum, el Replica es un proxy Beacon desplegado en 0x5d94309e5a0090b165fa4181519701637b6daeba. Existen dos versiones del contrato lógico: la primera versión desplegada en 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b, y la segunda versión desplegada en 0xb92336759618f55bd0f8313bd843604592e27bd8.

Primero revisamos la versión anterior del contrato lógico, específicamente la función 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;

Solo mostramos una parte de esta función. En este segmento de código, se calcula el hash del mensaje y se verifica contra el mapeo messages para asegurarse de que este mensaje haya sido previamente probado, luego la verificación de reentrada y la actualización del estado del mensaje.

También revisamos brevemente la antigua función 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 especial aquí: verificación de duplicados, cálculo de la raíz del árbol y, si es aceptable, se marca como probado. Entonces, en la versión anterior del contrato Replica, hay una marca especial (MessageStatus.Proven = 1) para todos los mensajes que están probados.

Luego revisamos la segunda versión del contrato lógico. Para la nueva versión, primero verificamos la función 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;
}

Inmediatamente notamos un cambio importante: por alguna razón, los desarrolladores decidieron registrar la raíz calculada como estado probado en lugar de una marca especial. Para esta función es correcto porque se garantiza que el hash de la raíz del árbol de Merkle no sea cero. También es razonable porque en cuanto se confirma la raíz del árbol, cualquier nuevo mensaje probado con esta raíz está listo para ejecutarse.

Luego revisamos la función process() en la nueva versión:

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 la línea messages[_messageHash]. Es un error común que recuperar una entrada inexistente de un mapeo devuelva cero. En este contexto significa que la raíz del árbol de Merkle asociada con este hash de mensaje es cero. Necesitamos verificar más a fondo el resultado de este cero. Por lo tanto, debemos revisar cuidadosamente la nueva función 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;
}

Básicamente, esta función verifica el mapeo confirmAt para comprobar si la raíz del árbol de Merkle ha sido confirmada.

Desafortunadamente, en AMBAS versiones del contrato Replica, el hash cero se establece en 1 en el 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);
}

En la versión anterior del contrato Replica esto es completamente correcto: en prove() ningún hash de raíz del árbol puede ser cero, por lo que es seguro establecer la entrada del hash cero en 1 en el mapeo confirmAt.

Sin embargo, en la nueva versión, para un nuevo mensaje, messages[_messageHash] devuelve cero. Luego acceptableRoot accederá a la entrada del hash cero en el mapeo confirmAt y devolverá verdadero.

El Ataque

A partir del análisis del código anterior, sabemos que cualquier mensaje no visto previamente puede simplemente pasar la lógica de validación y ejecutarse. Así que basta con falsificar un mensaje y llamar a process().

Curiosamente, la primera llamada a la función process() en este contrato fue hace solo dos días (en el bloque 15249565) en 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.

En la siguiente figura, podemos ver que el slot de almacenamiento para la variable de estado messages de este mensaje era originalmente cero, lo que significa que el contrato Replica no conocía este mensaje previamente.

Luego este slot se estableció en dos (es decir, el estado LEGACY_STATUS_PROCESSED, lo que significa que este mensaje ha sido procesado). Esto indica que un mensaje inválido eludió la lógica de prove() y fue procesado directamente.

Conclusión

Este es otro ataque clásico que explota el valor de retorno no verificado obtenido de un mapeo. Los desarrolladores de Solidity deben prestar especial atención al trabajar con mapeos 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