Ingeniería inversa de un contrato
Introducción
No hay secretos en la cadena de bloques, todo lo que sucede es consistente, verificable y está disponible públicamente. Idealmente, los contratos deberían tener su código fuente publicado y verificado en Etherscan (opens in a new tab). Sin embargo, ese no es siempre el caso (opens in a new tab). En este artículo aprenderás cómo aplicar ingeniería inversa a los contratos analizando un contrato sin código fuente, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).
Existen descompiladores inversos, pero no siempre producen resultados utilizables (opens in a new tab). En este artículo aprenderás cómo aplicar ingeniería inversa manualmente y entender un contrato a partir de los códigos de operación (opens in a new tab), así como a interpretar los resultados de un descompilador.
Para poder entender este artículo, ya deberías conocer los conceptos básicos de la EVM y estar al menos algo familiarizado con el ensamblador de la EVM. Puedes leer sobre estos temas aquí (opens in a new tab).
Preparar el código ejecutable
Puede obtener los códigos de operación yendo a Etherscan para el contrato, haciendo clic en la pestaña Contract y luego en Switch to Opcodes View. Obtendrá una vista que muestra un código de operación por línea.
Sin embargo, para poder entender los saltos, necesita saber en qué parte del código se encuentra cada código de operación. Para hacerlo, una forma es abrir una hoja de cálculo de Google y pegar los códigos de operación en la columna C. Puede omitir los siguientes pasos haciendo una copia de esta hoja de cálculo ya preparada (opens in a new tab).
El siguiente paso es obtener las ubicaciones correctas del código para que podamos entender los saltos. Pondremos el tamaño del código de operación en la columna B y la ubicación (en hexadecimal) en la columna A. Escriba esta función en la celda B1 y luego cópiela y péguela en el resto de la columna B, hasta el final del código. Después de hacer esto, puede ocultar la columna B.
=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)
Primero, esta función añade un byte para el propio código de operación y luego busca PUSH. Los códigos de operación PUSH son especiales porque necesitan tener bytes adicionales para el valor que se está insertando. Si el código de operación es un PUSH, extraemos el número de bytes y lo sumamos.
En A1 ponga el primer desplazamiento, cero. Luego, en A2, ponga esta función y vuelva a copiarla y pegarla para el resto de la columna A:
=dec2hex(hex2dec(A1)+B1)
Necesitamos que esta función nos dé el valor hexadecimal porque los valores que se insertan antes de los saltos (JUMP y JUMPI) se nos dan en hexadecimal.
El punto de entrada (0x00)
Los contratos siempre se ejecutan desde el primer byte. Esta es la parte inicial del código:
| Offset | Código de operación | Pila (después del código de operación) |
|---|---|---|
| 0 | PUSH1 0x80 | 0x80 |
| 2 | PUSH1 0x40 | 0x40, 0x80 |
| 4 | MSTORE | Vacía |
| 5 | PUSH1 0x04 | 0x04 |
| 7 | CALLDATASIZE | CALLDATASIZE 0x04 |
| 8 | LT | CALLDATASIZE<4 |
| 9 | PUSH2 0x005e | 0x5E CALLDATASIZE<4 |
| C | JUMPI | Vacía |
Este código hace dos cosas:
- Escribe 0x80 como un valor de 32 bytes en las ubicaciones de memoria 0x40-0x5F (0x80 se almacena en 0x5F, y de 0x40 a 0x5E son todo ceros).
- Lee el tamaño de los datos de llamada. Normalmente, los datos de llamada para un contrato de Ethereum siguen la ABI (interfaz binaria de aplicación) (opens in a new tab), que como mínimo requiere cuatro bytes para el selector de función. Si el tamaño de los datos de llamada es menor a cuatro, salta a 0x5E.
El manejador en 0x5E (para datos de llamada que no son de la ABI)
| Offset | Código de operación |
|---|---|
| 5E | JUMPDEST |
| 5F | CALLDATASIZE |
| 60 | PUSH2 0x007c |
| 63 | JUMPI |
Este fragmento comienza con un JUMPDEST. Los programas de la EVM (máquina virtual de Ethereum) lanzan una excepción si se salta a un código de operación que no sea JUMPDEST. Luego, observa el CALLDATASIZE, y si es "verdadero" (es decir, no es cero) salta a 0x7C. Llegaremos a eso más adelante.
| Offset | Código de operación | Pila (después del código de operación) |
|---|---|---|
| 64 | CALLVALUE | proporcionado por la llamada. Llamado msg.value en Solidity |
| 65 | PUSH1 0x06 | 6 CALLVALUE |
| 67 | PUSH1 0x00 | 0 6 CALLVALUE |
| 69 | DUP3 | CALLVALUE 0 6 CALLVALUE |
| 6A | DUP3 | 6 CALLVALUE 0 6 CALLVALUE |
| 6B | SLOAD | Storage[6] CALLVALUE 0 6 CALLVALUE |
Por lo tanto, cuando no hay datos de llamada, leemos el valor de Storage[6]. Aún no sabemos cuál es este valor, pero podemos buscar transacciones que el contrato haya recibido sin datos de llamada. Las transacciones que simplemente transfieren ETH sin ningún dato de llamada (y, por lo tanto, sin método) tienen en Etherscan el método Transfer. De hecho, la primera transacción que recibió el contrato (opens in a new tab) es una transferencia.
Si observamos esa transacción y hacemos clic en Click to see More (Hacer clic para ver más), vemos que los datos de llamada, denominados datos de entrada (input data), están efectivamente vacíos (0x). Observe también que el valor es 1.559 ETH, lo cual será relevante más adelante.
A continuación, haga clic en la pestaña State (Estado) y expanda el contrato al que le estamos aplicando ingeniería inversa (0x2510...). Puede ver que Storage[6] sí cambió durante la transacción, y si cambia Hex a Number (Número), verá que se convirtió en 1,559,000,000,000,000,000, el valor transferido en wei (agregué las comas para mayor claridad), correspondiente al siguiente valor del contrato.
Si observamos los cambios de estado causados por otras transacciones Transfer del mismo período (opens in a new tab), vemos que Storage[6] rastreó el valor del contrato durante un tiempo. Por ahora lo llamaremos Value*. El asterisco (*) nos recuerda que aún no sabemos qué hace esta variable, pero no puede ser solo para rastrear el valor del contrato porque no hay necesidad de usar el almacenamiento, que es muy costoso, cuando puede obtener el saldo de sus cuentas usando ADDRESS BALANCE. El primer código de operación introduce en la pila la propia dirección del contrato. El segundo lee la dirección en la parte superior de la pila y la reemplaza con el saldo de esa dirección.
| Offset | Código de operación | Pila |
|---|---|---|
| 6C | PUSH2 0x0075 | 0x75 Value* CALLVALUE 0 6 CALLVALUE |
| 6F | SWAP2 | CALLVALUE Value* 0x75 0 6 CALLVALUE |
| 70 | SWAP1 | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 71 | PUSH2 0x01a7 | 0x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 74 | JUMP |
Continuaremos rastreando este código en el destino del salto.
| Offset | Código de operación | Pila |
|---|---|---|
| 1A7 | JUMPDEST | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1A8 | PUSH1 0x00 | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AA | DUP3 | CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AB | NOT | 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
El NOT es a nivel de bits, por lo que invierte el valor de cada bit en el valor de la llamada.
| Offset | Código de operación | Pila |
|---|---|---|
| 1AC | DUP3 | Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AD | GT | Value*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AE | ISZERO | Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AF | PUSH2 0x01df | 0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1B2 | JUMPI |
Saltamos si Value* es menor que 2^256-CALLVALUE-1 o igual a este. Esto parece lógica para prevenir el desbordamiento. Y, de hecho, vemos que después de algunas operaciones sin sentido (escribir en la memoria está a punto de eliminarse, por ejemplo) en el offset 0x01DE, el contrato se revierte si se detecta el desbordamiento, lo cual es un comportamiento normal.
Tenga en cuenta que tal desbordamiento es extremadamente improbable, porque requeriría que el valor de la llamada más Value* fuera comparable a 2^256 wei, aproximadamente 10^59 ETH. El suministro total de ETH, al momento de escribir este artículo, es de menos de doscientos millones (opens in a new tab).
| Offset | Código de operación | Pila |
|---|---|---|
| 1DF | JUMPDEST | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E0 | POP | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E1 | ADD | Value*+CALLVALUE 0x75 0 6 CALLVALUE |
| 1E2 | SWAP1 | 0x75 Value*+CALLVALUE 0 6 CALLVALUE |
| 1E3 | JUMP |
Si llegamos aquí, obtenemos Value* + CALLVALUE y saltamos al offset 0x75.
| Offset | Código de operación | Pila |
|---|---|---|
| 75 | JUMPDEST | Value*+CALLVALUE 0 6 CALLVALUE |
| 76 | SWAP1 | 0 Value*+CALLVALUE 6 CALLVALUE |
| 77 | SWAP2 | 6 Value*+CALLVALUE 0 CALLVALUE |
| 78 | SSTORE | 0 CALLVALUE |
Si llegamos aquí (lo que requiere que los datos de llamada estén vacíos) sumamos a Value* el valor de la llamada. Esto es consistente con lo que decimos que hacen las transacciones Transfer.
| Offset | Código de operación |
|---|---|
| 79 | POP |
| 7A | POP |
| 7B | STOP |
Finalmente, limpiamos la pila (lo cual no es necesario) y señalamos el final exitoso de la transacción.
Para resumir todo, aquí hay un diagrama de flujo para el código inicial.
El manejador en 0x7C
A propósito, no puse en el encabezado lo que hace este manejador. El objetivo no es enseñarte cómo funciona este contrato específico, sino cómo aplicar ingeniería inversa a los contratos. Aprenderás lo que hace de la misma manera que yo, siguiendo el código.
Llegamos aquí desde varios lugares:
- Si hay datos de llamada de 1, 2 o 3 bytes (desde el desplazamiento 0x63)
- Si la firma del método es desconocida (desde los desplazamientos 0x42 y 0x5D)
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 7C | JUMPDEST | |
| 7D | PUSH1 0x00 | 0x00 |
| 7F | PUSH2 0x009d | 0x9D 0x00 |
| 82 | PUSH1 0x03 | 0x03 0x9D 0x00 |
| 84 | SLOAD | Storage[3] 0x9D 0x00 |
Esta es otra celda de almacenamiento, una que no pude encontrar en ninguna transacción, por lo que es más difícil saber qué significa. El código a continuación lo aclarará.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 85 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xff....ff Storage[3] 0x9D 0x00 |
| 9A | AND | Storage[3]-como-dirección 0x9D 0x00 |
Estos códigos de operación truncan el valor que leemos de Storage[3] a 160 bits, la longitud de una dirección de Ethereum.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-como-dirección 0x00 |
| 9C | JUMP | Storage[3]-como-dirección 0x00 |
Este salto es superfluo, ya que vamos al siguiente código de operación. Este código no es tan eficiente en gas como podría ser.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 9D | JUMPDEST | Storage[3]-como-dirección 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-como-dirección |
| 9F | POP | Storage[3]-como-dirección |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-como-dirección |
| A2 | MLOAD | Mem[0x40] Storage[3]-como-dirección |
Al principio del código establecemos Mem[0x40] en 0x80. Si buscamos 0x40 más adelante, vemos que no lo cambiamos, por lo que podemos asumir que es 0x80.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| A3 | CALLDATASIZE | CALLDATASIZE 0x80 Storage[3]-como-dirección |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-como-dirección |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-como-dirección |
| A7 | CALLDATACOPY | 0x80 Storage[3]-como-dirección |
Copia todos los datos de llamada a la memoria, comenzando en 0x80.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-como-dirección |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-como-dirección |
| AB | CALLDATASIZE | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-dirección |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-dirección |
| AD | DUP6 | Storage[3]-como-dirección 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-dirección |
| AE | GAS | GAS Storage[3]-como-dirección 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-dirección |
| AF | DELEGATE_CALL |
Ahora las cosas están mucho más claras. Este contrato puede actuar como un proxy (opens in a new tab), llamando a la dirección en Storage[3] para hacer el trabajo real. DELEGATE_CALL llama a un contrato separado, pero permanece en el mismo almacenamiento. Esto significa que el contrato delegado, para el cual somos un proxy, accede al mismo espacio de almacenamiento. Los parámetros para la llamada son:
- Gas: Todo el gas restante
- Dirección llamada: Storage[3]-como-dirección
- Datos de llamada: Los bytes de CALLDATASIZE que comienzan en 0x80, que es donde pusimos los datos de llamada originales
- Datos de retorno: Ninguno (0x00 - 0x00) Obtendremos los datos de retorno por otros medios (ver a continuación)
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
Aquí copiamos todos los datos de retorno al búfer de memoria que comienza en 0x80.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| B6 | DUP2 | (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B7 | DUP1 | (((éxito/fracaso de la llamada))) (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B8 | ISZERO | (((¿falló la llamada?))) (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| B9 | PUSH2 0x00c0 | 0xC0 (((¿falló la llamada?))) (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| BC | JUMPI | (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| BD | DUP2 | RETURNDATASIZE (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| BE | DUP5 | 0x80 RETURNDATASIZE (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| BF | RETURN |
Así que después de la llamada copiamos los datos de retorno al búfer 0x80 - 0x80+RETURNDATASIZE, y si la llamada es exitosa, entonces hacemos RETURN exactamente con ese búfer.
DELEGATECALL falló
Si llegamos aquí, a 0xC0, significa que el contrato al que llamamos se revirtió. Como solo somos un proxy para ese contrato, queremos devolver los mismos datos y también revertir.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| C0 | JUMPDEST | (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| C1 | DUP2 | RETURNDATASIZE (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((éxito/fracaso de la llamada))) RETURNDATASIZE (((éxito/fracaso de la llamada))) 0x80 Storage[3]-como-dirección |
| C3 | REVERT |
Así que hacemos REVERT con el mismo búfer que usamos para RETURN anteriormente: 0x80 - 0x80+RETURNDATASIZE
Llamadas a la ABI
Si el tamaño de los datos de llamada es de cuatro bytes o más, esto podría ser una llamada a la ABI válida.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| D | PUSH1 0x00 | 0x00 |
| F | CALLDATALOAD | (((Primera palabra (256 bits) de los datos de llamada))) |
| 10 | PUSH1 0xe0 | 0xE0 (((Primera palabra (256 bits) de los datos de llamada))) |
| 12 | SHR | (((primeros 32 bits (4 bytes) de los datos de llamada))) |
Etherscan nos dice que 1C es un código de operación desconocido, porque se añadió después de que Etherscan escribiera esta función (opens in a new tab) y no la han actualizado. Una tabla de códigos de operación actualizada (opens in a new tab) nos muestra que esto es un desplazamiento a la derecha (shift right)
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 13 | DUP1 | (((primeros 32 bits (4 bytes) de los datos de llamada))) (((primeros 32 bits (4 bytes) de los datos de llamada))) |
| 14 | PUSH4 0x3cd8045e | 0x3CD8045E (((primeros 32 bits (4 bytes) de los datos de llamada))) (((primeros 32 bits (4 bytes) de los datos de llamada))) |
| 19 | GT | 0x3CD8045E>primeros-32-bits-de-los-datos-de-llamada (((primeros 32 bits (4 bytes) de los datos de llamada))) |
| 1A | PUSH2 0x0043 | 0x43 0x3CD8045E>primeros-32-bits-de-los-datos-de-llamada (((primeros 32 bits (4 bytes) de los datos de llamada))) |
| 1D | JUMPI | (((primeros 32 bits (4 bytes) de los datos de llamada))) |
Al dividir las pruebas de coincidencia de firmas de métodos en dos de esta manera, se ahorra la mitad de las pruebas en promedio. El código que sigue inmediatamente a esto y el código en 0x43 siguen el mismo patrón: DUP1 los primeros 32 bits de los datos de llamada, PUSH4 (((method signature>, ejecuta EQ para comprobar la igualdad, y luego JUMPI si la firma del método coincide. Aquí están las firmas de los métodos, sus direcciones y, si se conoce, la definición del método correspondiente (opens in a new tab):
| Método | Firma del método | Desplazamiento al que saltar |
|---|---|---|
| splitter() (opens in a new tab) | 0x3cd8045e | 0x0103 |
| ??? | 0x81e580d3 | 0x0138 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0158 |
| ??? | 0x1f135823 | 0x00C4 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x00ED |
Si no se encuentra ninguna coincidencia, el código salta a el manejador del proxy en 0x7C, con la esperanza de que el contrato del cual somos un proxy tenga una coincidencia.
splitter()
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 103 | JUMPDEST | |
| 104 | CALLVALUE | CALLVALUE |
| 105 | DUP1 | CALLVALUE CALLVALUE |
| 106 | ISZERO | CALLVALUE==0 CALLVALUE |
| 107 | PUSH2 0x010f | 0x010F CALLVALUE==0 CALLVALUE |
| 10A | JUMPI | CALLVALUE |
| 10B | PUSH1 0x00 | 0x00 CALLVALUE |
| 10D | DUP1 | 0x00 0x00 CALLVALUE |
| 10E | REVERT |
Lo primero que hace esta función es comprobar que la llamada no haya enviado ningún ETH. Esta función no es payable (opens in a new tab). Si alguien nos envió ETH, debe ser un error y queremos REVERT para evitar tener ese ETH donde no puedan recuperarlo.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 10F | JUMPDEST | |
| 110 | POP | |
| 111 | PUSH1 0x03 | 0x03 |
| 113 | SLOAD | (((Storage[3] también conocido como el contrato para el cual somos un proxy))) |
| 114 | PUSH1 0x40 | 0x40 (((Storage[3] también conocido como el contrato para el cual somos un proxy))) |
| 116 | MLOAD | 0x80 (((Storage[3] también conocido como el contrato para el cual somos un proxy))) |
| 117 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xFF...FF 0x80 (((Storage[3] también conocido como el contrato para el cual somos un proxy))) |
| 12C | SWAP1 | 0x80 0xFF...FF (((Storage[3] también conocido como el contrato para el cual somos un proxy))) |
| 12D | SWAP2 | (((Storage[3] también conocido como el contrato para el cual somos un proxy))) 0xFF...FF 0x80 |
| 12E | AND | ProxyAddr 0x80 |
| 12F | DUP2 | 0x80 ProxyAddr 0x80 |
| 130 | MSTORE | 0x80 |
Y 0x80 ahora contiene la dirección del proxy
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 131 | PUSH1 0x20 | 0x20 0x80 |
| 133 | ADD | 0xA0 |
| 134 | PUSH2 0x00e4 | 0xE4 0xA0 |
| 137 | JUMP | 0xA0 |
El código E4
Esta es la primera vez que vemos estas líneas, pero se comparten con otros métodos (ver más abajo). Así que llamaremos X al valor en la pila, y solo recuerda que en splitter() el valor de esta X es 0xA0.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| E4 | JUMPDEST | X |
| E5 | PUSH1 0x40 | 0x40 X |
| E7 | MLOAD | 0x80 X |
| E8 | DUP1 | 0x80 0x80 X |
| E9 | SWAP2 | X 0x80 0x80 |
| EA | SUB | X-0x80 0x80 |
| EB | SWAP1 | 0x80 X-0x80 |
| EC | RETURN |
Por lo tanto, este código recibe un puntero de memoria en la pila (X) y hace que el contrato ejecute RETURN con un búfer que es 0x80 - X.
En el caso de splitter(), esto devuelve la dirección para la cual somos un proxy. RETURN devuelve el búfer en 0x80-0x9F, que es donde escribimos estos datos (desplazamiento 0x130 arriba).
currentWindow()
El código en los offsets 0x158-0x163 es idéntico a lo que vimos en 0x103-0x10E en splitter() (aparte del destino JUMPI), por lo que sabemos que currentWindow() tampoco es payable.
| Offset | Código de operación | Pila |
|---|---|---|
| 164 | JUMPDEST | |
| 165 | POP | |
| 166 | PUSH2 0x00da | 0xDA |
| 169 | PUSH1 0x01 | 0x01 0xDA |
| 16B | SLOAD | Storage[1] 0xDA |
| 16C | DUP2 | 0xDA Storage[1] 0xDA |
| 16D | JUMP | Storage[1] 0xDA |
El código DA
Este código también se comparte con otros métodos. Así que llamaremos Y al valor en la pila, y solo recordaremos que en currentWindow() el valor de esta Y es Storage[1].
| Offset | Código de operación | Pila |
|---|---|---|
| DA | JUMPDEST | Y 0xDA |
| DB | PUSH1 0x40 | 0x40 Y 0xDA |
| DD | MLOAD | 0x80 Y 0xDA |
| DE | SWAP1 | Y 0x80 0xDA |
| DF | DUP2 | 0x80 Y 0x80 0xDA |
| E0 | MSTORE | 0x80 0xDA |
Escribe Y en 0x80-0x9F.
| Offset | Código de operación | Pila |
|---|---|---|
| E1 | PUSH1 0x20 | 0x20 0x80 0xDA |
| E3 | ADD | 0xA0 0xDA |
Y el resto ya se explicó anteriormente. Por lo tanto, los saltos a 0xDA escriben la parte superior de la pila (Y) en 0x80-0x9F y devuelven ese valor. En el caso de currentWindow(), devuelve Storage[1].
merkleRoot()
El código en los offsets 0xED-0xF8 es idéntico a lo que vimos en 0x103-0x10E en splitter() (aparte del destino de JUMPI), por lo que sabemos que merkleRoot() tampoco es payable.
| Offset | Código de operación | Pila |
|---|---|---|
| F9 | JUMPDEST | |
| FA | POP | |
| FB | PUSH2 0x00da | 0xDA |
| FE | PUSH1 0x00 | 0x00 0xDA |
| 100 | SLOAD | Storage[0] 0xDA |
| 101 | DUP2 | 0xDA Storage[0] 0xDA |
| 102 | JUMP | Storage[0] 0xDA |
Lo que sucede después del salto ya lo hemos averiguado. Así que merkleRoot() devuelve Storage[0].
0x81e580d3
El código en los desplazamientos 0x138-0x143 es idéntico a lo que vimos en 0x103-0x10E en splitter() (aparte del destino JUMPI), por lo que sabemos que esta función tampoco es payable.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 144 | JUMPDEST | |
| 145 | POP | |
| 146 | PUSH2 0x00da | 0xDA |
| 149 | PUSH2 0x0153 | 0x0153 0xDA |
| 14C | CALLDATASIZE | CALLDATASIZE 0x0153 0xDA |
| 14D | PUSH1 0x04 | 0x04 CALLDATASIZE 0x0153 0xDA |
| 14F | PUSH2 0x018f | 0x018F 0x04 CALLDATASIZE 0x0153 0xDA |
| 152 | JUMP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 18F | JUMPDEST | 0x04 CALLDATASIZE 0x0153 0xDA |
| 190 | PUSH1 0x00 | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 192 | PUSH1 0x20 | 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 194 | DUP3 | 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 195 | DUP5 | CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 196 | SUB | CALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 197 | SLT | CALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 198 | ISZERO | CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 199 | PUSH2 0x01a0 | 0x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19C | JUMPI | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
Parece que esta función toma al menos 32 bytes (una palabra) de datos de llamada.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 19D | DUP1 | 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19E | DUP2 | 0x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19F | REVERT |
Si no obtiene los datos de llamada, la transacción se revierte sin ningún dato de retorno.
Veamos qué sucede si la función sí obtiene los datos de llamada que necesita.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 1A0 | JUMPDEST | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A1 | POP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A2 | CALLDATALOAD | calldataload(4) CALLDATASIZE 0x0153 0xDA |
calldataload(4) es la primera palabra de los datos de llamada después de la firma del método
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 1A3 | SWAP2 | 0x0153 CALLDATASIZE calldataload(4) 0xDA |
| 1A4 | SWAP1 | CALLDATASIZE 0x0153 calldataload(4) 0xDA |
| 1A5 | POP | 0x0153 calldataload(4) 0xDA |
| 1A6 | JUMP | calldataload(4) 0xDA |
| 153 | JUMPDEST | calldataload(4) 0xDA |
| 154 | PUSH2 0x016e | 0x016E calldataload(4) 0xDA |
| 157 | JUMP | calldataload(4) 0xDA |
| 16E | JUMPDEST | calldataload(4) 0xDA |
| 16F | PUSH1 0x04 | 0x04 calldataload(4) 0xDA |
| 171 | DUP2 | calldataload(4) 0x04 calldataload(4) 0xDA |
| 172 | DUP2 | 0x04 calldataload(4) 0x04 calldataload(4) 0xDA |
| 173 | SLOAD | Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 174 | DUP2 | calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 175 | LT | calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 176 | PUSH2 0x017e | 0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 179 | JUMPI | calldataload(4) 0x04 calldataload(4) 0xDA |
Si la primera palabra no es menor que Storage[4], la función falla. Se revierte sin ningún valor devuelto:
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 17A | PUSH1 0x00 | 0x00 ... |
| 17C | DUP1 | 0x00 0x00 ... |
| 17D | REVERT |
Si calldataload(4) es menor que Storage[4], obtenemos este código:
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 17E | JUMPDEST | calldataload(4) 0x04 calldataload(4) 0xDA |
| 17F | PUSH1 0x00 | 0x00 calldataload(4) 0x04 calldataload(4) 0xDA |
| 181 | SWAP2 | 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 182 | DUP3 | 0x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 183 | MSTORE | calldataload(4) 0x00 calldataload(4) 0xDA |
Y las ubicaciones de memoria 0x00-0x1F ahora contienen los datos 0x04 (0x00-0x1E son todos ceros, 0x1F es cuatro)
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 184 | PUSH1 0x20 | 0x20 calldataload(4) 0x00 calldataload(4) 0xDA |
| 186 | SWAP1 | calldataload(4) 0x20 0x00 calldataload(4) 0xDA |
| 187 | SWAP2 | 0x00 0x20 calldataload(4) calldataload(4) 0xDA |
| 188 | SHA3 | (((SHA3 de 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA |
| 189 | ADD | (((SHA3 de 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA |
| 18A | SLOAD | Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA |
Por lo tanto, hay una tabla de búsqueda en el almacenamiento, que comienza en el SHA3 de 0x000...0004 y tiene una entrada para cada valor legítimo de datos de llamada (valor por debajo de Storage[4]).
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 18B | SWAP1 | calldataload(4) Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18C | POP | Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18D | DUP2 | 0xDA Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18E | JUMP | Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA |
Ya sabemos lo que hace el código en el desplazamiento 0xDA, devuelve el valor superior de la pila al llamador. Así que esta función devuelve el valor de la tabla de búsqueda al llamador.
0x1f135823
El código en los desplazamientos 0xC4-0xCF es idéntico a lo que vimos en 0x103-0x10E en splitter() (aparte del destino JUMPI), por lo que sabemos que esta función tampoco es payable.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| D0 | JUMPDEST | |
| D1 | POP | |
| D2 | PUSH2 0x00da | 0xDA |
| D5 | PUSH1 0x06 | 0x06 0xDA |
| D7 | SLOAD | Valor* 0xDA |
| D8 | DUP2 | 0xDA Valor* 0xDA |
| D9 | JUMP | Valor* 0xDA |
Ya sabemos lo que hace el código en el desplazamiento 0xDA, devuelve el valor superior de la pila al llamador. Así que esta función devuelve Value*.
Resumen de métodos
¿Sientes que entiendes el contrato en este punto? Yo no. Hasta ahora tenemos estos métodos:
| Método | Significado |
|---|---|
| Transferencia | Aceptar el valor proporcionado por la llamada y aumentar Value* en esa cantidad |
| splitter() | Devolver Storage[3], la dirección del contrato proxy |
| currentWindow() | Devolver Storage[1] |
| merkleRoot() | Devolver Storage[0] |
| 0x81e580d3 | Devolver el valor de una tabla de búsqueda, siempre que el parámetro sea menor que Storage[4] |
| 0x1f135823 | Devolver Storage[6], también conocido como Valor* |
Pero sabemos que cualquier otra funcionalidad es proporcionada por el contrato en Storage[3]. Tal vez si supiéramos qué es ese contrato nos daría una pista. Afortunadamente, esto es la cadena de bloques y todo se sabe, al menos en teoría. No vimos ningún método que establezca Storage[3], por lo que debe haber sido establecido por el constructor.
El constructor
Cuando observamos un contrato (opens in a new tab) también podemos ver la transacción que lo creó.
Si hacemos clic en esa transacción, y luego en la pestaña Estado, podemos ver los valores iniciales de los parámetros. Específicamente, podemos ver que Storage[3] contiene 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Ese contrato debe contener la funcionalidad que falta. Podemos entenderlo usando las mismas herramientas que usamos para el contrato que estamos investigando.
El contrato proxy
Usando las mismas técnicas que utilizamos para el contrato original anterior, podemos ver que el contrato se revierte si:
- Hay algún ETH adjunto a la llamada (0x05-0x0F)
- El tamaño de los datos de llamada es menor a cuatro (0x10-0x19 y 0xBE-0xC2)
Y que los métodos que admite son:
| Método | Firma del método | Desplazamiento para saltar a |
|---|---|---|
| scaleAmountByPercentage(uint256,uint256) (opens in a new tab) | 0x8ffb5c97 | 0x0135 |
| isClaimed(uint256,address) (opens in a new tab) | 0xd2ef0795 | 0x0151 |
| claim(uint256,address,uint256,bytes32[]) (opens in a new tab) | 0x2e7ba6ef | 0x00F4 |
| incrementWindow() (opens in a new tab) | 0x338b1d31 | 0x0110 |
| ??? | 0x3f26479e | 0x0118 |
| ??? | 0x1e7df9d3 | 0x00C3 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0148 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x0107 |
| ??? | 0x81e580d3 | 0x0122 |
| ??? | 0x1f135823 | 0x00D8 |
Podemos ignorar los cuatro métodos inferiores porque nunca llegaremos a ellos. Sus firmas son tales que nuestro contrato original se encarga de ellos por sí mismo (puede hacer clic en las firmas para ver los detalles anteriores), por lo que deben ser métodos que se anulan (opens in a new tab).
Uno de los métodos restantes es claim(<params>) y otro es isClaimed(<params>), por lo que parece un contrato de airdrop. En lugar de revisar el resto código de operación por código de operación, podemos probar el descompilador (opens in a new tab), que produce resultados utilizables para tres funciones de este contrato. La ingeniería inversa de las demás se deja como ejercicio para el lector.
scaleAmountByPercentage
Esto es lo que nos da el descompilador para esta función:
def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
require calldata.size - 4 >=′ 64
if _param1 and _param2 > -1 / _param1:
revert with 0, 17
return (_param1 * _param2 / 100 * 10^6)
El primer require comprueba que los datos de llamada tienen, además de los cuatro bytes de la firma de la función, al menos 64 bytes, suficientes para los dos parámetros. Si no es así, obviamente hay algo mal.
La declaración if parece comprobar que _param1 no es cero y que _param1 * _param2 no es negativo. Probablemente sea para evitar casos de desbordamiento.
Finalmente, la función devuelve un valor escalado.
claim
El código que crea el descompilador es complejo y no todo es relevante para nosotros. Voy a omitir parte de él para centrarme en las líneas que creo que proporcionan información útil.
def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
...
require _param2 == addr(_param2)
...
if currentWindow <= _param1:
revert with 0, 'cannot claim for a future window'
Aquí vemos dos cosas importantes:
_param2, aunque se declara como unuint256, en realidad es una dirección_param1es la ventana que se está reclamando, que tiene que sercurrentWindowo anterior.
...
if stor5[_claimWindow][addr(_claimFor)]:
revert with 0, 'Account already claimed the given window'
Así que ahora sabemos que Storage[5] es una matriz de ventanas y direcciones, y si la dirección reclamó la recompensa para esa ventana.
...
idx = 0
s = 0
while idx < _param4.length:
...
if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
mem[mem[64] + 32] = mem[(32 * idx) + 296]
...
s = sha3(mem[_62 + 32 len mem[_62]])
continue
...
s = sha3(mem[_66 + 32 len mem[_66]])
continue
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
Sabemos que unknown2eb4a7ab es en realidad la función merkleRoot(), por lo que este código parece estar verificando una prueba de Merkle (opens in a new tab). Esto significa que _param4 es una prueba de Merkle.
call addr(_param2) with:
value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
gas 30000 wei
Así es como un contrato transfiere su propio ETH a otra dirección (contrato o de propiedad externa). La llama con un valor que es la cantidad a transferir. Por lo tanto, parece que se trata de un airdrop de ETH.
if not return_data.size:
if not ext_call.success:
require ext_code.size(stor2)
call stor2.deposit() with:
value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
Las dos últimas líneas nos dicen que Storage[2] también es un contrato al que llamamos. Si miramos la transacción del constructor (opens in a new tab) vemos que este contrato es 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), un contrato de ether envuelto (WETH) cuyo código fuente se ha subido a Etherscan (opens in a new tab).
Por lo tanto, parece que el contrato intenta enviar ETH a _param2. Si puede hacerlo, genial. Si no, intenta enviar WETH (opens in a new tab). Si _param2 es una cuenta de propiedad externa (EOA) entonces siempre puede recibir ETH, pero los contratos pueden negarse a recibir ETH. Sin embargo, WETH es ERC-20 y los contratos no pueden negarse a aceptarlo.
...
log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)
Al final de la función vemos que se genera una entrada de registro. Mire las entradas de registro generadas (opens in a new tab) y filtre por el tema que comienza con 0xdbd5.... Si hacemos clic en una de las transacciones que generó dicha entrada (opens in a new tab) vemos que, de hecho, parece un reclamo: la cuenta envió un mensaje al contrato al que le estamos haciendo ingeniería inversa y, a cambio, obtuvo ETH.
1e7df9d3
Esta función es muy similar a claim anterior. También comprueba una prueba de Merkle, intenta transferir ETH a la primera y produce el mismo tipo de entrada de registro.
def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
...
idx = 0
s = 0
while idx < _param3.length:
if idx >= mem[96]:
revert with 0, 50
_55 = mem[(32 * idx) + 128]
if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
...
s = sha3(mem[_58 + 32 len mem[_58]])
continue
mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
...
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
...
call addr(_param1) with:
value s wei
gas 30000 wei
if not return_data.size:
if not ext_call.success:
require ext_code.size(stor2)
call stor2.deposit() with:
value s wei
gas gas_remaining wei
...
log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
La principal diferencia es que el primer parámetro, la ventana a retirar, no está ahí. En su lugar, hay un bucle sobre todas las ventanas que podrían ser reclamadas.
idx = 0
s = 0
while idx < currentWindow:
...
if stor5[mem[0]]:
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s
continue
...
stor5[idx][addr(_param1)] = 1
if idx >= unknown81e580d3.length:
revert with 0, 50
mem[0] = 4
if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
revert with 0, 17
if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
revert with 0, 17
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
continue
Así que parece una variante de claim que reclama todas las ventanas.
Conclusión
A estas alturas ya deberías saber cómo entender contratos cuyo código fuente no está disponible, usando ya sea los códigos de operación o (cuando funciona) el descompilador. Como es evidente por la longitud de este artículo, aplicar ingeniería inversa a un contrato no es trivial, pero en un sistema donde la seguridad es esencial, es una habilidad importante poder verificar que los contratos funcionen como se promete.



![El cambio en Storage[6]](/_next/image/?url=%2Fcontent%2Fdevelopers%2Ftutorials%2Freverse-engineering-a-contract%2Fstorage6.png&w=1920&q=75)



