WEB3DEV Español

Cover image for La Espada de doble filo de abi.decode
Hector
Hector

Posted on

La Espada de doble filo de abi.decode

Este artículo es una traducción de Xdeadbeefx, hecha por Héctor Botero. Puedes encontrar el artículo original aquí.
Sería genial escucharte en nuestro Discord, puedes contarnos tus ideas, comentarios, sugerencias y dejarnos saber lo que necesitas.
Si prefieres puedes escribirnos a @web3dev_eshttps://twitter.com/web3dev_es en Twitter.

El decoder Solidity ABI es ampliamente usado en protocolos de nivel superior. En este blog, entenderemos cómo funciona en el fondo y cómo puede ser aprovechado por actores maliciosos para explotar protocolos.

Antes de sumergirnos en los detalles internos de abi.decode, es importante entender su rol en la decodificación de mensajes personalizados revertidos.

*Razones de reversión personalizados *

Revirtiendo con una razón personalizada

Solidity ha sacado un post de un blog detallando cómo, eficientemente, revertir con una razón personalizada. El post incluye un ejemplo de bloque de montaje que puede ser usado para el retorno de un mensaje de error personalizado:

let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(add(free_mem_ptr, 4), 32)
mstore(add(free_mem_ptr, 36), 12)
mstore(add(free_mem_ptr, 68), "Unauthorized")
revert(free_mem_ptr, 100)
Enter fullscreen mode Exit fullscreen mode
  1. Carga la siguiente dirección disponible desde el puntero de memoria libre.
  2. Bytes 0-32: el selector de “Error(string)” a los primeros 32 bytes (0x08c379a0).
  3. Bytes 4-36: el offset de la longitud de la cadena de error.
  4. Bytes 36-68: longitud de la cadera (12).
  5. Bytes 68-100: la cadena en sí misma (“Unauthorized”).

Esencialmente, usando YUL en cualquier contrato, puede ser escrito para ser revertido con cualquier dato arbitrario.

Analizando la razón de la reversión

Es común analizar la razón de la reversión en la función de llamada usando la siguiente implementación. Esta implementación puede ser encontrada en protocolos populares como UniswapV3:

(bool success, bytes memory result) = address(target).call(data);

if (!success) {
   // Las siguientes 5 líneas son de  https://ethereum.stackexchange.com/a/83577
   if (result.length < 68) revert();
   assembly {
       result := add(result, 0x04)
   }
   revert(abi.decode(result, (string)));
}
Enter fullscreen mode Exit fullscreen mode

En el fragmento del código anterior, los datos que retornaron de la invocación de bajo nivel son apuntados en “results” y el código añade 4 bytes al offset de los resultados apuntados.

Este paso es necesario para alinear los datos para que abi.decode sea capaz de decodificar la razón para revertir la cadena e ignorar el selector de los 4 bytes “0x08c379a0” el cual es el selector de “Error(string)”.

Jugando con la memoria de los punteros

Aunque nos ayuda a decodificar correctamente la razón de la reversión, también hace que los datos codificados sean largos, los cuales aprovecharemos para explotarlos.

Date cuenta que el resultado de esta llamada es del tipo “bytes memory”:

(bool success, bytes memory result) = address(target).call(data);
Enter fullscreen mode Exit fullscreen mode

Los “bytes memory” de los primeros 32 bytes, apuntan a la longitud de su propia carga.

Aplicando el cambio de abajo, los primeros 32 bytes incluirán los bytes desde el offset [32-36] los cuales son 0x08c379a0. En este caso, el ´result.length´ será muy alto (longitud original + 0x08c379a0)

result := add(result, 0x04)
Enter fullscreen mode Exit fullscreen mode

“Results” antes del cambio

[0] 0000000000000000000000000000000000000000000000000000000000000064
[1] 08c379a000000000000000000000000000000000000000000000000000000000
[2] 0000002000000000000000000000000000000000000000000000000000000000
[3] 0000000c556e617574686f72697a656400000000000000000000000000000000
[4] 0000000000000000000000000000000000000000000000000000000000000000
Enter fullscreen mode Exit fullscreen mode

“Results” luego del cambio

[0] 0000000000000000000000000000000000000000000000000000006408c379a0
[1] 0000000000000000000000000000000000000000000000000000000000000020
[2] 000000000000000000000000000000000000000000000000000000000000000c
[3] 556e617574686f72697a65640000000000000000000000000000000000000000
[4] 0000000000000000000000000000000000000000000000000000000000000000
Enter fullscreen mode Exit fullscreen mode

Luego de alinearlo, los datos son correctamente formateados y serán enviados a abi.decode. La longitud de los datos son 0x6408c379a0.

¿Qué hemos aprendido hasta ahora?

  1. Los contratos invocados pueden ser revertidos con cualquier dato retornado arbitrariamente.
  2. Cambiando el selector “Error(string)” hace que incremente el tamaño de los bytes a un valor más largo.

¿Qué puede ir mal?

Muchos protocolos incorporan retransmisores o porteros que ejecutan transacciones y devolución de llamadas y les pagan por el costo de la ejecución. Estos roles, típicamente, requieren que las invocaciones devueltas no sean revertidas. Los protocolos implementan revisiones de validación para asegurarse de esto.

Un ejemplo de cómo es vulnerable un protocolo, se verá así:

