Weiter zum Hauptinhalt

Reverse Engineering eines Contracts

evm
opcodes
Fortgeschritten
Ori Pomerantz
30. Dezember 2021
32 Minuten Lesedauer

Einführung

Auf der Blockchain gibt es keine Geheimnisse, alles, was geschieht, ist konsistent, nachprüfbar und öffentlich zugänglich. Idealerweise sollten Contracts ihren Quellcode auf Etherscan veröffentlichen und verifizieren lassenopens in a new tab. Das ist jedoch nicht immer der Fallopens in a new tab. In diesem Artikel lernen Sie, wie Sie Contracts per Reverse Engineering analysieren, indem Sie sich einen Contract ohne Quellcode ansehen: 0x2510c039cc3b061d79e564b38836da87e31b342fopens in a new tab.

Es gibt Reverse-Compiler, aber sie liefern nicht immer brauchbare Ergebnisseopens in a new tab. In diesem Artikel lernen Sie, wie Sie einen Contract manuell per Reverse Engineering analysieren und anhand der Opcodesopens in a new tab verstehen können, und wie Sie die Ergebnisse eines Decompilers interpretieren.

Um diesen Artikel zu verstehen, sollten Sie bereits die Grundlagen der EVM kennen und zumindest einigermaßen mit EVM-Assembler vertraut sein. Hier können Sie mehr über diese Themen lesenopens in a new tab.

Vorbereitung des ausführbaren Codes

Sie können die Opcodes abrufen, indem Sie auf Etherscan zum Contract navigieren, auf den Tab Contract und dann auf Switch to Opcodes View klicken. Sie erhalten eine Ansicht mit einem Opcode pro Zeile.

Opcode-Ansicht von Etherscan

Um Sprünge (Jumps) zu verstehen, müssen Sie jedoch wissen, wo sich die einzelnen Opcodes im Code befinden. Eine Möglichkeit besteht darin, ein Google Spreadsheet zu öffnen und die Opcodes in Spalte C einzufügen. Sie können die folgenden Schritte überspringen, indem Sie eine Kopie dieser bereits vorbereiteten Tabelle erstellenopens in a new tab.

Der nächste Schritt besteht darin, die korrekten Code-Positionen zu ermitteln, damit wir die Sprünge verstehen können. Wir tragen die Opcode-Größe in Spalte B und die Position (in hexadezimaler Schreibweise) in Spalte A ein. Geben Sie diese Funktion in Zelle B1 ein und kopieren Sie sie dann für den Rest von Spalte B bis zum Ende des Codes. Danach können Sie Spalte B ausblenden.

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

Zuerst fügt diese Funktion ein Byte für den Opcode selbst hinzu und sucht dann nach PUSH. Push-Opcodes sind besonders, da sie zusätzliche Bytes für den Wert benötigen, der gepusht wird. Wenn der Opcode ein PUSH ist, extrahieren wir die Anzahl der Bytes und addieren sie.

In A1 setzen Sie den ersten Offset, Null. Geben Sie dann in A2 diese Funktion ein und kopieren Sie sie wieder für den Rest der Spalte A:

1=dec2hex(hex2dec(A1)+B1)

Wir benötigen diese Funktion, um den hexadezimalen Wert zu erhalten, da die Werte, die vor Sprüngen (JUMP und JUMPI) gepusht werden, in hexadezimaler Form angegeben werden.

Der Einstiegspunkt (0x00)

Contracts werden immer ab dem ersten Byte ausgeführt. Dies ist der erste Teil des Codes:

OffsetOpcodeStack (nach dem Opcode)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTORELeer
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPILeer

Dieser Code macht zwei Dinge:

  1. Schreibt 0x80 als 32-Byte-Wert in die Speicherstellen 0x40-0x5F (0x80 wird in 0x5F gespeichert, und 0x40-0x5E sind alle Nullen).
  2. Liest die Calldata-Größe. Normalerweise folgen die Calldata für einen Ethereum-Contract dem ABI (Application Binary Interface)opens in a new tab, das mindestens vier Bytes für den Funktions-Selektor erfordert. Wenn die Calldata-Größe kleiner als vier ist, springe zu 0x5E.

