
Solidityコンパイラの脆弱性分析:ABI再エンコーディングの欠陥
TechFlow厳選深潮セレクト

Solidityコンパイラの脆弱性分析:ABI再エンコーディングの欠陥
このプロセス自体に大きな論理的問題はないが、Solidityのcleanupメカニズムと組み合わせた場合、Solidityコンパイラのコード自体の不備により、脆弱性が生じてしまう。
概要
本稿では、Solidityコンパイラ(0.5.8 ≦ バージョン < 0.8.16)のABIリエンコーディングプロセスにおいて、固定長のuintおよびbytes32型配列の処理に起因する脆弱性について、ソースコードレベルでの詳細な分析を行い、関連する解決策および回避策を提案する。
脆弱性の詳細
ABIエンコーディング形式は、ユーザーまたはコントラクトがコントラクトの関数を呼び出し、引数を渡す際に使用される標準的なエンコーディング方式である。詳しくは、Solidity公式ドキュメントのABIエンコーディングを参照されたい。
コントラクト開発において、ユーザーまたは他のコントラクトから送られてくるcalldataから必要なデータを取得し、その後そのデータを転送したり、emitしたりすることがある。EVM仮想マシンのすべてのopcode操作はmemory、stack、storageに基づいているため、Solidityでは、データに対してABIエンコーディングを行う場合、calldata内のデータを新しい順序でABI形式に従ってエンコードし、memoryに格納することになる。
このプロセス自体に大きな論理的問題はないが、Solidityのcleanupメカニズムと組み合わさった場合、Solidityコンパイラ自身の実装上の不備により、脆弱性が生じる。
ABIエンコーディングルールによれば、関数セレクタを除いた後、ABIエンコードされたデータはhead部とtail部の二つの部分に分かれる。データ形式が固定長のuintまたはbytes32配列の場合、これらのデータはすべてhead部に格納される。一方、Solidityにおけるmemoryのcleanupメカニズムは、現在のインデックスのメモリを使用した後、次のインデックスのメモリをゼロクリアすることで、次回のメモリ使用時に不要なデータの影響を受けないようにしている。また、Solidityが一連のパラメータデータをABIエンコードする際には、左から右へと順番にエンコードを行う!!
以下のようなコントラクトコードを考えることで、脆弱性の原理をより明確に理解できる。
contract Eocene {
event VerifyABI( bytes[], uint[ 2 ]);
function verifyABI(bytes[] calldata a, uint[ 2 ] calldata b) public {
emit VerifyABI(a, b); // イベントデータはABI形式にエンコードされてブロックチェーン上に記録される
}
}
コントラクトEoceneのverifyABI関数は、関数引数として受け取った可変長bytes[] aと固定長uint[2] bをemitするだけの機能を持つ。
ここで注意すべきは、eventイベントもABIエンコーディングをトリガーすることである。つまり、引数a、bはABI形式にエンコードされた後にブロックチェーン上に保存される。
v0.8.14バージョンのSolidityを使用してコントラクトコードをコンパイルし、Remix上でデプロイし、verifyABI(['0xaaaaaa'],['0xbbbbbb'],[0x11111, 0x22222])を入力する。
まず、verifyABI(['0xaaaaaa'],['0xbbbbbb'],[0x11111, 0x22222])の正しいエンコーディング形式を見てみよう。
0x52cd1a9c // bytes4(sha3("verify(bytes[], uint[2])"))
0000000000000000000000000000000000000000000000000000000000000060 // aのインデックス
0000000000000000000000000000000000000000000000000000000000011111 // b[0]
0000000000000000000000000000000000000000000000000000000000022222 // b[1]
0000000000000000000000000000000000000000000000000000000000000002 // aの長さ
0000000000000000000000000000000000000000000000000000000000000040 // a[0]のインデックス
0000000000000000000000000000000000000000000000000000000000000080 // a[1]のインデックス
0000000000000000000000000000000000000000000000000000000000000003 // a[0]の長さ
aaaaaa0000000000000000000000000000000000000000000000000000000000 // a[0]
0000000000000000000000000000000000000000000000000000000000000003 // a[1]の長さ
bbbbbb0000000000000000000000000000000000000000000000000000000000 // a[1]
Solidityコンパイラが正常に動作していれば、引数a, bがeventイベントによってブロックチェーン上に記録される際、データ形式は送信時と同じはずである。実際にコントラクトを呼び出してみて、ブロックチェーン上のlogを確認してみよう。自分で比較したい場合は、以下のTXを参照のこと。
正常に呼び出された後、コントラクトのeventイベントに記録された内容は以下の通り。
!!驚愕、b[1]の直後に続く、aの長さを示す値が誤って削除されている!!
0000000000000000000000000000000000000000000000000000000000000060 // aのインデックス
0000000000000000000000000000000000000000000000000000000000011111 // b[0]
0000000000000000000000000000000000000000000000000000000000022222 // b[1]
0000000000000000000000000000000000000000000000000000000000000000 // aの長さ??なぜ0になった??
0000000000000000000000000000000000000000000000000000000000000040 // a[0]のインデックス
0000000000000000000000000000000000000000000000000000000000000080 // a[1]のインデックス
0000000000000000000000000000000000000000000000000000000000000003 // a[0]の長さ
aaaaaa0000000000000000000000000000000000000000000000000000000000 // a[0]
0000000000000000000000000000000000000000000000000000000000000003 // a[1]の長さ
bbbbbb0000000000000000000000000000000000000000000000000000000000 // a[1]
なぜこのような現象が起きるのか?
前述の通り、Solidityが複数のパラメータをABIエンコードする際、エンコードの順序は左から右へと行われる。具体的なa、bのエンコード手順は以下の通り。
-
まずSolidityはaをABIエンコードする。エンコードルールに従い、aのインデックスはヘッダーに配置され、aの要素の長さおよび実際の値はテール部に格納される。
-
次にbのデータを処理する。bのデータ型はuint[2]であるため、実際の値はhead部に格納される。しかし、Solidityのcleanupメカニズムにより、b[1]がmemoryに格納された後、その次のメモリアドレス(aの要素長を格納する場所)がゼロクリアされてしまう。
-
ABIエンコード処理が終了し、誤ってエンコードされたデータがブロックチェーン上に保存され、SOL-2022-6脆弱性が発生する。
ソースコードレベルでも、この誤りのロジックは明確である。calldataから固定長のbytes32またはuint配列をmemoryにコピーする際、Solidityは常にコピー後に次のメモリインデックスをゼロに設定してしまう。また、ABIエンコードにはhead部とtail部があり、エンコード順序も左から右であるため、この脆弱性が成立する。
この脆弱性に関連するSolidityコンパイラのコードは以下の通り。
ソースデータの格納位置がCalldataであり、かつデータ型がByteArray、String、または配列の基本型がuintまたはbytes32の場合、ABIFunctions::abiEncodingFunctionCalldataArrayWithoutCleanup()に入る。

