Grim Finance SPIRIT-LP Vault Reentrancy (2021-12-18)

  • Name: Grim Finance SPIRIT-LP vault reentrancy
  • Date: 2021-12-18
  • Protocol: Grim Finance
  • Chain: Fantom
  • Category: balance-delta provenance failure / yield-vault share inflation / reentrancy / malicious-token parameter injection
  • Loss: approximately $30M
  • Summary: Grim Finance’s GrimBoostVault deposit flow let the caller choose the token address passed into depositFor(). The vault strategy’s beforeDeposit() logic and the vault’s safeTransferFrom() call treated that token like a passive asset transfer. It was not passive. The attacker first used a malicious token contract to re-enter depositFor() five times during safeTransferFrom(), repeatedly resetting the strategy’s _pool checkpoint to the current vault balance. In the last nested call they switched the token parameter back to the real SPIRIT-LP asset, so real LP value entered only once at the bottom of the stack. As execution unwound, several earlier frames still observed a non-zero _amount and each reminted claims against that same one economically real LP inflow, inflating the attacker’s receipt tokens before withdrawal.
  • Trigger / entry point:
    • GrimBoostVault.depositFor() accepted a user-specified token address instead of restricting the path to the intended SPIRIT-LP asset.
    • The vault called safeTransferFrom() on that user-specified token, which handed execution to an attacker-controlled contract.
    • Grim’s own explanation says the exploit passed through the strategy’s beforeDeposit() hook, where _pool was repeatedly reset to balance() during each reentrant loop before the final real LP deposit landed.
  • Root cause:
    • Grim combined two dangerous assumptions: that the deposit token parameter would be honest, and that safeTransferFrom() was just an inert asset move.
    • Because the reentrant path kept rewriting the pre-deposit balance checkpoint, the later share-mint logic did not measure a fresh deposit once; it measured the same economically real deposit multiple times.
    • A missing or incomplete reentrancy guard left the whole share-mint path callable while the vault was mid-update.
  • Exploit path:
    1. The attacker took a flash loan in WFTM and WBTC.
    2. The attacker added that liquidity on SpiritSwap and received SPIRIT-LP tokens.
    3. The attacker called depositFor() on Grim’s vault while pointing the token parameter at an attacker-controlled contract.
    4. During safeTransferFrom(), the malicious token contract re-entered depositFor() five times.
    5. In each reentrant frame, Grim’s strategy logic reset _pool to the current balance, so the bookkeeping baseline for the eventual share mint kept getting overwritten.
    6. On the last loop, the attacker broke the recursion and let the vault receive the real SPIRIT-LP value.
    7. As execution unwound, each stacked frame saw a positive _amount and minted the corresponding Grim vault shares again, effectively multiplying the same deposit into several share issuances.
    8. The attacker withdrew the inflated vault shares for excess SPIRIT-LP, removed the SpiritSwap liquidity, and repaid the flash loan.
  • What the attacker needed to be true:
    • The vault needed to let the caller choose an arbitrary token contract in the deposit path.
    • The deposit path needed to call into that contract before sealing the vault’s own share-accounting state.
    • Share minting needed to depend on a mutable balance() - _pool style delta so repeated checkpoint resets could monetize one real deposit multiple times.
    • The vault needed enough LP-token value inside it for the inflated shares to cash out into real assets.
  • Technical anatomy worth remembering:
    • This was a vault-native reentrancy bug, not a lending-market callback and not a compiler-lock failure. The attacker monetized receipt-token inflation directly at the vault share-mint boundary.
    • Grim’s own description says the attacker executed five reentrancy loops, with every loop resetting _pool; rekt’s step-by-step reconstruction preserves the extra detail that the innermost / last call switched back to token == SPIRIT-LP, so one real LP transfer at the bottom of the stack was re-measured and re-minted multiple times on unwind.
    • Relative to Akropolis, Grim preserves the same one real deposit observed by earlier frames on unwind accounting family but in a more layered form: Akropolis is the clean two-frame version where one outer fake-token frame remints once, while Grim is the repeated-checkpoint-reset multi-frame vault version where several earlier callback frames keep making the same bottom-of-stack deposit look fresh.
    • Relative to Origin Dollar, Grim’s second and later claim paths stayed inside direct vault-share issuance rather than flowing through rebasing balance presentation. Origin’s extra monetization rail ran through _rebase() / changeSupply(); Grim’s ran through repeated share mints against a callback-distorted balance() - _pool delta.
    • The exploit shows why user-supplied token parameters are dangerous in deposit routers and vault wrappers: even if the intended asset is safe, the function has already lost if the caller can smuggle an arbitrary token contract into the transfer step.
    • The core accounting mistake was using a deposit-delta measurement that was not stable across callbacks.
    • This also makes Grim a clean interface looked ERC-20-ish but transfer semantics were attacker-authored control flow exemplar, not just a generic missing-nonReentrant story.
  • Representative transactions and addresses:
    • Exploit transaction: 0x19315e5b150d0a83e797203bb9c957ec1fa8a6f404f4f761d970cb29a74a5dd6
    • Attacker payout address identified by Grim and cited by rekt: 0xdefc385d7038f391eb0063c2f7c238cfb55b206c
  • Response / mitigation:
    • Grim disclosed that the exploit lived in the vault contract and warned that all vaults and deposited funds were at risk.
    • The protocol paused deposits while investigating and said it had contacted Circle, DAI-related counterparties, and AnySwap regarding attacker addresses and potential fund freezes.
    • The incident also triggered immediate criticism of Grim’s audit posture because the audited contracts were believed to have relevant reentrancy protection, yet the live path was still exploitable.

