「残高で判定するな」― selfdestruct が招くスマートコントラクトの死角
現場でコードを叩いていると、たまに「なぜこんな単純なミスを?」と頭を抱えたくなる設計に出会う。今回は、スマートコントラクト、特にEVM系チェーンにおいて、多くのエンジニアが陥る「コントラクト残高への過度な信頼」が引き起こす脆弱性について話そう。
特に、selfdestruct(現在は非推奨だが、依然として過去のコントラクトには存在する)を悪用した資金凍結や、強制送金によるロジック破壊は、Web3特有の「泥臭い」攻撃ベクトルだ。
—
なぜ selfdestruct でコントラクトが死ぬのか?
多くの開発者は、address(this).balance を見て「ここには〇〇ETH入っているから、これを使って報酬を計算しよう」というロジックを書きがちだ。これが致命傷になる。
攻撃者は selfdestruct(target) を実行することで、コントラクトのロジックを通さず、問答無用で強制的にETHを送りつけることが可能だ。
例えば、「コントラクトの残高が100ETHを超えたら、ある条件で送金する」というコントラクトがあったとする。攻撃者が外部から強制的にETHを流し込めば、ロジックは即座に崩壊し、コントラクトは「正常に動くはずのロジック」を完遂できなくなる。これが、資金凍結や計算ロジックの破綻を招くメカニズムだ。
—
具体的な攻撃手法:PoCの考え方
攻撃者は以下のような「空のコントラクト」を用意し、selfdestruct を呼び出す。
// 攻撃者のコントラクト例
contract Attacker {
address payable target;
constructor(address payable _target) {
target = _target;
}
// コンストラクタでETHを受け取り、即座にターゲットへ自爆攻撃を行う
function attack() external payable {
selfdestruct(target);
}
}
このコードを実行されると、target に指定されたコントラクトの address(this).balance は、どんなに防御を固めていても強制的にインクリメントされる。もしあなたの計算式が「現在の残高」に依存していたら、その瞬間からあなたのシステムは「汚染」されたことになる。
—
どう防御すべきか?:実務的な解決策
解決策はシンプルだ。「コントラクトの残高(balance)を、ロジックの判定基準に使うな」。
その代わり、内部変数で「管理すべき残高」を追跡し、それを基準に計算を行うこと。外部からの強制送金は、この内部変数には影響を与えないため、ロジックを安全に保てる。
以下に、セキュアな実装パターンを示す。JavaScript(ethers.js等)からコントラクトを操作する際も、この設計思想を意識してほしい。
セキュアなコントラクトの実装例(Solidity)
// 推奨される実装パターン
contract SecureVault {
// 外部の残高に頼らず、独自に追跡する変数
uint256 public internalBalance;
function deposit() external payable {
// 残高を管理変数で正確にトラッキングする
internalBalance += msg.value;
}
function withdraw(uint256 amount) external {
// 判定には必ず internalBalance を使用する
require(internalBalance >= amount, "Insufficient funds");
internalBalance -= amount;
payable(msg.sender).transfer(amount);
}
}
—
Web3インフラ担当者への提言
コントラクトコードだけでなく、システム全体での防御も重要だ。
1. アクセス制限と監査: selfdestruct が含まれる古いライブラリの利用は厳禁だ。コンパイル時の警告を無視しないこと。
2. 監視体制の構築: address(this).balance が急激に変動した場合にアラートを飛ばすスクリプトを走らせておくべきだ。以下は、Pythonで実装する簡易的な監視のヒントである。
# 監視スクリプトの断片(Web3.py利用)
from web3 import Web3
w3 = Web3(Web3.HTTPProvider('YOUR_RPC_URL'))
target_contract = "0x..." # 監視対象のコントラクトアドレス
def check_balance_anomaly(last_balance):
current_balance = w3.eth.get_balance(target_contract)
# 期待される残高変動以上の変化があれば通知する
if abs(current_balance - last_balance) > EXPECTED_THRESHOLD:
print("ALERT: 予期せぬ残高変動を検知!")
# ここでSlackやPagerDutyへ飛ばす処理を実装する
—
まとめ:セキュリティの「盲点」を突く
Web3の世界では、「コードが法律」であるがゆえに、一度デプロイされたロジックの脆さは致命的だ。selfdestruct による強制送金は、Web2的なファイアウォールやWAFでは防げない。
- 鉄則1:
address(this).balanceをrequireや計算のロジックに組み込むな。 - 鉄則2: 常に内部変数で状態を管理せよ。
- 鉄則3: ロジックが外部要因(強制送金)によって破壊される可能性を常に設計段階で考慮せよ。
泥臭い実装かもしれないが、これこそが「ハックされないシステム」を作るための唯一の道だ。きれいごとを並べるホワイトペーパーを読むよりも、こういった「攻撃者の視点」をコードに落とし込む姿勢を大切にしてほしい。現場からは以上だ。
コメント