ここに入ると、まずfromArrayType.isDynamicallySized()によって、ソースデータが固定長配列かどうかを判定する。固定長配列のみが脆弱性のトリガー条件に該当する。
次にisByteArrayOrString()の判定結果をYulUtilFunctions::copyToMemoryFunction()に渡す。 この結果に基づき、calldatacopy操作後に次のインデックス位置のcleanupを行うかどうかを決定する。
上記の制約条件が組み合わさることで、calldata内の固定長のuintまたはbytes32配列がmemoryにコピーされるときにのみ脆弱性が発生する。これが脆弱性のトリガー条件となる。

ABIがパラメータをエンコードする際は常に左から右の順序であるため、脆弱性の利用条件を考える際には、固定長のuintおよびbytes32配列の前に、動的長のデータがABIエンコードのtail部に格納されており、かつ固定長のuintまたはbytes32配列がエンコード対象の最後のパラメータでなければならないことを理解しなければならない。
理由は明らかである。固定長のデータが最後のパラメータでなければ、次のパラメータがそのメモリ位置を上書きするため、次のメモリ位置をゼロクリアしても影響はない。また、固定長データの前にtail部に格納されるデータがなければ、次のメモリ位置がゼロクリアされても問題ない。なぜなら、その位置はABIエンコードで使用されないからである。
さらに、暗黙的または明示的なすべてのABI操作、および該当フォーマットのすべてのタプル(一連のデータ)が、この脆弱性の影響を受けることに注意が必要である。
具体的に影響を受ける操作は以下の通り。
-
event
-
error
-
abi.encode*
-
returns // 関数の戻り値
-
struct // ユーザー定義構造体
-
すべてのexternal call
解決策
-
コントラクトコード内で上記の影響を受ける操作を使用する場合、最後のパラメータを固定長のuintまたはbytes32配列にしないようにする。
-
この脆弱性の影響を受けないSolidityコンパイラを使用する。
-
専門のセキュリティ担当者に相談し、コントラクトに対して専門的なセキュリティ監査を依頼する。
TechFlow公式コミュニティへようこそ
Telegram購読グループ:https://t.me/TechFlowDaily
Twitter公式アカウント:https://x.com/TechFlowPost
Twitter英語アカウント:https://x.com/BlockFlow_News