Pattern tags

  • user-chosen token parameter turned safeTransferFrom into an attacker-authored reentrancy trampoline
  • repeated beforeDeposit/_pool checkpoint resets made one bottom-of-stack SPIRIT-LP inflow look fresh to several earlier frames
  • vault receipt-token inflation was monetized directly through withdraw, not through an oracle or rebasing side path
  • multi-frame vault-share cousin to Akropolis's simpler one-outer-unwind remint

Why the exploit worked

  • Grim’s decisive mistake was not merely depositFor() lacked a lock. The vault let the caller choose an arbitrary token contract for a path whose accounting only made sense for the canonical SPIRIT-LP asset, then called into that contract before the share-mint baseline was safe.
  • Each reentrant pass through beforeDeposit() rewrote _pool to the then-current balance. That meant the vault kept forgetting the earlier checkpoint and re-preparing itself to treat the same eventual LP inflow as new deposit value.
  • The last nested call supplied the real SPIRIT-LP once at the bottom of the stack. The unwind then monetized that same economically real inflow several times because earlier frames still carried positive _amount assumptions against the freshly reset checkpoints.
  • Withdrawal completed the loss path. Once Grim honored the inflated vault shares as ordinary redemption claims, the share-accounting error turned straight into excess SPIRIT-LP, then into extra WFTM and WBTC after liquidity removal.

Detection and audit pivots

  • Audit any vault entrypoint where the user can choose a token parameter that is supposed to equal the vault’s canonical asset. If accounting only makes sense for one LP token, that constraint has to be enforced before any external transfer occurs.
  • Review hook chains like beforeDeposit() plus safeTransferFrom() plus share minting as one accounting window, not as separate benign helpers. Mutable pre-deposit checkpoints are dangerous if callbacks can revisit them before the final mint.
  • Fuzz multi-level reentrancy where early frames use a malicious token and the last frame switches back to the real vault asset. Grim’s family is specifically about one real deposit at the bottom, many claims on unwind, not only recursive deposit succeeded.
  • Check whether audit conclusions actually covered the live forked path and its parameterization. Grim is a good reminder that inherited vault code, local modifications, and strategy hooks can reopen a family auditors thought was already guarded elsewhere.
  • Ask whether inflated receipt tokens have an immediate, routine path to underlying assets. When withdraw trusts the minted share supply by default, any callback-time over-mint becomes a direct liquidity drain rather than a latent accounting inconsistency.

Why this incident matters

  • It is a clean vault-share analogue to the better-known lending reentrancy cases: the protocol did not lose money because a token transfer failed, but because callback-time state let one real deposit mint several claims.
  • It is also one of the clearest multi-frame cousins to Akropolis inside the broader balance-provenance family: instead of one outer unwind remint, Grim stacked several earlier frames above one real bottom-of-stack deposit by repeatedly resetting the accounting checkpoint.
  • It shows that safeTransferFrom() is a control-flow handoff when the token itself is attacker-chosen.
  • It also shows that reentrancy defenses are not just about adding a modifier; input validation and accounting design both have to agree on what asset is actually allowed to enter the vault.

