Velocore V2 ConstantProductPool unauthenticated velocore__execute() / feeMultiplier overflow liquidity-overmint drain (2024-06-02)

  • Name: Velocore V2 ConstantProductPool unauthenticated velocore__execute() / feeMultiplier overflow liquidity-overmint drain
  • Date: 2024-06-02
  • Protocol: Velocore V2
  • Chain: Linea, zkSync Era
  • Category: missing pool-entrypoint authentication / fee-multiplier overflow / liquidity overmint / pool drain
  • Loss: public reporting clustered around about $6.8M-$7.6M; Velocore and rekt.news emphasized about $6.8M in drained ETH-equivalent, while flow-of-funds reporting tracked about 1,807 ETH laundered back to Tornado Cash on Ethereum mainnet
  • Summary: Velocore’s Balancer-style ConstantProductPool expected the Vault to be the only caller of pool-local velocore__execute() swap logic, but the pool contract never authenticated that boundary. The attacker called velocore__execute() directly on volatile-pool contracts with crafted r arrays that simulated huge LP withdrawals in the same block, ratcheting feeMultiplier high enough that effectiveFee1e9 exceeded 100%. Once the fee path crossed that boundary, later pool math underflowed and treated a small single-token withdrawal / LP-mint path as if it were a huge deposit, minting far too many LP claims. The attacker then unwound those counterfeit claims through ordinary pool logic, drained volatile pools on Linea and zkSync, bridged proceeds to Ethereum, and sent roughly 1,807 ETH into Tornado Cash.
  • Trigger / entry point:
    • Velocore’s public router path normally entered Vault.execute(), which in turn called pool-local ISwap(opDst).velocore__execute(...) on ConstantProductPool instances.
    • The ConstantProductPool implementation exposed velocore__execute(address, Token[] calldata, int128[] memory, bytes calldata) as external and did not check that msg.sender was the canonical Vault.
    • Inside that function, LP-withdraw-like flows updated feeMultiplier via feeMultiplier = ((feeMultiplier * invariantMax) / (invariantMax - uint256(int256(r.u(iLp))))).toUint128(); whenever r[iLp] > 0.
    • The same function later derived effectiveFee1e9 = (fee1e9 * feeMultiplier) / 1e9 under unchecked math when lastWithdrawTimestamp == block.timestamp.
  • Root cause:
    • Velocore treated velocore__execute() as if it were an internal pool callback reachable only through the Vault, but encoded that assumption socially instead of in-contract.
    • Because the pool itself owned feeMultiplier state, a direct caller could mutate the same pricing state that later legitimate Vault-routed operations trusted.
    • The design then compounded the missing-authentication bug with unsafe arithmetic assumptions: effectiveFee1e9 was allowed to grow past 1e9, meaning past 100% fees.
    • Once that happened, the fee-adjustment branch could underflow the growth math and invert the economics of a withdrawal / LP-mint path, so the pool priced a tiny payment as if it funded a much larger liquidity claim.
  • Exploit path:
    1. The attacker funded fresh addresses from Tornado Cash and targeted Velocore V2 volatile ConstantProductPool deployments on Linea and zkSync.
    2. They called pool-local velocore__execute() directly instead of going through the Vault, supplying crafted token arrays and extreme r values that simulated very large LP withdrawals.
    3. Repeating that maneuver inside one block kept lastWithdrawTimestamp == block.timestamp and ratcheted feeMultiplier upward through the pool’s own withdrawal-side state update.
    4. On subsequent execution, effectiveFee1e9 became greater than 1e9, so the fee term inside unaccountedFeeAsGrowth1e18 = rpow(1e18 - ((1e18 - k) * effectiveFee1e9) / 1e9, ...) crossed the range the surrounding math expected.
    5. That broken fee state made the pool mis-handle a later operation as if a small withdrawal were actually a huge deposit, minting an outsized amount of LP value / pool claim for trivial input.
    6. The attacker then used ordinary pool exits and swaps to redeem those counterfeit claims for real pool assets, draining the affected volatile pools.
    7. They consolidated proceeds, bridged assets to Ethereum mainnet via Across, converted funds toward ETH, and routed roughly 1,807 ETH into Tornado Cash.
  • What the attacker needed to be true:
    • The targeted pool had to expose velocore__execute() publicly without Vault-only authentication.
    • The pool’s same-block withdrawal logic had to keep compounding feeMultiplier instead of bounding or saturating it below the 100% fee threshold.
    • Later quote / settlement logic had to keep trusting the corrupted effectiveFee1e9 value strongly enough that a malformed withdrawal path flipped into a highly favorable mint / deposit outcome.
    • The volatile pools had to remain liquid enough that the counterfeit LP claim could be redeemed into real assets before operators paused the system.
  • Technical anatomy worth remembering:
    • This was not just an arithmetic bug and not just an access-control bug. The decisive failure was their composition: an unauthenticated internal-looking pool verb let attackers mutate a fee state variable that downstream math assumed stayed within protocol-authored bounds.
    • The exploit is a clean official entrypoint versus real state authority lesson. Velocore’s public UX and router centered on Vault.execute(), but the economically decisive state mutation lived in a pool-local function that anyone could call.
    • The cash-out rail was classic issuance/redemption asymmetry: once the pool undercharged the claim-creation side, normal redemption and swap logic still paid out against real reserves.
    • The affected surface was the volatile ConstantProductPool implementation; Velocore stated stable pools and Slipstream were unaffected.
    • Linea’s operator halted block production for roughly an hour to slow bridge-out and limit further damage, which also made the incident a memorable example of L2 operator intervention during live protocol failure.
  • Representative transactions and addresses:
    • Attacker EOA highlighted by rekt.news: 0x8cdc37ed79c5ef116b9dc2a53cb86acaca3716bf
    • Representative Linea attack tx: 0xed11d5b013bf3296b1507da38b7bcb97845dd037d33d3d1b0c5e763889cdbed1
    • Representative Linea attack tx: 0x37434e674efc4e7cfeed7746095301ace5636028906fe548b786ead286e35eb0
    • Representative zkSync attack tx: 0x4156d73cadc18419220f5bcf10deb4d97a3d3f7533d63ba90daeabc5fd11ba17
    • Final Ethereum destination before Tornado Cash: 0xe4062fcade7ac0ed47ad794028967a2314ee02b3
    • Exploited Linea pool contracts cited publicly: 0xed4e130f6f9e68918996f7e1e46a3306b3e12cec, 0xb7f6354b2cfd3018b3261fbc63248a56a24ae91a, 0xc030fba4b741b770f03e715c3a27d02c41fc9dae
    • Exploited zkSync pool contract cited publicly: 0xf7f76b30a301524fe76508546B1e3762eF2B9267
  • Response / mitigation:
    • Velocore acknowledged the exploit on 2024-06-02, paused the affected volatile pools, and published a postmortem describing the fee-overflow and access-control failure.
    • The team stated stable pools and Slipstream were unaffected.
    • Linea halted the sequencer for about an hour as an emergency measure to impede further bridging-out while trying to contact the Velocore team.
    • Velocore later sent an onchain message offering the attacker a 10% whitehat bounty in exchange for return of funds.
    • The incident triggered renewed scrutiny of whether audited protocols had actually authenticated their internal-looking pool callbacks and whether same-block fee or withdrawal multipliers had hard upper bounds.

