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
GrimBoostVaultdeposit flow let the caller choose the token address passed intodepositFor(). The vault strategy’sbeforeDeposit()logic and the vault’ssafeTransferFrom()call treated that token like a passive asset transfer. It was not passive. The attacker first used a malicious token contract to re-enterdepositFor()five times duringsafeTransferFrom(), repeatedly resetting the strategy’s_poolcheckpoint 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_amountand 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_poolwas repeatedly reset tobalance()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.
- Grim combined two dangerous assumptions: that the deposit token parameter would be honest, and that
- Exploit path:
- The attacker took a flash loan in WFTM and WBTC.
- The attacker added that liquidity on SpiritSwap and received SPIRIT-LP tokens.
- The attacker called
depositFor()on Grim’s vault while pointing the token parameter at an attacker-controlled contract. - During
safeTransferFrom(), the malicious token contract re-entereddepositFor()five times. - In each reentrant frame, Grim’s strategy logic reset
_poolto the current balance, so the bookkeeping baseline for the eventual share mint kept getting overwritten. - On the last loop, the attacker broke the recursion and let the vault receive the real SPIRIT-LP value.
- As execution unwound, each stacked frame saw a positive
_amountand minted the corresponding Grim vault shares again, effectively multiplying the same deposit into several share issuances. - 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() - _poolstyle 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 totoken == 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 unwindaccounting 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-distortedbalance() - _pooldelta. - 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 flowexemplar, not just a generic missing-nonReentrantstory.
- Representative transactions and addresses:
- Exploit transaction:
0x19315e5b150d0a83e797203bb9c957ec1fa8a6f404f4f761d970cb29a74a5dd6 - Attacker payout address identified by Grim and cited by rekt:
0xdefc385d7038f391eb0063c2f7c238cfb55b206c
- Exploit transaction:
- 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 trampolinerepeated beforeDeposit/_pool checkpoint resets made one bottom-of-stack SPIRIT-LP inflow look fresh to several earlier framesvault receipt-token inflation was monetized directly through withdraw, not through an oracle or rebasing side pathmulti-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_poolto 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
_amountassumptions 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
tokenparameter 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()plussafeTransferFrom()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 onlyrecursive 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
tokenparameter 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 stackfromamount earned by this exact deposit frame. Grim needed a source-specific receipt object, not a laterbalance() - _poolreread 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) -> unwindand for deeper multi-loop variants. The property to assert isone 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
_poolcheckpoint 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() - _pooldelta 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
_poolpart 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
_poolbaseline can be just as security-critical as total supply when later share minting depends on it. The vault balance increasedis not the same statement asthis 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, butwhat 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
-
The leverage sat in Grim’s deposit router and vault strategy assumptions: who gets to choose the token contract, when
_poolis checkpointed, and whether share minting is allowed to observe a callback-distorted balance delta. -
The exploit was not a mysterious flash-loan trick. It was a vault telling itself that an attacker-selected token transfer was just bookkeeping.
-
Sources:
- https://rekt.news/grim-finance-rekt
- https://slowmist.medium.com/analysis-of-the-grim-finance-hack-bc440108b069
- https://www.trmlabs.com/resources/blog/grim-finance-hacked-600-million-in-crypto-stolen-in-december?scLang=en
- https://decrypt.co/88727/grim-finance-hacked-30-million-fantom-tokens
- https://x.com/financegrim/status/1472357770846519312
- https://x.com/financegrim/status/1472357772847165442
-
Last reviewed: 2026-07-21 UTC