Flussdiagramm für diesen Abschnitt

Der Handler bei 0x5E (für Nicht-ABI-Calldata)

OffsetOpcode
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Dieses Snippet beginnt mit einem JUMPDEST. EVM-Programme (Ethereum Virtual Machine) lösen eine Ausnahme aus, wenn Sie zu einem Opcode springen, der nicht JUMPDEST ist. Dann prüft er die CALLDATASIZE und springt, wenn sie „wahr“ ist (d. h. nicht null), zu 0x7C. Darauf kommen wir unten zu sprechen.

OffsetOpcodeStack (nach Opcode)
64CALLVALUE, der durch den Aufruf bereitgestellt wird. Wird in Solidity msg.value genannt
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADSpeicher[6] CALLVALUE 0 6 CALLVALUE

Wenn es also keine Calldata gibt, lesen wir den Wert von Speicher[6]. Wir wissen noch nicht, was dieser Wert ist, aber wir können nach Transaktionen suchen, die der Contract ohne Calldata erhalten hat. Transaktionen, die nur ETH ohne Calldata (und damit ohne Methode) übertragen, haben in Etherscan die Methode Transfer. Tatsächlich ist die allererste Transaktion, die der Contract erhalten hatopens in a new tab, ein Transfer.

Wenn wir uns diese Transaktion ansehen und auf Click to see More klicken, sehen wir, dass die Calldata, als Input Data bezeichnet, tatsächlich leer sind (0x). Beachten Sie auch, dass der Wert 1,559 ETH beträgt, was später relevant sein wird.

Die Calldata sind leer

Klicken Sie als Nächstes auf den Tab State und erweitern Sie den Contract, den wir per Reverse Engineering analysieren (0x2510...). Sie können sehen, dass sich Speicher[6] während der Transaktion geändert hat, und wenn Sie Hex auf Number ändern, sehen Sie, dass es 1.559.000.000.000.000.000 wurde, der in Wei übertragene Wert (ich habe die Kommas zur besseren Lesbarkeit hinzugefügt), der dem nächsten Contract-Wert entspricht.

Die Änderung in Speicher[6]

Wenn wir uns die Zustandsänderungen ansehen, die durch andere Transfer-Transaktionen aus demselben Zeitraumopens in a new tab verursacht wurden, sehen wir, dass Speicher[6] den Wert des Contracts eine Zeit lang nachverfolgt hat. Vorerst nennen wir es Wert*. Das Sternchen (*) erinnert uns daran, dass wir noch nicht wissen, was diese Variable tut, aber sie kann nicht nur dazu dienen, den Contract-Wert zu verfolgen, da es nicht nötig ist, Speicher zu verwenden, der sehr teuer ist, wenn man den Kontostand seines Accounts mit ADDRESS BALANCE abrufen kann. Der erste Opcode pusht die eigene Adresse des Contracts. Der zweite liest die Adresse an der Spitze des Stacks und ersetzt sie durch das Guthaben dieser Adresse.

OffsetOpcodeStack
6CPUSH2 0x00750x75 Wert* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Wert* 0x75 0 6 CALLVALUE
70SWAP1Wert* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Wert* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

Wir werden diesen Code am Sprungziel weiterverfolgen.

OffsetOpcodeStack
1A7JUMPDESTWert* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE

Das NOT ist bitweise, also kehrt es den Wert jedes Bits im Aufrufwert um.

OffsetOpcodeStack
1ACDUP3Wert* 2^256-CALLVALUE-1 0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTWert*>2^256-CALLVALUE-1 0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROWert*<=2^256-CALLVALUE-1 0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Wert*<=2^256-CALLVALUE-1 0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Wir springen, wenn Wert* kleiner oder gleich 2^256-CALLVALUE-1 ist. Das sieht nach einer Logik zur Vermeidung von Überläufen aus. Und tatsächlich sehen wir, dass der Contract nach ein paar unsinnigen Operationen (z. B. das Schreiben in den Speicher, der gleich gelöscht wird) bei Offset 0x01DE zurückgesetzt wird, wenn der Überlauf erkannt wird, was ein normales Verhalten ist.

