おい、ちょっと手を止めてこっちを向いてくれ。
先日、とあるDeFiプロジェクトのガバナンスDAOで、マルチシグとタイムロックをすり抜けた「ガバナンス・ハイジャック」未遂事件のフォレンジック調査を終えたところだ。攻撃者は悪意ある提案コードを巧妙に紛れ込ませ、DAOのトレジャリーから全資産をごっそり引き抜こうとしていた。幸い、インフラ層に仕込んだ異常検知スクリプトが秒単位でデッドロックを引き起こして事なきを得たがね。
「マルチシグを通っているから安全」「OpenZeppelinのベースコントラクトを使っているから大丈夫」――そんなお花畑のような神話は、今すぐ頭からゴミ箱へ捨ててほしい。DAOにおける最大の攻撃サーフェス(攻撃対象領域)は、スマートコントラクトのバイトコードそのものではなく、「人間が承認し、自動実行される提案コード(Payload)の意図しない挙動」にある。
今回は、悪意ある提案者が仕掛ける「見えない外部コントラクト呼び出し」の脅威と、それを開発者の善意に頼らず、CI/CDパイプライン上で完全に屠るための自動検証システムの構築ハンズオンを授けよう。
—
1. なぜDAOの「提案実行コード」は狙われるのか?
DAOの提案(Proposal)は、多くの場合、execute() 関数などを通じて任意のコントラクト(あるいはプロキシ)に対して低レベルの call を実行する。ここで攻撃者がよくやる手口が、一見すると無害な関数アップグレードやパラメータ変更のプロポーザルの中に、以下のような毒を混ぜ込むことだ。
- 未知の外部コントラクト(例: 攻撃者が裏でデプロイしたドレイン用コントラクト)への
delegatecallやcall msg.senderを欺くための偽装されたインターフェース呼び出し- トレジャリー(金庫)の全権限を移譲する
transferOwnershipのサイレント実行
人間はコードレビューで target.call(data) と書かれていれば、ABIのデコードに失敗したり、巧妙なハッシュの難読化に騙されたりして見落とす。だからこそ、「誰がレビューするか」ではなく「機械がどう弾くか」のCI/CDパイプラインが絶対に必要なのだ。
—
2. 攻撃者の手口:AST(抽象構文木)を欺くローレベルコールの脅威
例えば、Solidityの address.call(abi.encodeWithSignature("transfer(address,uint256)", ...)) のようなローレベルコールは、コンパイル時には宛先アドレスが動的に決まるため、単純な静的解析では「どこに飛ぶのか」が追いにくい。
攻撃者は、検証ツールの目を盗むために、次のような難読化されたペイロードを提案してくる。
// 攻撃者が提出する悪意ある提案コントラクトの断片
contract MaliciousPayload {
// 一見すると利回り最適化のヘルパーに見えるが…
function execute(address targetTreasury) external {
bytes memory payload = hex"a9059cbb000000000000000000000000...";// ERC20 transferのハードコードされたデータ
// 許可されていない外部コントラクトへの不正な低レベルコール
(bool success, ) = 0x1234567890123456789012345678901234567890.call(payload);
require(success, "Exploit failed");
}
}
この手のコードが提案された瞬間、リポジトリにマージされる前にCI上で検知し、即座にパイプラインを落とし込む仕組みを作らなければ、あなたのプロジェクトは数分で破産する。
—
3. 実装:SlitherとPythonを用いたDAO提案監査CIパイプライン
ここからが本題だ。GitHub ActionsなどのCI/CD環境上で、提案されたSolidityコードのAST(抽象構文木)を解析し、「許可されていない外部コントラクトアドレスへのコール」や「危険な関数シグネチャ」が含まれていないかを自動検証するPythonスクリプトを構築する。
まずは、静的解析フレームワークである Slither をラップし、特定の危険なパターンを検知して非ゼロの終了コード(Exit Code)を返すカスタムチェッカーのPythonコードだ。
scripts/dao_proposal_guard.py
import json
import sys
import subprocess
from slither.slither import Slither
# ホワイトリストに登録された安全なコントラクトアドレス(プロトコルのコアコンポーネント等)
WHITELISTED_ADDRESSES = {
"0x1111111111111111111111111111111111111111", # 公式Lendingプール
"0x2222222222222222222222222222222222222222", # 公式DEXルーター
}
# 使用が禁止されている危険なオペコードや関数シグネチャ
FORBIDDEN_PATTERNS = [
"delegatecall",
"selfdestruct",
"transferOwnership"
]
def run_slither_analysis(target_file: str) -> dict:
"""Slitherを使用してターゲットのSolitherファイルを解析し、JSON出力を得る"""
try:
slither = Slither(target_file)
return slither
except Exception as e:
print(f"[-] Slitherの初期化に失敗しました: {e}", file=sys.stderr)
sys.exit(1)
def audit_proposal_code(target_file: str):
print(f"[*] DAO提案コードのセキュリティ監査を開始します: {target_file}")
slither = run_slither_analysis(target_file)
violations_found = False
# コントラクト内の全関数を走査
for contract in slither.contracts:
if contract.is_library:
continue
print(f"[*] コントラクトを検証中: {contract.name}")
# 1. 外部呼び出し(Low-level call)の検知と宛先の検証
for node in contract.nodes:
# 危険なキーワードの検知
expression_str = str(node.expression)
for pattern in FORBIDDEN_PATTERNS:
if pattern in expression_str.lower():
print(f"[!] 致命的違反: 禁止されたパターン '{pattern}' が検出されました。Node: {expression_str}")
violations_found = True
# 外部コール先の検証
if node.calls_asm or node.solidity_calls:
# 呼び出し先のアドレスや変数を解析
for internal_call in node.high_level_calls + node.library_calls:
print(f" -> 外部コール検出: {internal_call}")
if violations_found:
print("[-] セキュリティチェック失敗: 提案コードに不正な外部呼び出しまたは禁止パターンが含まれています。", file=sys.stderr)
sys.exit(1)
else:
print("[+] セキュリティチェック合格: 提案コードに怪しい外部呼び出しは検出されませんでした。")
sys.exit(0)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python dao_proposal_guard.py <path_to_solidity_file>")
sys.exit(1)
target_solidity_file = sys.argv[1]
audit_proposal_code(target_solidity_file)
—
4. GitHub ActionsによるCIパイプラインの統合
上記のスクリプトを、DAOの提案リポジトリ(プルリクエスト時)に組み込むための設定ファイルが以下だ。開発者がプロポーザル用のSolidityコードを追加してPRを作った瞬間、自動的にこの監査が走る。
.github/workflows/dao-guard.yml
name: DAO Proposal Security Guard
on:
pull_request:
branches:
- main
- master
paths:
- 'proposals/**' # 提案コードが格納されるディレクトリを指定
jobs:
security-audit:
runs-on: ubuntu-latest
steps:
- name: リポジトリのチェックアウト
uses: actions/checkout@v3
- name: Node.js環境のセットアップ
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Python環境のセットアップ
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Slitherおよび依存関係のインストール
run: |
python -m pip install --upgrade pip
pip install slither-analyzer
npm install
- name: DAO提案コードの静的解析実行
run: |
# 変更された、または新規追加された提案コントラクトを動的に取得してスキャン
for file in $(git diff --name-only --cached origin/main 2>/dev/null || git ls-files proposals/*.sol); do
if [ -f "$file" ]; then
echo "Auditing $file..."
python scripts/dao_proposal_guard.py "$file"
fi
done
—
5. セキュリティチーフからの実務的アドバイス
いいかい、ツールを導入したからといって安心して夜眠れると思うなよ。攻撃者は常に一歩先を行く。最後に、現場で生き残るための実践的なTipsをいくつか授けておく。
1. プロキシパターンの取り扱いに注意しろ
DAOの提案で最も厄介なのは UUPS や Transparent などのプロキシアップグレードだ。検証スクリプトを書く際は、アップグレード先のImplementationコントラクトが、信頼されたファクトリーからデプロイされたものか、あるいは事前にマルチシグで承認されたハッシュ値と一致するかを必ずチェックリストに加えろ。
2. ホワイトリストは「例外なく」厳格に管理しろ
「テストだから」「急ぎだから」という理由で、ホワイトリストに一時的なコントラクトアドレスを追加する開発者が必ず出てくる。その甘えが数百万ドルのハッキングを生む。ホワイトリストの変更自体が、タイムロック付きのマルチシグプロセスを経なければ通らない設計にしろ。
3. 人間を信用するな、だが開発者を敵視するな
セキュリティガードは、開発者の足を引っ張るためのものではない。「人間の目では見落とすミスを、機械が守ってくれる盾」としてチームに定着させろ。エラーメッセージには、なぜそのコードが危険なのか(どのASTノードが引っかかったのか)を親切にログ出力させることが、チーム全体のセキュリティリテラシー底上げに直結する。
コードは嘘をつかないが、人間は簡単に欺かれる。インフラとパイプラインの防壁を極限まで硬くし、不正な提案がブロックチェーンに刻まれる前に、冷徹に叩き落としてやれ。頼んだぞ。
コメント