Pattern tags

  • issuance-redemption-solvency-asymmetry
  • entrypoint-only-policy-off-path-state-mutation
  • The economically decisive break was mutate pool-local fee state off-path, then mint or withdraw a reserve-backed LP claim on-path: the attacker did not need to globally falsify balances, only to poison a fee variable that later pricing logic treated as protocol-authored truth.
  • Velocore is also a good internal-looking external verb became the real trust boundary exemplar. Vault.execute() was the intended product-facing path, but ConstantProductPool.velocore__execute() itself controlled the fee state and quote semantics that determined how many real reserves a tiny input could later command.
  • The exploit belongs to the payment side under fiction, unwind side under reality subfamily: once effectiveFee1e9 moved outside its intended domain, the pool could mint or release too much LP-side value for trivial payment while ordinary exit logic still honored those claims against live assets.

Why the exploit worked

  • The exploit worked because Velocore placed the real solvency boundary inside a pool-local function but only socially enforced who was supposed to cross it. The Vault and router were presented as the official path, yet velocore__execute() itself held authority to mutate feeMultiplier and therefore to shape the pricing state every later user-facing operation relied on.
  • The arithmetic bug mattered only because it sat behind that missing caller check. A bounded same-block fee multiplier might have been a local pricing oddity; an unbounded multiplier in a publicly callable pool verb became attacker-authored reserve truth. Once repeated direct calls pushed effectiveFee1e9 above 1e9, the surrounding growth math stopped meaning small withdrawal pays a fee and started meaning small input can create a much larger LP claim than reserves justify.
  • The decisive asymmetry was issue or release cheaply, redeem honestly later. Velocore did not need to lose track of every balance in the system. It only needed one path that undercharged for LP-style claim creation while later pool exits and swaps still paid out against real volatile-pool inventory.
  • This is why the incident is more useful than a generic overflow bug label suggests. The important lesson is not just fees should not exceed 100%; it is pool-local quote state, caller authority, and reserve-paying unwind logic were one joined balance-sheet object. Velocore broke that object at the function boundary first and only then at the arithmetic boundary.
  • Linea’s sequencer halt reinforces the same point operationally: once the counterfeit claim could already be redeemed into real bridgeable assets, even a chain-level response was reduced to damage containment rather than preventing the underlying accounting lie.