Beachten Sie, dass ein solcher Überlauf extrem unwahrscheinlich ist, da der Aufrufwert plus Wert* vergleichbar mit 2^256 Wei sein müsste, was etwa 10^59 ETH entspricht. Die gesamte ETH-Menge beträgt zum Zeitpunkt der Erstellung dieses Artikels weniger als zweihundert Millionenopens in a new tab.

OffsetOpcodeStack
1DFJUMPDEST0x00 Wert* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPWert* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDWert*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Wert*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Wenn wir hier angekommen sind, erhalten wir Wert* + CALLVALUE und springen zu Offset 0x75.

OffsetOpcodeStack
75JUMPDESTWert*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Wert*+CALLVALUE 6 CALLVALUE
77SWAP26 Wert*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Wenn wir hier ankommen (was leere Calldata voraussetzt), addieren wir den Aufrufwert zu Wert*. Dies stimmt mit dem überein, was Transfer-Transaktionen laut unserer Aussage tun.

OffsetOpcode
79POP
7APOP
7BSTOP

Leeren Sie schließlich den Stack (was nicht notwendig ist) und signalisieren Sie das erfolgreiche Ende der Transaktion.

Zusammenfassend finden Sie hier ein Flussdiagramm für den ursprünglichen Code.

Flussdiagramm für den Einstiegspunkt

Der Handler bei 0x7C

Ich habe absichtlich nicht in die Überschrift geschrieben, was dieser Handler tut. Der Punkt ist nicht, Ihnen beizubringen, wie dieser spezielle Contract funktioniert, sondern wie man Contracts per Reverse Engineering analysiert. Sie werden auf die gleiche Weise wie ich lernen, was er tut, indem Sie dem Code folgen.

Wir gelangen von mehreren Stellen hierher:

  • Wenn Calldata von 1, 2 oder 3 Bytes vorhanden sind (von Offset 0x63)
  • Wenn die Methodensignatur unbekannt ist (von Offsets 0x42 und 0x5D)
OffsetOpcodeStack
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADSpeicher[3] 0x9D 0x00

Dies ist eine weitere Speicherzelle, die ich in keiner Transaktion finden konnte, so dass es schwieriger ist zu wissen, was sie bedeutet. Der folgende Code wird dies verdeutlichen.

OffsetOpcodeStack
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Speicher[3] 0x9D 0x00
9AANDSpeicher[3]-als-Adresse 0x9D 0x00

Diese Opcodes kürzen den Wert, den wir aus Speicher[3] lesen, auf 160 Bit, die Länge einer Ethereum-Adresse.

OffsetOpcodeStack
9BSWAP10x9D Speicher[3]-als-Adresse 0x00
9CJUMPSpeicher[3]-als-Adresse 0x00

Dieser Sprung ist überflüssig, da wir zum nächsten Opcode gehen. Dieser Code ist bei weitem nicht so gas-effizient, wie er sein könnte.

OffsetOpcodeStack
9DJUMPDESTSpeicher[3]-als-Adresse 0x00
9ESWAP10x00 Speicher[3]-als-Adresse
9FPOPSpeicher[3]-als-Adresse
A0PUSH1 0x400x40 Speicher[3]-als-Adresse
A2MLOADMem[0x40] Speicher[3]-als-Adresse

Ganz am Anfang des Codes setzen wir Mem[0x40] auf 0x80. Wenn wir später nach 0x40 suchen, sehen wir, dass wir es nicht ändern - wir können also davon ausgehen, dass es 0x80 ist.

