Ir al contenido principal

Aplicación de ingeniería inversa en un contrato

evmcódigos de operación
Recursos avanzados
Ori Pomerantz
30 de diciembre de 2021
33 minuto leído minute read

Introducción

No hay secretos en la cadena de bloques, todo lo que sucede es consistente, verificable y se encuentra públicamente disponible. Idealmente, los contratos debieran tener su codigo fuente publicado y verificado en Etherscan(opens in a new tab). Sin embargo, este no siempre es el caso(opens in a new tab). En este articulo puede aprender cómo aplicar ingenieria inversa a los contratos revisando el contrato sin su código fuente, 0x2510c039cc3b061d79e564b38836da87e31b342f(opens in a new tab).

Hay compiladores inversos, pero no siempre producen resultados utilizables(opens in a new tab). En este artículo aprenderá a realizar 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 decompilador.

Para poder entender este artículo, ya debería conocer los conceptos básicos de la EVM y estar al menos un poco familiarizado con el ensamblador de 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 que mostrará 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 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 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 para 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 agrega un byte para el código de operación en sí y luego busca PUSH. Los códigos de operación push son especiales porque necesitan tener bytes adicionales para el valor que se está empujando. Si el código de operación es PUSH, extraemos el número de bytes y lo añadimos.

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:

1=dec2hex(hex2dec(A1)+B1)

Necesitamos que esta función nos dé el valor hexadecimal porque los valores que se empujan antes de los saltos (JUMP y JUMPI) se nos dan en valor hexadecimal.

El punto de entrada (0x00)

Los contratos siempre se ejecutan desde el primer byte. Esta es la parte inicial del código:

OffsetCó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 valor de 32 bytes en las ubicaciones de memoria 0x40-0x5F (0x80 se almacena en 0x5F, y 0x40-0x5E son todos ceros).
  2. Leer 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 la llamada es inferior a cuatro, se salta a 0x5E.

Diagrama de flujo de esta porción

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

OffsetCódigo de operación
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Este fragmento comienza con un JUMPDEST. Los programas de EVM (máquina virtual de Ethereum) lanzan una excepción si salta a un código de operación que no sea JUMPDEST. Luego mira el CALLDATASIZE y si es "verdadero" (es decir, distinto de cero), salta a 0x7C. Veremos esto a continuación.

