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. Idealerweise 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 man Reverse Engineering bei Verträgen anwendet, 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 man einen Vertrag anhand der Opcodes (opens in a new tab) manuell reverse-engineert und versteht, sowie wie man die Ergebnisse eines Dekompilierers interpretiert.

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 Reiter Contract klicken und dann Switch to Opcodes View auswählen. Sie erhalten eine Ansicht mit einem Opcode pro Zeile.

Opcode View from Etherscan

Um jedoch Sprünge verstehen zu können, 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 dieses bereits vorbereiteten Spreadsheets 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 (hexadezimal) 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+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 diese.

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:

=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 hexadezimal vorliegen.

Der Einstiegspunkt (0x00)

Verträge 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 Aufrufdaten. Normalerweise folgen die Aufrufdaten 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 Aufrufdaten kleiner als vier ist, springe zu 0x5E.

Flowchart for this portion

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

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 Aufrufdaten 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 Vertrag ohne Aufrufdaten erhalten hat. Transaktionen, die einfach nur ETH ohne Aufrufdaten (und daher ohne Methode) transferieren, haben in Etherscan die Methode Transfer. Tatsächlich ist die allererste Transaktion, die der Vertrag erhalten hat (opens in a new tab), ein Transfer.

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

The call data is empty

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

Die Änderung in Storage[6]

Wenn wir uns die Zustandsä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, Speicher (Storage) zu verwenden, der sehr teuer ist, wenn man den Kontostand mit ADDRESS BALANCE abrufen kann. Der erste Opcode legt die eigene Adresse des Vertrags auf den Stack. Der zweite liest die Adresse ganz oben auf dem Stack und ersetzt sie durch den Kontostand 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 Aufrufwert 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 Verhinderung eines Überlaufs (Overflow) aus. Und tatsächlich sehen wir, dass nach ein paar unsinnigen Operationen (Schreiben in den Speicher, der gleich gelöscht wird, zum Beispiel) bei Offset 0x01DE der Vertrag rückgängig gemacht wird, wenn der Überlauf erkannt wird, was ein normales Verhalten ist.

Beachten Sie, dass ein solcher Überlauf extrem unwahrscheinlich ist, da der Aufrufwert plus Value* vergleichbar mit 2^256 Wei sein müsste, also etwa 10^59 ETH. Das gesamte ETH-Angebot beträgt zum Zeitpunkt des Schreibens 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 Aufrufdaten leer sind), addieren wir den Aufrufwert zu Value*. Dies stimmt mit dem überein, was wir über 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.

Entry point flowchart

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 Vertrag funktioniert, sondern wie man Verträge 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 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, eine, die ich in keinen Transaktionen finden konnte, weshalb es schwieriger ist zu wissen, was sie bedeutet. Der folgende Code wird dies klarer machen.