OffsetOpcodeStack
A3CALLDATASIZECALLDATASIZE 0x80 Speicher[3]-als-Adresse
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Speicher[3]-als-Adresse
A6DUP30x80 0x00 CALLDATASIZE 0x80 Speicher[3]-als-Adresse
A7CALLDATACOPY0x80 Speicher[3]-als-Adresse

Kopieren Sie alle Calldata in den Speicher, beginnend bei 0x80.

OffsetOpcodeStack
A8PUSH1 0x000x00 0x80 Speicher[3]-als-Adresse
AADUP10x00 0x00 0x80 Speicher[3]-als-Adresse
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Speicher[3]-als-Adresse
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Speicher[3]-als-Adresse
ADDUP6Speicher[3]-als-Adresse 0x80 CALLDATASIZE 0x00 0x00 0x80 Speicher[3]-als-Adresse
AEGASGAS Speicher[3]-als-Adresse 0x80 CALLDATASIZE 0x00 0x00 0x80 Speicher[3]-als-Adresse
AFDELEGATE_CALL

Jetzt sind die Dinge viel klarer. Dieser Contract kann als Proxyopens in a new tab fungieren und die Adresse in Speicher[3] aufrufen, um die eigentliche Arbeit zu erledigen. DELEGATE_CALL ruft einen separaten Contract auf, bleibt aber im selben Speicher. Das bedeutet, dass der delegierte Contract, für den wir ein Proxy sind, auf denselben Speicherplatz zugreift. Die Parameter für den Aufruf sind:

  • Gas: Das gesamte verbleibende Gas
  • Aufgerufene Adresse: Speicher[3]-als-Adresse
  • Calldata: Die CALLDATASIZE-Bytes, die bei 0x80 beginnen, wo wir die ursprünglichen Calldata platziert haben
  • Rückgabedaten: Keine (0x00 - 0x00) Wir erhalten die Rückgabedaten auf andere Weise (siehe unten)
OffsetOpcodeStack
B0RETURNDATASIZERETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B1DUP1RETURNDATASIZE RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B5RETURNDATACOPYRETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse

Hier kopieren wir alle Rückgabedaten in den Speicherpuffer, beginnend bei 0x80.

