メインコンテンツへスキップ

コントラクトのリバースエンジニアリング

EVM
オペコード
上級
オリ・ポメランツ
2021年12月30日
45 分で読めます
ページを編集 (opens in a new tab)

はじめに

ブロックチェーン上に秘密はありません。発生するすべてのことは一貫性があり、検証可能で、公開されています。理想的には、コントラクトはソースコードが公開され、Etherscan上で検証されているべきです (opens in a new tab)。しかし、常にそうであるとは限りません (opens in a new tab)。この記事では、ソースコードのないコントラクトである0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab)を調べることで、コントラクトをリバースエンジニアリングする方法を学びます。

逆コンパイラ(デコンパイラ)は存在しますが、常に使える結果 (opens in a new tab)が得られるとは限りません。この記事では、オペコード (opens in a new tab)から手動でコントラクトをリバースエンジニアリングして理解する方法と、デコンパイラの結果を解釈する方法を学びます。

この記事を理解するには、EVMの基礎をすでに知っており、EVMアセンブラに少なくともある程度精通している必要があります。これらのトピックについてはこちらで読むことができます (opens in a new tab)

実行可能コードの準備

コントラクトのEtherscanにアクセスし、ContractタブをクリックしてからSwitch to Opcodes Viewをクリックすると、オペコードを取得できます。1行に1つのオペコードが表示されるビューになります。

Opcode View from Etherscan

ただし、ジャンプを理解するためには、各オペコードがコード内のどこにあるかを知る必要があります。そのための1つの方法は、Googleスプレッドシートを開き、C列にオペコードを貼り付けることです。この準備済みのスプレッドシートのコピーを作成することで、以下の手順をスキップできます (opens in a new tab)

次のステップは、ジャンプを理解できるように正しいコードの場所を取得することです。B列にオペコードのサイズを、A列に場所(16進数)を入力します。セルB1にこの関数を入力し、コードの最後までB列の残りの部分にコピーして貼り付けます。これを行った後、B列を非表示にすることができます。

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

まず、この関数はオペコード自体に1バイトを追加し、次にPUSHを探します。Pushオペコードは、プッシュされる値のための追加のバイトを持つ必要があるため特別です。オペコードがPUSHの場合、バイト数を抽出してそれを追加します。

A1に最初のオフセットであるゼロを入力します。次に、A2にこの関数を入力し、A列の残りの部分に再度コピーして貼り付けます。

=dec2hex(hex2dec(A1)+B1)

ジャンプ(JUMPおよびJUMPI)の前にプッシュされる値は16進数で与えられるため、16進数の値を取得するためにこの関数が必要です。

エントリーポイント (0x00)

コントラクトは常に最初のバイトから実行されます。以下はコードの初期部分です。

オフセットオペコードスタック (オペコード実行後)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTORE
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPI

このコードは2つのことを行います。

  1. 0x80を32バイトの値としてメモリ位置0x40〜0x5Fに書き込みます(0x80は0x5Fに保存され、0x40〜0x5Eはすべてゼロになります)。
  2. コールデータのサイズを読み取ります。通常、イーサリアムのコントラクトのコールデータはABI(アプリケーション・バイナリ・インターフェース) (opens in a new tab)に従い、関数セレクタとして最低4バイトを必要とします。コールデータのサイズが4未満の場合、0x5Eにジャンプします。

Flowchart for this portion

0x5Eのハンドラ(非ABIコールデータ用)

オフセットオペコード
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

このスニペットはJUMPDESTから始まります。EVM(イーサリアム仮想マシン)プログラムは、JUMPDEST以外のオペコードにジャンプすると例外をスローします。次にCALLDATASIZEを確認し、それが「真」(つまりゼロではない)であれば0x7Cにジャンプします。これについては後述します。

オフセットオペコードスタック (オペコード実行後)
64CALLVALUE呼び出しによって提供された。Solidityではmsg.valueと呼ばれます。
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

つまり、コールデータがない場合、Storage[6]の値を読み取ります。この値が何であるかはまだわかりませんが、コントラクトがコールデータなしで受信したトランザクションを探すことができます。コールデータなし(したがってメソッドもなし)で単にETHを送金するトランザクションは、EtherscanではTransferというメソッドになります。実際、このコントラクトが受信した最初のトランザクション (opens in a new tab)は送金です。

そのトランザクションを見てClick to see Moreをクリックすると、入力データと呼ばれるコールデータが確かに空(0x)であることがわかります。また、値が1.559 ETHであることにも注目してください。これは後で関連してきます。