OffsetOpcodeStack
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-als-Adresse 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]-als-Adresse 0x00
9CJUMPStorage[3]-als-Adresse 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]-als-Adresse 0x00
9ESWAP10x00 Storage[3]-als-Adresse
9FPOPStorage[3]-als-Adresse
A0PUSH1 0x400x40 Storage[3]-als-Adresse
A2MLOADMem[0x40] Storage[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 Storage[3]-als-Adresse
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-als-Adresse
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-als-Adresse
A7CALLDATACOPY0x80 Storage[3]-als-Adresse

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

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

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

  • Gas: Das gesamte verbleibende Gas
  • Aufgerufene Adresse: Storage[3]-als-Adresse
  • 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 erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
B1DUP1RETURNDATASIZE RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
B5RETURNDATACOPYRETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[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 Storage[3]-als-Adresse
B7DUP1(((Aufruf erfolgreich/fehlgeschlagen))) (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
B8ISZERO(((ist der Aufruf fehlgeschlagen))) (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
B9PUSH2 0x00c00xC0 (((ist der Aufruf fehlgeschlagen))) (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
BCJUMPI(((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
BDDUP2RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[3]-als-Adresse
BEDUP50x80 RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) RETURNDATASIZE (((Aufruf erfolgreich/fehlgeschlagen))) 0x80 Storage[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 ein RETURN mit genau diesem Puffer aus.

DELEGATECALL fehlgeschlagen

Wenn wir hierher gelangen, zu 0xC0, bedeutet das, dass der Vertrag, den wir aufgerufen haben, revertiert ist. Da wir nur ein Proxy-Contract für diesen Vertrag sind, wollen wir dieselben Daten zurückgeben und ebenfalls revertieren.

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

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

Call to proxy flowchart

ABI-Aufrufe

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

OffsetOpcodeStack
DPUSH1 0x000x00
FCALLDATALOAD(((Erstes Wort (256 Bit) der Aufrufdaten)))
10PUSH1 0xe00xE0 (((Erstes Wort (256 Bit) der Aufrufdaten)))
12SHR(((erste 32 Bit (4 Byte) 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 diese 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 Bit (4 Byte) der Aufrufdaten))) (((erste 32 Bit (4 Byte) der Aufrufdaten)))
14PUSH4 0x3cd8045e0x3CD8045E (((erste 32 Bit (4 Byte) der Aufrufdaten))) (((erste 32 Bit (4 Byte) der Aufrufdaten)))
19GT0x3CD8045E>erste-32-Bit-der-Aufrufdaten (((erste 32 Bit (4 Byte) der Aufrufdaten)))
1APUSH2 0x00430x43 0x3CD8045E>erste-32-Bit-der-Aufrufdaten (((erste 32 Bit (4 Byte) der Aufrufdaten)))
1DJUMPI(((erste 32 Bit (4 Byte) der Aufrufdaten)))

Indem die Tests zum Abgleich der Methodensignatur auf diese Weise in zwei Hälften geteilt werden, spart man im Durchschnitt die Hälfte der Tests. Der Code, der unmittelbar darauf folgt, und der Code in 0x43 folgen demselben Muster: DUP1 auf die ersten 32 Bit der Aufrufdaten, PUSH4 (((method signature>, 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 für den Sprung
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 als Proxy fungieren, eine Übereinstimmung aufweist.

ABI calls flowchart

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 mit dem Aufruf kein ETH gesendet wurde. Diese Funktion ist nicht payable (opens in a new tab). Wenn uns jemand ETH gesendet hat, muss das ein Fehler sein, und wir wollen REVERT ausführen, um zu vermeiden, dass dieses ETH dort landet, wo man es nicht mehr zurückbekommt.

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

Wir sehen diese Zeilen hier zum ersten Mal, aber sie werden auch von anderen Methoden verwendet (siehe unten). Wir nennen den Wert im Stack also X und merken uns einfach, dass der Wert dieses X in splitter() 0xA0 beträgt.

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

Dieser Code erhält also einen Speicherzeiger im Stack (X) und veranlasst den Vertrag, mit einem Puffer von 0x80 - X zu RETURN.

Im Fall von splitter() wird dadurch die Adresse zurückgegeben, für die wir ein Proxy sind. RETURN gibt den Puffer in 0x80-0x9F zurück, also dort, 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 (abgesehen vom Ziel JUMPI), daher wissen wir, dass currentWindow() auch 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 auf dem Stack also Y und merken uns einfach, dass der Wert dieses Y in currentWindow() 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 nach 0xDA schreiben also die Spitze des Stacks (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), daher 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 Ziel JUMPI), 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 Aufrufdaten entgegennimmt.

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

Wenn sie die Aufrufdaten 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 Aufrufdaten 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 Aufrufdaten 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 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 Speicher (Storage), die beim SHA3 von 0x000...0004 beginnt und einen Eintrag für jeden legitimen Aufrufdaten-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 Ziel JUMPI), 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, den Vertrag an diesem Punkt zu 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] zurück, die Proxy-Adresse
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] zurück, auch bekannt als Value*

Aber wir wissen, dass jede andere Funktionalität durch den Vertrag in Storage[3] bereitgestellt wird. Wenn wir wüssten, was dieser Vertrag 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 Vertrag ansehen (opens in a new tab), können wir auch die Transaktion sehen, die ihn erstellt hat.

Click the create transaction

Wenn wir auf diese Transaktion und dann auf den Reiter Zustand 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 Vertrag muss die fehlende Funktionalität enthalten. Wir können ihn mit denselben Werkzeugen verstehen, die wir für den Vertrag verwendet haben, den wir gerade untersuchen.

Der Proxy-Contract

Mit denselben Techniken, die wir oben für den ursprünglichen Vertrag verwendet haben, können wir sehen, dass der Vertrag die Ausführung rückgängig macht, wenn:

  • Dem Aufruf ETH beigefügt ist (0x05-0x0F)
  • Die Größe der Aufrufdaten 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:

def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
  require calldata.size - 4 >=64
  if _param1 and _param2 > -1 / _param1:
      revert with 0, 17
  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 Anweisung if scheint zu überprüfen, ob _param1 nicht null ist und dass _param1 * _param2 nicht negativ ist. Dies dient wahrscheinlich dazu, Fälle von Überlauf 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, die meiner Meinung nach nützliche Informationen liefern.

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

Wir sehen hier zwei wichtige Dinge:

  • _param2 ist eigentlich eine Adresse, obwohl es als uint256 deklariert ist.
  • _param1 ist das beanspruchte Fenster, das currentWindow oder früher sein muss.
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

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

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

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

Auf diese Weise transferiert ein Vertrag seine eigenen ETH an eine andere Adresse (Vertrag oder in externem Besitz). Er ruft sie mit einem Wert auf, der dem zu transferierenden Betrag entspricht. Es sieht also so aus, als wäre dies ein Airdrop von ETH.

  if not return_data.size:
      if not ext_call.success:
          require ext_code.size(stor2)
          call stor2.deposit() with:
             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 kann, großartig. Wenn nicht, versucht er, WETH (opens in a new tab) zu senden. Wenn _param2 ein Konto in externem Besitz (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.

  ...
  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 an (opens in a new tab) und filtern Sie nach dem 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 Anspruch aussieht – das Konto hat eine Nachricht an den Vertrag gesendet, den wir per Reverse Engineering untersuchen, und im Gegenzug ETH erhalten.

A claim transaction

1e7df9d3

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

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.

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

Fazit

Mittlerweile sollten Sie wissen, wie man Verträge 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 Vertrags nicht trivial, aber in einem System, in dem Sicherheit unerlässlich ist, ist es eine wichtige Fähigkeit, überprüfen zu können, ob Verträge wie versprochen funktionieren.

Hier finden Sie weitere meiner Arbeiten (opens in a new tab).

Letzte Aktualisierung der Seite: 3. April 2026