OffsetOpcodeStack
B6DUP2(((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B7DUP1(((Aufruf erfolgreich/fehlgeschlagen))) (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B8ISZERO(((ist der Aufruf fehlgeschlagen))) (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
B9PUSH2 0x00c00xC0 (((ist der Aufruf fehlgeschlagen))) (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
BCJUMPI(((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
BDDUP2RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
BEDUP50x80 RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
BFRETURN

Nach dem Aufruf kopieren wir also die Rückgabedaten in den Puffer 0x80 - 0x80+RETURNDATASIZE, und wenn der Aufruf erfolgreich ist, führen wir RETURN mit genau diesem Puffer aus.

DELEGATECALL fehlgeschlagen

Wenn wir hier, bei 0xC0, ankommen, bedeutet das, dass der aufgerufene Contract zurückgesetzt wurde. Da wir nur ein Proxy für diesen Contract sind, wollen wir dieselben Daten zurückgeben und ebenfalls zurücksetzen.

OffsetOpcodeStack
C0JUMPDEST(((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
C1DUP2RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
C2DUP50x80 RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Speicher[3]-als-Adresse
C3REVERT

Wir führen also REVERT mit demselben Puffer aus, den wir zuvor für RETURN verwendet haben: 0x80 - 0x80+RETURNDATASIZE

Aufruf an Proxy-Flussdiagramm

ABI-Aufrufe

Wenn die Calldata-Größe vier Bytes oder mehr beträgt, könnte dies ein gültiger ABI-Aufruf sein.

OffsetOpcodeStack
DPUSH1 0x000x00
FCALLDATALOAD(((Erstes Wort (256 Bit) der Calldata)))
10PUSH1 0xe00xE0 (((Erstes Wort (256 Bit) der Calldata)))
12SHR(((erste 32 Bits (4 Bytes) der Calldata)))

Etherscan teilt uns mit, dass 1C ein unbekannter Opcode ist, da er hinzugefügt wurde, nachdem Etherscan diese Funktion geschrieben hatopens in a new tab und sie sie nicht aktualisiert haben. Eine aktuelle Opcode-Tabelleopens in a new tab zeigt uns, dass dies eine Rechtsverschiebung ist

OffsetOpcodeStack
13DUP1(((erste 32 Bits (4 Bytes) der Calldata))) (((erste 32 Bits (4 Bytes) der Calldata)))
14PUSH4 0x3cd8045e0x3CD8045E (((erste 32 Bits (4 Bytes) der Calldata))) (((erste 32 Bits (4 Bytes) der Calldata)))
19GT0x3CD8045E>erste-32-Bits-der-Calldata (((erste 32 Bits (4 Bytes) der Calldata)))
1APUSH2 0x00430x43 0x3CD8045E>erste-32-Bits-der-Calldata (((erste 32 Bits (4 Bytes) der Calldata)))
1DJUMPI(((erste 32 Bits (4 Bytes) der Calldata)))

Indem die Tests zum Abgleich der Methodensignatur auf diese Weise in zwei Teile aufgeteilt werden, wird im Durchschnitt die Hälfte der Tests eingespart. Der Code, der unmittelbar darauf folgt, und der Code in 0x43 folgen demselben Muster: DUP1 die ersten 32 Bits der Calldata, PUSH4 (((Methodensignatur>, EQ ausführen, um auf Gleichheit zu prüfen, und dann JUMPI, wenn die Methodensignatur übereinstimmt. Hier sind die Methodensignaturen, ihre Adressen und, falls bekannt, die entsprechende Methodendefinitionopens in a new tab:

MethodeMethodensignaturOffset, zu dem gesprungen wird
splitter()opens in a new tab0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

Wenn keine Übereinstimmung gefunden wird, springt der Code zum Proxy-Handler bei 0x7C in der Hoffnung, dass der Contract, für den wir ein Proxy sind, eine Übereinstimmung hat.

Flussdiagramm der ABI-Aufrufe

splitter()

OffsetOpcodeStack
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Als Erstes prüft diese Funktion, ob der Aufruf keine ETH gesendet hat. Diese Funktion ist nicht payableopens in a new tab. Wenn uns jemand ETH geschickt hat, muss das ein Fehler sein und wir wollen REVERT ausführen, um zu vermeiden, dass diese ETH dort sind, wo sie sie nicht zurückbekommen können.

OffsetOpcodeStack
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Speicher[3] a.k.a. der Contract, für den wir ein Proxy sind)))
114PUSH1 0x400x40 (((Speicher[3] a.k.a. der Contract, für den wir ein Proxy sind)))
116MLOAD0x80 (((Speicher[3] a.k.a. der Contract, für den wir ein Proxy sind)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Speicher[3] a.k.a. der Contract, für den wir ein Proxy sind)))
12CSWAP10x80 0xFF...FF (((Speicher[3] a.k.a. der Contract, für den wir ein Proxy sind)))
12DSWAP2(((Speicher[3] a.k.a. der Contract, für den wir ein Proxy sind))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Und 0x80 enthält nun die Proxy-Adresse

OffsetOpcodeStack
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Der E4-Code

Wir sehen diese Zeilen zum ersten Mal, aber sie werden mit anderen Methoden geteilt (siehe unten). Wir nennen den Wert im Stack also X und merken uns nur, dass in splitter() der Wert dieses X 0xA0 ist.

OffsetOpcodeStack
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Dieser Code empfängt also einen Speicherzeiger im Stack (X) und veranlasst den Contract, mit einem Puffer, der 0x80 - X ist, RETURN auszuführen.

Im Fall von splitter() wird die Adresse zurückgegeben, für die wir ein Proxy sind. RETURN gibt den Puffer in 0x80-0x9F zurück, wo wir diese Daten geschrieben haben (Offset 0x130 oben).

currentWindow()

Der Code in den Offsets 0x158-0x163 ist identisch mit dem, was wir in 0x103-0x10E in splitter() gesehen haben (außer dem JUMPI-Ziel), also wissen wir, dass currentWindow() auch nicht payable ist.

OffsetOpcodeStack
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADSpeicher[1] 0xDA
16CDUP20xDA Speicher[1] 0xDA
16DJUMPSpeicher[1] 0xDA

Der DA-Code

Dieser Code wird auch von anderen Methoden verwendet. Wir nennen den Wert im Stack also Y und merken uns nur, dass in currentWindow() der Wert dieses Y Speicher[1] ist.

OffsetOpcodeStack
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Schreiben Sie Y nach 0x80-0x9F.

OffsetOpcodeStack
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Und der Rest wird bereits oben erklärt. Sprünge zu 0xDA schreiben also den obersten Stack-Wert (Y) in 0x80-0x9F und geben diesen Wert zurück. Im Fall von currentWindow() wird Speicher[1] zurückgegeben.

merkleRoot()

Der Code in den Offsets 0xED-0xF8 ist identisch mit dem, was wir in 0x103-0x10E in splitter() gesehen haben (abgesehen vom JUMPI-Ziel), also wissen wir, dass merkleRoot() ebenfalls nicht payable ist.

OffsetOpcodeStack
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADSpeicher[0] 0xDA
101DUP20xDA Speicher[0] 0xDA
102JUMPSpeicher[0] 0xDA

Was nach dem Sprung passiert, haben wir bereits herausgefunden. merkleRoot() gibt also Speicher[0] zurück.

0x81e580d3

Der Code in den Offsets 0x138-0x143 ist identisch mit dem, was wir in 0x103-0x10E in splitter() gesehen haben (abgesehen vom JUMPI-Ziel), also wissen wir, dass diese Funktion ebenfalls nicht payable ist.

OffsetOpcodeStack
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

Es sieht so aus, als ob diese Funktion mindestens 32 Bytes (ein Wort) an Calldata benötigt.

OffsetOpcodeStack
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Wenn sie die Calldata nicht erhält, wird die Transaktion ohne Rückgabedaten zurückgesetzt.

Sehen wir uns an, was passiert, wenn die Funktion die benötigten Calldata doch erhält.

OffsetOpcodeStack
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) ist das erste Wort der Calldata nach der Methodensignatur

OffsetOpcodeStack
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
173SLOADSpeicher[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Speicher[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Speicher[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Speicher[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

Wenn das erste Wort nicht kleiner als Speicher[4] ist, schlägt die Funktion fehl. Sie wird ohne zurückgegebenen Wert zurückgesetzt:

OffsetOpcodeStack
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Wenn calldataload(4) kleiner als Speicher[4] ist, erhalten wir diesen Code:

OffsetOpcodeStack
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

Und die Speicherorte 0x00-0x1F enthalten nun die Daten 0x04 (0x00-0x1E sind alle Nullen, 0x1F ist vier)

OffsetOpcodeStack
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 von 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 von 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADSpeicher[(((SHA3 von 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

Es gibt also eine Nachschlagetabelle im Speicher, die beim SHA3 von 0x000...0004 beginnt und einen Eintrag für jeden legitimen Calldata-Wert hat (Wert unter Speicher[4]).

OffsetOpcodeStack
18BSWAP1calldataload(4) Speicher[(((SHA3 von 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPSpeicher[(((SHA3 von 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Speicher[(((SHA3 von 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPSpeicher[(((SHA3 von 0x00-0x1F))) + calldataload(4)] 0xDA

Wir wissen bereits, was der Code bei Offset 0xDA tut, er gibt den obersten Wert des Stacks an den Aufrufer zurück. Diese Funktion gibt also den Wert aus der Nachschlagetabelle an den Aufrufer zurück.

0x1f135823

Der Code in den Offsets 0xC4-0xCF ist identisch mit dem, was wir in 0x103-0x10E in splitter() gesehen haben (abgesehen vom JUMPI-Ziel), also wissen wir, dass diese Funktion ebenfalls nicht payable ist.

OffsetOpcodeStack
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADWert* 0xDA
D8DUP20xDA Wert* 0xDA
D9JUMPWert* 0xDA

Wir wissen bereits, was der Code bei Offset 0xDA tut, er gibt den obersten Wert des Stacks an den Aufrufer zurück. Diese Funktion gibt also Wert* zurück.

Methoden-Zusammenfassung

Haben Sie das Gefühl, dass Sie den Contract an diesem Punkt verstehen? Ich nicht. Bisher haben wir diese Methoden:

MethodeBedeutung
TransferAkzeptieren Sie den vom Aufruf bereitgestellten Wert und erhöhen Sie Wert* um diesen Betrag
splitter()Gibt Speicher[3], die Proxy-Adresse, zurück
currentWindow()Gibt Speicher[1] zurück
merkleRoot()Gibt Speicher[0] zurück
0x81e580d3Gibt den Wert aus einer Nachschlagetabelle zurück, vorausgesetzt, der Parameter ist kleiner als Speicher[4]
0x1f135823Gibt Speicher[6] zurück, a.k.a. Wert*

Aber wir wissen, dass jede andere Funktionalität vom Contract in Speicher[3] bereitgestellt wird. Vielleicht gibt es uns einen Hinweis, wenn wir wüssten, was dieser Contract ist. Zum Glück ist dies die Blockchain und alles ist bekannt, zumindest in der Theorie. Wir haben keine Methoden gesehen, die Speicher[3] setzen, also muss er vom Konstruktor gesetzt worden sein.

Der Konstruktor

Wenn wir uns einen Contract ansehenopens in a new tab, können wir auch die Transaktion sehen, die ihn erstellt hat.

Klicken Sie auf die Erstellungstransaktion

Wenn wir auf diese Transaktion und dann auf den Tab State klicken, können wir die Anfangswerte der Parameter sehen. Insbesondere können wir sehen, dass Speicher[3] 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens in a new tab enthält. Dieser Contract muss die fehlende Funktionalität enthalten. Wir können ihn mit denselben Werkzeugen verstehen, die wir für den von uns untersuchten Contract verwendet haben.

Der Proxy-Contract

Mit den gleichen Techniken, die wir für den ursprünglichen Contract oben verwendet haben, können wir sehen, dass der Contract zurückgesetzt wird, wenn:

  • Dem Aufruf ETH beigefügt ist (0x05-0x0F)
  • Die Calldata-Größe ist kleiner als vier (0x10-0x19 und 0xBE-0xC2)

Und dass die Methoden, die es unterstützt, sind:

Wir können die unteren vier Methoden ignorieren, weil wir sie nie erreichen werden. Ihre Signaturen sind so, dass unser ursprünglicher Contract sich selbst um sie kümmert (Sie können auf die Signaturen klicken, um die Details oben zu sehen), also müssen es Methoden sein, die überschrieben werdenopens in a new tab.

Eine der verbleibenden Methoden ist claim(<params>), und eine andere ist isClaimed(<params>), also sieht es wie ein Airdrop-Contract aus. Anstatt den Rest Opcode für Opcode durchzugehen, können wir den Decompiler ausprobierenopens in a new tab, der für drei Funktionen aus diesem Contract brauchbare Ergebnisse liefert. Das Reverse Engineering der anderen wird dem Leser als Übung überlassen.

scaleAmountByPercentage

Das gibt uns der Decompiler für diese Funktion aus:

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)

Das erste require testet, ob die Calldata zusätzlich zu den vier Bytes der Funktionssignatur mindestens 64 Bytes haben, genug für die beiden Parameter. Wenn nicht, dann ist offensichtlich etwas falsch.

Die if-Anweisung scheint zu prüfen, dass _param1 nicht Null ist und dass _param1 * _param2 nicht negativ ist. Es dient wahrscheinlich dazu, Fälle von Wrap-Around zu verhindern.

Schließlich gibt die Funktion einen skalierten Wert zurück.

claim

Der vom Decompiler erstellte Code ist komplex und nicht alles davon ist für uns relevant. Ich werde einen Teil davon überspringen, um mich auf die Zeilen zu konzentrieren, von denen ich glaube, dass sie nützliche Informationen liefern

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'

Wir sehen hier zwei wichtige Dinge:

  • _param2 ist, obwohl als uint256 deklariert, tatsächlich eine Adresse
  • _param1 ist das Fenster, das beansprucht wird, und muss currentWindow oder früher sein.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'

Jetzt wissen wir also, dass Speicher[5] ein Array von Fenstern und Adressen ist und ob die Adresse die Belohnung für dieses Fenster beansprucht hat.

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'
Alles anzeigen

Wir wissen, dass unknown2eb4a7ab eigentlich die Funktion merkleRoot() ist, also sieht dieser Code so aus, als würde er einen Merkle-Beweisopens in a new tab verifizieren. Das bedeutet, dass _param4 ein Merkle-Beweis ist.

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

So überträgt ein Contract seine eigenen ETH an eine andere Adresse (Contract oder externer Besitz). Er ruft ihn mit einem Wert auf, der dem zu übertragenden Betrag entspricht. Es sieht also so aus, als ob dies ein Airdrop von ETH ist.

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

Die unteren beiden Zeilen sagen uns, dass Speicher[2] auch ein Contract ist, den wir aufrufen. Wenn wir uns die Konstruktor-Transaktion ansehenopens in a new tab, sehen wir, dass dieser Contract 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2opens in a new tab ist, ein Wrapped Ether-Contract, dessen Quellcode auf Etherscan hochgeladen wurdeopens in a new tab.

Es sieht also so aus, als ob die Contracts versuchen, ETH an _param2 zu senden. Wenn er es tun kann, großartig. Wenn nicht, versucht er, WETHopens in a new tab zu senden. Wenn _param2 ein Konto in externem Besitz (EOA) ist, kann es immer ETH empfangen, aber Contracts können den Empfang von ETH verweigern. WETH ist jedoch ERC-20 und Contracts können die Annahme nicht verweigern.

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

Am Ende der Funktion sehen wir, dass ein Log-Eintrag generiert wird. Sehen Sie sich die generierten Log-Einträge anopens in a new tab und filtern Sie nach dem Thema, das mit 0xdbd5... beginnt. Wenn wir auf eine der Transaktionen klicken, die einen solchen Eintrag generiert habenopens in a new tab, sehen wir, dass es tatsächlich wie ein Anspruch aussieht - das Konto hat eine Nachricht an den Contract gesendet, den wir per Reverse Engineering analysieren, und im Gegenzug ETH erhalten.

Eine Anspruchs-Transaktion

1e7df9d3

Diese Funktion ist sehr ähnlich zu claim oben. Es prüft auch einen Merkle-Beweis, versucht ETH auf den ersten zu übertragen und erzeugt die gleiche Art von Log-Eintrag.

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)
Alles anzeigen

Der Hauptunterschied besteht darin, dass der erste Parameter, das abzuhebende Fenster, nicht vorhanden ist. Stattdessen gibt es eine Schleife über alle Fenster, die beansprucht werden könnten.

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
Alles anzeigen

Es sieht also wie eine claim-Variante aus, die alle Fenster beansprucht.

Fazit

Inzwischen sollten Sie wissen, wie Sie Contracts verstehen können, deren Quellcode nicht verfügbar ist, indem Sie entweder die Opcodes oder (wenn es funktioniert) den Decompiler verwenden. Wie aus der Länge dieses Artikels ersichtlich ist, ist das Reverse Engineering eines Contracts nicht trivial, aber in einem System, in dem Sicherheit unerlässlich ist, ist es eine wichtige Fähigkeit, überprüfen zu können, ob Contracts wie versprochen funktionieren.

Hier finden Sie mehr von meiner Arbeitopens in a new tab.

Seite zuletzt aktualisiert: 22. August 2025

War dieses Tutorial hilfreich?