Zum Hauptinhalt springen

Reverse Engineering eines Vertrags

evm
opcodes
Experte
Ori Pomerantz
30. Dezember 2021
32 Minuten Lesezeit

Einführung

Es gibt keine Geheimnisse auf der Blockchain, alles, was passiert, ist konsistent, verifizierbar und öffentlich zugänglich. Im Idealfall sollte der Quellcode von Verträgen auf Etherscan veröffentlicht und verifiziert sein (opens in a new tab). Allerdings ist das nicht immer der Fall (opens in a new tab). In diesem Artikel lernen Sie, wie Sie Verträge durch Reverse Engineering analysieren können, indem wir uns einen Vertrag ohne Quellcode ansehen: 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Es gibt Reverse-Compiler, aber sie liefern nicht immer brauchbare Ergebnisse (opens in a new tab). In diesem Artikel lernen Sie, wie Sie einen Vertrag anhand der Opcodes (opens in a new tab) manuell rückentwickeln und verstehen können, sowie wie Sie die Ergebnisse eines Dekompilierers interpretieren.

Um diesen Artikel verstehen zu können, sollten Sie bereits die Grundlagen der EVM kennen und zumindest ein wenig mit EVM-Assembler vertraut sein. Sie können hier mehr über diese Themen lesen (opens in a new tab).

Den ausführbaren Code vorbereiten

Sie können die Opcodes abrufen, indem Sie auf Etherscan für den Vertrag gehen, auf den Tab Contract klicken und dann Switch to Opcodes View auswählen. Sie erhalten eine Ansicht mit einem Opcode pro Zeile.

Opcode-Ansicht von Etherscan

Um jedoch Sprünge (Jumps) zu verstehen, müssen Sie wissen, wo sich jeder Opcode im Code befindet. Eine Möglichkeit dazu 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 erstellen (opens in a new tab).

Der nächste Schritt besteht darin, die korrekten Code-Positionen zu ermitteln, damit wir Sprünge verstehen können. Wir tragen die Opcode-Größe in Spalte B und die Position (in hexadezimaler Form) 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 fügen diese hinzu.

Tragen Sie in A1 den ersten Offset ein, also null. Fügen Sie dann in A2 diese Funktion ein und kopieren Sie sie erneut für den Rest von Spalte A:

1=dec2hex(hex2dec(A1)+B1)

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

Der Einstiegspunkt (0x00)

Smart Contracts werden immer ab dem ersten Byte ausgeführt. Dies ist der anfängliche 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 Speicherorte 0x40-0x5F (0x80 wird in 0x5F gespeichert und 0x40-0x5E sind alle Nullen).
  2. Liest die Größe der Calldata. Normalerweise folgen die Call-Daten für einen Ethereum-Vertrag der ABI (Application Binary Interface) (opens in a new tab), die mindestens vier Bytes für den Funktionsselektor erfordert. Wenn die Größe der Call-Daten kleiner als vier ist, springe zu 0x5E.

Flussdiagramm für diesen Teil

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

OffsetOpcode
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Dieses Snippet beginnt mit einem JUMPDEST. EVM-Programme (Ethereum Virtual Machine) werfen eine Ausnahme, wenn man zu einem Opcode springt, der nicht JUMPDEST ist. Dann wird die CALLDATASIZE betrachtet, und wenn sie "wahr" ist (also nicht null), wird zu 0x7C gesprungen. Darauf kommen wir weiter unten zurück.

OffsetOpcodeStack (nach dem Opcode)
64CALLVALUE, die durch den Aufruf bereitgestellt werden. In Solidity msg.value genannt
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Wenn es also keine Call-Daten gibt, lesen wir den Wert von Storage[6]. Wir wissen noch nicht, was dieser Wert ist, aber wir können nach Transaktionen suchen, die der Smart Contract ohne Call-Daten empfangen hat. Transaktionen, die einfach nur ETH ohne Call-Daten (und daher ohne Methode) übertragen, haben in Etherscan die Methode Transfer. Tatsächlich ist die allererste Transaktion, die der Vertrag empfangen hat (opens in a new tab), eine Überweisung.

