Ingeniería inversa de un contrato
Introducción
No hay secretos en la cadena de bloques; todo lo que sucede es coherente, verificable y está disponible públicamente. Idealmente, los contratos deberían tener su código fuente publicado y verificado en Etherscanopens in a new tab. Sin embargo, no siempre es asíopens in a new tab. En este artículo aprenderá a aplicar la ingeniería inversa a los contratos, observando un contrato sin código fuente: 0x2510c039cc3b061d79e564b38836da87e31b342fopens in a new tab.
Existen decompiladores, pero no siempre producen resultados utilizablesopens in a new tab. En este artículo, aprenderá a realizar manualmente la ingeniería inversa y a entender un contrato a partir de los códigos de operaciónopens in a new tab, así como a interpretar los resultados de un decompilador.
Para poder entender este artículo, debe conocer los conceptos básicos de la EVM y estar, al menos, algo familiarizado con el ensamblador de la EVM. Puede 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 con 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 ello, una forma es abrir una hoja de cálculo de Google y pegar los códigos de operación en la columna C. Puede saltarse los siguientes pasos haciendo una copia de esta hoja de cálculo ya preparadaopens 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, a continuación, 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=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, a continuación, 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 PUSH, extraemos el número de bytes y lo sumamos.
En A1, ponga el primer desplazamiento, cero. A continuación, en A2, ponga esta función y de nuevo cópiela y péguela para el resto de la columna A:
1=dec2hex(hex2dec(A1)+B1)Necesitamos esta función para que 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 se ejecutan siempre desde el primer byte. Esta es la parte inicial del código:
| Desplazamiento | 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ío |
| 5 | PUSH1 0x04 | 0x04 |
| 7 | CALLDATASIZE | CALLDATASIZE 0x04 |
| 8 | LT | CALLDATASIZE<4 |
| 9 | PUSH2 0x005e | 0x5E CALLDATASIZE<4 |
| C | JUMPI | Vacío |
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-0x5E son todo ceros).
- Lee el tamaño de los datos de llamada. Normalmente, los datos de llamada de un contrato de Ethereum siguen la ABI (interfaz binaria de la aplicación)opens in a new tab, que como mínimo requiere cuatro bytes para el selector de funciones. Si el tamaño de los datos de llamada es inferior a cuatro, salta a 0x5E.
El controlador en 0x5E (para datos de llamada que no son de ABI)
| Desplazamiento | Código de operación |
|---|---|
| 5E | JUMPDEST |
| 5E | 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 es JUMPDEST. A continuación, mira el CALLDATASIZE y si es «verdadero» (es decir, no es cero) salta a 0x7C. Llegaremos a eso más abajo.
| Desplazamiento | 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 |
Así que cuando no hay datos de llamada, leemos el valor de Almacenamiento[6]. Todavía no sabemos cuál es este valor, pero podemos buscar transacciones que el contrato haya recibido sin datos de llamada. Las transacciones que solo transfieren ETH sin ningún dato de llamada (y, por lo tanto, sin ningún método) tienen en Etherscan el método Transfer. De hecho, la primera transacción que recibió el contratoopens in a new tab es una transferencia.
Si miramos en esa transacción y hacemos clic en Click to see More, vemos que los datos de llamada, denominados datos de entrada, están de hecho vacíos (0x). Tenga en cuenta también que el valor es de 1,559 ETH, lo que será relevante más adelante.
A continuación, haga clic en la pestaña State y expanda el contrato en el que estamos haciendo ingeniería inversa (0x2510...). Puede ver que Storage[6] cambió durante la transacción, y si cambia de Hex a Number, verá que se convirtió en 1.559.000.000.000.000.000, el valor transferido en wei (añadí los puntos para mayor claridad), correspondiente al valor del contrato siguiente.
Si observamos los cambios de estado causados por otras transacciones de Transfer del mismo períodoopens 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 todavía no sabemos lo que hace esta variable, pero no puede ser solo para rastrear el valor del contrato, porque no es necesario usar el almacenamiento, que es muy caro, cuando se puede obtener el saldo de sus cuentas usando ADDRESS BALANCE. El primer código de operación inserta 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.
| Desplazamiento | 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.
| Desplazamiento | 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 bit a bit, por lo que invierte el valor de cada bit en el valor de llamada.
| Desplazamiento | 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 o igual que 2^256-CALLVALUE-1. Esto parece una 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 desplazamiento 0x01DE, el contrato se revierte si se detecta el desbordamiento, que es un comportamiento normal.
Tenga en cuenta que dicho desbordamiento es extremadamente improbable, ya que 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, en el momento de redactar este informe, es inferior a doscientos millonesopens in a new tab.
| Desplazamiento | 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 desplazamiento 0x75.
| Desplazamiento | 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 la llamada estén vacíos), añadimos a Value* el valor de la llamada. Esto es coherente con lo que decimos que hacen las transacciones de Transfer.
| Desplazamiento | Código de operación |
|---|---|
| 79 | POP |
| 7A | POP |
| 7B | DETENER |
Finalmente, borre la pila (no es necesario) e indique el final exitoso de la transacción.
Para resumir, aquí hay un diagrama de flujo para el código inicial.
El controlador en 0x7C
A propósito, no puse en el encabezado lo que hace este controlador. El objetivo no es enseñarle cómo funciona este contrato específico, sino cómo aplicar la ingeniería inversa a los contratos. Aprenderá lo que hace de la misma manera que lo hice 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 se desconoce la firma del método (de 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 lo que significa. El siguiente código lo aclarará.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 85 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xff....ff Storage[3] 0x9D 0x00 |
| 9A | AND | Storage[3]-as-address 0x9D 0x00 |
Estos códigos de operación truncan el valor que leemos de Almacenamiento[3] a 160 bits, la longitud de una dirección de Ethereum.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-as-address 0x00 |
| 9C | JUMP | Storage[3]-as-address 0x00 |
Este salto es superfluo, ya que vamos al siguiente código de operación. Este código no es tan eficiente en materia de gas como podría ser.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 9D | JUMPDEST | Storage[3]-as-address 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-as-address |
| 9F | POP | Storage[3]-as-address |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-as-address |
| A2 | MLOAD | Mem[0x40] Storage[3]-as-address |
Al principio del código, establecimos Mem[0x40] en 0x80. Si miramos el 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]-as-address |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A7 | CALLDATACOPY | 0x80 Storage[3]-as-address |
Copie todos los datos de la llamada a la memoria, comenzando en 0x80.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-as-address |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-as-address |
| AB | CALLDATASIZE | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AD | DUP6 | Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AE | GAS | GAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AF | DELEGATE_CALL |
Ahora las cosas están mucho más claras. Este contrato puede actuar como un proxyopens in a new tab, llamando a la dirección en Almacenamiento[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 que somos un proxy, accede al mismo espacio de almacenamiento. Los parámetros para la llamada son:
- Gas: Todo el gas restante
- Dirección llamada: Almacenamiento[3]-como-dirección
- Datos de llamada: los bytes CALLDATASIZE que comienzan en 0x80, que es donde colocamos los datos de llamada originales
- Datos de retorno: ninguno (0x00 - 0x00). Obtendremos los datos de retorno por otros medios (véase más adelante)
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
Aquí copiamos todos los datos de retorno al búfer de memoria a partir de 0x80.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| B6 | DUP2 | (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B7 | DUP1 | (((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B8 | ISZERO | (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B9 | PUSH2 0x00c0 | 0xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BC | JUMPI | (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BD | DUP2 | RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BE | DUP5 | 0x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BF | RETURN |
Así que después de la llamada copiamos los datos de retorno en el búfer 0x80 - 0x80+RETURNDATASIZE, y si la llamada tiene éxito, entonces hacemos RETURN con exactamente ese búfer.
Error en DELEGATECALL
Si llegamos aquí, a 0xC0, significa que el contrato al que llamamos se revirtió. Como solo somos un proxy de ese contrato, queremos devolver los mismos datos y también revertirlos.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| C0 | JUMPDEST | (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| C1 | DUP2 | RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| C3 | REVERT |
Así que hacemos REVERT con el mismo búfer que usamos para RETURN antes: 0x80 - 0x80+RETURNDATASIZE.
Llamadas de ABI
Si el tamaño de los datos de llamada es de cuatro bytes o más, podría tratarse de una llamada de ABI válida.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| D | PUSH1 0x00 | 0x00 |
| F | CALLDATALOAD | (((Primera palabra (256 bits) de los datos de la llamada))) |
| 10 | PUSH1 0xe0 | 0xE0 (((Primera palabra (256 bits) de los datos de la 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 característicaopens in a new tab y no la han actualizado. Una tabla de códigos de operación actualizadaopens in a new tab nos muestra que se trata de un desplazamiento a la derecha
| 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))) |
Dividir de este modo en dos las pruebas de coincidencia de firma del método 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 (((firma de método>, ejecutar 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 correspondienteopens in a new tab:
| Método | Firma del método | Desplazamiento para el salto |
|---|---|---|
| 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 al controlador del proxy en 0x7C, con la esperanza de que el contrato para el que 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 ETH. Esta función no es pagaderaopens in a new tab. Si alguien nos envió ETH, debe ser un error, y queremos hacer REVERT para evitar tener ETH que no puedan recuperar.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| 10F | JUMPDEST | |
| 110 | POP | |
| 111 | PUSH1 0x03 | 0x03 |
| 113 | SLOAD | (((Almacenamiento[3], es decir, el contrato para el que somos un proxy))) |
| 114 | PUSH1 0x40 | 0x40 (((Almacenamiento[3], es decir, el contrato para el que somos un proxy))) |
| 116 | MLOAD | 0x80 (((Almacenamiento[3], es decir, el contrato para el que somos un proxy))) |
| 117 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xFF...FF 0x80 (((Almacenamiento[3], es decir, el contrato para el que somos un proxy))) |
| 12C | SWAP1 | 0x80 0xFF...FF (((Almacenamiento[3], es decir, el contrato para el que somos un proxy))) |
| 12D | SWAP2 | (((Almacenamiento[3], es decir, el contrato para el que 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 (véase más abajo). Así que llamaremos al valor de la pila X y simplemente recordaremos 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 |
Así que este código recibe un puntero de memoria en la pila (X) y hace que el contrato haga RETURN con un búfer que es 0x80 - X.
En el caso de splitter(), esto devuelve la dirección para la que 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 desplazamientos 0x158-0x163 es idéntico al que vimos en 0x103-0x10E en splitter() (aparte del destino de JUMPI), por lo que sabemos que currentWindow() tampoco es pagadero.
| Desplazamiento | 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 al valor de la pila Y y simplemente recordaremos que en currentWindow() el valor de esta Y es Almacenamiento[1].
| Desplazamiento | 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 |
Escriba Y en 0x80-0x9F.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| E1 | PUSH1 0x20 | 0x20 0x80 0xDA |
| E3 | ADD | 0xA0 0xDA |
Y el resto ya está explicado arriba. Así que 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 Almacenamiento[1].
merkleRoot()
El código en los desplazamientos 0xED-0xF8 es idéntico al que vimos en 0x103-0x10E en splitter() (aparte del destino de JUMPI), por lo que sabemos que merkleRoot() tampoco es pagadero.
| Desplazamiento | 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 descubierto. Así que merkleRoot() devuelve Almacenamiento[0].
0x81e580d3
El código en los desplazamientos 0x138-0x143 es idéntico al que vimos en 0x103-0x10E en splitter() (aparte del destino de JUMPI), por lo que sabemos que esta función tampoco es pagadera.
| 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 recibe los datos de la llamada, la transacción se revierte sin ningún dato de devolución.
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 inferior a Almacenamiento[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 Almacenamiento[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 (de 0x00 a 0x1E son todo 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 |
Así que hay una tabla de búsqueda en el almacenamiento que comienza en el SHA3 de 0x000...0004 y tiene una entrada para cada valor de datos de llamada legítimo (valor por debajo de Almacenamiento[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 a la persona que llama. Así que esta función devuelve el valor de la tabla de búsqueda al que llama.
0x1f135823
El código en los desplazamientos 0xC4-0xCF es idéntico a lo que vimos en 0x103-0x10E en splitter() (aparte del destino de JUMPI), por lo que sabemos que esta función tampoco es pagadera.
| Desplazamiento | Código de operación | Pila |
|---|---|---|
| D0 | JUMPDEST | |
| D1 | POP | |
| D2 | PUSH2 0x00da | 0xDA |
| D5 | PUSH1 0x06 | 0x06 0xDA |
| D7 | SLOAD | Value* 0xDA |
| D8 | DUP2 | 0xDA Value* 0xDA |
| D9 | JUMP | Value* 0xDA |
Ya sabemos lo que hace el código en el desplazamiento 0xDA, devuelve el valor superior de la pila a la persona que llama. Así que esta función devuelve Value*.
Resumen del método
¿Siente que entiende el contrato en este momento? Yo no. Hasta ahora tenemos estos métodos:
| Método | Significado |
|---|---|
| Transferir | Aceptar el valor proporcionado por la llamada y aumentar Value* en esa cantidad |
| splitter() | Devolver Almacenamiento[3], la dirección del proxy |
| currentWindow() | Devolver Almacenamiento[1] |
| merkleRoot() | Devolver Almacenamiento[0] |
| 0x81e580d3 | Devolver el valor de una tabla de búsqueda, siempre que el parámetro sea menor que Almacenamiento[4] |
| 0x1f135823 | Devolver Almacenamiento[6], es decir Valor* |
Pero sabemos que cualquier otra funcionalidad es proporcionada por el contrato en Almacenamiento[3]. Tal vez si supiéramos cuál es ese contrato, nos daría una pista. Afortunadamente, esta es la cadena de bloques y todo se sabe, al menos en teoría. No vimos ningún método que estableciera Almacenamiento[3], por lo que debe haber sido establecido por el constructor.
El constructor
Cuando miramos un contratoopens 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 State, podemos ver los valores iniciales de los parámetros. Específicamente, podemos ver que Almacenamiento[3] contiene 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens 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 de proxy
Utilizando 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 inferior a cuatro (0x10-0x19 y 0xBE-0xC2).
Y que los métodos que admite son:
| Método | Firma del método | Desplazamiento para el salto |
|---|---|---|
| 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 últimos métodos porque nunca llegaremos a ellos. Sus firmas son tales que nuestro contrato original se encarga de ellas por sí mismo (puede hacer clic en las firmas para ver los detalles más arriba), por lo que deben ser métodos que se anulanopens 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 decompiladoropens in a new tab, que produce resultados utilizables para tres funciones de este contrato. La ingeniería inversa de los otros se deja como ejercicio para el lector.
scaleAmountByPercentage
Esto es lo que el decompilador nos da para esta función:
1def unknown8ffb5c97(uint256 _param1, uint256 _param2) pagadero:2 require calldata.size - 4 >=′ 643 if _param1 and _param2 > -1 / _param1:4 revert with 0, 175 return (_param1 * _param2 / 100 * 10^6)La primera prueba require que tienen los datos de la llamada, además de los cuatro bytes de la firma de la función, es de al menos 64 bytes, suficientes para los dos parámetros. Si no, 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 envoltura.
Finalmente, la función devuelve un valor escalado.
claim
El código que crea el decompilador 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.
1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) pagadero:2 ...3 require _param2 == addr(_param2)4 ...5 if currentWindow <= _param1:6 revert with 0, 'no se puede reclamar por una ventana futura'Aquí vemos dos cosas importantes:
_param2, aunque se declara comouint256, es en realidad una dirección._param1es la ventana que se reclama, que tiene que sercurrentWindowo anterior.
1 ...2 if stor5[_claimWindow][addr(_claimFor)]:3 revert with 0, 'La cuenta ya ha reclamado para la ventana dada'Así que ahora sabemos que Almacenamiento[5] es una matriz de ventanas y direcciones, y si la dirección reclamó la recompensa por esa ventana.
1 ...2 idx = 03 s = 04 while idx < _param4.length:5 ...6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:7 mem[mem[64] + 32] = mem[(32 * idx) + 296]8 ...9 s = sha3(mem[_62 + 32 len mem[_62]])10 continue11 ...12 s = sha3(mem[_66 + 32 len mem[_66]])13 continue14 if unknown2eb4a7ab != s:15 revert with 0, 'Prueba no válida'Mostrar todoSabemos que unknown2eb4a7ab es en realidad la función merkleRoot(), por lo que este código parece que está verificando una prueba de Merkleopens in a new tab. Esto significa que _param4 es una prueba de Merkle.
1 call addr(_param2) with:2 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei3 gas 30000 weiAsí es como un contrato transfiere su propio ETH a otra dirección (contrato o de propiedad externa). Lo llama con un valor que es la cantidad a transferir. Así que parece que se trata de un airdrop de ETH.
1 if not return_data.size:2 if not ext_call.success:3 require ext_code.size(stor2)4 call stor2.deposit() with:5 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 weiLas dos últimas líneas nos dicen que Almacenamiento[2] también es un contrato al que llamamos. Si observamos la transacción del constructoropens in a new tab vemos que este contrato es 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2opens in a new tab, un contrato de Wrapped Ether cuyo código fuente se ha subido a Etherscanopens in a new tab.
Así que parece que el contrato intenta enviar ETH a _param2. Si puede hacerlo, genial. Si no, intenta enviar WETHopens 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, el WETH es un ERC-20 y los contratos no pueden negarse a aceptarlo.
1 ...2 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 generadasopens in a new tab y filtre por el tema que comienza con 0xdbd5.... Si hacemos clic en una de las transacciones que generaron dicha entradaopens in a new tab, vemos que, de hecho, parece una reclamación: la cuenta envió un mensaje al contrato en el que estamos haciendo ingeniería inversa y a cambio obtuvo ETH.
1e7df9d3
Esta función es muy similar a la de claim anterior. También comprueba una prueba de Merkle, intenta transferir ETH a la primera y produce el mismo tipo de entrada de registro.
1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) pagadero:2 ...3 idx = 04 s = 05 while idx < _param3.length:6 if idx >= mem[96]:7 revert with 0, 508 _55 = mem[(32 * idx) + 128]9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:10 ...11 s = sha3(mem[_58 + 32 len mem[_58]])12 continue13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])14 ...15 if unknown2eb4a7ab != s:16 revert with 0, 'Prueba no válida'17 ...18 call addr(_param1) with:19 value s wei20 gas 30000 wei21 if not return_data.size:22 if not ext_call.success:23 require ext_code.size(stor2)24 call stor2.deposit() with:25 value s wei26 gas gas_remaining wei27 ...28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)Mostrar todoLa principal diferencia es que el primer parámetro, la ventana para retirar, no está ahí. En su lugar, hay un bucle sobre todas las ventanas que se podrían reclamar.
1 idx = 02 s = 03 while idx < currentWindow:4 ...5 if stor5[mem[0]]:6 if idx == -1:7 revert with 0, 178 idx = idx + 19 s = s10 continue11 ...12 stor5[idx][addr(_param1)] = 113 if idx >= unknown81e580d3.length:14 revert with 0, 5015 mem[0] = 416 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:17 revert with 0, 1718 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):19 revert with 0, 1720 if idx == -1:21 revert with 0, 1722 idx = idx + 123 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)24 continueMostrar todoAsí que parece una variante de claim que reclama todas las ventanas.
Conclusión
A estas alturas ya debería saber cómo entender los contratos cuyo código fuente no está disponible, utilizando los códigos de operación o (cuando funciona) el decompilador. Como es evidente por la extensión de este artículo, la ingeniería inversa de un contrato no es trivial, pero en un sistema donde la seguridad es esencial, es una habilidad importante poder verificar que los contratos funcionan como se promete.
Vea aquí más de mi trabajoopens in a new tab.
Última actualización de la página: 22 de agosto de 2025



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



