3MIKAN
仮想通貨直コン

Reentrancyの種類と防ぎ方:cross-function・read-only・callbackをFoundryで再現する

Solidityのreentrancyをclassic、cross-function、cross-contract、read-only、token callback、delegatecallに分け、CEIとguardの射程をFoundryで検証します。

3MIKANのブランドキャラクターが共通のlockと未完了のstate機構を点検し、別入口から戻るreentrancy経路を追う記事画像

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) == totalCreditstotalCredits <= 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が起こり得ると説明しています。

3MIKANのキャラクターが未完了の中央state機構と外部interactionから別入口へ戻る連続経路を確認するreentrancy call sequence図
図は制御が外へ出て別入口から戻る関係の補助表現です。正確なcall順とstate差は下のtraceと公開JSONを正本にします。

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
3MIKANのキャラクターを中心にsame-function、cross-function、read-only観測、receiver callback、共有stateを使うmoduleの5領域を比べる分類図
分類名、標準、guard scopeは画像ではなくこのHTML表を正本にします。複数分類が同じcall chainに重なることもあります。

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=0totalCredits-=amountを送金前へ移します。nested withdrawNO_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のcallreceiveまたは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を分離します。

確認した一次情報