OffsetCó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 Storage[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, ningún 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 miramos en esa transacción y hacemos clic en Click to see More, vemos que los datos de llamada, llamados 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 la 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 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 miramos los cambios de estado causados por otras transacciones Transfer del mismo período(opens in a new tab), vemos que Storage[6] realizó un seguimiento del valor del contrato durante un tiempo. Por ahora lo llamaremos Value*. El asterisco (*) nos recuerda que aún no sabemos lo que 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 caro, cuando puede obtener el saldo de sus cuentas usando ADDRESS BALANCE. El primer código de operación empuja la 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.

OffsetCó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

Seguiremos rastreando este código en el destino de salto.

OffsetCó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 bitwise, por lo que invierte el valor de cada bit en el valor de llamada.

OffsetCó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 que 2^256-CALLVALUE-1 o igual. Esto parece lógica para evitar 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 tal desbordamiento es extremadamente improbable, porque requeriría que el valor de llamada más Value* sea comparable a 2^256 wei, alrededor de 10^59 ETH. El suministro total de ETH, al momento de escribir esto, es inferior a doscientos millones(opens in a new tab).

OffsetCó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í, obtenga Value* + CALLVALUE y salte al desplazamiento 0x75.

OffsetCó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 consistente con lo que decimos que hacen las transferencias Transfer.

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

Finalmente, borre la pila (no es necesario) e indique el final exitoso de la transacción.

En resumen, aquí hay un diagrama de flujo del código inicial.

Diagrama de flujo del punto de entrada

El controlador en 0x7C

No puse a propósito en el encabezado lo que hace este controlador. El punto no es enseñarle cómo funciona este contrato específico, sino cómo aplicar ingeniería inversa a los contratos. Aprenderá 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 se desconoce la firma del método (de los desplazamientos 0x42 y 0x5D)
OffsetCó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 hará más claro.

OffsetCó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 Storage[3] a 160 bits, la longitud de una dirección de Ethereum.

OffsetCó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.

OffsetCó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 0x40 más adelante, vemos que no lo cambiamos, por lo que podemos asumir que es 0x80.

OffsetCó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.

OffsetCó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 proxy(opens in a new tab), llamando a la dirección en Storage[3] para que haga 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 de la llamada son:

  • Gas: Todo el gas restante
  • Called address: Storage[3]-as-address
  • Call data: Los bytes CALLDATASIZE a partir de 0x80, que es donde ponemos los datos de llamada originales
  • Return data: Ninguno (0x00 - 0x00); obtendremos los datos de devolución por otros medios (ver más abajo)
OffsetCó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.

OffsetCó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 devolvemos (RETURN) con exactamente ese búfer.

DELEGATECALL fallido

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.

OffsetCó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 llamada a proxy

Llamadas a ABI

Si el tamaño de los datos de la llamada es de cuatro bytes o más, esta podría ser una llamada ABI válida.

OffsetCódigo de operaciónPila
DPUSH1 0x000x00
FCALLDATALOAD(((First word (256 bits) of the call data)))
10PUSH1 0xe00xE0 (((First word (256 bits) of the call data)))
12SHR(((first 32 bits (4 bytes) of the call data)))

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 actualizada de códigos de operación(opens in a new tab) nos muestra que esto es un cambio a la derecha (shift right).

OffsetCódigo de operaciónPila
13DUP1(((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
14PUSH4 0x3cd8045e0x3CD8045E (((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
19GT0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1APUSH2 0x00430x43 0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1DJUMPI(((first 32 bits (4 bytes) of the call data)))

Al dividir las pruebas de coincidencia de firma del método en dos de esta forma, esto ahorra la mitad de las pruebas en promedio. El código que inmediatamente sigue 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>, ejecutar EQ para revisar la equidad y luego JUMPI si la firma del método coincide. Aquí están las firmas del método, sus direcciones y, si se conoce, la definición de método correspondiente(opens in a new tab):

MétodoFirma del métodoOffset para saltar
splitter()(opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()(opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()(opens in a new tab)0x2eb4a7ab0x00ED

Si no se encuentra ninguna coincidencia, el código salta al controlador de proxy en 0x7C, con la esperanza de que el contrato en el que somos proxy tenga una coincidencia.

Diagrama de flujo de llamadas ABI

splitter()

OffsetCó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 pagable(opens in a new tab). Si alguien nos envió ETH, debe ser un error, y queremos revertir (REVERT) para evitar tener ETH que no puedan recuperar.

OffsetCódigo de operaciónPila
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] a.k.a the contract for which we are a proxy)))
114PUSH1 0x400x40 (((Storage[3] a.k.a the contract for which we are a proxy)))
116MLOAD0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] a.k.a the contract for which we are a proxy)))
12DSWAP2(((Storage[3] a.k.a the contract for which we are a proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Y 0x80 ahora contiene la dirección del proxy

OffsetCó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 (ver 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.

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

Por lo tanto, 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 JUMPI), por lo que sabemos que currentWindow() tampoco es payable.

OffsetCó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 Storage[1].

OffsetCó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.

OffsetCódigo de operaciónPila
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Y el resto ya está explicado arriba. Así que salta a 0xDA, escribe la parte superior de la pila (Y) en 0x80-0x9F y devuelve ese valor. En el caso de currentWindow(), devuelve Storage[1].

merkleRoot()

El código en los desplazamientos 0xED-0xF8 es idéntico al que vimos en 0x103-0x10E en splitter() (aparte del destino JUMPI), por lo que sabemos que merkleRoot() tampoco es payable.

OffsetCó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 descubrimos. Así que merkleRoot() devuelve Storage[0].

0x81e580d3

El código en los desplazamientos 0x138-0x143 es idéntico al que vimos en 0x103-0x10E en splitter() (aparte del destino JUMPI), por lo que sabemos que esta función tampoco es payable.

OffsetCó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.

OffsetCó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.

OffsetCó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

OffsetCó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 Storage[4], la función falla. Se revierte sin ningún valor devuelto:

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

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

OffsetCó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 (0x00-0x1E son todos ceros, 0x1F es cuatro)

OffsetCó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 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((SHA3 of 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 Storage[4]).

OffsetCódigo de operaciónPila
18BSWAP1calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA

Ya sabemos lo que hace el código en offset 0xDA: devuelve el valor superior de la pila al invocante o el 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 JUMPI), por lo que sabemos que esta función tampoco es de pago (payable).

OffsetCó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 offset 0xDA: devuelve el valor superior de la pila al invocante o el que llama. Así que esta función devuelve Value*.

Resumen de métodos

¿Siente que entiende el contrato en este momento? No. Hasta ahora tenemos estos métodos:

MétodoSignificado
TransferirAceptar el valor proporcionado por la llamada y aumentar Value* en esa cantidad
splitter()Return Storage[3], the proxy address
currentWindow()Return Storage[1]
merkleRoot()Return Storage[0]
0x81e580d3Mostrar el valor de una tabla de búsqueda, siempre que el parámetro sea menor que Storage[4]
0x1f135823Return Storage[6], a.k.a. Value*

Pero sabemos que cualquier otra funcionalidad es proporcionada por el contrato en Storage[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 Storage[3], por lo que debe haber sido establecido por el constructor.

El constructor

Cuando analizamos un contrato(opens in a new tab), también podemos ver la transacción que lo creó.

Haga clic en crear transacció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 Storage[3] contiene 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761(opens in a new tab). Ese contrato debe contener la funcionalidad que falta. Podemos entenderla 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 la llamada es inferior a cuatro (0x10-0x19 y 0xBE-0xC2).

Y que los métodos que admite son:

MétodoFirma del métodoOffset para saltar
scaleAmountByPercentage(uint256,uint256)(opens in a new tab)0x8ffb5c970x0135
isClaimed(uint256,address)(opens in a new tab)0xd2ef07950x0151
claim(uint256,address,uint256,bytes32[])(opens in a new tab)0x2e7ba6ef0x00F4
incrementWindow()(opens in a new tab)0x338b1d310x0110
???0x3f26479e0x0118
???0x1e7df9d30x00C3
currentWindow()(opens in a new tab)0xba0bafb40x0148
merkleRoot()(opens in a new tab)0x2eb4a7ab0x0107
???0x81e580d30x0122
???0x1f1358230x00D8

Podemos ignorar los cuatro últimos métodos 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 arriba), por lo que deben ser métodos anulados(opens in a new tab).

Uno de los métodos restantes es claim(<params>), y otro es isClaimed(<params>), así que parece un contrato de airdrop. En lugar de ver el resto opcode por opcode, podemos probar el decompilador(opens 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) payable:
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)
Copiar

Las primeras pruebas require que tienen los datos de la llamada, 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, 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 wrap around.

Por último, 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 algo de eso para centrarme en las líneas que creo que proporcionan información útil.

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 require _param2 == addr(_param2)
4 ...
5 if currentWindow <= _param1:
6 revert with 0, 'cannot claim for a future window'
Copiar

Aquí vemos dos cosas importantes:

  • _param2, aunque se declara como uint256, es en realidad una dirección
  • _param1 es la ventana reclamada, que tiene que ser currentWindow o anterior.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'
Copiar

Así que ahora sabemos que Storage[5] es una serie 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, 'Invalid proof'
Mostrar todo
Copiar

Sabemos que unknown2eb4a7ab es en realidad la función merkleRoot(), por lo que este código parece que está verificando una prueba de merkle(opens 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
Copiar

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
Copiar

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 (Wrapped Ether) cuyo código fuente se subió a Etherscan(opens in a new tab).

Así que 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), 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.

1 ...
2 log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)
Copiar

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 el tema que comienza con 0xdbd5.... Si hacemos clic en una de las transacciones que generaron dicha entrada(opens 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 como retribución obtuvo ETH.

Transacción de reclamación (claim)

1e7df9d3

Esta función es muy similar a claim arriba. 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) payable:
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, 'Invalid proof'
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
Copiar

La principal diferencia es que el primer parámetro, la ventana para hacer el retiro, 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
Copiar

Así que parece una variante de claim que reclama todas las ventanas.

Conclusión

A estas alturas debería saber cómo entender los contratos cuyo código fuente no esté disponible, utilizando los códigos de operación (u opcodes) o, cuando funcione, el decompilador. Como es evidente en la longitud 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 funcionen según lo previsto.

Última edición: @wackerow(opens in a new tab), 2 de abril de 2024

¿Le ha resultado útil este tutorial?