Wenn wir uns diese Transaktion ansehen und auf Click to see More klicken, sehen wir, dass die Call-Daten, auch Eingabedaten genannt, tatsächlich leer sind (0x). Beachten Sie auch, dass der Wert 1,559 ETH beträgt, was später noch relevant sein wird.

Die Call-Daten sind leer

Klicken Sie als Nächstes auf den Tab State und erweitern Sie den Smart Contract, den wir per Reverse Engineering untersuchen (0x2510...). Sie können sehen, dass sich Storage[6] während der Transaktion geändert hat, und wenn Sie Hex in Number ändern, sehen Sie, dass es zu 1.559.000.000.000.000.000 wurde, dem in Wei übertragenen Wert (ich habe die Punkte zur besseren Übersichtlichkeit hinzugefügt), was dem nächsten Vertragswert entspricht.

Die Änderung in Storage[6]

Wenn wir uns die Statusänderungen ansehen, die durch andere Transfer-Transaktionen aus demselben Zeitraum (opens in a new tab) verursacht wurden, sehen wir, dass Storage[6] den Wert des Vertrags eine Zeit lang verfolgt hat. Für den Moment nennen wir es Value*. Das Sternchen (*) erinnert uns daran, dass wir noch nicht wissen, was diese Variable tut, aber sie kann nicht nur dazu dienen, den Vertragswert zu verfolgen, da es nicht nötig ist, den sehr teuren Speicher (Storage) zu verwenden, wenn man das Kontoguthaben mit ADDRESS BALANCE abrufen kann. Der erste Opcode pusht die eigene Adresse des Vertrags. Der zweite liest die Adresse oben auf dem Stack und ersetzt sie durch das Guthaben dieser Adresse.

OffsetOpcodeStack
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

Wir werden diesen Code am Sprungziel weiter verfolgen.

OffsetOpcodeStack
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

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

OffsetOpcodeStack
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

Wir springen, wenn Value* kleiner als 2^256-CALLVALUE-1 oder gleich groß ist. Dies sieht nach einer Logik zur Vermeidung eines Überlaufs aus. Und tatsächlich sehen wir, dass nach ein paar unsinnigen Operationen (das Schreiben in den Arbeitsspeicher wird zum Beispiel gleich gelöscht) bei Offset 0x01DE der Smart Contract rückgängig gemacht wird (reverts), wenn der Überlauf erkannt wird, was ein normales Verhalten ist.

Beachten Sie, dass ein solcher Überlauf extrem unwahrscheinlich ist, da der Call-Wert plus Value* vergleichbar mit 2^256 Wei sein müsste, also etwa 10^59 ETH. Das gesamte ETH-Angebot liegt zum Zeitpunkt des Schreibens bei weniger als zweihundert Millionen (opens in a new tab).

OffsetOpcodeStack
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

Wenn wir hier angekommen sind, holen wir uns Value* + CALLVALUE und springen zu Offset 0x75.

OffsetOpcodeStack
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Wenn wir hier ankommen (was voraussetzt, dass die Call-Daten leer sind), addieren wir den Call-Wert zu Value*. Dies stimmt mit dem überein, was wir über die Funktionsweise von Transfer-Transaktionen gesagt haben.

OffsetOpcode
79POP
7APOP
7BSTOP

Schließlich wird der Stack geleert (was nicht notwendig ist) und das erfolgreiche Ende der Transaktion signalisiert.

Zusammenfassend ist hier ein Flussdiagramm für den anfänglichen Code.

Flussdiagramm des Einstiegspunkts

Der Handler bei 0x7C

Ich habe absichtlich nicht in die Überschrift geschrieben, was dieser Handler tut. Es geht nicht darum, Ihnen beizubringen, wie dieser spezifische Smart Contract funktioniert, sondern wie man Smart Contracts per Reverse Engineering analysiert. Sie werden auf dieselbe Weise lernen, was er tut, wie ich es getan habe: indem Sie dem Code folgen.