contract Protocol {
   event CallbackFailed(string reason);

   function executeSomethingForUser(address callback) external {
       try ICallback(callback).callbackFunc(0) {
       } catch (bytes memory reasonBytes) {
           // Las siguientes 5 líneas Next 5 lines son de https://ethereum.stackexchange.com/a/83577
           if (reasonBytes.length < 68) revert();
           assembly {
               reasonBytes := add(reasonBytes, 0x04)
           }
           emit CallbackFailed(abi.decode(reasonBytes, (string)));
       }
       // Ser pagado por la ejecución 
   }
}
Enter fullscreen mode Exit fullscreen mode

Aunque decodificar la razón de la reversión puede parecer que está funcionando como es esperado, hay una vulnerabilidad que puede ser explotada. Específicamente, si un atacante puede causar que la función abi.decode en el bloque de captura sea revertida o se consuma una cantidad de gas considerable, entonces esto llevará a la vulnerabilidad donde el usuario controla cuando la ejecución sea realizada exitosamente o drena los fondos de la invocación.

Para poder romper el código de arriba, veamos el fondo de abi.decode

Sumergiéndonos en abi.decode

El decodificador de Solidity ABI es difícil de entender desde el nivel del código fuente. Es fácil desensamblar los códigos de los bytes para entender, exactamente, cómo funciona.

Hay algunas repeticiones opcode que podemos ignorar y usar rattle nos ayudará a realizar análisis estáticos binarios en una forma legible.

Desensamblaremos el siguiente contrato para entender cómo abi.decode funciona:

pragma solidity 0.8.17;

contract test {
   function testDecode() external {
       bytes memory result = abi.encode("aaaa");
       abi.decode(result, (string));
   }
}
Enter fullscreen mode Exit fullscreen mode

Acuérdate, nuestro objetivo es identificar cómo abi.decode funciona:

  1. Revertir.
  2. Consumir una gran cantidad de gas.

Abajo, hay una captura de los opcodes generados donde abi.decode es invocado:

Las tres flechas rojas apuntan a tres diferentes caminos revertidos y, la última flecha verde, apunta al bloque donde grandes consumos de gas puede suceder.

Reversiones

De acuerdo con el ensamblaje, los tres caminos a revertir, se revierten cuando:

  1. El offset de la carga es menor a 32 bytes.
  2. El offset de la carga es mayor que 0xffffffffffffffff el cual es el final de la memoria apilada del evm (uint64).
  3. El offset de la carga es mayor que la longitud de los datos codificados + 0x1f.

Un actor malicioso puede almacenar cualquier valor que cumpla con alguno de los tres caminos revertidos, en los datos que retornan para que abi.decode se revierta.

Ejemplo de aprovechamiento #2:

let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(add(free_mem_ptr, 4), 0xfffffffffffffffff)
mstore(add(free_mem_ptr, 36), 12)
mstore(add(free_mem_ptr, 68), "Unauthorized")
revert(free_mem_ptr, 100)
Enter fullscreen mode Exit fullscreen mode

Bomba de gas

Ahora que hemos visto cómo hacer que abi.decode se revierta, veamos cómo un actor malicioso puede explotar abi.decode para drenar los fondos de los protocolos de los retransmisores o porteros, creando una bomba de gas.

Como hemos aprendido antes, cambiar los datos de retorno en 4 bytes, crea una larga longitud para los datos codificados.

Por lo tanto, un atacante puede crear un offset de carga que esté justo debajo del tamaño de los datos codificados para desperdiciar gas pero no hacer que abi.decode se revierta.

Abi.decode intentará MLOAD desde la memoria, con un offset muy grande el cual desencadenará una expansión de la memoria que consume mucho gas.

Puedes leer más sobre las expansiones de la memoria aquí: https://www.evm.codes/about#memoryexpansion

El MLOAD opcode carga desde el offset para obtener la longitud de la carga

Proporcionando un valor offset que esté justo debajo de la longitud de los datos codificados, la cantidad de gas que será consumido en el MLOAD es más que el límite del bloque de gas. Un ejemplo de un valor offset es “0x6408c37900”.

Un actor malicioso puede poner la siguiente carga para crear una bomba de gas que hará que sea el máximo de todo el gas suministrado a la transacción.

let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(add(free_mem_ptr, 4), 0x6408c37900) // just below len
mstore(add(free_mem_ptr, 36), 12)
mstore(add(free_mem_ptr, 68), "Unauthorized")
revert(free_mem_ptr, 100)
Enter fullscreen mode Exit fullscreen mode

Alternativamente, si el actor malicioso están intentando drenar la transacción del gas y NO causa una reversión “sin gas”, la reversión puede calcular un offset dinámico basado en el gas restante (usando la función gasleft() con el fin de desperdiciar casi todo el gas sin causar una reversión.

Prevención

Es importante validar los datos retornados desde una función de invocación que haya sido revertida

  1. Valida que el selector xxx sea el primero de los 4 bytes de los datos.
  2. Valida que el offset de la carga esté limitado en un número bajo como 200.
  3. Valida que la longitud de la cadena sea menor que el tamaño de los datos codificados desde el offset.

Resumen

Entender cómo funciones por defectos como el decodificador ABI y el codificador trabajan en el fondo, es esencial para mantener el ecosistema de la Web3 seguro.

Si tienes pregunta, siéntete libre de contactarme en twitter: https://twitter.com/0xdeadbeef____

Discussion (0)