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

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

evm
オペコード
上級
Ori Pomerantz
2021年12月30日
43 分の読書

はじめに

ブロックチェーン上に秘密はありません。ブロックチェーン上で起こる全てのことは、一貫性があり、検証可能で、公開されています。 理想的には、コントラクトのソースコードはEtherscanで公開・検証されるべきですopens in a new tab。 しかし、必ずしもそうとは限りませんopens in a new tab。 この記事では、ソースコードがないコントラクト0x2510c039cc3b061d79e564b38836da87e31b342fopens 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つのオペコードが表示されるビューになります。

Etherscanのオペコードビュー

ただし、ジャンプを理解するには、コード内の各オペコードの場所を知る必要があります。 そのためには、Googleスプレッドシートを開き、オペコードをC列に貼り付けるという方法があります。こちらの準備済みのスプレッドシートのコピーを作成すれば、次の手順をスキップできますopens in a new tab

次のステップでは、ジャンプを理解できるように、正しいコードのロケーションを取得します。 B列にオペコードサイズを、A列に(16進数の)ロケーションを入れます。セルB1にこの関数を入力し、コードの最後までB列の残りのセルにコピー&ペーストします。 これを行った後、B列を非表示にできます。

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

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

A1に最初のオフセットであるゼロを配置します。 次に、A2にこの関数を配置し、A列の残りの部分にコピーアンドペーストします。

1=dec2hex(hex2dec(A1)+B1)

ジャンプ(JUMPJUMPI)の前にプッシュされる値は16進数で渡されるため、この関数で16進数の値を得る必要があります。

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

コントラクトは、必ず最初のバイトから実行されます。 以下は、コードの冒頭部分です。

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

このコードは、次の2つのことをしています。

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

この部分のフローチャート

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

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

このスニペットは、JUMPDESTで始まります。 イーサリアム仮想マシン(EVM)プログラムは、JUMPDESTではないオペコードにジャンプした場合に例外を投げます。 次に、CALLDATASIZEを確認し、それが「true」の場合(ゼロではない場合)、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]の値を読み取ります。 このStorage[6]の値はまだわかりませんが、コールデータなしで受信したコントラクトのトランザクションを探すことはできます。 コールデータなしで(つまりメソッドなしで)ETHを送金するだけのトランザクションの場合、EtherscanにTransferメソッドがあります。 実際、コントラクトが受け取った最初のトランザクションopens in a new tabは送金です。

このトランザクションを調べ、「Click to see More」をクリックすると、コールデータ(インプットデータ)が実際に空(0x)であることが分かります。 また、値が1.559ETHであることに留意してください。これに関しては、後述します。

コールデータが空である

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

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

最後に、スタックをクリアし(任意)、トランザクションが正常に終了したことを通知します。

まとめると、最初のコードのフローチャートは次のようになります。

エントリーポイントのフローチャート

0x7Cのハンドラ

意図的にこのハンドラが何をするか見出しに入れませんでした。 特定のコントラクトの動作を教えるのではなく、どのようにコントラクトをリバースエンジニアリングするかを学ぶのがポイントだからです。 同じ方法で、コードを追って何をするか学ぶことができます。

ここへ到達するのは、次のような場合です。

  • (オフセット0x63から)1バイト、2バイトまたは3バイトのコールデータがある場合
  • (オフセット0x42と0x5Dから)メソッドのシグネチャが不明な場合
オフセットオペコードスタック
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

これは別のストレージセルです。このセルはどのトランザクションにも見つからなかったので、これが何を意味しているのか理解するのは困難です。 しかし、以下のコードがこれを明確にします。

オフセットオペコードスタック
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

これらのオペコードは、Storage[3]から読み取った値を160ビットに切り捨てています。これは、イーサリアムアドレスの長さです。

オフセットオペコードスタック
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

次のオペコードに進むため、このジャンプは不要です。 このコードは、ガス効率が良くありません。

オフセットオペコードスタック
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であると推測できます。

オフセットオペコードスタック
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から始まるすべてのコールデータをメモリにコピーします。

オフセットオペコードスタック
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は、別のコントラクトを呼び出しますが、同じストレージ内に留まります。 つまり、委任されたコントラクト(現在のコントラクトがこのコントラクトのプロキシとなります)が、同じストレージスペースにアクセスします。 コール(呼び出し)のパラメータは次の通りです。

  • ガス: 残りの全ガス
  • 呼び出し先アドレス: Storage[3]-as-address
  • コールデータ: 元のコールデータを配置する、0x80から始まるCALLDATASIZEバイト
  • リターンデータ: なし(0x00 - 0x00)。リターンデータは他の方法で取得(以下を参照)