Wir gelangen von mehreren Stellen hierher:

  • Wenn es Aufrufdaten (Call Data) von 1, 2 oder 3 Bytes gibt (von Offset 0x63)
  • Wenn die Methodensignatur unbekannt ist (von den Offsets 0x42 und 0x5D)
OffsetOpcodeStack
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Dies ist eine weitere Speicherzelle, die ich in keinen Transaktionen finden konnte, weshalb es schwieriger ist zu wissen, was sie bedeutet. Der untenstehende Code wird dies klarer machen.

OffsetOpcodeStack
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

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

OffsetOpcodeStack
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

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

OffsetOpcodeStack
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

Ganz am Anfang des Codes haben wir Mem[0x40] auf 0x80 gesetzt. 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 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

Kopiere alle Aufrufdaten in den Speicher, beginnend bei 0x80.

OffsetOpcodeStack
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

Jetzt sind die Dinge viel klarer. Dieser Smart Contract kann als Proxy (opens in a new tab) fungieren und die Adresse in Storage[3] aufrufen, um die eigentliche Arbeit zu erledigen. DELEGATE_CALL ruft einen separaten Smart Contract auf, bleibt aber im selben Speicher (Storage). Das bedeutet, dass der delegierte Smart 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: Storage[3]-as-address
  • Aufrufdaten: Die CALLDATASIZE-Bytes beginnend bei 0x80, wo wir die ursprünglichen Aufrufdaten abgelegt haben
  • Rückgabedaten: Keine (0x00 - 0x00) Wir erhalten die Rückgabedaten auf andere Weise (siehe unten)
OffsetOpcodeStack
B0RETURNDATASIZERETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address

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

