Pular para o conteúdo principal

Engenharia Reversa de um Contrato

evm
códigos de operação
Avançado
Ori Pomerantz
30 de dezembro de 2021
33 minutos de leitura

Introdução

Não há segredos no blockchain, tudo o que acontece é consistente, verificável e está disponível publicamente. Idealmente, os contratos devem ter seu código-fonte publicado e verificado no Etherscan (opens in a new tab). No entanto, nem sempre é o caso (opens in a new tab). Neste artigo, você aprenderá como fazer engenharia reversa de contratos analisando um contrato sem código-fonte, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Existem compiladores reversos, mas eles nem sempre produzem resultados utilizáveis (opens in a new tab). Neste artigo, você aprenderá a fazer engenharia reversa manualmente e a entender um contrato a partir dos códigos de operação (opens in a new tab), bem como a interpretar os resultados de um descompilador.

Para entender este artigo, você já deve saber o básico da EVM e estar pelo menos um pouco familiarizado com a linguagem de montagem da EVM. Você pode ler sobre esses tópicos aqui (opens in a new tab).

Preparar o código executável

Você pode obter os códigos de operação indo para o Etherscan para o contrato, clicando na guia Contrato e depois em Mudar para Visualização de Opcodes. Você obterá uma visualização de um código de operação por linha.

Visualização de código de operação do Etherscan

Para entender os saltos, no entanto, você precisa saber onde no código cada código de operação está localizado. Para fazer isso, uma maneira é abrir uma planilha do Google e colar os códigos de operação na coluna C. Você pode pular as etapas a seguir fazendo uma cópia desta planilha já preparada (opens in a new tab).

O próximo passo é obter os locais corretos do código para que possamos entender os saltos. Colocaremos o tamanho do código de operação na coluna B e o local (em hexadecimal) na coluna A. Digite esta função na célula B1 e depois copie e cole no resto da coluna B, até o final do código. Depois de fazer isso, você pode ocultar a coluna B.

1=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)

Primeiro, esta função adiciona um byte para o próprio código de operação e, em seguida, procura por PUSH. Os códigos de operação de push são especiais porque precisam ter bytes adicionais para o valor que está sendo inserido. Se o código de operação for um PUSH, extraímos o número de bytes e o adicionamos.

Em A1, coloque o primeiro deslocamento, zero. Depois, em A2, insira esta função e copie e cole novamente para o resto da coluna A:

1=dec2hex(hex2dec(A1)+B1)

Precisamos desta função para nos dar o valor hexadecimal, porque os valores que são inseridos antes dos saltos (JUMP e JUMPI) são nos dados em hexadecimal.

O ponto de entrada (0x00)

Os contratos são sempre executados a partir do primeiro byte. Esta é a parte inicial do código:

DeslocamentoCódigo de OperaçãoPilha (após o código de operação)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREVazio
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIVazio

Este código faz duas coisas:

  1. Escreve 0x80 como um valor de 32 bytes para os locais de memória 0x40-0x5F (0x80 é armazenado em 0x5F e 0x40-0x5E são todos zeros).
  2. Lê o tamanho dos dados de chamada. Normalmente, os dados de chamada para um contrato Ethereum seguem a ABI (interface binária de aplicativo) (opens in a new tab), que no mínimo requer quatro bytes para o seletor de função. Se o tamanho dos dados da chamada for menor que quatro, salta para 0x5E.

Fluxograma para esta parte

O manipulador em 0x5E (para dados de chamada não ABI)

DeslocamentoCódigo de Operação
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Este trecho começa com um JUMPDEST. Programas EVM (máquina virtual Ethereum) lançam uma exceção se você saltar para um código de operação que não seja JUMPDEST. Em seguida, ele analisa o CALLDATASIZE e, se for "verdadeiro" (ou seja, diferente de zero), salta para 0x7C. Veremos isso abaixo.

DeslocamentoCódigo de OperaçãoPilha (após o código de operação)
64CALLVALUE fornecido pela chamada. Chamado de msg.value no Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Então, quando não há dados de chamada, lemos o valor de Storage[6]. Ainda não sabemos qual é esse valor, mas podemos procurar transações que o contrato recebeu sem dados de chamada. As transações que apenas transferem ETH sem quaisquer dados de chamada (e, portanto, sem método) têm no Etherscan o método Transfer. Na verdade, a primeira transação que o contrato recebeu (opens in a new tab) é uma transferência.

