Akropolis SavingsModule Malicious-Token Reentrancy / PoolToken Over-Mint (2020-11-12)

  • Name: Akropolis SavingsModule malicious-token reentrancy / poolToken over-mint
  • Date: 2020-11-12
  • Protocol: Akropolis Delphi Savings Pools
  • Chain: Ethereum
  • Category: balance-delta provenance failure / savings-pool share inflation / malicious-token admission / reentrancy / unbacked poolToken mint
  • Loss: about 2,030,841.0177 DAI from the yCurve and sUSD pools
  • Summary: Akropolis let SavingsModule.deposit() accept an attacker-supplied token address and mint pool tokens from a before/after balance delta measured around depositToProtocol(). The attacker supplied a malicious ERC20-like token whose transferFrom() re-entered deposit() with real DAI borrowed from dYdX. The real DAI only entered once, inside the nested inner call. But when that inner deposit finished and control returned to the outer fake-token frame, the outer frame re-measured the same DAI balance increase as if it were its own deposit. Akropolis therefore counted one economically real DAI inflow twice, minted too many pool tokens, and then let those unbacked pool tokens redeem against real stablecoin liquidity in the yCurve and sUSD pools.
  • Trigger / entry point:
    • SavingsModule.deposit() trusted the caller-supplied token and did not enforce that the token belonged to the supported asset set for the pool.
    • The deposit path lacked reentrancy protection around the balance-delta accounting that determined poolToken mint quantity.
    • The attacker used malicious token contract 0xe2307837524db8961c4541f943598654240bd62f, whose transferFrom() executed a hook that re-entered Akropolis.
  • Root cause:
    • Akropolis treated a token transfer as if it were a passive accounting step rather than a full control-flow surrender.
    • PoolToken minting depended on the pool balance difference before and after depositToProtocol(), but that balance-delta method was not safe under reentrancy.
    • Because the attacker could re-enter deposit() with real DAI during the fake-token transferFrom(), the one economically real DAI deposit landed in the inner frame while the outer fake-token frame later claimed that same balance increase as its own and minted a second, unbacked set of pool tokens.
    • The exploit also depended on unsupported-token admission: the protocol let an attacker choose a crafted token contract for a path that should only have interacted with known assets.
  • Exploit path:
    1. The attacker borrowed capital with a dYdX flash loan and targeted Akropolis’s stablecoin savings pools.
    2. The attacker called SavingsModule.deposit() using malicious token contract 0xe2307837524db8961c4541f943598654240bd62f as the token argument.
    3. During the outer call’s transferFrom() on that fake token, the malicious token re-entered SavingsModule.deposit().
    4. The re-entrant inner deposit used real DAI — public reporting cites roughly 800,000 DAI in the main setup — and legitimately increased the pool’s DAI balance.
    5. The inner call completed and minted poolTokens once against that real DAI balance increase.
    6. Execution then returned to the outer fake-token deposit, which resumed its own before/after balance check.
    7. Because the pool balance had risen during the inner DAI deposit, the outer call observed the same increase and minted a second set of poolTokens even though the fake token had contributed no real value.
    8. The attacker redeemed the over-minted poolTokens out of the yCurve and sUSD pools, repeating the pattern across 17 exploit transactions until about 2.03 million DAI had been drained.
  • What the attacker needed to be true:
    • A pool mint path had to accept an attacker-chosen token contract instead of only an allowlisted supported asset.
    • Re-entrancy into deposit() had to remain possible during the token transfer path.
    • PoolToken issuance had to depend on a balance delta that could be observed both by the inner and outer deposits.
    • The over-minted poolTokens had to remain redeemable for real assets in downstream pool accounting.
    • Sufficient stablecoin liquidity had to remain in the affected pools for the fake poolTokens to cash out.
  • Technical anatomy worth remembering:
    • This was not just withdraw before balance update. It was a share-mint double count: one real DAI deposit was priced twice because a malicious token controlled the timing of the balance-delta measurement.
    • The mechanically important detail is where the real value entered: not in the outer fake-token call, but at the bottom of the two-frame nested stack when the inner DAI deposit completed. The outer frame then resumed and claimed that already-booked DAI increase a second time.
    • The unsupported-token angle mattered as much as the missing reentrancy guard. Akropolis let a hostile token contract participate in a mint path that assumed vanilla token semantics.
    • PeckShield’s example makes the invariant failure vivid: if the inner deposit moved 25,000 DAI, both the inner and outer calls could mint as if they had each delivered that 25,000 DAI, turning one real inflow into 50,000 DAI worth of poolTokens.
    • That makes Akropolis a clean two-frame cousin to Grim’s later multi-frame vault-share version: the accounting bug is not merely reentrancy happened, but economically real value entered once in the nested frame and earlier bookkeeping frames still monetized it on the way back out.
    • The profits were realized only because Akropolis honored the resulting poolTokens as real redemption claims against live yCurve and sUSD liquidity.