The call data is empty

次に、Stateタブをクリックし、リバースエンジニアリングしているコントラクト(0x2510...)を展開します。トランザクション中にStorage[6]が変更されたことがわかります。HexをNumberに変更すると、1,559,000,000,000,000,000になっていることがわかります。これはwei単位で送金された値(わかりやすくするためにカンマを追加しました)であり、次のコントラクトの値に対応しています。

Storage[6]の変更

同時期の他のTransferトランザクション (opens in a new tab)によって引き起こされた状態の変更を見ると、Storage[6]がしばらくの間コントラクトの値を追跡していたことがわかります。今のところ、これをValue*と呼ぶことにします。アスタリスク(*)は、この変数が何をするのかまだ_わかっていない_ことを思い出させるためのものですが、単にコントラクトの値を追跡するためだけのものではないはずです。なぜなら、ADDRESS BALANCEを使用してアカウントの残高を取得できるのに、非常に高価なストレージを使用する必要はないからです。最初のオペコードはコントラクト自身のアドレスをプッシュします。2つ目のオペコードはスタックの一番上にあるアドレスを読み取り、それをそのアドレスの残高に置き換えます。

オフセットオペコードスタック
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

ジャンプ先でこのコードのトレースを続けます。

オフセットオペコードスタック
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

NOTはビット単位の演算であるため、コール値のすべてのビットの値を反転させます。

オフセットオペコードスタック
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

Value*が2^256-CALLVALUE-1より小さいか等しい場合にジャンプします。これはオーバーフローを防ぐためのロジックのようです。実際、いくつかの無意味な操作(例えば、メモリへの書き込みが削除されようとしているなど)の後、オフセット0x01DEでオーバーフローが検出された場合にコントラクトがリバートすることがわかります。これは通常の動作です。

このようなオーバーフローが発生する可能性は極めて低いことに注意してください。なぜなら、コール値とValue*の合計が2^256 wei、つまり約10^59 ETHに匹敵する必要があるからです。執筆時点でのETHの総供給量は2億未満です (opens in a new tab)

オフセットオペコードスタック
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

ここに到達した場合、Value* + CALLVALUEを取得してオフセット0x75にジャンプします。

オフセットオペコードスタック
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

ここに到達した場合(コールデータが空である必要があります)、Value*にコール値を加算します。これは、Transferトランザクションが行うと述べたことと一致しています。

オフセットオペコード
79POP
7APOP
7BSTOP

最後に、スタックをクリアし(これは必須ではありません)、トランザクションの正常終了を通知します。

まとめると、以下が初期コードのフローチャートです。

Entry point flowchart

0x7Cのハンドラ

このハンドラが何をするのか、あえて見出しには書きませんでした。目的は、この特定のコントラクトがどのように機能するかを教えることではなく、コントラクトをリバースエンジニアリングする方法を教えることだからです。私がしたのと同じように、コードを追うことで、それが何をするのかを学ぶことができます。

ここにはいくつかの場所から到達します:

  • 1、2、または3バイトのコールデータがある場合(オフセット0x63から)
  • メソッドの署名が不明な場合(オフセット0x42および0x5Dから)
OffsetOpcodeStack
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

これは別のストレージセルですが、どのトランザクションでも見つけることができなかったため、その意味を知るのはより困難です。以下のコードを見れば、より明確になるでしょう。

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

これらのオペコードは、Storage[3]から読み取った値を、Ethereumのアドレスの長さである160ビットに切り詰めます。

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

次のオペコードに進むため、このジャンプは不要です。このコードは、可能な限りガス効率が良いとは言えません。

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

コードの最初で、Mem[0x40]を0x80に設定しました。後で0x40を探すと、変更されていないことがわかるため、0x80であると想定できます。

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

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

これで状況がずっと明確になりました。このコントラクトはプロキシ (opens in a new tab)として機能し、Storage[3]のアドレスを呼び出して実際の作業を行うことができます。DELEGATE_CALLは別のコントラクトを呼び出しますが、同じストレージに留まります。つまり、委譲されたコントラクト(私たちがプロキシとなっているコントラクト)は、同じストレージ空間にアクセスします。呼び出しのパラメータは以下の通りです:

  • Gas: 残りのすべてのガス
  • Called address: Storage[3]-as-address
  • Call data: 0x80から始まるCALLDATASIZEバイト(ここに元のコールデータを配置しました)
  • Return data: なし(0x00 - 0x00)戻り値のデータは別の方法で取得します(以下を参照)