Se olharmos nessa transação e clicarmos em Clique para ver mais, vemos que os dados da chamada, chamados de dados de entrada, estão de fato vazios (0x). Observe também que o valor é 1,559 ETH, o que será relevante mais tarde.

Os dados da chamada estão vazios

Em seguida, clique na guia Estado e expanda o contrato do qual estamos fazendo engenharia reversa (0x2510...). Você pode ver que Storage[6] mudou durante a transação, e se você mudar de Hex para Número, verá que se tornou 1.559.000.000.000.000.000, o valor transferido em wei (adicionei os pontos para maior clareza), correspondendo ao próximo valor do contrato.

A mudança em Storage[6]

Se olharmos nas mudanças de estado causadas por outras transações de Transfer do mesmo período (opens in a new tab), vemos que Storage[6] rastreou o valor do contrato por um tempo. Por enquanto, vamos chamá-lo de Valor*. O asterisco (*) nos lembra que ainda não sabemos o que essa variável faz, mas não pode ser apenas para rastrear o valor do contrato porque não há necessidade de usar o armazenamento, que é muito caro, quando você pode obter o saldo de suas contas usando ADDRESS BALANCE. O primeiro código de operação insere o próprio endereço do contrato. O segundo lê o endereço no topo da pilha e o substitui pelo saldo desse endereço.

DeslocamentoCódigo de OperaçãoPilha
6CPUSH2 0x00750x75 Valor* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Valor* 0x75 0 6 CALLVALUE
70SWAP1Valor* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Valor* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

Continuaremos a rastrear este código no destino do salto.

DeslocamentoCódigo de OperaçãoPilha
1A7JUMPDESTValor* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE

O NOT é bit a bit, então ele inverte o valor de cada bit no valor da chamada.

DeslocamentoCódigo de OperaçãoPilha
1ACDUP3Valor* 2^256-CALLVALUE-1 0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTValor*>2^256-CALLVALUE-1 0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROValor*<=2^256-CALLVALUE-1 0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Valor*<=2^256-CALLVALUE-1 0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Saltamos se Valor* for menor ou igual a 2^256-CALLVALUE-1. Isso parece uma lógica para evitar overflow. E, de fato, vemos que, após algumas operações sem sentido (escrever na memória que está prestes a ser excluída, por exemplo), no deslocamento 0x01DE, o contrato reverte se o overflow for detectado, o que é um comportamento normal.

Note que tal overflow é extremamente improvável, porque exigiria que o valor da chamada mais Valor* fosse comparável a 2^256 wei, cerca de 10^59 ETH. O fornecimento total de ETH, no momento da escrita, é inferior a duzentos milhões (opens in a new tab).

DeslocamentoCódigo de OperaçãoPilha
1DFJUMPDEST0x00 Valor* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPValor* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDValor*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Valor*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Se chegamos aqui, obtemos Valor* + CALLVALUE e saltamos para o deslocamento 0x75.

DeslocamentoCódigo de OperaçãoPilha
75JUMPDESTValor*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Valor*+CALLVALUE 6 CALLVALUE
77SWAP26 Valor*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Se chegarmos aqui (o que exige que os dados da chamada estejam vazios), adicionamos o valor da chamada a Valor*. Isso é consistente com o que dizemos que as transações de Transfer fazem.

DeslocamentoCódigo de Operação
79POP
7APOP
7BSTOP

Finalmente, limpe a pilha (o que não é necessário) e sinalize o fim bem-sucedido da transação.

Para resumir tudo, aqui está um fluxograma para o código inicial.

Fluxograma do ponto de entrada

O manipulador em 0x7C

Eu propositalmente não coloquei no título o que este manipulador faz. O objetivo não é ensinar como este contrato específico funciona, mas como fazer engenharia reversa de contratos. Você aprenderá o que ele faz da mesma maneira que eu, seguindo o código.

Chegamos aqui de vários lugares:

  • Se houver dados de chamada de 1, 2 ou 3 bytes (do deslocamento 0x63)
  • Se a assinatura do método for desconhecida (dos deslocamentos 0x42 e 0x5D)
DeslocamentoCódigo de OperaçãoPilha
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Esta é outra célula de armazenamento, uma que não consegui encontrar em nenhuma transação, então é mais difícil saber o que significa. O código abaixo tornará isso mais claro.

DeslocamentoCódigo de OperaçãoPilha
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-como-endereço 0x9D 0x00

