Ir al contenido principal

Ingeniería inversa de un contrato

evm
códigos de operación
Avanzado
Ori Pomerantz
30 de diciembre de 2021
33 minuto leído

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.

Vista de código de operación de Etherscan

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:

DesplazamientoCódigo de operaciónPila (después del código de operación)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREVacío
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIVacío

Este código hace dos cosas:

  1. 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).
  2. 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.

Diagrama de flujo para esta porción

El controlador en 0x5E (para datos de llamada que no son de ABI)

DesplazamientoCódigo de operación
5EJUMPDEST
5ECALLDATASIZE
60PUSH2 0x007c
63JUMPI

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.

DesplazamientoCódigo de operaciónPila (después del código de operación)
64CALLVALUE proporcionado por la llamada. Llamado msg.value en Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[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.

Los datos de llamada están vacíos

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.

El cambio en Storage[6]

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.

DesplazamientoCódigo de operaciónPila
6CPUSH2 0x00750x75 Value* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Value* 0x75 0 6 CALLVALUE
70SWAP1Value* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

Continuaremos rastreando este código en el destino del salto.

DesplazamientoCódigo de operaciónPila
1A7JUMPDESTValue* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^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.

DesplazamientoCódigo de operaciónPila
1ACDUP3Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTValue*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROValue*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

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.

DesplazamientoCódigo de operaciónPila
1DFJUMPDEST0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPValue* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDValue*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Value*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Si llegamos aquí, obtenemos Value* + CALLVALUE y saltamos al desplazamiento 0x75.

DesplazamientoCódigo de operaciónPila
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 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.

DesplazamientoCódigo de operación
79POP
7APOP
7BDETENER

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.

Diagrama de flujo del punto de entrada

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)
DesplazamientoCódigo de operaciónPila
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[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á.

DesplazamientoCódigo de operaciónPila
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[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.

DesplazamientoCódigo de operaciónPila
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[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.

DesplazamientoCódigo de operaciónPila
9DJUMPDESTStorage[3]-as-address 0x00
9ESWAP10x00 Storage[3]-as-address
9FPOPStorage[3]-as-address
A0PUSH1 0x400x40 Storage[3]-as-address
A2MLOADMem[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.

DesplazamientoCódigo de operaciónPila
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-as-address
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-as-address
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address
A7CALLDATACOPY0x80 Storage[3]-as-address

Copie todos los datos de la llamada a la memoria, comenzando en 0x80.

DesplazamientoCódigo de operaciónPila
A8PUSH1 0x000x00 0x80 Storage[3]-as-address
AADUP10x00 0x00 0x80 Storage[3]-as-address
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ADDUP6Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AEGASGAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AFDELEGATE_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)
DesplazamientoCódigo de operaciónPila
B0RETURNDATASIZERETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address

Aquí copiamos todos los datos de retorno al búfer de memoria a partir de 0x80.

DesplazamientoCódigo de operaciónPila
B6DUP2(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B7DUP1(((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B8ISZERO(((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BCJUMPI(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BFRETURN

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.

DesplazamientoCódigo de operaciónPila
C0JUMPDEST(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C3REVERT

Así que hacemos REVERT con el mismo búfer que usamos para RETURN antes: 0x80 - 0x80+RETURNDATASIZE.

Diagrama de flujo de la llamada al proxy

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.

DesplazamientoCódigo de operaciónPila
DPUSH1 0x000x00
FCALLDATALOAD(((Primera palabra (256 bits) de los datos de la llamada)))
10PUSH1 0xe00xE0 (((Primera palabra (256 bits) de los datos de la llamada)))
12SHR(((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

DesplazamientoCódigo de operaciónPila
13DUP1(((primeros 32 bits (4 bytes) de los datos de llamada))) (((primeros 32 bits (4 bytes) de los datos de llamada)))
14PUSH4 0x3cd8045e0x3CD8045E (((primeros 32 bits (4 bytes) de los datos de llamada))) (((primeros 32 bits (4 bytes) de los datos de llamada)))
19GT0x3CD8045E>primeros-32-bits-de-los-datos-de-llamada (((primeros 32 bits (4 bytes) de los datos de llamada)))
1APUSH2 0x00430x43 0x3CD8045E>primeros-32-bits-de-los-datos-de-llamada (((primeros 32 bits (4 bytes) de los datos de llamada)))
1DJUMPI(((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étodoFirma del métodoDesplazamiento para el salto
splitter()opens in a new tab0x3cd8045e0x0103
¿?0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
¿?0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

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.

Diagrama de flujo de las llamadas de ABI

splitter()

DesplazamientoCódigo de operaciónPila
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

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.

DesplazamientoCódigo de operaciónPila
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Almacenamiento[3], es decir, el contrato para el que somos un proxy)))
114PUSH1 0x400x40 (((Almacenamiento[3], es decir, el contrato para el que somos un proxy)))
116MLOAD0x80 (((Almacenamiento[3], es decir, el contrato para el que somos un proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Almacenamiento[3], es decir, el contrato para el que somos un proxy)))
12CSWAP10x80 0xFF...FF (((Almacenamiento[3], es decir, el contrato para el que somos un proxy)))
12DSWAP2(((Almacenamiento[3], es decir, el contrato para el que somos un proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Y 0x80 ahora contiene la dirección del proxy.

DesplazamientoCódigo de operaciónPila
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

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.

DesplazamientoCódigo de operaciónPila
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

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.

DesplazamientoCódigo de operaciónPila
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[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].

DesplazamientoCódigo de operaciónPila
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Escriba Y en 0x80-0x9F.

DesplazamientoCódigo de operaciónPila
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 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.

DesplazamientoCódigo de operaciónPila
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[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.

DesplazamientoCódigo de operaciónPila
144JUMPDEST
145POP
146PUSH2 0x00da0xDA
149PUSH2 0x01530x0153 0xDA
14CCALLDATASIZECALLDATASIZE 0x0153 0xDA
14DPUSH1 0x040x04 CALLDATASIZE 0x0153 0xDA
14FPUSH2 0x018f0x018F 0x04 CALLDATASIZE 0x0153 0xDA
152JUMP0x04 CALLDATASIZE 0x0153 0xDA
18FJUMPDEST0x04 CALLDATASIZE 0x0153 0xDA
190PUSH1 0x000x00 0x04 CALLDATASIZE 0x0153 0xDA
192PUSH1 0x200x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
194DUP30x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
195DUP5CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
196SUBCALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
197SLTCALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
198ISZEROCALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
199PUSH2 0x01a00x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19CJUMPI0x00 0x04 CALLDATASIZE 0x0153 0xDA

Parece que esta función toma al menos 32 bytes (una palabra) de datos de llamada.

DesplazamientoCódigo de operaciónPila
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

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 obtiene los datos de llamada que necesita.

DesplazamientoCódigo de operaciónPila
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) es la primera palabra de los datos de llamada después de la firma del método

DesplazamientoCódigo de operaciónPila
1A3SWAP20x0153 CALLDATASIZE calldataload(4) 0xDA
1A4SWAP1CALLDATASIZE 0x0153 calldataload(4) 0xDA
1A5POP0x0153 calldataload(4) 0xDA
1A6JUMPcalldataload(4) 0xDA
153JUMPDESTcalldataload(4) 0xDA
154PUSH2 0x016e0x016E calldataload(4) 0xDA
157JUMPcalldataload(4) 0xDA
16EJUMPDESTcalldataload(4) 0xDA
16FPUSH1 0x040x04 calldataload(4) 0xDA
171DUP2calldataload(4) 0x04 calldataload(4) 0xDA
172DUP20x04 calldataload(4) 0x04 calldataload(4) 0xDA
173SLOADStorage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(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:

DesplazamientoCódigo de operaciónPila
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Si calldataload(4) es menor que Almacenamiento[4], obtenemos este código:

DesplazamientoCódigo de operaciónPila
17EJUMPDESTcalldataload(4) 0x04 calldataload(4) 0xDA
17FPUSH1 0x000x00 calldataload(4) 0x04 calldataload(4) 0xDA
181SWAP20x04 calldataload(4) 0x00 calldataload(4) 0xDA
182DUP30x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA
183MSTOREcalldataload(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).

DesplazamientoCódigo de operaciónPila
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 de 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 de 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((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]).

DesplazamientoCódigo de operaciónPila
18BSWAP1calldataload(4) Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((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.

DesplazamientoCódigo de operaciónPila
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 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étodoSignificado
TransferirAceptar 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]
0x81e580d3Devolver el valor de una tabla de búsqueda, siempre que el parámetro sea menor que Almacenamiento[4]
0x1f135823Devolver 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ó.

Haga clic en la transacción de creación

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:

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 >=64
3 if _param1 and _param2 > -1 / _param1:
4 revert with 0, 17
5 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 como uint256, es en realidad una dirección.
  • _param1 es la ventana que se reclama, que tiene que ser currentWindow o 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 = 0
3 s = 0
4 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 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 if unknown2eb4a7ab != s:
15 revert with 0, 'Prueba no válida'
Mostrar todo

Sabemos 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 wei
3 gas 30000 wei

Así 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 wei

Las 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.

Una transacción de reclamación

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 = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _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 continue
13 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 wei
20 gas 30000 wei
21 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 wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Mostrar todo

La 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 = 0
2 s = 0
3 while idx < currentWindow:
4 ...
5 if stor5[mem[0]]:
6 if idx == -1:
7 revert with 0, 17
8 idx = idx + 1
9 s = s
10 continue
11 ...
12 stor5[idx][addr(_param1)] = 1
13 if idx >= unknown81e580d3.length:
14 revert with 0, 50
15 mem[0] = 4
16 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
17 revert with 0, 17
18 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
19 revert with 0, 17
20 if idx == -1:
21 revert with 0, 17
22 idx = idx + 1
23 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
24 continue
Mostrar todo

Así 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

¿Le ha resultado útil este tutorial?