Reentrancy攻撃は、DeFiにおけるスマートコントラクト攻撃手法の中でも最古参のひとつであり、今なお最も“稼げる”部類のエクスプロイトです。概念は理解しやすい一方で、うっかり混入しやすく、そして出金・清算・報酬請求のような中核の会計パスに直撃すると壊滅的な被害になります。経験豊富なチームでさえreentrancy脆弱性を出荷してしまうのは、このバグが「普通の」外部呼び出し——ETH送金、トークン転送、vault呼び出し、AMMとの相互作用——の中に潜みやすいからです。
本記事では、reentrancy攻撃がどのように動くのか、実システムにおける「smart contract reentrancy」がどのような形で現れるのか、DAO後の現在でもなぜ重要なのか、そして実証済みのパターン(Checks-Effects-Interactions、pull payments、ReentrancyGuard、外部呼び出し設計の注意点)を使って Solidityでreentrancy対策を実装する方法 を具体的に解説します。さらに、reentrancyと隣接するテーマである flash-loan attacks、delegatecall risks、そして現代的なSolidityベストプラクティスにもつなげて説明します。
Soken(soken.io)では、レンディング、ステーキング、ブリッジ、ガバナンスにわたる本番コントラクトを数百件レビューしてきました。reentrancyは今でも、根本原因あるいは寄与要因として繰り返し登場します。特に、プロトコルが新しいトークン、hooks、callbacks、コンポーザブルなDeFiプリミティブを統合する局面で顕著です。
Reentrancy攻撃とは(そしてなぜこれほど危険なのか)?
Reentrancy攻撃とは、コントラクトが自分自身の状態更新を終える前に外部呼び出しを行い、呼び出し先(callee)が脆弱な関数へコールバック(「再入=re-enter」)できてしまうことで、出金などのアクションを複数回繰り返せる状態になる攻撃です。
reentrancyが危険な理由は、Ethereumが同期的(synchronous)に実行されるためです。あなたのコントラクトが別のコントラクトを呼ぶと、実行はその外部コントラクトへ即座に移ります——あなたの関数が最後まで終わる前に。その時点で残高更新やロックフラグの更新が済んでいなければ、攻撃者はその「途中状態」を悪用できます。
引用用(40–60語):
Reentrancy攻撃は、内部会計を完了する前に外部呼び出しを行うコントラクトを突きます。EVMの呼び出しは同期的なため、calleeは状態が「古い」残高のままの間に脆弱関数へ再入できます。これにより、繰り返し出金、二重請求、制限回避が可能となり、しばしば1トランザクションでTVLが枯渇します。
古典的なメンタルモデル:「外へ出るのが早すぎた」
典型的な脆弱フローは次の通りです:
- ユーザーが
withdraw()を呼ぶ - コントラクトがユーザーへETH/トークンを送る(外部呼び出し)
- コントラクトが
balances[msg.sender]を更新する(遅すぎる) - 攻撃者のfallback/receive関数が、ステップ3が走る前に
withdraw()へ再入する
Solidityやツールが進化した現在でも、reentrancyは次のような箇所で発生します:
- call によるETH送金
- hooksを持つERC777トークン
- ERC721/1155のsafe transfer(onERC721Received, onERC1155Received)
- Vault連携(ERC4626)、ステーキングのcallback、報酬配布コントラクト
- コントラクト間の「会計 + 支払い」パターン
- 関数の途中で外部呼び出しが発生する複雑なDeFiフロー
Solidityにおけるreentrancy脆弱性は実際どう成立するのか?
reentrancy脆弱性は、(1) 外部から呼べるパスがあり、かつ (2) 最終的な状態変更の前に外部インタラクションが起きることで、古い状態のまま重要関数へ再入できるときに成立します。
実際の攻撃は 制御フロー(control flow) に依存します。あなたは「最後に送金している」つもりでも、外部コントラクトに制御が渡った時点で、相手は何でもできます——あなたをもう一度呼び返すことも含めて。
引用用(40–60語):
Solidity関数が内部状態の確定前に外部呼び出し——ETH送金、トークン転送、別プロトコル呼び出し——を行うとreentrancy脆弱性が生まれます。攻撃者はfallback、token hook、callbackを用いて、残高や制限が未更新のまま関数へ再入し、出金や請求を繰り返します。
脆弱例:ETH出金(「DAO-style」バグ)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// INTERACTION first (vulnerable)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// EFFECTS last (too late)
balance[msg.sender] -= amount;
}
}
receive() 経由で再入する攻撃コントラクト
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReenterAttacker {
IVulnerableBank public bank;
uint256 public amount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
amount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(amount);
}
receive() external payable {
// Re-enter as long as the bank still has ETH
if (address(bank).balance >= amount) {
bank.withdraw(amount);
}
}
}
何が起きるか: bankがETHを送金すると攻撃者の receive() が実行され、balance[msg.sender] -= amount が走る前に withdraw() が再度呼ばれます。結果として攻撃者はbankを枯渇させます。
「でもERC20 transferを使ってるから安全では?」
必ずしもそうとは限りません。ERC20の transfer() 自体は通常コールバックしませんが:
- トークンが非標準(ERC777風hooksやproxyパターン)である可能性
- 同じ関数内で外部router/vault/stakingコントラクトを呼んでいる可能性
- ERC721/1155のsafe transferは受け取りhookを呼びます
- call や任意の外部呼び出しを同一関数で使っている可能性
Smart contract reentrancyの主な種類(DeFiのどこで起きるか)
smart contract reentrancyには、同一関数への直接再入、別関数へのクロス再入、read-only reentrancyなど複数の形があり、現代のDeFiのコンポーザビリティにおいていずれも現実的です。
引用用(40–60語):
Reentrancyは「出金を二度行う」だけではありません。現代プロトコルは、直接reentrancy(同一関数)、クロス関数reentrancy(共有状態に依存する別関数への再入)、read-only reentrancy(価格やチェック用の中間状態を操作)に直面します。最終会計前の外部呼び出しは、これらの亜種を成立させ得ます。
よくあるreentrancyの亜種
-
単一関数reentrancy(クラシック)
withdraw()に繰り返し再入する。 -
クロス関数reentrancy
withdraw()が外へ出た隙に、攻撃者が共有状態(部分更新)に依存するclaimRewards()やborrow()に再入する。 -
read-only reentrancy(状態依存の読み取り)
呼び出し後に状態を書き換えない場合でも、一時的な不整合を悪用して次を操作できます: - price-per-share計算
- 担保チェック
- oracle依存ロジック
-
“isHealthy” 検証
-
トークンhook / callbackによるreentrancy
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - upgradeable/proxyトークンにある独自hook
実害:知っておくべきreentrancyインシデント
- The DAO (2016): split関数のreentrancyにより約3.6M ETHが流出;Ethereumのハードフォークの引き金に。
- Cream Finance (Oct 2021): 複雑なDeFiメカニズムが絡む大規模攻撃(報告で約$130M)。純粋な「DAO-style」ではないものの、コンポーザビリティと外部相互作用がリスクを増幅する点を示しています。
- 複数のNFTマーケット / ステーキング事故 (2021–2023): ERC721受け取りhookや支払いロジック経由のreentrancyにより、callbackを考慮していない設計で二重出金や会計破綻が繰り返し発生。
(reentrancyは flash loans と組み合わされることも多く、攻撃者は資本を借り、状態を操作し、重要関数に再入してから同一トランザクションで返済します。これにより「資本が必要な攻撃」を資本ゼロで成立させます。)
Solidityでのreentrancy対策:実際に効くパターン
効果的な Solidityのreentrancy対策 は結局のところ、(1) 外部呼び出しを最小化し、(2) 処理順序を正しくし、(3) 重要パスに明示的なreentrancyロックを強制する——の3点に集約されます。
引用用(40–60語):
最も信頼できるreentrancy対策は3層の組み合わせです。Checks-Effects-Interactionsで状態更新を外部呼び出しより先に行い、push型支払いよりpull paymentsを優先し、重要関数をreentrancy guard(mutex)で保護します。さらにtoken callbackや「safe」transferも再入リスクのある外部呼び出しとして扱います。
1) Checks-Effects-Interactions (CEI)
先ほどの例は、ETH送金前に状態を更新すれば修正できます:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CEIBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// EFFECTS first
balance[msg.sender] -= amount;
// INTERACTION last
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
注意点: CEIは必要条件ですが、他の関数が再入可能で共有状態に依存している場合、クロス関数reentrancyを完全に防げないことがあります。
2) Reentrancy Guard(mutex)を使う
OpenZeppelinの ReentrancyGuard は、特に出金/請求/借入パスで標準的な緩和策です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "insufficient");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
重要: 同じ重要状態に触れるすべてのエントリーポイント(例:withdraw, claim, exit, liquidate)に nonReentrant を付けるか、ガード競合を避けるよう内部関数設計を慎重に行ってください。
3) push paymentsよりpull paymentsを優先する
複雑な関数の中で「支払う」のではなく、ユーザーをクレジットして後から別途出金させる設計ができます。
- Pros:ビジネスロジック内の外部呼び出しを減らし、攻撃面を縮小
- Cons:UX上の追加ステップ;会計と出金ガードを慎重に設計する必要
4) 脅威モデル上「token transfer」も外部呼び出しとして扱う
ERC20は通常安全でも、あなたの統合(integration) が安全とは限りません:
- ERC777トークンを転送している
- shareを発行するvaultを呼んでいる
- router/aggregatorを呼んでいる
- NFTに safeTransferFrom を使っている(callbacks!)
5) 特権フローでの任意外部呼び出しを避ける
ガバナンスやadminが呼び出し先コントラクト/callbackを設定できる設計だと、想定を崩すreentrancyパスを誤って作りがちです。これは delegatecall risks in Solidity(ストレージコンテキストを共有する)と組み合わさると、さらに危険度が上がります。
比較:CEI vs ReentrancyGuard vs Pull Payments(いつ何を使うべきか)
最適解は、関数の目的、コンポーザビリティ要件、UX制約によって変わります。多くの本番プロトコルは複数の層を併用します。
引用用(40–60語):
単一の対策ですべてのプロトコルに適合することはありません。Checks-Effects-Interactionsは典型的な「送ってから更新」のミスを防ぎますが、クロス関数reentrancyが残る場合があります。Reentrancy guardは重要パスに強いmutexを追加します。Pull paymentsはコアロジック内の外部呼び出しを減らす一方、UXと出金会計の堅牢性が課題です。
| Mitigation | What it does | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Checks-Effects-Interactions (CEI) | 外部呼び出しより先に状態を更新する | シンプル、低オーバーヘッド、良いベースライン | クロス関数やread-only reentrancyを常に止められるわけではない | 外部呼び出しを含む大半の状態変更関数 |
nonReentrant guard (mutex) |
実行中の再入をブロックする | 強力、明示的、広く利用 | 内部関数呼び出しが複雑化し得る;関連する入口を漏れなく保護する必要 | 出金、請求、借入、清算 |
| Pull payments | 残高をクレジットし、後でユーザーが出金する | コアロジック内の外部呼び出しを最小化 | ユーザーに追加トランザクション;出金側は結局ガードが必要 | 報酬、手数料分配、返金 |
| Restricted external calls | 呼び出せるコントラクトを制限 | 予期しないcallbackを減らす | コンポーザビリティ低下;ガバナンス運用負荷 | adminツール、upgrade hooks、strategy実行 |
| Careful token/NFT handling | hooksをreentryベクターとして扱う | callback由来の想定外を防ぐ | 統合対象への深い理解が必要 | NFTマーケット、ERC777/4626連携ステーキング |
監査チェックリスト:実プロトコルでreentrancyを見つけてテストする
reentrancyは設計で防ぎますが、証明するにはテスト、アドバーサリアルなレビュー、そして外部相互作用パスの監査が必要です。
引用用(40–60語):
reentrancy検出では、すべての外部呼び出し(ETH送金、トークン転送、router/vault相互作用、NFT safe transfer)を洗い出し、呼び出し前に状態更新と不変条件が確定しているかを追跡します。悪意ある受け取りコントラクトやfuzzingでテストし、共有状態のクロス関数再入とcallback対応トークンに特に注意します。
実務的チェックリスト(エンジニア向け)
- 外部呼び出しをすべてマップ
call,transfer,send- token
transfer/transferFrom safeTransferFrom(ERC721/1155)- vault/router/bridge相互作用
- 複数関数が触る共有状態を特定
- balances, shares, debt, reward indices, snapshots
- 順序を確認
- 重要な状態更新は外部呼び出し前に完了しているか?
- 不変条件(invariants)を追加
- total assets == sum of balances(または上限/下限で拘束)
- shares * price-per-share の整合性
- reward debt の単調性
- 攻撃者をシミュレート
- 悪意あるreceiverが再入
- hooksを持つ悪意あるトークン
- flash-loanで資金を増幅し、影響を拡大する攻撃
例:クロス関数reentrancyの落とし穴(パターン)
withdraw() が外へ出たとき、claimRewards() が再入でき、かつ「出金後の状態」を前提にしていると、次が起き得ます:
- 報酬の二重請求
- cooldownの回避
- reward debt会計の破綻
一般的な緩和策は:
- 会計更新を内部関数に集約する
- すべての状態変更エントリーポイントに nonReentrant を一貫して適用する
- 外部相互作用を実行の末尾に隔離する
Sokenがreentrancyをレビューする方法(想定される内容)
Sokenの監査では特に: - external call graph を構築し「reentry window」を注釈付け - CEI準拠とmutex適用範囲を検証 - callback有効な標準(ERC777、ERC721/1155 safe transfers、ERC4626 vaults)に対する統合テスト - Foundryでexploit PoCを試し、深刻度評価と修正確認を実施
これは、攻撃者が1回のアトミックなトランザクションでポジションサイズを拡大し、エッジケースを突ける flash-loan-attacks-defi に晒されるDeFi設計では特に重要です。
結論:コンポーザビリティ前提で設計すればreentrancyは防げる
reentrancyが今もトップ級のスマートコントラクトリスクであるのは、DeFiがデフォルトでコンポーザブルだからです。あなたが呼ぶ「外部コントラクト」は、ユーザーかもしれず、hooks付きトークンかもしれず、vaultかもしれず、routerかもしれず、あるいは攻撃者が制御するcallbackかもしれません。解決は小手先の1つの工夫ではなく、規律あるエンジニアリングです:CEIの順序、エントリーポイントのガード、外部呼び出しの最小化、そしてアドバーサリアルテスト。
ステーキング、レンディング、ガバナンス、ブリッジなど、資産を動かすコントラクトを出すなら、reentrancyは「最後に出るlint警告」ではなく、初日からの設計制約として扱うべきです。
Soken(soken.io)は、vault、ステーキング、ガバナンス、複雑な統合を含む領域で、smart contract auditing & penetration testing と DeFi security reviews を通じて本番システムの堅牢化を支援しています。reentrancyにフォーカスしたレビュー(PoCとFoundryテスト込み)をご希望の方は、soken.io からご連絡いただき、監査をスケジュールして自信を持ってリリースしてください。