Esses códigos de operação truncam o valor que lemos de Storage[3] para 160 bits, o comprimento de um endereço Ethereum.

DeslocamentoCódigo de OperaçãoPilha
9BSWAP10x9D Storage[3]-como-endereço 0x00
9CJUMPStorage[3]-como-endereço 0x00

Este salto é supérfluo, já que estamos indo para o próximo código de operação. Este código não é tão eficiente em termos de gás quanto poderia ser.

DeslocamentoCódigo de OperaçãoPilha
9DJUMPDESTStorage[3]-como-endereço 0x00
9ESWAP10x00 Storage[3]-como-endereço
9FPOPStorage[3]-como-endereço
A0PUSH1 0x400x40 Storage[3]-como-endereço
A2MLOADMem[0x40] Storage[3]-como-endereço

Bem no início do código, definimos Mem[0x40] como 0x80. Se procurarmos por 0x40 mais tarde, veremos que não o alteramos - então podemos assumir que é 0x80.

DeslocamentoCódigo de OperaçãoPilha
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-como-endereço
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-como-endereço
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-como-endereço
A7CALLDATACOPY0x80 Storage[3]-como-endereço

Copie todos os dados da chamada para a memória, começando em 0x80.

DeslocamentoCódigo de OperaçãoPilha
A8PUSH1 0x000x00 0x80 Storage[3]-como-endereço
AADUP10x00 0x00 0x80 Storage[3]-como-endereço
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-endereço
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-endereço
ADDUP6Storage[3]-como-endereço 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-endereço
AEGASGAS Storage[3]-como-endereço 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-como-endereço
AFDELEGATE_CALL

Agora as coisas estão muito mais claras. Este contrato pode atuar como um proxy (opens in a new tab), chamando o endereço em Storage[3] para fazer o trabalho real. DELEGATE_CALL chama um contrato separado, mas permanece no mesmo armazenamento. Isso significa que o contrato delegado, para o qual somos um proxy, acessa o mesmo espaço de armazenamento. Os parâmetros para a chamada são:

  • Gás: todo o gás restante
  • Endereço chamado: Storage[3]-como-endereço
  • Dados de chamada: Os bytes CALLDATASIZE começando em 0x80, que é onde colocamos os dados de chamada originais
  • Dados de retorno: Nenhum (0x00 - 0x00) Obteremos os dados de retorno por outros meios (veja abaixo)
DeslocamentoCódigo de OperaçãoPilha
B0RETURNDATASIZERETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B1DUP1RETURNDATASIZE RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B5RETURNDATACOPYRETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço

Aqui copiamos todos os dados de retorno para o buffer de memória começando em 0x80.