Detection and audit pivots

  • Inventory every external pool or strategy verb that product docs describe as only called by the vault, router, manager, or core. If the contract itself does not authenticate that boundary, the real authority surface is wider than the architecture diagram.
  • Treat fee multipliers, same-block withdrawal penalties, growth factors, and quote caches as solvency state, not UX state. If poisoning one of those variables can later change LP mint, burn, or swap economics, it belongs inside the reserve boundary.
  • Differential-test small payment -> large LP claim -> ordinary unwind round trips under same-block repeated calls, extreme signed deltas, and attacker-chosen token arrays. The useful question is not only can the math overflow? but can an off-path caller move quote state into a domain where later on-path operations still honor counterfeit value?
  • Explicitly assert domain limits such as effectiveFee1e9 <= 1e9 or whatever stronger invariant the design actually needs. Do not rely on economic intuition that a multiplier can never get there if one public state-mutating path still exists.
  • Review whether pool-local execution surfaces both mutate state and interpret user intent. The more one function simultaneously parses token arrays, updates fee state, and computes reserve effects, the more dangerous a missing caller check becomes.

Failure-prevention checklist

  • Authenticate every pool-local execution verb at the pool boundary. If only the Vault may call velocore__execute(), the pool must enforce that invariant itself rather than inheriting it from docs, routers, or frontends.
  • Bound fee-state variables with fail-closed arithmetic and semantic caps. Multipliers, penalties, and growth factors should revert or saturate before they cross domains the surrounding math was never designed to price.
  • Separate state that tracks user activity from state that certifies solvency-critical quote truth. If one same-block withdrawal statistic can later change how much LP value a trivial input receives, that state needs the same protection as reserve accounting.
  • Add invariant tests for add/mint/remove/burn round trips under poisoned fee state, repeated same-block operations, attacker-sized signed deltas, and direct pool calls that bypass normal routing.
  • Constrain blast radius even if quote state fails: per-pool withdrawal caps, rate limits, delayed large exits, and fast pauses sized for pricing state may already be counterfeit rather than only for normal volatility.
  • Inventory every internal-looking public function during audits and upgrades. A function is part of the threat model if it can still mutate reserve-significant state, even if the intended UI never calls it directly.

Audit prompts

  • Which exact external functions can mutate fee, growth, inventory, or quote state without first proving they were reached through the canonical Vault or router?
  • Can an attacker supply token arrays, signed deltas, or synthetic withdrawal-like parameters that update fee state without paying the economic cost the state variable is supposed to summarize?
  • What hard invariant keeps effectiveFee1e9, feeMultiplier, or similar derived fee variables inside the domain assumed by downstream mint/burn/swap math?
  • If quote state becomes poisoned for one block, which later operations still honor it as reserve truth: LP minting, LP withdrawal, swaps, gauge accounting, or bridgeable asset extraction?
  • Does any same-block fast path combine three roles at once — parsing user intent, mutating pricing state, and releasing value — such that one missing caller check becomes a full solvency bug rather than a local accounting nuisance?
  • What concrete cap, pause, or delayed-withdrawal control limits loss if an attacker can already create counterfeit LP claims before operators notice?

Lessons that should already be standard

  • Only the vault calls this is not a security property unless the pool enforces it. Architecture intent and frontend routing do not narrow onchain authority by themselves.
  • Fee state is part of the custody model once later quote, mint, or unwind logic trusts it. Multipliers and same-block penalty variables are not harmless tuning knobs when they can reprice reserve-backed claims.
  • Access-control bugs and arithmetic bugs compound. A state variable that is safe under trusted callers may become a treasury-drain primitive under public callers, even if each issue looks less dramatic in isolation.
  • Same-block convenience logic deserves hostile review. Paths added for anti-MEV, anti-withdrawal-gaming, or fee fairness often become the easiest place to smuggle state into an unintended domain.
  • Reserve-outflow brakes still matter after correctness work. Linea’s halt did not fix Velocore’s bug, but it did preserve the lesson that bounded-loss design is the difference between one broken quote and an instantly unrecoverable treasury event.

Why this incident matters

  • Velocore is a strong exemplar of how only the router / vault is supposed to call this is not a security boundary unless the pool itself enforces it.
  • It also preserves a particularly useful CLMM/AMM cousin of issuance-redemption asymmetry: the attacker did not need to forge balances globally; they only needed one path that minted liquidity claims too cheaply while the unwind side stayed economically honest.
  • The event is also worth preserving because chain-operator intervention became part of the incident story: the exploit anatomy and the emergency halt are both central to understanding real-world L2 risk surfaces.

Internal linkages

Control surface