Reentrancyの種類と防ぎ方:cross-function・read-only・callbackをFoundryで再現する
Solidityのreentrancyをclassic、cross-function、cross-contract、read-only、token callback、delegatecallに分け、CEIとguardの射程をFoundryで検証します。

Reentrancyは「同じwithdrawがもう一度呼ばれること」だけではありません。外部call中に制御を渡し、同じ不変条件に関係するstateが確定する前に、到達可能な別entry pointや別contractがその中間stateを使うことが本質です。
そのため、withdrawへChecks-Effects-Interactions(CEI)やnonReentrantを付けただけでは調査は終わりません。別function、view、token receiver、flash loan callback、hook、fallback、proxy moduleを含むcall graphと、共有すべきlockの範囲を確認します。
この記事ではSolidity 0.8.36、Cancun EVM、Foundry 1.8.0に固定したローカル検証用実装で、classic、cross-function / cross-contract、read-only、ERC-777-style callback、delegatecall moduleを再現します。外部RPC、mainnet fork、wallet、個人鍵、public transaction、実資産は使いません。
reentrancyはcall stackとinvariantから定義する
最初に「どのfunctionへ戻ったか」ではなく、守る式を置きます。今回のETH vaultなら、最低限のsolvencyは次です。
address(vault).balance >= honest userが請求できるcredit
通常の実装では、sum(credits) == totalCredits、totalCredits <= liquid assets、shareとassetの換算、price snapshotなど複数の性質があります。外部callの前後でどの式が一時的に崩れ、callbackからどこへ到達できるかを追います。
outer entry
→ state Aを読む
→ state Bだけ更新する
→ external interaction
→ callback / fallback / hook
→ 同じfunction、別function、別contract、viewへ到達
→ 残りのstateを更新
外部interactionはETH送金だけではありません。任意contractへのcall、receiver hook、flash loan callback、AMM hook、safe mint、proxy / moduleのdelegatecallも、呼出先へ制御を渡します。Solidity公式も、Ether transferに限らず外部contractへの任意callでreentrancyが起こり得ると説明しています。
6つの境界へ分類する
同じattack contractでも、再入先と観測対象で防御の射程が変わります。
| 分類 | callbackから到達する先 | 中間stateで起きること | 主な確認点 |
|---|---|---|---|
| classic single-function | 同じmutating function | 同じcreditを複数回読む | effectをinteraction前へ移せるか |
| cross-function | 同じcontractの別entry point | 共通ledgerを別経路で変更 | guardが関連entry pointを全部覆うか |
| cross-contract | 別contractを経由して元stateを利用 | consumerが途中の値を確定値として保存 | trust boundaryとsnapshot |
| read-only | view getter |
一時的に不整合なprice / rateを返す | readもshared invariantへ含めるか |
| receiver / hook callback | token、NFT、loan、AMMのcallback | transferや会計の途中で任意call | callback sender、更新順、再到達先 |
| delegatecall / module | 同じhost state上の別module | module固有lockを迂回 | hostで共有するlock slot |
classic:interaction後にcreditを消すと同じ額を再利用できる
脆弱版はcreditを読み、ETHを送った後でcreditとtotalCreditsを減らします。
uint256 amount = credit[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success);
credit[msg.sender] = 0;
totalCredits -= amount;
honest userが4 ETH、attack contractが1 ETHをdepositし、receiverが最大3回戻る再現traceは次です。
ClassicReenter.attack(3)
→ deposit(1 ETH)
→ withdraw: credit = 1 ETH
→ call attacker: 1 ETH
→ withdraw: creditはまだ1 ETH
→ call attacker: 1 ETH
→ withdraw → call attacker: 1 ETH
→ withdraw → call attacker: 1 ETH
→ 4 frameがreturnし、それぞれcreditを0、totalCreditsを-1 ETH
| state | interaction前 | 脆弱版の完了後 | CEI修正版の完了後 |
|---|---|---|---|
| attacker入金 | 1 ETH | 1 ETH | 1 ETH |
| attacker受取合計 | 0 | 4 ETH | 1 ETH |
| vault ETH残高 | 5 ETH | 1 ETH | 4 ETH |
| honest credit | 4 ETH | 4 ETH | 4 ETH |
| totalCredits | 5 ETH | 1 ETH | 4 ETH |
| nested call | 未実行 | 3回成功 | 1回目を拒否 |
修正版はcredit=0とtotalCredits-=amountを送金前へ移します。nested withdrawはNO_CREDITとなり、attack contract側で拒否1回を観測しました。外部call自体をなくせるpull設計も、誰がいつcallbackを受けるかを狭めます。
cross-function:withdrawだけのguardでは共通ledgerを守れない
次はwithdrawにstorage lockがあっても、moveCreditに同じlockがない例です。送金callback中、attack contractは同じwithdrawではなくmoveCredit(receiver, 1 ether)へ入ります。
withdraw reads attacker credit = 1 ETH
→ external call transfers 1 ETH
→ moveCredit(receiver, 1 ETH)
attacker credit: 1 → 0
receiver credit: 0 → 1
→ withdraw resumes
attacker credit: 0
totalCredits: 5 → 4
完了時のvault残高は4 ETHです。しかしclaimはhonest 4 ETHとreceiver 1 ETHの合計5 ETHで、claims > balanceになります。withdrawの再実行を防いでも、同じcredit ledgerを変更する別functionは開いていました。
moveCreditにも同じlock instanceを付けた修正版ではcallbackの移動が拒否され、receiver creditは0、honest creditとvault残高はともに4 ETHでした。ここで必要なのはfunction名単位のmutexではなく、「どのentry pointが同じinvariantを共有するか」というscopeです。
この経路は、外部のattack contractを経て元vaultの別functionへ戻るためcross-contractでもあります。ただし、より長いcross-contract経路では、protocol Aのcallback中にoracle / consumer BがAのviewを読み、B側のclaimやquoteへ保存することがあります。contract境界を越えても、同じ途中stateへ依存するなら一つのthreat modelです。
read-only:viewは書かなくても途中の価格を公開する
read-only reentrancyでは、再入したviewが直接stateを書かないことがあります。問題は、その戻り値を別contractが信頼して書き込むことです。
今回のpoolはspotPrice = accountedAssets / totalSharesです。honest deposit後は100 / 100 = 1.0。attack contractが10を追加してexitするとき、脆弱版は先にshareだけ減らし、ETH送金後にasset会計を減らします。
exit(10)
totalShares: 110 → 100
accountedAssets: 110のまま
→ callback
spotPrice = 110 / 100 = 1.1
PriceConsumer.claimDuringMutation()がclaimを1件保存
accountedAssets: 110 → 100
post-state spotPrice = 1.0
transaction後にpoolだけを見ると価格は1.0へ戻っていますが、consumerのclaimは残ります。修正版はshareとassetの両方をinteraction前に確定し、callback中も1.0を返すためclaimを拒否しました。
CEIは有効ですが、1 function内の順序だけを機械的に見ても不十分です。価格、share rate、virtual balance、reward indexなど、外部contractが読むviewまで含めてconsistent stateを作る必要があります。OpenZeppelin 5.xのnonReentrantViewも、guard中に不整合なreadを拒否したい境界向けです。利用versionのAPIと、全readerがそのgetterを通るかを確認します。
ERC-777・ERC-721・ERC-1155はreceiver callを前提にする
token transferは常に単純な残高移動とは限りません。
- ERC-777の
tokensReceivedはreceiver contractへ制御を渡す - ERC-721のsafe transfer / safe mintは
onERC721Receivedを呼ぶ - ERC-1155のsafe transferはbalanceとeventを更新した後でreceiver hookを呼び、仕様もre-entryを明示する
- ERC-3156 flash loanは
onFlashLoan、AMMやvaultのhook設計もcallbackを持つ - raw ETHの
callはreceiveまたはfallbackへ入る
ローカル検証用実装ではERC-777-styleのtokensReceivedを最小再現しました。vaultは100 tokenを預かり、token.sendでreceiver hookを呼んだ後にshareを0にします。callbackから3回戻ると、預入後500あったvault tokenのうち400がattack contractへ移り、vaultは100だけになりました。
| token callback case | attacker token | vault token | rejected reentry |
|---|---|---|---|
| 脆弱版の完了後 | 400 | 100 | 0 |
| shareを先に0にする修正版 | 100 | 400 | 1 |
これはERC-777全体の実装ではなく、receiver callback境界だけを扱う教育用の最小再現です。実装reviewではcallback senderが本物のtoken / managerか、balance更新とprotocol会計の順序、batch transfer、mint / burn、operator、hook内の別entry pointまで確認します。
ERC-1155のreceiver hookを、必須callbackを持たないERC-6909 coreと同じ操作順で比べる場合は、ERC-6909とERC-1155のbatch・callback・event比較で、受取拒否、nested forward、ID別allowance、誤送付境界を確認できます。
delegatecall:module固有lockはhost全体のlockではない
delegatecallではmodule codeがwallet / proxyのstorage contextで動きます。それでもmodule AとBが別々のlock slotを使えば、Aのcallback中にBへ入れます。
wallet.run(module, enterLocalA)
→ delegatecall: local-A lockをset、counter 0 → 1
→ callback
wallet.run(module, enterLocalB)
→ local-B lockは0なので成功、counter 1 → 2
検証用moduleの固有lockではnested callが成功し、host counterは2でした。A/Bが同じstorage lock slotを使う修正版ではnested callが失敗しcounterは1、同じtransient lock slotでもnested callは失敗しました。transient版はtop-level callを終えた後の次のtransactionでlockが消え、通常のB callによってcounterが2になりました。
EIP-1153のtransient storageもdelegatecallでは呼出元hostのownership contextを使います。この性質はmodule間でlockを共有する用途に合いますが、slot名の衝突、success pathの明示cleanup、同じtransaction内の後続callまでreviewします。transient storageのCALL・DELEGATECALL・cleanup境界で寿命と所有者を詳しく検証しています。
CEI・guard・pull・snapshotは役割が違う
| 防御 | 強い範囲 | 単独では残る境界 |
|---|---|---|
| CEI | 1 operationの関連effectをinteraction前に確定 | 別functionで漏れたeffect、外部consumer、意図したcallback中state |
shared nonReentrant |
同じguard instanceを使うentry point間 | guard外function、別contract、unguarded view、別module slot |
| pull payment | push callbackの時点と責務を分離 | withdraw側のcallback、token hook、他のexternal call |
| accounting snapshot | callback中のrate / priceを安定化 | snapshot自体のsource、別ledgerの未更新 |
| storage guard | 幅広いEVMで同一contractのmutex | persistent write / clear cost、scope設計 |
| transient guard | transaction-scoped mutex、次transactionで自動消去 | 対応EVM、同一transactionのdirty lock、slot ownership |
OpenZeppelinのReentrancyGuardは一つのguardを共有するため、nonReentrant function同士を直接呼べません。共通処理をprivateに置き、外部entry pointをguardする構成が必要です。同時に「全部へ付ける」ことを目的にせず、callback中に許可したいcompositionと禁止するshared invariantを分けます。
storage版とtransient版の最小実装は、どちらもnested entryを拒否し、normal return後の次callを成功させました。同じcompiler / Cancun環境の方向比較ではtransient版のexecution gasがstorage版より小さくなりましたが、これは普遍的な削減率ではありません。実entrypoint、compiler、optimizer、chain hardforkで再計測します。
invariant testは最小attack sequenceを回帰へ戻す
unit testは各分類の既知scenarioを13件固定しました。さらにhandlerへattack(rawDepth)だけを公開し、次の性質をstateful invariantにしました。
fixed vault balance >= honest credit
修正版は固定seedで64 runs × 32 calls、合計2,048 calls、revert 0でpassしました。意図的な脆弱版は同じ性質に失敗し、original 1 call、shrunk 1 callの最小sequenceになりました。
prank(honest actor)
ClassicAttackHandler.attack(bound(rawDepth, 1, 3))
→ invariant_VaultAlwaysBacksHonestCredit fails
→ reason: honest liability undercollateralized
attack depthの具体的なraw値より、bound後に1回のattackだけでhonest liabilityを裏付けられないことが重要です。固定環境、13 unit tests、2,048-call passing campaign、1-call counterexample、各state diffは公開検証JSONへ保存しました。handler・ghost・counterexampleの設計も合わせて確認できます。
passは全経路の証明やaudit完了ではありません。handlerに含めなかったentry point、未知のtoken behavior、upgrade後のmodule、production固有のoracle / hookは探索範囲外です。
vault・hookへ適用するreview checklist
-
balance >= liabilitiesなど守るinvariantを式で書いた - public / external entry pointとfallback / receiveを列挙した
- ETH、token、NFT、loan、hook、router、oracleへのexternal interactionを列挙した
- 各interactionから同じfunction、別function、別contract、viewへ戻る経路を追った
- callback前のintermediate stateをgetterごとに記録した
- CEIが一つのledgerだけでなく関連stateをすべて確定するか確認した
- guardを同じinvariantに関わるentry pointとviewで共有した
- proxy / delegatecall moduleが同じhost lock slotを使うか確認した
- receiver hookのsender、operator、batch、mint / burnをtestした
- storage / transient guardのsuccess、revert、catch、次callをtestした
- fixed unit regressionとstateful invariantを両方残した
- traceの最初のstate差とtransaction後のstateを分けて読んだ
- public RPC、実在target、実資産を使わずに最小実装で再現した
ERC-4626のasset / share会計ではliabilityとroundingを、Uniswap v4のhook / flash accountingではcallback senderとtransaction中deltaを別のモデルで検証しています。どちらも「最後に残高が合う」だけでcallback途中のreadや別entry pointまで安全になるわけではありません。
Reentrancyを調べる順番は、invariant → entry points → external interaction → callback → intermediate state → shared guard scope → trace → state diff → invariant regressionです。nonReentrantの有無を最初の結論にせず、何を、どの入口から、どのcontractまで一緒に守るlockなのかを説明できる状態を完成とします。
この記事にtoken購入、特定protocolの利用、wallet接続、署名、transaction送信、実資産操作を促す導線はありません。将来audit、formal verification、testing platform、security trainingの広告を掲載する場合も、広告表記とローカル再現結果、security judgmentを分離します。
確認した一次情報
- Solidity Security Considerations: Reentrancy確認日: 2026/09/01
- OpenZeppelin Contracts 5.x ReentrancyGuard確認日: 2026/09/01
- EIP-1153 Transient Storage Opcodes確認日: 2026/09/01
- ERC-777 Token Standard確認日: 2026/09/01
- ERC-721 Non-Fungible Token Standard確認日: 2026/09/01
- ERC-1155 Multi Token Standard確認日: 2026/09/01
- ERC-3156 Flash Loans確認日: 2026/09/01
- Foundry v1.8.0確認日: 2026/09/01



