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

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

イーサリアム仮想マシン(EVM)オペコード
上級
Ori Pomerantz
2021年12月30日
43 分の読書 minute read

はじめに

ブロックチェーン上に秘密はありません。ブロックチェーン上で起こる全てのことは、一貫性があり、検証可能で、公開されています。 理想的には、コントラクトは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)アセンブラにある程度精通している必要があります。 イーサリアム仮想マシン(EVM)に関するトピックについてはこちら(opens in a new tab)をご覧ください。

実行可能コードの準備

Etherscanにアクセスするとコントラクトのオペコードを入手できます。「Contract」タブをクリックし、次に「Switch to Opcodes View」をクリックしてください。 これで一行ずつオペコートが表示されます。

Etherscanでのオペコードの表示

ジャンプ (JUMP) を理解するには、コード内のオペコードの場所を理解する必要があります。 そのための1つの方法は、Googleスプレッドシートを開き、オペコードをC列に貼り付けることです。既に準備されているこのスプレッドシートをコピーすれば、次のステップをスキップできます(opens in a new tab)

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

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

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

A1に最初のオフセットである0を配置します。 次に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. コールデータサイズ(CALLDATASIZE)を読み取ります。 通常、イーサリアムコントラクトのコールデータは、アプリケーションバイナリインターフェース(ABI)(opens in a new tab)に従います。アプリケーションバイナリインターフェース(ABI)では、最低でも4バイトが関数セレクタに必要です。 コールデータのサイズが4未満の場合、0x5Eへジャンプします。

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

0x5Eのハンドラ(非アプリケーションバイナリインターフェース(ABI)コールデータの処理)

オフセットオペコード
5EJUMPDEST
5FCALLDATASIZE
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)は、送金(transfer)です。

このトランザクションを調べるには、「Click to show more」をクリックします。コールデータ(Input Dataと表示される)が実際に空(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を使用してアカウント残高を取得できる場合には、非常に高価なストレージを使う必要がないからです。 最初のオペコードは、コントラクト自体のアドレスをプッシュ(PUSH)します。 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

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

オフセットオペコードスタック
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は、ビットごとの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以下の場合にジャンプ(JUMP)します。 これは、オーバーフローを防ぐためのロジックに見えます。 実際に、オフセット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

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

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

オフセットオペコードスタック
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)にコピーする呼び出しの後、その呼び出しが成功した場合は、そのバッファを正確に返します(RETURNします)。

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(((First word (256 bits) of the call data)))
10PUSH1 0xe00xE0 (((First word (256 bits) of the call data)))
12SHR(((first 32 bits (4 bytes) of the call data)))

Etherscanでは、1Cが未知のオペコードとなっていますが、これはEtherscanでこの機能が作成された後に追加された(opens in a new tab)ため、まだ反映がされていないのが理由です。 最新のオペコードテーブル(opens in a new tab)では、これが右シフトであることが示されています

オフセットオペコードスタック
13DUP1(((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
14PUSH4 0x3cd8045e0x3CD8045E (((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
19GT0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1APUSH2 0x00430x43 0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1DJUMPI(((first 32 bits (4 bytes) of the call data)))

このようにメソッドシグネチャのマッチングテストを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)呼び出しのフローチャート

splitter()

オフセットオペコードスタック
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する)必要があります。

オフセットオペコードスタック
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] a.k.a the contract for which we are a proxy)))
114PUSH1 0x400x40 (((Storage[3] a.k.a the contract for which we are a proxy)))
116MLOAD0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] a.k.a the contract for which we are a proxy)))
12DSWAP2(((Storage[3] a.k.a the contract for which we are a proxy))) 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

ジャンプ(JUMP)の後に何が起こるかは、すでに分かっています。 つまり、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*を返します。

メソッドのサマリー

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

メソッド説明
Transfer呼び出しによって提供された値を受け入れ、その額でValue*を増やす
splitter()Return Storage[3], the proxy address
currentWindow()Return Storage[1]
merkleRoot()Return Storage[0]
0x81e580d3パラメータがStorage[4]より小さい場合、ルックアップテーブルにある値を返す
0x1f135823Return Storage[6], a.k.a. Value*

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

コンストラクタ

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

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

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

プロキシコントラクト

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

  • 呼び出し(コール)にETHが伴う(0x05~0x0F)
  • コールデータのサイズが4未満(0x10~0x19と0xBE~0xC2)

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

メソッドメソッドシグネチャジャンプ先のオフセット
scaleAmountByPercentage(uint256,uint256)(opens in a new tab)0x8ffb5c970x0135
isClaimed(uint256,address)(opens in a new tab)0xd2ef07950x0151
claim(uint256,address,uint256,bytes32[])(opens in a new tab)0x2e7ba6ef0x00F4
incrementWindow()(opens in a new tab)0x338b1d310x0110
???0x3f26479e0x0118
???0x1e7df9d30x00C3
currentWindow()(opens in a new tab)0xba0bafb40x0148
merkleRoot()(opens in a new tab)0x2eb4a7ab0x0107
???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:
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)
コピー

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

if文は、_param1がゼロでないこと、さらに_param1 * _param2が負でないことを確認していると思われます。 これは、オーバー(アンダー)フローを防止している可能性があります。

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

claim

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

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'
コピー

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

  • _param2は、uint256として宣言されているが、実際にはアドレスである
  • _param1は、請求対象のウィンドウであり、currentWindowであるか、前のウィンドウである必要がある
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'
コピー

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

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'
すべて表示
コピー

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

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

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

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

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

1 ...
2 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:
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)
すべて表示
コピー

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

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
すべて表示
コピー

そのため、すべてのウィンドウについて請求するclaimを少し変えたもののように思われます。

まとめ

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

最終編集者: @wackerow(opens in a new tab), 2024年4月2日

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