Three receipt-authority promotions this incident collapsed

  1. Some balance movement existed at the pool boundary
    • A real DAI inflow really did land in Akropolis during the nested inner deposit.
    • But that only proved the pool balance changed somewhere during this stack, not this outer deposit frame earned the change.
  2. That observed movement still counted as this exact deposit frame’s receipt truth
    • This is the promotion Akropolis got wrong. The inner DAI deposit had already consumed the real receipt event and minted poolTokens once.
    • When control returned to the outer fake-token frame, Akropolis re-read the same ambient balance increase and promoted it into fresh receipt truth for a second deposit story.
    • The crucial failure was not merely reentrancy in the abstract, but that the protocol could not keep one real inflow bound to one exact deposit frame.
  3. That accepted receipt truth still deserved live mint-and-redemption authority
    • Even after the protocol observed a balance increase, healthy design would still need one bounded claim path, delayed redemption, or some other brake before ambiguous receipt state became cash-equivalent poolTokens.
    • Akropolis instead let the second receipt story inherit ordinary poolToken mint authority immediately, and those extra poolTokens were then redeemable against live yCurve and sUSD liquidity.

This is why Akropolis is such a useful corpus case: the callback window mattered, but the monetizable jump was from some real DAI entered during nested execution to two different deposit frames may both publish that same DAI as their own backing truth.

  • Representative transactions and addresses:
    • Vulnerable Akropolis contract highlighted in public analysis: 0x73fC3038B4cD8FfD07482b92a52Ea806505e5748
    • Malicious token / attack contract: 0xe2307837524db8961c4541f943598654240bd62f
    • dYdX flash-loan kickoff transaction cited by PeckShield: 0xddf8c15880a20efa0f3964207d345ff71fbb9400032b5d33b9346876bd131dc2
    • Representative exploit transaction: 0xe1f375a47172b5612d96496a4599247049f07c9a7d518929fbe296b0c281e04d
    • PeckShield-tracked stolen-funds wallet: 0x9f26ae5cd245bfeeb5926d61497550f79d9c6c1c
  • Response / mitigation:
    • Akropolis paused its stablecoin pools after detecting the drain and said other pools, including bUSD, sBTC, Aave, and Compound integrations, were unaffected.
    • The team said the exploit combined reentrancy with flash-loan-funded execution and began fixing contract-level issues.
    • Akropolis later offered the attacker a $200,000 bug bounty in exchange for returning funds and said it was adding token checks / whitelist functions, more test coverage, and broader security review.

Pattern tags

  • unsupported token admission let attacker-authored transfer semantics decide when share-mint accounting reopened
  • one real DAI inflow at the bottom of the stack was minted once in the inner frame and again in the outer frame
  • poolToken redemption gave a deposit-accounting mistake direct line-of-sight to live yCurve and sUSD liquidity
  • early seam case where interface-conformance failure opened the door and balance-delta provenance failure paid out

Why the exploit worked

  • Akropolis did not merely miss a nonReentrant modifier. It let a caller-supplied token contract participate in a mint path that assumed honest supported-asset semantics, then priced poolToken issuance from a raw before/after balance delta around that external call.
  • The malicious token’s economic job was effectively zero; its real job was to reopen the deposit frame. The only economically meaningful inflow was the nested real DAI deposit, but the outer frame later resumed and treated that already-booked DAI increase as if it were its own receipt event.
  • That made the core invariant failure one live stablecoin inflow, two redeemable poolToken stories. Once Akropolis honored those extra poolTokens as ordinary claims, the accounting bug immediately became withdrawable yCurve and sUSD liquidity rather than a paper-only share inflation.
  • This is why Akropolis matters as more than generic reentrancy: the callback window was the transport, but the monetizable mistake was provenance. The protocol never proved which exact deposit frame had earned the observed balance increase.

Detection and audit pivots

  • Treat any deposit(token, ...) or router-style mint path as high risk when the caller chooses the token contract and the protocol later mints from balanceAfter - balanceBefore. Supported-asset validation and accounting-boundary discipline have to be reviewed together.
  • Fuzz malicious transferFrom() callbacks that re-enter the same deposit path with the real asset on the inner frame. The key test is not only can reentrancy happen, but can one real inflow be observed by two accounting frames.
  • Distinguish the contract balance increased from this exact call delivered the increase. If the code cannot prove source-specific receipt, it is already one step away from double-crediting the same backing.
  • Review whether any over-minted receipt token is immediately redeemable against live pool assets. Share inflation is especially dangerous when the mis-minted object already has a routine cash-out path into reserve liquidity.
  • Ask whether unsupported-token admission is being treated as a UX detail instead of a solvency boundary. In Akropolis, the hostile token contract was not an incidental parameter mistake; it was the mechanism that rewrote when deposit truth was allowed to be observed.