OffsetOpcodeStack
B6DUP2(((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B7DUP1(((Aufruf Erfolg/Fehlschlag))) (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B8ISZERO(((ist der Aufruf fehlgeschlagen))) (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((ist der Aufruf fehlgeschlagen))) (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
BCJUMPI(((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
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 ein RETURN mit genau diesem Puffer aus.

DELEGATECALL fehlgeschlagen

Wenn wir hierher gelangen, zu 0xC0, bedeutet das, dass der aufgerufene Smart Contract revertiert (rückgängig gemacht) wurde. Da wir nur ein Proxy für diesen Smart Contract sind, möchten wir dieselben Daten zurückgeben und ebenfalls revertieren.

OffsetOpcodeStack
C0JUMPDEST(((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) RETURNDATASIZE (((Aufruf Erfolg/Fehlschlag))) 0x80 Storage[3]-as-address
C3REVERT

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

Flussdiagramm für Proxy-Aufruf

ABI-Aufrufe

Wenn die Größe der Aufrufdaten (Call Data) vier Bytes oder mehr beträgt, könnte dies ein gültiger ABI-Aufruf sein.

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

Etherscan teilt uns mit, dass 1C ein unbekannter Opcode ist, da er hinzugefügt wurde, nachdem Etherscan diese Funktion geschrieben hat (opens in a new tab), und sie ihn noch nicht aktualisiert haben. Eine aktuelle Opcode-Tabelle (opens in a new tab) zeigt uns, dass dies eine Rechtsverschiebung (Shift Right) ist.

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

Durch die Zweiteilung der Tests zum Abgleich der Methodensignatur auf diese Weise wird im Durchschnitt die Hälfte der Tests eingespart. Der unmittelbar darauf folgende Code und der Code in 0x43 folgen demselben Muster: DUP1 die ersten 32 Bits der Aufrufdaten, 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 Methodendefinition (opens in a new tab):

MethodeMethodensignaturOffset zum Hineinspringen
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

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

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

Das Erste, was diese Funktion tut, ist zu überprüfen, ob der Aufruf kein ETH gesendet hat. Diese Funktion ist nicht payable (opens in a new tab). Wenn uns jemand ETH gesendet hat, muss das ein Fehler sein, und wir wollen ein REVERT ausführen, um zu vermeiden, dass dieses ETH dort liegt, wo sie es nicht zurückbekommen können.

OffsetOpcodeStack
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] d. h. der Vertrag, für den wir ein Proxy sind)))
114PUSH1 0x400x40 (((Storage[3] d. h. der Vertrag, für den wir ein Proxy sind)))
116MLOAD0x80 (((Storage[3] d. h. der Vertrag, für den wir ein Proxy sind)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] d. h. der Vertrag, für den wir ein Proxy sind)))
12CSWAP10x80 0xFF...FF (((Storage[3] d. h. der Vertrag, für den wir ein Proxy sind)))
12DSWAP2(((Storage[3] d. h. der Vertrag, 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

Dies ist das erste Mal, dass wir diese Zeilen sehen, aber sie werden mit anderen Methoden geteilt (siehe unten). Wir nennen den Wert im Stack also X und merken uns einfach, dass der Wert dieses X in splitter() 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 Vertrag, ein RETURN mit einem Puffer von 0x80 - X auszuführen.

Im Fall von splitter() gibt dies die Adresse zurück, für die wir ein Proxy sind. RETURN gibt den Puffer in 0x80-0x9F zurück, in den 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 (abgesehen vom JUMPI-Ziel), also wissen wir, dass currentWindow() ebenfalls nicht payable ist.

OffsetOpcodeStack
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Der DA-Code

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

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

Schreibe Y nach 0x80-0x9F.

OffsetOpcodeStack
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Und der Rest wurde bereits oben erklärt. Sprünge zu 0xDA schreiben also die Stack-Spitze (Y) nach 0x80-0x9F und geben diesen Wert zurück. Im Fall von currentWindow() wird Storage[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
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Was nach dem Sprung passiert, haben wir bereits herausgefunden. Also gibt merkleRoot() Storage[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 rückgängig gemacht.

Schauen wir uns an, was passiert, wenn die Funktion die benötigten Calldata tatsächlich 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
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

Wenn das erste Wort nicht kleiner als Storage[4] ist, schlägt die Funktion fehl. Sie wird ohne Rückgabewert rückgängig gemacht:

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

Wenn calldataload(4) kleiner als Storage[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 Memory-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 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

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

OffsetOpcodeStack
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

Wir wissen bereits, was der Code bei Offset 0xDA macht: Er gibt den obersten Wert des Stacks an den Aufrufer zurück. Diese Funktion gibt also den Wert aus der Lookup-Tabelle 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
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

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

Methodenzusammenfassung

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

MethodeBedeutung
TransferAkzeptiert den durch den Aufruf bereitgestellten Wert und erhöht Value* um diesen Betrag
splitter()Gibt Storage[3], die Proxy-Adresse, zurück
currentWindow()Gibt Storage[1] zurück
merkleRoot()Gibt Storage[0] zurück
0x81e580d3Gibt den Wert aus einer Lookup-Tabelle zurück, vorausgesetzt, der Parameter ist kleiner als Storage[4]
0x1f135823Gibt Storage[6], auch bekannt als Value*, zurück

Aber wir wissen, dass jede andere Funktionalität durch den Smart Contract in Storage[3] bereitgestellt wird. Wenn wir wüssten, was dieser Smart Contract ist, würde uns das vielleicht einen Hinweis geben. Zum Glück ist dies die Blockchain und alles ist bekannt, zumindest theoretisch. Wir haben keine Methoden gesehen, die Storage[3] festlegen, also muss es durch den Konstruktor festgelegt worden sein.

Der Konstruktor

Wenn wir uns einen Smart Contract ansehen (opens 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 Reiter State klicken, können wir die Anfangswerte der Parameter sehen. Genauer gesagt können wir sehen, dass Storage[3] 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab) enthält. Dieser Smart Contract muss die fehlende Funktionalität enthalten. Wir können ihn mit denselben Werkzeugen verstehen, die wir für den Smart Contract verwendet haben, den wir gerade untersuchen.

Der Proxy-Vertrag

Mit denselben Techniken, die wir oben für den ursprünglichen Vertrag verwendet haben, können wir sehen, dass der Vertrag einen Revert ausführt, wenn:

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

Und dass die unterstützten Methoden folgende sind:

Wir können die unteren vier Methoden ignorieren, da wir sie niemals erreichen werden. Ihre Signaturen sind so beschaffen, dass unser ursprünglicher Vertrag sie selbst übernimmt (Sie können auf die Signaturen klicken, um die Details oben zu sehen), also müssen es überschriebene Methoden (opens in a new tab) sein.

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

scaleAmountByPercentage

Das liefert uns der Decompiler für diese Funktion:

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 prüft, ob die Aufrufdaten zusätzlich zu den vier Bytes der Funktionssignatur mindestens 64 Bytes umfassen, was für die beiden Parameter ausreicht. Wenn nicht, stimmt offensichtlich etwas nicht.

Die if-Anweisung scheint zu überprüfen, ob _param1 nicht null ist und dass _param1 * _param2 nicht negativ ist. Dies dient wahrscheinlich dazu, Fälle von Wrap-Around (Überlauf) zu verhindern.

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

claim

Der Code, den der Decompiler erstellt, 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 es als uint256 deklariert ist, tatsächlich eine Adresse
  • _param1 ist das beanspruchte Fenster (Window), das currentWindow oder früher sein muss.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'

Jetzt wissen wir also, dass Storage[5] ein Array von Fenstern und Adressen ist und speichert, 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'
Alle anzeigen

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

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

Auf diese Weise überträgt ein Vertrag seine eigenen ETH an eine andere Adresse (Vertrag oder Extern verwaltetes Konto). Er ruft sie mit einem Wert auf, der dem zu übertragenden Betrag entspricht. Es sieht also so aus, als wäre dies ein Airdrop von 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

Die unteren beiden Zeilen sagen uns, dass Storage[2] ebenfalls ein Vertrag ist, den wir aufrufen. Wenn wir uns die Konstruktor-Transaktion ansehen (opens in a new tab), sehen wir, dass dieser Vertrag 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab) ist, ein Wrapped Ether-Vertrag, dessen Quellcode auf Etherscan hochgeladen wurde (opens in a new tab).

Es sieht also so aus, als ob der Vertrag versucht, ETH an _param2 zu senden. Wenn er das tun kann, großartig. Wenn nicht, versucht er, WETH (opens in a new tab) zu senden. Wenn _param2 ein Extern verwaltetes Konto (EOA) ist, kann es immer ETH empfangen, aber Verträge können den Empfang von ETH ablehnen. WETH ist jedoch ERC-20 und Verträge 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 Protokolleintrag (Log) generiert wird. Sehen Sie sich die generierten Protokolleinträge an (opens in a new tab) und filtern Sie nach dem Thema (Topic), das mit 0xdbd5... beginnt. Wenn wir auf eine der Transaktionen klicken, die einen solchen Eintrag generiert haben (opens in a new tab), sehen wir, dass es tatsächlich wie ein Claim aussieht – das Konto hat eine Nachricht an den Vertrag gesendet, den wir per Reverse Engineering untersuchen, und im Gegenzug ETH erhalten.

Eine Claim-Transaktion

1e7df9d3

Diese Funktion ist der obigen Funktion claim sehr ähnlich. Sie überprüft ebenfalls einen Merkle-Proof, versucht, ETH an die erste Adresse zu übertragen, und erzeugt dieselbe Art von Protokolleintrag.

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

Es sieht also nach einer claim-Variante aus, die alle Fenster beansprucht.

Fazit

Inzwischen sollten Sie wissen, wie man Smart Contracts versteht, deren Quellcode nicht verfügbar ist, indem man entweder die Opcodes oder (wenn es funktioniert) den Decompiler verwendet. Wie aus der Länge dieses Artikels ersichtlich ist, ist das Reverse Engineering eines Smart 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 Smart Contracts wie versprochen funktionieren.

Weitere meiner Arbeiten finden Sie hier (opens in a new tab).

Letzte Aktualisierung der Seite: 22. August 2025

War dieses Tutorial hilfreich?