おい、最近スマートコントラクトの開発現場で「うちはちゃんとした設計にしてるから再入攻撃(Reentrancy)なんて対策済みだよ」なんて油断しているエンジニアを見かけるが、それは大きな間違いだ。
昔ながらの transfer() や call.value() を悪用した古典的なシングルファンクションの再入攻撃なんて、今や初歩の初歩。攻撃者はもっと狡猾に、複数の関数をまたいだり、状態を変えずに読み取り専用(View)の値を改ざんして外部のDeFiプロトコルをハッキングする「高度な変種」を虎視眈々と狙っている。
今回は、現場のセキュリティチーフである俺が、クロスファンクション再入攻撃とリードオンリー再入攻撃の生々しい手口と、それを完全にねじ伏せるための実戦的な防御コードを徹底的に叩き込んでやる。心して聞け。
—
1. 現代のスマートコントラクトを狙う「高度な再入攻撃」の正体
古典的な再入攻撃は、withdraw() のような単一の関数内で、残高を減らす前に外部コントラクトへの送金(call)を実行してしまうことが原因だった。しかし、今のSolidityエンジニアは ReentrancyGuard(Mutex)を導入するか、Checks-Effects-Interactionsパターンを徹底しているため、そう単純にはやられない。
だからこそ、攻撃者は「盲点」を突いてくる。それが以下の2つの高度な変種だ。
クロスファンクション再入攻撃 (Cross-Function Reentrancy)
同じコントラクト内の別の関数と状態を共有している場合に発生する。
例えば、withdraw() 関数自体にはガードがかかっていて再入できなくても、同じ状態変数(ユーザーの残高など)を参照・操作する別の関数 transferFrom() や stake() にガードが抜けていれば、送金処理中のフォールバック関数からその別関数を叩かれて、状態の整合性を破壊される。
リードオンリー再入攻撃 (Read-Only Reentrancy)
これが最近のDeFiインシデントで最も厄介なやつだ。
攻撃者は、あるコントラクトの状態変更中の「一貫性が崩れている瞬間(値がまだ更新されていないのに外部呼び出しが行われている状態)」に割り込み、別のコントラクトの view 関数(読み取り専用関数)を叩かせる。
view 関数は通常、状態を変更しないため安全だと思われがちだが、その瞬間の「歪んだ計算結果」を別のプロトコル(オラクルや貸出プラットフォーム)が信用して参照してしまい、担保価値の不正つり上げや不正な借入を引き起こす。
—
2. 攻撃者の視点:脆弱なコントラクトの構造
百聞は一見に如かず。まずは、一見セキュリティを意識しているようで、実は致命的な穴だらけのコントラクトのコードを見てみよう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @dev 危険な実装例:クロスファンクションとリードオンリーの罠
*/
contract VulnerableVault {
mapping(address => uint256) public balances;
mapping(address => uint256) public shares;
bool private locked; // 単一の関数しか守れていない不完全なMutex
// 個別の関数だけにmodifierをつけて安心しているパターン
function deposit() external payable {
balances[msg.sender] += msg.value;
shares[msg.sender] += msg.value;
}
// この関数自体は再入防止(Mutex)がかかっているつもり
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 外部コントラクトへの送金(ここでコントロールが攻撃者に渡る)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
// 状態の更新(Checks-Effects-Interactionsの順序が逆転している!)
balances[msg.sender] -= _amount;
shares[msg.sender] -= _amount;
}
/**
* @dev 【致命的な脆弱性】
* withdrawの送金中に、この関数が外部から呼び出される可能性がある。
* withdraw内で balances がまだ減らされていないため、
* 攻撃者は別のロジックで不正な操作(または計算の歪みを利用)できてしまう。
*/
function calculateCollateralValue(address _user) external view returns (uint256) {
// リードオンリー再入攻撃の標的:
// withdraw実行中の「不整合な状態」でこのview関数が呼ばれると、
// 実際の残高以上の価値を返してしまう可能性がある。
return shares[_user] * 2;
}
}
このコードの何がヤバいか分かるか? withdraw 関数の中で、送金(msg.sender.call)が完了する前に状態変数が更新されていない。しかも、calculateCollateralValue という view 関数は、他のプロトコルから価格参照などに使われることが多く、ここに割って入られることでエコシステム全体を巻き込む大惨事につながるのだ。
—
3. 現場で使える!完全に硬化されたセキュアな実装
では、この泥沼からどうやって這い上がるか。答えはシンプルだ。
1. Checks-Effects-Interactions パターンの徹底(外部呼び出しは必ず一番最後にする)。
2. OpenZeppelinの ReentrancyGuard を使い、コントラクト全体の重要な状態変更を伴う関数、および影響を受けるすべての関数を一網打尽でロックする。
3. 読み取り専用関数(view)であっても、状態が一時的に崩れる瞬間に影響を受ける設計を排除する。
以下に、実務でそのままコピーして使えるセキュアなボールト(Vault)コントラクトのサンプルを示す。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title SecureVault
* @dev クロスファンクションおよびリードオンリー再入攻撃を完全に防御したセキュア実装
*/
contract SecureVault is ReentrancyGuard, Ownable {
mapping(address => uint256) public balances;
mapping(address => uint256) public shares;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
constructor() Ownable(msg.sender) {}
/**
* @dev 預金機能
*/
function deposit() external payable nonReentrant {
require(msg.value > 0, "Zero deposit");
// 1. Checks (検証)
// 2. Effects (状態更新) 先に状態を更新する
balances[msg.sender] += msg.value;
shares[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
/**
* @dev 引き出し機能
* nonReentrant を付与し、かつ状態更新を外部呼び出しの前に完了させる(CEIパターン)
*/
function withdraw(uint256 _amount) external nonReentrant {
// 1. Checks (検証)
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 2. Effects (状態更新:外部呼び出しの「前」に必ず実行する)
balances[msg.sender] -= _amount;
shares[msg.sender] -= _amount;
// 3. Interactions (外部呼び出し:すべての状態更新が完了した後に実行)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
emit Withdrawn(msg.sender, _amount);
}
/**
* @dev セキュアな計算ロジック
* nonReentrant を付与することで、withdrawの実行中にこの関数が
* 割り込んで呼ばれる(リードオンリー再入攻撃)のをブロックする。
*/
function calculateCollateralValue(address _user) external view returns (uint256) {
// 注意: view関数に nonReentrant は直接付与できない(状態を変更しないため)が、
// そもそも状態がロックされている最中に外部から安全に参照される設計、
// または非同期なオラクル設計を取り入れることが肝要。
return shares[_user] * 2;
}
/**
* @notice もし外部プロトコルから安全に参照させたい場合は、
* 状態が完全にコミットされたスナップショットや、パッチ済みの計算ロジックを提供する。
*/
}
—
4. セキュリティチーフからの実務的アドバイス
コードを書くだけで仕事が終わったと思うなよ。インフラや運用、テストのフェーズにおいて、以下のポイントを必ずチームの共通認識として徹底してくれ。
- 静的解析ツールの導入:
CI/CDパイプラインには必ず Slither や Mythril を組み込め。特に Slither は reentrancy-eth や reentrancy-no-eth などの検出ルールで、今回解説したような変種の兆候をビルド段階で弾き出してくれる。
- ファジングテスト (Fuzz Testing):
Foundryを使ったインバリアント(不変条件)テストを書きまくれ。「Vault全体のETH残高と balances の総和が常に一致しているか」といった不変条件を破るテストケースをFuzzで回せば、人間の目で追いきれない複雑な状態遷移のバグもあぶり出せる。
- 「動いてから直す」の禁止:
Web3の世界では、一度デプロイされたコントラクトのイミュータブル(変更不可能な)性質が牙をむく。プロキシパターン(Upgradeable)を使っていたとしても、アップグレード権限のガバナンスが乗っ取られたら終わりだ。実装前の設計レビューの段階で、外部呼び出し前後の状態変数の書き換え順序を全員で声に出して指さし確認しろ。
セキュリティとは、派手なハックを防ぐことではなく、地味で退屈なルールの徹底の積み重ねだ。頼むから、次のデプロイ前には自分のコードをもう一度隅々まで見直してくれよ。健闘を祈る。
コメント