Failure-prevention checklist

  • Hard-bind the vault entrypoint to the canonical deposit asset before any external transfer occurs. If the vault is for one SPIRIT-LP token, the path should never accept an arbitrary caller-supplied token parameter and hope later logic treats it honestly.
  • Treat beforeDeposit() checkpoint writes, token transfer, and share mint as one accounting window. No helper in that window should be allowed to rewrite the pre-deposit baseline after attacker-controlled code has regained execution.
  • Separate amount moved into the vault sometime during this stack from amount earned by this exact deposit frame. Grim needed a source-specific receipt object, not a later balance() - _pool reread of ambient LP inventory.
  • Make unsupported or callback-capable token semantics fail closed at admission. If the vault is not explicitly modeling hostile hooks, then user-chosen token contracts should be rejected rather than normalized by assumption.
  • Keep recognized deposit accounting separate from raw token balances so an inner real transfer cannot be rediscovered and re-monetized by outer frames on unwind.
  • Apply reentrancy coverage across the whole mint path, including strategy hooks and any helper functions that can reset deposit checkpoints or influence share pricing.
  • Add invariant tests for deposit(fake token) -> callback -> deposit(real asset) -> unwind and for deeper multi-loop variants. The property to assert is one economically real LP inflow can create at most one redeemable share claim.
  • Add withdrawal-side containment where practical — delayed redemption, anomaly caps, or pause-on-share-supply discontinuity — so callback-time over-mints cannot instantly empty the vault before operators react.

Audit prompts

  • Why does this entrypoint accept a free-form token argument at all, and what exact business case justifies letting user-chosen token code sit on the deposit boundary?
  • Between the first _pool checkpoint and final share mint, can any callback-capable transfer, strategy hook, or sibling function rewrite the baseline or otherwise make one later inflow look fresh to more than one frame?
  • Does the measured balance() - _pool delta prove that this call delivered new LP value, or only that the vault happened to hold more LP by the time control returned?
  • If one real SPIRIT-LP transfer lands in the innermost frame, what exact state prevents earlier outer frames from resuming and claiming that same inflow as their own deposit?
  • Are strategy helper variables like _pool part of solvency-critical accounting? If so, are they treated with the same discipline as share supply and vault balances, or as if they were harmless cache state?
  • If the vault supports only one canonical asset, do tests prove that any other token address fails closed before transfer logic, rather than merely failing economically most of the time?
  • Once shares are over-minted, what controls stop immediate withdrawal against live LP reserves? Is there any anomaly detection around share-supply jumps versus recognized deposit events?
  • Did the reviewed / audited code path actually match the production vault plus strategy hook composition, or were reentrancy assumptions inherited from adjacent code that did not cover this parameterization?

Lessons that should already be standard

  • safeTransferFrom() is only safe relative to the asset whose semantics you already constrained. If the caller chooses the token, the transfer step is a user-chosen execution environment.
  • Deposit checkpoints are solvency state, not housekeeping. A mutable _pool baseline can be just as security-critical as total supply when later share minting depends on it.
  • The vault balance increased is not the same statement as this deposit earned new shares. Grim’s whole payout path came from collapsing those two claims together.
  • A strategy hook that rewrites baseline state inside a callback window is already part of the mint-authority boundary, even if it was written as pre-deposit bookkeeping.
  • Multi-frame callback exploits deserve their own tests. Akropolis’s two-frame cousin was already enough warning that one bottom-of-stack real deposit can be cashed out several times by earlier frames on unwind.
  • Asset admission and reentrancy review cannot be separated. Grim did not need a weird LP token; it needed only the right to smuggle any hostile token contract into a vault path that assumed the real LP asset.
  • Receipt-token systems should assume that any over-mint is immediately a reserve-drain bug if ordinary withdrawal honors the inflated supply without further skepticism.

Internal linkages

  • Core recurring pattern: reentrancy.
  • Asset-admission lens: interface-conformance-semantic-compatibility-failure — Grim only became exploitable because depositFor() let the caller smuggle an arbitrary token contract into a vault path that assumed the intended LP asset’s passive transfer semantics.
  • Receipt-boundary companion: actual-receipt-accounting-and-token-semantics-admission-discipline — the healthy design question is not just did some tokens arrive, but what exact token semantics may touch share minting, and what proves this frame earned the observed LP increase?
  • Accounting lens: balance-delta-provenance-failure — Grim is the repeated-checkpoint-reset multi-frame vault cousin: the real SPIRIT-LP deposit only arrived once at the bottom of the stack, but several earlier frames still re-minted against it on unwind.
  • Callback-frame companion: callback-frame-truth-security-object — the deeper control problem is that several unfinished deposit frames remained live and all still believed they could claim the next observed LP inflow as their own.

Control surface