オフセットオペコードスタック
B0RETURNDATASIZERETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address

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

オフセットオペコードスタック
B6DUP2(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B7DUP1(((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B8ISZERO(((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BCJUMPI(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BFRETURN

リターンデータをバッファ(0x80~0x80+RETURNDATASIZE)にコピーする呼び出しの後、その呼び出しが成功した場合は、そのバッファを正確に返します。

DELEGATECALL失敗

0xC0に到達した場合は、現在のコントラクトが呼び出したコントラクトが、元に戻された(REVERTされた)ことを意味します。 現在のコントラクトはこのコントラクトの単なるプロキシであるため、現在のコントラクトも同じデータを返して、元に戻す(REVERTする)必要があります。

オフセットオペコードスタック
C0JUMPDEST(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C3REVERT

そのため、前にRETURNで使用した同じバッファ(0x80~0x80+RETURNDATASIZE)を元に戻します(REVERTします)。

プロキシへのコールに関するフローチャート

ABIコール

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

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

Etherscanによると1Cは未知のオペコードですが、これはEtherscanがこの機能を実装した後に追加されたopens in a new tabもので、まだ更新されていないためです。 最新のオペコード表opens in a new tabを見ると、これが右シフトであることがわかります

オフセットオペコードスタック
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 tab0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

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

ABIコールのフローチャート

splitter()

オフセットオペコードスタック
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

この関数が最初に行うことは、呼び出しがETHを送金していないことを確認することです。 この関数はpayableopens in a new tabではありません。 誰かがETHを送金してきた場合は、何かの間違いです。ETHを戻せなくなる前にREVERTして回避する必要があります。

オフセットオペコードスタック
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にはプロキシアドレスが含まれるようになりました

オフセットオペコードスタック
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

E4コード

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

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

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

オフセットオペコードスタック
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]を返します。

0x81e580d3

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

オフセットオペコードスタック
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ワード)を必要とするようです。

オフセットオペコードスタック
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

コールデータを取得しなかった場合、トランザクションはリターンデータなしで元に戻されます。

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

オフセットオペコードスタック
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4)は、メソッドシグネチャの_後_にあるコールデータの最初のワードです

オフセットオペコードスタック
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]以上の場合、この関数は失敗します。 次のように、戻り値なしで元に戻されます。

オフセットオペコードスタック
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

calldataload(4)がStorage[4]より小さい場合、次のコードになります。

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

オフセットオペコードスタック
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]より小さい値)のエントリが含まれています。

オフセットオペコードスタック
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ではないことが分かります。

オフセットオペコードスタック
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

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

メソッドの概要

この時点でコントラクトを理解したと思えますか? 私は思えません。 これまで、次のようなメソッドを見てきました。

メソッド意味
送金呼び出しによって提供された値を受け入れ、その額でValue*を増やす
splitter()Storage3を返す
currentWindow()Storage[1]を返す
merkleRoot()Storage[0]を返す
0x81e580d3パラメータがStorage[4]より小さい場合、ルックアップテーブルにある値を返す
0x1f135823Storage[6](別名、 Value*)

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

コンストラクタ

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

作成トランザクションをクリック

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

プロキシコントラクト

上記のオリジナルのコントラクトで使用したのと同じ手法を使用すると、次の場合にコントラクトが元に戻されることが分かります。

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

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

メソッドメソッドシグネチャジャンプ先のオフセット
scaleAmountByPercentage(uint256,uint256)opens in a new tab0x8ffb5c970x0135
isClaimed(uint256,address)opens in a new tab0xd2ef07950x0151
claim(uint256,address,uint256,bytes32[])opens in a new tab0x2e7ba6ef0x00F4
incrementWindow()opens in a new tab0x338b1d310x0110
???0x3f26479e0x0118
???0x1e7df9d30x00C3
currentWindow()opens in a new tab0xba0bafb40x0148
merkleRoot()opens in a new tab0x2eb4a7ab0x0107
???0x81e580d30x0122
???0x1f1358230x00D8

下の4つのメソッドに到達することはないので、無視しても問題ありません。 それらのシグネチャは、オリジナルのコントラクトが単独で処理します(シグネチャをクリックすると、上記の詳細が表示されます)。そのため、オーバーライドされたメソッドopens in a new tabである必要があります。

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

scaleAmountByPercentage

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

1def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:\n require calldata.size - 4 >=64\n if _param1 and _param2 > -1 / _param1:\n revert with 0, 17\n return (_param1 * _param2 / 100 * 10^6)

最初のrequireでは、コールデータ内に2つのパラメータにとって十分なサイズ、つまり関数シグネチャの4バイトに加え少なくとも64バイトあるかどうかをテストしています。 そうでない場合、問題があるのは明らかです。

if文は、_param1がゼロでないこと、さらに_param1 * _param2が負でないことを確認していると思われます。 これは、ラップアラウンドを防ぐためでしょう。

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

claim

デコンパイラが作成するコードは複雑であり、すべてが重要なわけではありません。 役立つ情報を提供していると思われる行に焦点を当てるので、一部をスキップします。

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:\n ...\n require _param2 == addr(_param2)\n ...\n if currentWindow <= _param1:\n revert with 0, '将来のウィンドウに対して請求することはできません'

ここでは、重要事項が2つあります。

  • _param2は、uint256として宣言されていますが、実際にはアドレスです
  • _param1は、請求対象のウィンドウであり、currentWindowであるか、それ以前のウィンドウである必要があります。
1 ...\n if stor5[_claimWindow][addr(_claimFor)]:\n revert with 0, 'アカウントはすでに指定されたウィンドウで請求済みです'

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

1 ...\n idx = 0\n s = 0\n while idx < _param4.length:\n ...\n if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:\n mem[mem[64] + 32] = mem[(32 * idx) + 296]\n ...\n s = sha3(mem[_62 + 32 len mem[_62]])\n continue\n ...\n s = sha3(mem[_66 + 32 len mem[_66]])\n continue\n if unknown2eb4a7ab != s:\n revert with 0, '無効な証明です'

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

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

これは、コントラクトが自身のETHを別のアドレス(コントラクトまたは外部所有)に送金する方法です。 送金する金額の値で呼び出します。 これはETHのエアドロップであると思われます。

1 if not return_data.size:\n if not ext_call.success:\n require ext_code.size(stor2)\n call stor2.deposit() with:\n value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei

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

このコントラクトは、_param2にETHを送金しようとしていると思われます。 送金できれば問題ありません。 そうでなければ、WETHopens in a new tabを送ろうと試みます。 _param2が外部所有アカウント (EOA)である場合、必ずETHを受け取ることができますが、コントラクトはETHの受け取りを拒否することができます。 しかし、WETHはERC-20であるため、コントラクトは受け取りを拒否できません。

1 ...\n 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を取得しています。

請求トランザクション

1e7df9d3

この関数は、上記のclaimと非常に類似しています。 この関数はマークル証明も同様に確認し、ETHを最初に送金しようと試み、同じタイプのログエントリを生成します。

1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:\n ...\n idx = 0\n s = 0\n while idx < _param3.length:\n if idx >= mem[96]:\n revert with 0, 50\n _55 = mem[(32 * idx) + 128]\n if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:\n ...\n s = sha3(mem[_58 + 32 len mem[_58]])\n continue\n mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])\n ...\n if unknown2eb4a7ab != s:\n revert with 0, '無効な証明です'\n ...\n call addr(_param1) with:\n value s wei\n gas 30000 wei\n if not return_data.size:\n if not ext_call.success:\n require ext_code.size(stor2)\n call stor2.deposit() with:\n value s wei\n gas gas_remaining wei\n ...\n log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)

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

1 idx = 0\n s = 0\n while idx < currentWindow:\n ...\n if stor5[mem[0]]:\n if idx == -1:\n revert with 0, 17\n idx = idx + 1\n s = s\n continue\n ...\n stor5[idx][addr(_param1)] = 1\n if idx >= unknown81e580d3.length:\n revert with 0, 50\n mem[0] = 4\n if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:\n revert with 0, 17\n if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):\n revert with 0, 17\n if idx == -1:\n revert with 0, 17\n idx = idx + 1\n s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)\n continue

そのため、すべてのウィンドウについて請求するclaimの変種のようです。

結論

ここまでで、オペコードまたは(動作する場合は)デコンパイラを使用して、ソースコードが入手できないコントラクトを理解する方法を身に付けられたはずです。 この記事の長さが示しているように、コントラクトのリバースエンジニアリングは簡単ではありません。しかしながら、セキュリティが不可欠なシステムでは、コントラクトが想定した通りに動作しているかを検証できることは、非常に重要なスキルです。

私の他の作品はこちらでご覧いただけますopens in a new tab.

最終更新: 2025年8月22日

このチュートリアルは役に立ちましたか?