DeslocamentoCódigo de OperaçãoPilha
B6DUP2(((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B7DUP1(((sucesso/falha da chamada))) (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B8ISZERO(((a chamada falhou))) (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
B9PUSH2 0x00c00xC0 (((a chamada falhou))) (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
BCJUMPI(((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
BDDUP2RETURNDATASIZE (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
BEDUP50x80 RETURNDATASIZE (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
BFRETURN

Então, após a chamada, copiamos os dados de retorno para o buffer 0x80 - 0x80+RETURNDATASIZE e, se a chamada for bem-sucedida, fazemos um RETURN com exatamente esse buffer.

DELEGATECALL Falhou

Se chegarmos aqui, em 0xC0, significa que o contrato que chamamos reverteu. Como somos apenas um proxy para esse contrato, queremos retornar os mesmos dados e também reverter.

DeslocamentoCódigo de OperaçãoPilha
C0JUMPDEST(((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
C1DUP2RETURNDATASIZE (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
C2DUP50x80 RETURNDATASIZE (((sucesso/falha da chamada))) RETURNDATASIZE (((sucesso/falha da chamada))) 0x80 Storage[3]-como-endereço
C3REVERT

Portanto, fazemos REVERT com o mesmo buffer que usamos para RETURN anteriormente: 0x80 - 0x80+RETURNDATASIZE

Fluxograma de chamada para proxy

Chamadas ABI

Se o tamanho dos dados da chamada for de quatro bytes ou mais, pode ser uma chamada ABI válida.

DeslocamentoCódigo de OperaçãoPilha
DPUSH1 0x000x00
FCALLDATALOAD(((Primeira palavra (256 bits) dos dados da chamada)))
10PUSH1 0xe00xE0 (((Primeira palavra (256 bits) dos dados da chamada)))
12SHR(((primeiros 32 bits (4 bytes) dos dados de chamada)))

O Etherscan nos diz que 1C é um código de operação desconhecido, porque foi adicionado depois que o Etherscan escreveu este recurso (opens in a new tab) e eles não o atualizaram. Uma tabela de códigos de operação atualizada (opens in a new tab) nos mostra que isso é um deslocamento para a direita

DeslocamentoCódigo de OperaçãoPilha
13DUP1(((primeiros 32 bits (4 bytes) dos dados de chamada))) (((primeiros 32 bits (4 bytes) dos dados de chamada)))
14PUSH4 0x3cd8045e0x3CD8045E (((primeiros 32 bits (4 bytes) dos dados de chamada))) (((primeiros 32 bits (4 bytes) dos dados de chamada)))
19GT0x3CD8045E>primeiros-32-bits-dos-dados-de-chamada (((primeiros 32 bits (4 bytes) dos dados de chamada)))
1APUSH2 0x00430x43 0x3CD8045E>primeiros-32-bits-dos-dados-de-chamada (((primeiros 32 bits (4 bytes) dos dados de chamada)))
1DJUMPI(((primeiros 32 bits (4 bytes) dos dados de chamada)))

Dividir os testes de correspondência da assinatura do método em dois, dessa forma, economiza metade dos testes, em média. O código que se segue imediatamente e o código em 0x43 seguem o mesmo padrão: DUP1 os primeiros 32 bits dos dados da chamada, PUSH4 (((assinatura do método, executa EQ para verificar a igualdade e, em seguida, JUMPI se a assinatura do método corresponder. Aqui estão as assinaturas do método, seus endereços e, se conhecida, a definição do método correspondente (opens in a new tab):

MétodoAssinatura do métodoDeslocamento 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

Se nenhuma correspondência for encontrada, o código salta para o manipulador de proxy em 0x7C, na esperança de que o contrato para o qual somos um proxy tenha uma correspondência.

Fluxograma de chamadas ABI

splitter()

DeslocamentoCódigo de OperaçãoPilha
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

A primeira coisa que esta função faz é verificar se a chamada não enviou nenhum ETH. Esta função não é payable (opens in a new tab). Se alguém nos enviou ETH, deve ter sido um erro, e queremos fazer um REVERT para evitar que esse ETH fique onde eles não possam recuperá-lo.

DeslocamentoCódigo de OperaçãoPilha
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] ou seja, o contrato para o qual somos um proxy)))
114PUSH1 0x400x40 (((Storage[3] ou seja, o contrato para o qual somos um proxy)))
116MLOAD0x80 (((Storage[3] ou seja, o contrato para o qual somos um proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] ou seja, o contrato para o qual somos um proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] ou seja, o contrato para o qual somos um proxy)))
12DSWAP2(((Storage[3] ou seja, o contrato para o qual somos um proxy))) 0xFF...FF 0x80
12EANDEndereçoProxy 0x80
12FDUP20x80 EndereçoProxy 0x80
130MSTORE0x80

E 0x80 agora contém o endereço do proxy

DeslocamentoCódigo de OperaçãoPilha
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

O Código E4

Essa é a primeira vez que vemos essas linhas, mas elas são compartilhadas com outros métodos (veja abaixo). Então, vamos chamar o valor na pilha de X e apenas lembrar que em splitter() o valor desse X é 0xA0.

DeslocamentoCódigo de OperaçãoPilha
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Portanto, esse código recebe um ponteiro de memória na pilha (X) e faz com que o contrato retorne (RETURN) com um buffer que é 0x80 - X.

No caso do splitter(), isso retorna o endereço para o qual somos um proxy. RETURN retorna o buffer em 0x80-0x9F, que é onde escrevemos esses dados (deslocamento 0x130 acima).

currentWindow()

O código nos deslocamentos 0x158-0x163 é idêntico ao que vimos em 0x103-0x10E em splitter() (além do destino de JUMPI), então sabemos que currentWindow() também não é payable.

DeslocamentoCódigo de OperaçãoPilha
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

O código DA

Esse código também é compartilhado com outros métodos. Então, chamaremos o valor na pilha de Y e lembre-se de que em currentWindow() o valor desse Y é Storage[1].

DeslocamentoCódigo de OperaçãoPilha
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Escreve Y em 0x80-0x9F.

DeslocamentoCódigo de OperaçãoPilha
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

E o resto já foi explicado acima. Portanto, saltos para 0xDA escrevem o valor do topo da pilha (Y) em 0x80-0x9F e retornam esse valor. No caso de currentWindow(), ele retorna Storage[1].

merkleRoot()

O código nos deslocamentos 0xED-0xF8 é idêntico ao que vimos em 0x103-0x10E em splitter() (além do destino de JUMPI), então sabemos que merkleRoot() também não é payable.

DeslocamentoCódigo de OperaçãoPilha
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

O que acontece após o salto já descobrimos. Portanto, merkleRoot() retorna Storage[0].

0x81e580d3

O código nos deslocamentos 0x138-0x143 é idêntico ao que vimos em 0x103-0x10E em splitter() (além do destino de JUMPI), então sabemos que esta função também não é payable.

DeslocamentoCódigo de OperaçãoPilha
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 função leva pelo menos 32 bytes (uma palavra) de dados de chamada.

DeslocamentoCódigo de OperaçãoPilha
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Se não obtiver os dados da chamada, a transação é revertida sem nenhum dado de retorno.

Vamos ver o que acontece se a função receber os dados de chamada de que precisa.

DeslocamentoCódigo de OperaçãoPilha
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) é a primeira palavra dos dados da chamada após a assinatura do método

DeslocamentoCódigo de OperaçãoPilha
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

Se a primeira palavra não for menor que Storage[4], a função falhará. Ela reverte sem nenhum valor retornado:

DeslocamentoCódigo de OperaçãoPilha
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Se calldataload(4) for menor que Storage[4], obtemos este código:

DeslocamentoCódigo de OperaçãoPilha
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

E os locais de memória 0x00-0x1F agora contêm os dados 0x04 (0x00-0x1E são todos zeros, 0x1F é quatro)

DeslocamentoCódigo de OperaçãoPilha
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

Portanto, há uma tabela de pesquisa no armazenamento, que começa no SHA3 de 0x000...0004 e tem uma entrada para cada valor de dados de chamada legítimo (valor abaixo de Storage[4]).

DeslocamentoCódigo de OperaçãoPilha
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

Já sabemos o que o código no deslocamento 0xDA faz, ele retorna o valor do topo da pilha para o chamador. Logo, esta função retorna o valor da tabela de pesquisa para o chamador.

0x1f135823

O código nos deslocamentos 0xC4-0xCF é idêntico ao que vimos em 0x103-0x10E em splitter() (além do destino de JUMPI), então sabemos que esta função também não é payable.

DeslocamentoCódigo de OperaçãoPilha
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValor* 0xDA
D8DUP20xDA Valor* 0xDA
D9JUMPValor* 0xDA

Já sabemos o que o código no deslocamento 0xDA faz, ele retorna o valor do topo da pilha para o chamador. Portanto, esta função retorna Valor*.

Resumo do método

Você sente que entende o contrato até este ponto? Eu não. Até o momento, temos esses métodos:

MétodoSignificado
TransferirAceite o valor fornecido pela chamada e incremente Valor* nesse valor
splitter()Retornar Storage[3], o endereço do proxy
currentWindow()Retornar Storage[1]
merkleRoot()Retornar Storage[0]
0x81e580d3Retorna o valor de uma tabela de pesquisa, desde que o parâmetro seja menor que Storage[4]
0x1f135823Retornar Storage[6], ou seja, Valor*

Mas nós sabemos que qualquer outra funcionalidade é fornecida pelo contrato no Storage[3]. Talvez se soubéssemos qual o contrato, isso nos daria uma pista. Felizmente, isto é blockchain e tudo é conhecido, ao menos em teoria. Não vimos nenhum método que defina Storage[3], então deve ter sido definido pelo construtor.

O construtor

Quando olhamos para um contrato (opens in a new tab), também podemos ver a transação que o criou.

Clique em criar transação

Se clicarmos nessa transação e depois na guia Estado, podemos ver os valores iniciais dos parâmetros. Especificamente, podemos ver que Storage[3] contém 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Esse contrato deve conter a funcionalidade ausente. Podemos entendê-lo usando as mesmas ferramentas que utilizamos para o contrato que estamos investigando.

O contrato de proxy

Utilizando as mesmas técnicas que usamos para o contrato original acima, podemos ver que o contrato reverte se:

  • Houver algum ETH anexado à chamada (0x05-0x0F)
  • O tamanho dos dados da chamada for menor que quatro (0x10-0x19 e 0xBE-0xC2)

E que os métodos que ele suporta são:

Nós podemos ignorar os quatro métodos inferiores porque nunca chegaremos a eles. Suas assinaturas são tais que nosso contrato original cuida delas por si só (você pode clicar nas assinaturas para ver os detalhes acima), por isso devem ser métodos que são substituídos (opens in a new tab).

Um dos métodos restantes é claim(<params>), e outro é isClaimed(<params>), então parece um contrato de airdrop. Em vez de passar pelo restante código de operação por código de operação, podemos tentar o descompilador (opens in a new tab), que produz resultados úteis para três funções deste contrato. A engenharia reversa dos outros é deixada como um exercício para o leitor.

scaleAmountByPercentage

Isso é o que o descompilador nos fornece para essa função:

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)

O primeiro require testa que os dados da chamada tenham, além dos quatro bytes da assinatura da função, pelo menos 64 bytes, suficientes para os dois parâmetros. Do contrário, obviamente, há algo errado.

A instrução if parece verificar se _param1 não é zero e se _param1 * _param2 não é negativo. Provavelmente, isso é para evitar casos de wrap around (retorno).

Finalmente, a função retorna um valor escalado.

claim

O código que o descompilador cria é complexo, e nem todo ele é relevante para nós. Vou pular algumas partes para focar nas linhas que acredito fornecerem informações úteis

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, 'não é possível reivindicar para uma janela futura'

Vemos aqui duas coisas importantes:

  • _param2, embora esteja declarado como um uint256, é na verdade um endereço
  • _param1 é a janela que está sendo reivindicada, que tem que ser currentWindow ou anterior.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'A conta já reivindicou a janela fornecida'

Então, agora sabemos que Storage[5] é uma matriz de janelas e endereços, e se o endereço reivindicou a recompensa por essa janela.

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, 'Prova inválida'
Exibir tudo

Sabemos que unknown2eb4a7ab é, na verdade, a função merkleRoot(), então este código parece estar verificando uma prova de Merkle (opens in a new tab). Isso significa que _param4 é uma prova de Merkle.

1 call addr(_param2) with:
2 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
3 gas 30000 wei

É assim que um contrato transfere seu próprio ETH para outro endereço (de contrato ou de propriedade externa). Ele o chama com um valor que é a quantidade a ser transferida. Logo, parece que isso é um 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

As duas linhas inferiores nos dizem que Storage[2] também é um contrato que chamamos. Se olharmos para a transação do construtor (opens in a new tab) vemos que este contrato é 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), um contrato de Wrapped Ether cujo código-fonte foi carregado no Etherscan (opens in a new tab).

Assim, parece que os contratos tentam enviar ETH para _param2. Se puder fazer isso, ótimo. Caso contrário, ele tenta enviar WETH (opens in a new tab). Se _param2 for uma conta de propriedade externa (EOA), então ela sempre pode receber ETH, mas os contratos podem se recusar a receber ETH. No entanto, WETH é ERC-20 e os contratos não podem se recusar a aceitar isso.

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

No final da função, vemos uma entrada de log sendo gerada. Veja as entradas de log geradas (opens in a new tab) e filtre pelo tópico que começa com 0xdbd5.... Se clicarmos em uma das transações que gerou tal entrada (opens in a new tab), veremos que realmente parece uma reivindicação - a conta enviou uma mensagem para o contrato do qual estamos fazendo engenharia reversa e, em troca, recebeu ETH.

Uma transação de reivindicação

1e7df9d3

Esta função é muito semelhante a claim acima. Ela também verifica uma prova de Merkle, tenta transferir ETH para o primeiro e produz o mesmo tipo de entrada de log.

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, 'Prova invá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)
Exibir tudo

A principal diferença é que o primeiro parâmetro, a janela para retirada, não está lá. Em vez disso, há um loop sobre todas as janelas que podem ser reivindicadas.

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
Exibir tudo

Portanto, parece uma variante de claim que reivindica todas as janelas.

Conclusão

Até agora você já deve saber como entender contratos cujo código-fonte não está disponível, usando os códigos de operação ou (quando funciona) o descompilador. Como é evidente pela extensão deste artigo, a engenharia reversa de um contrato não é trivial, mas em um sistema onde a segurança é essencial, é uma habilidade importante ser capaz de verificar se os contratos funcionam como prometido.

Veja aqui mais do meu trabalho (opens in a new tab).

Última atualização da página: 22 de agosto de 2025

Este tutorial foi útil?