Why this incident matters

  • It is a clean seam case between unsupported-token admission and balance-delta provenance failure: the hostile token is what opens the path, but the monetizable invariant break is that one real DAI inflow gets credited to two accounting frames.
  • It is also a clean early vault/share-accounting cousin to Grim’s later multi-loop vault exploit: Akropolis preserves the simplest two-frame form of the same broader lesson that the last real deposit at the bottom of a nested call stack can be re-measured and re-minted by earlier frames on unwind.
  • The case broadens the reentrancy corpus beyond ERC-777 collateral overwrites and plain recursive withdrawals: here the attacker used callback control to make one real deposit count twice.
  • It is also a reminder that delta-based mint accounting is only safe if the delta cannot be adversarially altered mid-measurement.

Failure-prevention checklist

  • Constrain deposit assets at the path boundary. If a savings pool is meant to accept only specific stablecoins or known wrappers, enforce that allowlist before any transfer or mint logic begins rather than trusting a caller-supplied token argument.
  • Separate actual receipt from who earned credit for that receipt. Even correct balanceAfter - balanceBefore measurement is not enough unless one exact deposit frame, pool, and recipient is bound to that observed increase.
  • Close the whole share-mint accounting window before any attacker-influenced external call can move the same asset. If token transfer must happen first, the later mint step still needs a source-specific receipt object rather than a fresh read of ambient pool balance.
  • Treat callback-capable or semantically exotic tokens as unsupported by default unless an adapter narrows them into the exact semantics the deposit path expects.
  • Keep accounted reserves separate from raw token inventory so a real inflow recognized by one frame cannot be silently rediscovered and credited by another frame on unwind.
  • Make over-minted receipt tokens hard to cash out while an accounting anomaly is still plausible: supply caps, withdrawal throttles, or delayed redemption do not fix the bug, but they reduce how quickly double-credited shares become drained stablecoin liquidity.
  • Fuzz nested deposit(fake token) -> transferFrom callback -> deposit(real token) -> unwind outer frame traces directly. The invariant to assert is one economically real inflow can mint at most one redeemable poolToken claim.
  • Audit all helper and wrapper paths that can end in the same mint logic. A semantic boundary enforced only on the happy-path deposit entrypoint is not a real boundary.

Audit prompts

  • Which exact assets is this deposit path supposed to support, and where in code is that support contract enforced rather than merely documented?
  • What exact economic fact does the measured balance delta prove here: tokens reached the contract, or this specific deposit call delivered those tokens and therefore earned new poolTokens?
  • Between the first balance snapshot and the final poolToken mint, can any token hook, callback, sibling entrypoint, or nested call move the same underlying asset?
  • If a real stablecoin deposit lands during a nested frame, what prevents an earlier outer frame from later resuming and claiming that same inflow as its own receipt event?
  • Does any later sync, invest, or reserve-recognition helper reinterpret ambient token balances independently of the mint path that already consumed them?
  • If an unsupported token address is passed in, does the path fail closed immediately, or can interface-compatible but semantically hostile behavior still reach accounting logic?
  • Once poolTokens are minted, what controls prevent immediate redemption against live stablecoin liquidity if the mint was unbacked or ambiguously backed?
  • Would a new reviewer looking only at tests learn that one real inflow must never satisfy two nested deposit frames, or is that invariant still mostly tribal knowledge?

Lessons that should already be standard

  • Caller chooses the token is already a solvency boundary, not just an integration detail. If the token can define transfer semantics, it can often define accounting timing too.
  • Reentrancy is only half the story. The profitable mistake here was provenance: the protocol could not prove which frame owned the observed DAI increase.
  • balanceAfter - balanceBefore is evidence of movement, not authorship. A measured balance increase can still belong to the wrong user, wrong frame, or wrong economic event.
  • Share minting and redemption cannot be analyzed separately. If a dubious receipt path can mint an ordinary claim token, the cash-out path has already become part of the exploit surface.
  • Interface-compatible is not accounting-compatible. A token that satisfies the expected ABI can still be disallowed because its transfer behavior is too powerful for the path’s assumptions.
  • One live inflow should produce one redeemable claim story. If the system can tell two coherent narratives about who earned the same backing, the attacker will cash out both.

Internal linkages

  • Core recurring pattern: reentrancy.
  • Asset-admission lens: interface-conformance-semantic-compatibility-failure — Akropolis first failed by letting an attacker-chosen fake token into a deposit path that assumed a supported asset with passive transfer semantics.
  • Receipt-boundary companion: actual-receipt-accounting-and-token-semantics-admission-discipline — the deposit path needed a much narrower semantic contract than caller supplied something ERC20-like and balances changed, because the entire exploit rode on hostile transfer semantics being admitted into share-mint accounting.
  • Accounting lens: balance-delta-provenance-failure — Akropolis is the clean two-frame version of that family: one economically real DAI deposit landed in the inner frame, and one earlier outer frame resumed and claimed that same balance increase again on unwind.
  • Callback-frame companion: callback-frame-truth-security-object — the practical control question is not only can deposit re-enter, but which unfinished deposit frame is still allowed to claim the next observed inflow as its own.

Control surface