Appearance
L1-11: Reentrancy(リエントランシー攻撃)
1. 問題
リエントランシー脆弱性を持つコントラクトを作成し、次にその脆弱性を悪用する方法を示す攻撃者コントラクトを構築し、最後に CEI(Checks-Effects-Interactions)パターンと OpenZeppelin ReentrancyGuard を使用して脆弱性を修正します。
中核的な問題:コントラクトが自身の状態を更新する前に外部アドレスにETHを送信する場合、受信側の receive() または fallback() 関数が元のコントラクトの引き出し関数を再呼び出しできます。状態がまだ更新されていない(残高が差し引かれていない)ため、攻撃者はコントラクトの残高が枯渇するまで繰り返し資金を引き出せます。
2. 理由
リエントランシー攻撃はスマートコントラクトの歴史において最も破壊的な脆弱性カテゴリです。2016年の The DAO ハッキング事件では360万ETH(当時の価値で約6000万ドル)が盗まれ、イーサリアムのハードフォークを直接引き起こしました。今日(2026年)に至るまで、リエントランシー脆弱性はDeFiプロトコルで頻繁に発生しています——2023年の Curve/Vyper 脆弱性、2024年の複数のレンディングプロトコルでのリエントランシー事件がこれを証明しています。
リエントランシー攻撃のメカニズムを理解することは、すべてのSolidity開発者の必修科目です。3つのレイヤーがあります:第一に、なぜ状態更新が外部呼び出しより前でなければならないか(CEI原則);第二に、外部呼び出しを引き起こす可能性のあるすべての操作を特定すること(ETH転送、ERC20 transfer、safeTransfer、クロスコントラクト呼び出し);第三に、リエントランシーは必ずしも悪意のあるコントラクトから来るとは限らないことを理解すること——ETHを受け取る任意のコントラクトが意図的または非意図的にコールバックをトリガーする可能性があります。
3. 解決策
アーキテクチャ設計
プロジェクトには3つの中核コントラクトが含まれます:
- VulnerableVault(脆弱性コントラクト):先に転送してから状態を更新——典型的な反CEIパターン
- ReentrancyAttacker(攻撃者コントラクト):
receive()コールバックを通じてwithdraw()を繰り返し呼び出す - SecureVault(安全なコントラクト):CEIパターン +
nonReentrantモディファイアを使用
脆弱性コントラクト(攻撃面のデモ)
solidity
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 脆弱性パターン:Interactions が Effects より先
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
require(address(this).balance >= amount, "Insufficient contract balance");
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
balances[msg.sender] = 0; // 状態更新が外部呼び出しの後!
}
}
攻撃者コントラクト
solidity
contract ReentrancyAttacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // リエントランシー!この時点で balances[attacker] はまだ0に設定されていない
}
}
}
安全なコントラクト(2つの保護手法)
手法A:CEIパターン
solidity
contract SecureVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender]; // Checks
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // Effects(先に更新!)
(bool sent, ) = msg.sender.call{value: amount}(""); // Interactions
require(sent, "Transfer failed");
}
}
手法B:ReentrancyGuard
solidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVaultWithGuard is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
}
}
4. 遭遇した落とし穴
4.1 transfer() への盲目的な信頼
多くの開発者は address.transfer() が安全だと考えています。なぜなら2300 gasしか転送せず、リエントランシーを実行するには不十分だからです。しかし実際には、EIP-1884以降、特定のオペコードのガスコストが増加し、2300 gasの前提はもはや信頼できません。さらに、コントラクトが call{value}("")(推奨方式)を使用する場合、リエントランシー保護を自身で処理する必要があります。
4.2 クロスファンクションリエントランシー
たとえ withdraw() 自体がCEI保護を持っていても、withdraw() が別の保護されていない関数を呼び出した場合、その関数が再入される可能性があります。リエントランシーは必ずしも同じ関数への再帰呼び出しではなく——任意の公開関数を横断する可能性があります。
4.3 ERC20トークンのリエントランシー
ETH転送だけでなく、任意のERC20 transfer や safeTransfer が受信側のコールバックをトリガーする可能性があります(特にERC777と特定のERC20バリアント)。コントラクトが未知のトークンに資金を送信する場合も、同様にCEI保護が必要です。
4.4 nonReentrant は同一関数のリエントランシーのみ防止
OpenZeppelinの nonReentrant モディファイアは同じモディファイアのスコープ内でのみ効果を発揮します。2つの関数がそれぞれ独自の nonReentrant を使用する場合、それらの間で相互にリエントランシーが可能です。実際の攻撃ではこれを悪用したクロスファンクションリエントランシーがよく見られます。
5. 落とし穴の理由
5.1
イーサリアム仮想マシン(EVM)は CALL オペコードの実行時に制御をターゲットコントラクトに移します。ターゲットコントラクトのコードが元のコントラクトへの呼び出しをトリガーした場合、この新しい呼び出しは元の呼び出しの残りのコンテキスト(未更新のストレージを含む)内で実行されます。Solidityコンパイラはこのパターンを自動的に検出しません。
5.2
クロスファンクションリエントランシーが危険な理由は、開発者が通常単一の関数の正しさのみに注目し、グローバルな状態の一貫性には注目しないからです。コントラクトAがコントラクトBを呼び出すとき、コントラクトBはコントラクトAの任意の公開関数をコールバックする可能性があります——そしてその時点でコントラクトAの状態は中間状態にある可能性があります。
5.3
ERC777トークン標準は tokensReceived コールバックフックを導入し、任意のトークン転送が受信側のコード実行をトリガーできるようにしました。ERC20の安全性に関する多くの前提は、ERC777の前では崩れます。これが Uniswap V2 が新しいトークンペアを追加する際に明示的にERC777トークンを除外した理由です。
5.4
nonReentrant は _status 状態変数(値1 = ロック解除、2 = ロック中)を使用します。2つの独立した関数はそれぞれロックを取得・解放できますが、それらの間の呼び出しチェーンはブロックされません。
6. 落とし穴の解決方法
6.1
常にCEIパターンに従います:最初にすべてのチェックを行い(Checks)、次にすべての状態変数を更新し(Effects)、最後に外部呼び出しを行います(Interactions)。withdraw では、balances[msg.sender] = 0 を call{value}("") の前に配置します。
6.2
すべての公開関数に nonReentrant モディファイアを使用します。注意:関数Aと関数Bの両方を保護すべき場合、それらが同じリエントランシーロックの下にあることを確認します(同じモディファイアを使用)。
6.3
ERC20転送を処理する際は safeTransfer と safeTransferFrom(OpenZeppelin SafeERC20から)を使用します。しかし、それらを呼び出す前にまず自身の状態を更新します——safeTransfer が安全に見えるからといって警戒を緩めてはいけません。
6.4
監査時に、すべての外部呼び出しポイント(.call{}(), .transfer(), .send(), safeTransfer(), IERC20の transfer と transferFrom)を特定し、各ポイントについて検証します:この呼び出しの後に状態の書き込みがあるか?ある場合、リエントランシーに悪用される可能性があるか?
7. 技術的ポイント
| ポイント | 説明 |
|---|---|
| CEIパターン | Checks-Effects-Interactions:状態更新を外部呼び出しより先に |
| ReentrancyGuard | OpenZeppelinの nonReentrant モディファイア、ミューテックスロックを使用 |
| リエントランシー検出 | .call{}() / transfer / send の後のすべての状態書き込みを探す |
| クロスファンクションリエントランシー | 非再帰的リエントランシー、コールバックチェーンを通じて異なる関数を横断 |
| ERC777リスク | tokensReceived コールバックによりトークン転送でもリエントランシーが可能 |
| 監査戦略 | 制御フローグラフを描き、すべての外部呼び出しとその後の状態書き込みをマーク |