OffsetOpcodeStack
B0RETURNDATASIZERETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address

ここで、すべての戻り値のデータを0x80から始まるメモリバッファにコピーします。

OffsetOpcodeStack
B6DUP2(((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B7DUP1(((呼び出しの成功/失敗))) (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B8ISZERO(((呼び出しは失敗したか))) (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((呼び出しは失敗したか))) (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
BCJUMPI(((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
BFRETURN

したがって、呼び出しの後、戻り値のデータをバッファ0x80 - 0x80+RETURNDATASIZEにコピーし、呼び出しが成功した場合は、まさにそのバッファを使用してRETURNします。

DELEGATECALLの失敗

ここ(0xC0)に到達した場合、呼び出したコントラクトがリバートしたことを意味します。私たちはそのコントラクトのプロキシにすぎないため、同じデータを返してリバートさせたいと考えます。

OffsetOpcodeStack
C0JUMPDEST(((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((呼び出しの成功/失敗))) RETURNDATASIZE (((呼び出しの成功/失敗))) 0x80 Storage[3]-as-address
C3REVERT

したがって、先ほどRETURNに使用したのと同じバッファ(0x80 - 0x80+RETURNDATASIZE)を使用してREVERTします。

Call to proxy flowchart

ABIコール

コールデータのサイズが4バイト以上の場合、これは有効なABIコールである可能性があります。

OffsetOpcodeStack
DPUSH1 0x000x00
FCALLDATALOAD(((コールデータの最初のワード (256ビット))))
10PUSH1 0xe00xE0 (((コールデータの最初のワード (256ビット))))
12SHR(((コールデータの最初の32ビット (4バイト))))

Etherscanは1Cを不明なオペコードとして表示します。なぜなら、Etherscanがこの機能を記述した後にそれが追加され (opens in a new tab)、まだ更新されていないからです。最新のオペコード表 (opens in a new tab)を見ると、これが右シフト(shift right)であることがわかります。

OffsetOpcodeStack
13DUP1(((コールデータの最初の32ビット (4バイト)))) (((コールデータの最初の32ビット (4バイト))))
14PUSH4 0x3cd8045e0x3CD8045E (((コールデータの最初の32ビット (4バイト)))) (((コールデータの最初の32ビット (4バイト))))
19GT0x3CD8045E>コールデータの最初の32ビット (((コールデータの最初の32ビット (4バイト))))
1APUSH2 0x00430x43 0x3CD8045E>コールデータの最初の32ビット (((コールデータの最初の32ビット (4バイト))))
1DJUMPI(((コールデータの最初の32ビット (4バイト))))

このようにメソッド署名の照合テストを2つに分割することで、平均してテストの半分を節約できます。この直後に続くコードと0x43のコードは同じパターンに従っています。コールデータの最初の32ビットをDUP1し、PUSH4 (((method signature>し、EQを実行して等しいかどうかを確認し、メソッド署名が一致する場合はJUMPIします。以下は、メソッド署名、そのアドレス、および判明している場合は対応するメソッド定義 (opens in a new tab)です。

メソッドメソッド署名ジャンプ先のオフセット
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

一致するものが見つからない場合、コードは0x7Cのプロキシハンドラにジャンプし、プロキシ先のコントラクトに一致するものがあることを期待します。

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

この関数が最初に行うのは、呼び出しでETHが送信されていないことを確認することです。この関数はpayable (opens in a new tab)ではありません。もし誰かがETHを送信してきた場合、それは間違いであるはずなので、そのETHが取り戻せない場所に残るのを避けるためにREVERTしたいと考えます。

OffsetOpcodeStack
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] すなわち私たちがプロキシとして機能しているコントラクト)))
114PUSH1 0x400x40 (((Storage[3] すなわち私たちがプロキシとして機能しているコントラクト)))
116MLOAD0x80 (((Storage[3] すなわち私たちがプロキシとして機能しているコントラクト)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] すなわち私たちがプロキシとして機能しているコントラクト)))
12CSWAP10x80 0xFF...FF (((Storage[3] すなわち私たちがプロキシとして機能しているコントラクト)))
12DSWAP2(((Storage[3] すなわち私たちがプロキシとして機能しているコントラクト))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

そして、0x80には現在プロキシアドレスが含まれています。

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

E4コード

これらの行を見るのは初めてですが、他のメソッドと共有されています(後述)。したがって、スタック内の値をXと呼び、splitter()ではこのXの値が0xA0であることを覚えておいてください。

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

つまり、このコードはスタック内でメモリポインタ(X)を受け取り、コントラクトに0x80 - XのバッファでRETURNさせます。

splitter()の場合、これは私たちがプロキシとして機能しているアドレスを返します。RETURNは0x80〜0x9Fのバッファを返しますが、これは私たちがこのデータを書き込んだ場所です(上記のオフセット0x130)。

currentWindow()

オフセット0x158〜0x163のコードは、splitter()の0x103〜0x10Eで見たものと(JUMPIの宛先を除いて)同一です。したがって、currentWindow()payableではないことがわかります。

オフセットオペコードスタック
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

DAコード

このコードは他のメソッドとも共有されています。そのため、スタック内の値をYと呼び、currentWindow()ではこのYの値がStorage[1]であることを覚えておきましょう。

オフセットオペコードスタック
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Yを0x80〜0x9Fに書き込みます。

オフセットオペコードスタック
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

残りの部分は上記ですでに説明されています。つまり、0xDAへのジャンプはスタックのトップ(Y)を0x80〜0x9Fに書き込み、その値を返します。currentWindow()の場合、Storage[1]を返します。

merkleRoot()

オフセット0xED-0xF8のコードは、splitter()の0x103-0x10Eで見たものと(JUMPIの宛先を除いて)同一であるため、merkleRoot()payableではないことがわかります。

オフセットオペコードスタック
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

ジャンプ後に何が起こるかは、すでに解明しました。したがって、merkleRoot()はStorage[0]を返します。

オフセット0x138〜0x143のコードは、splitter()の0x103〜0x10Eで見たものと(JUMPIの宛先を除いて)同一であるため、この関数もpayableではないことがわかります。

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

この関数は少なくとも32バイト(1ワード)のコールデータを受け取るようです。

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

コールデータを取得できない場合、トランザクションは戻り値データなしでリバートされます。

関数が必要なコールデータを_取得した場合_に何が起こるか見てみましょう。

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

calldataload(4)は、メソッド署名の_後_のコールデータの最初のワードです。

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

最初のワードがStorage[4]未満でない場合、関数は失敗します。戻り値なしでリバートされます。

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

calldataload(4)がStorage[4]未満の場合、次のコードになります。

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

そして、メモリの場所0x00〜0x1Fにはデータ0x04が含まれるようになります(0x00〜0x1Eはすべてゼロで、0x1Fは4です)。

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

つまり、ストレージにはルックアップテーブルがあり、0x000...0004のSHA3から始まり、正当なコールデータ値(Storage[4]未満の値)ごとにエントリがあります。

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

オフセット0xDAのコードが何をするかはすでにわかっています。スタックの一番上の値を呼び出し元に返します。したがって、この関数はルックアップテーブルの値を呼び出し元に返します。

0x1f135823

オフセット0xC4〜0xCFのコードは、splitter()の0x103〜0x10Eで見たものと(JUMPIの宛先を除いて)同一であるため、この関数もpayableではないことがわかります。

OffsetOpcodeStack
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

オフセット0xDAのコードが何をするかはすでにわかっています。スタックの一番上の値を呼び出し元に返します。したがって、この関数はValue*を返します。

メソッドの概要

現時点でコントラクトを理解できたと感じますか?私はそうは思いません。これまでのところ、以下のメソッドがあります。

メソッド意味
Transfer呼び出しによって提供された値を受け入れ、その分だけValue*を増やす
splitter()Storage[3]、つまりプロキシ・アドレスを返す
currentWindow()Storage[1]を返す
merkleRoot()Storage[0]を返す
0x81e580d3パラメータがStorage[4]未満である場合、ルックアップテーブルから値を返す
0x1f135823Storage[6]、別名Value*を返す

しかし、他の機能はStorage[3]にあるコントラクトによって提供されることがわかっています。そのコントラクトが何であるかがわかれば、手がかりになるかもしれません。ありがたいことに、これはブロックチェーンであり、少なくとも理論上はすべてが既知です。Storage[3]を設定するメソッドは見当たらなかったため、コンストラクタによって設定されたに違いありません。

コンストラクタ

コントラクトを見る (opens in a new tab)と、それを作成したトランザクションも確認できます。

Click the create transaction

そのトランザクションをクリックし、次に状態タブをクリックすると、パラメータの初期値を確認できます。具体的には、Storage[3] に 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab) が含まれていることがわかります。そのコントラクトには、欠落している機能が含まれているはずです。調査中のコントラクトで使用したのと同じツールを使用して、それを理解することができます。

プロキシ・コントラクト

上記の元のコントラクトで使用したのと同じ手法を使用すると、次の場合にコントラクトがリバートすることがわかります。

  • 呼び出しにETHが添付されている場合 (0x05-0x0F)
  • コールデータのサイズが4未満の場合 (0x10-0x19 および 0xBE-0xC2)

そして、サポートされているメソッドは次のとおりです。

下部の4つのメソッドは、到達することがないため無視できます。これらの署名は、元のコントラクトが単独で処理するようになっているため(署名をクリックすると上記の詳細を確認できます)、オーバーライドされたメソッド (opens in a new tab)であるはずです。

残りのメソッドの1つは claim(<params>) で、もう1つは isClaimed(<params>) であるため、エアドロップのコントラクトのように見えます。残りをオペコードごとに確認する代わりに、デコンパイラを試す (opens in a new tab)ことができます。これにより、このコントラクトの3つの関数について有用な結果が得られます。他の関数のリバースエンジニアリングは、読者への演習として残しておきます。

scaleAmountByPercentage

この関数についてデコンパイラが提供する内容は次のとおりです。

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)

最初の require は、コールデータに、関数署名の4バイトに加えて、2つのパラメータに十分な少なくとも64バイトがあることをテストします。そうでない場合は、明らかに何かが間違っています。

if ステートメントは、_param1 がゼロではなく、_param1 * _param2 が負ではないことを確認しているようです。これはおそらく、ラップアラウンドのケースを防ぐためです。

最後に、関数はスケーリングされた値を返します。

請求

デコンパイラが作成するコードは複雑であり、そのすべてが私たちに関連しているわけではありません。有用な情報を提供していると思われる行に焦点を当てるため、一部を省略します。

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'

ここで2つの重要なことがわかります。

  • _param2uint256 として宣言されていますが、実際にはアドレスです。
  • _param1 は請求されるウィンドウであり、currentWindow 以前である必要があります。
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

これで、Storage[5] がウィンドウとアドレスの配列であり、そのアドレスがそのウィンドウの報酬を請求したかどうかがわかりました。

unknown2eb4a7ab が実際には関数 merkleRoot() であることがわかっているため、このコードはマークル証明 (opens in a new tab)を検証しているように見えます。これは、_param4 がマークル証明であることを意味します。

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

これは、コントラクトが自身のETHを別のアドレス(コントラクトまたは外部所有)に送金する方法です。送金する金額を値として呼び出します。したがって、これは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

下部の2行は、Storage[2] も私たちが呼び出すコントラクトであることを示しています。コンストラクタのトランザクションを見る (opens in a new tab)と、このコントラクトが 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab) であり、ソースコードがEtherscanにアップロードされている (opens in a new tab)ラップド・イーサのコントラクトであることがわかります。

したがって、コントラクトは _param2 にETHを送金しようとしているようです。それができれば素晴らしいです。できない場合は、WETH (opens in a new tab) を送金しようとします。_param2 が外部所有アカウント (EOA) の場合、常にETHを受け取ることができますが、コントラクトはETHの受け取りを拒否できます。ただし、WETHはERC-20であり、コントラクトはそれを受け入れることを拒否できません。

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

関数の最後で、ログエントリが生成されていることがわかります。生成されたログエントリを見て (opens in a new tab)0xdbd5... で始まるトピックでフィルタリングします。そのようなエントリを生成したトランザクションの1つをクリックする (opens in a new tab)と、確かに請求のように見えます。アカウントはリバースエンジニアリングしているコントラクトにメッセージを送信し、その見返りとしてETHを受け取りました。

A claim transaction

1e7df9d3

この関数は、上記の claim に非常に似ています。これもマークル証明をチェックし、最初のパラメータにETHを送金しようとし、同じタイプのログエントリを生成します。

主な違いは、最初のパラメータである引き出すウィンドウがないことです。代わりに、請求できるすべてのウィンドウに対するループがあります。

したがって、これはすべてのウィンドウを請求する claim のバリアントのようです。

まとめ

これで、オペコードや(機能する場合は)デコンパイラを使用して、ソースコードが公開されていないコントラクトを理解する方法がわかったはずです。この記事の長さからも明らかなように、コントラクトのリバースエンジニアリングは簡単なことではありませんが、セキュリティが不可欠なシステムにおいて、コントラクトが約束通りに機能することを検証できることは重要なスキルです。

私の他の記事についてはこちらをご覧ください (opens in a new tab)

ページの最終更新: 2026年4月3日