DeFi Exploit Breakdown
The Compound V2 Exploit Explained: A Smart Contract Audit Post-Mortem
On August 30, 2021, CREAM Finance — a direct fork of Compound V2 — lost $18.8 million in a single Ethereum transaction. The attacker exploited a reentrancy vulnerability buried inside borrowFresh(), the core lending primitive inherited directly from the Compound V2 codebase. The root cause was a single ordering mistake: the protocol transferred tokens to the borrower before recording the borrow in its own accounting state.
This post-mortem walks through exactly how the attack worked, why it was possible, and — most importantly — what a smart contract auditor should catch to prevent the same class of vulnerability from shipping to production.
Incident Summary
Protocol
CREAM Finance
Date
Aug 30, 2021
Loss
$18.8M
Attack Type
Reentrancy
What Is Compound V2?
Compound V2 is a decentralized money market protocol on Ethereum. Users supply assets to earn yield, and other users borrow against posted collateral. The protocol is governed by the COMP token via an on-chain governor and timelock.
The codebase centers on two contracts: the Comptroller (a risk engine that enforces collateral factors, validates borrows, and manages liquidations) and a family of cToken contracts — one per supported asset — each of which holds the actual lending logic. When you deposit DAI, you receive cDAI. When you borrow ETH, the protocol calls crETH.borrow().
CREAM Finance deployed an almost line-for-line copy of this architecture in 2020, with the key difference that it supported a wider range of ERC-20 tokens — including AMP, a staking token issued by Flexa that implements the ERC-1820 callback interface (functionally equivalent to ERC-777 hooks). That single configuration choice opened the door to the exploit.
The Vulnerability: CEI Violation in borrowFresh()
Every Solidity developer is taught the Checks-Effects-Interactions (CEI) pattern: validate inputs first, update all internal state second, and only then make external calls. The invariant is simple — by the time your contract calls out to an unknown address, your own state must already reflect the outcome of that operation.
The Compound V2 borrowFresh() function inverted this order. It called doTransferOut() — which physically sends tokens to the borrower — and only afterward wrote the borrow to accountBorrows. For plain ERC-20 tokens, this ordering does not matter: the transfer cannot re-enter the contract. For ERC-777 / ERC-1820 tokens with tokensReceived hooks, the transfer is an external call — and any contract registered as the recipient can execute arbitrary code mid-transfer, before the borrow state has been committed.
Below is the vulnerable pattern, simplified from the actual Compound V2 / CREAM Finance CToken.sol:
// CREAM Finance / Compound V2 — CToken.sol (simplified)
// VULNERABILITY: state update happens AFTER the external call
function borrowFresh(
address payable borrower,
uint borrowAmount
) internal returns (uint) {
// 1. CHECKS — validate the borrow is permitted
uint allowed = comptroller.borrowAllowed(
address(this), borrower, borrowAmount
);
require(allowed == 0, "comptroller rejected borrow");
// 2. INTERACTION — transfer tokens to borrower ← DANGER
// If the token has an ERC-777 / ERC-1820 tokensReceived hook,
// this call hands control to an attacker-controlled contract
// before any state has been updated.
doTransferOut(borrower, borrowAmount); // ← external call first
// 3. EFFECTS — record the borrow in state ← TOO LATE
// We only reach here after doTransferOut returns.
// A reentrant borrow() call sees the OLD accountBorrows,
// making it appear the attacker still has unused collateral.
accountBorrows[borrower].principal = vars.accountBorrows + borrowAmount;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrows + borrowAmount;
emit Borrow(borrower, borrowAmount, ...);
return NO_ERROR;
}The CEI Violation
The correct order is Checks → Effects → Interactions. In the vulnerable version the order is Checks → Interactions → Effects. During the interaction window, the protocol state is inconsistent — the borrow has been executed but not recorded. Any reentrant call made inside that window will pass the collateral check as though the previous borrow never happened.
The Exploit: Step by Step
The attacker deployed a malicious contract and registered it as an ERC-1820 implementer of tokensReceived for AMP tokens. This is a standard, on-chain operation that requires no special privileges — any address can register callback hooks through ERC-1820. Once the hook was registered, the attack proceeded as follows:
Step
01
Flash Loan
Borrow 500 WETH from AAVE in a single atomic transaction. No upfront capital required — the loan is drawn and repaid within the same block.
Step
02
Supply Collateral
Deposit the 500 WETH into CREAM Finance. Receive crETH tokens representing the collateral position. The Comptroller records this as ~$1.5M in collateral at the time.
Step
03
Borrow AMP (triggering the hook)
Call borrow() on crAMP to borrow 19,480,000 AMP tokens. CREAM calls doTransferOut(), which initiates an AMP ERC-1820 transfer. AMP's token contract calls tokensReceived on the attacker's contract before the transfer completes.
Step
04
Reenter: Borrow ETH mid-transfer
Inside tokensReceived, before step 03 has written anything to accountBorrows, the attacker calls crETH.borrow(355 ETH). The Comptroller checks liquidity — and because the AMP borrow is not yet recorded, the collateral appears fully available. The 355 ETH borrow is approved and executed.
Step
05
Repeat
The 355 ETH transfer itself triggers no hook (ETH is not ERC-777), but the attacker structured the attack to nest the AMP borrow call 17 times inside successive tokensReceived callbacks, extracting ETH at each layer of the call stack.
Step
06
Unwind and Profit
The call stack unwinds. CREAM finally records all the AMP borrows in accountBorrows — but the ETH is already gone. The attacker repays the AAVE flash loan and exits with approximately $18.8M in stolen assets.
What an Auditor Should Have Flagged
This vulnerability is not subtle. A methodical audit of the lending primitive against a standard checklist would have surfaced every one of the following issues before deployment:
External call precedes state update in borrowFresh()
doTransferOut() is called on line N before accountBorrows[borrower].principal is written. Any token that invokes a callback during transfer will observe stale state. Flag every function that calls doTransferOut(), doTransferIn(), or any low-level call() / transfer() and verify that all state mutations precede it.
No nonReentrant guard on borrow() / borrowFresh()
The function is public and mutable state is accessed mid-execution. Adding OpenZeppelin's ReentrancyGuard and applying the nonReentrant modifier to borrow() would have blocked the reentrant call in step 04 at zero cost. Every external-facing function that touches balances should carry this modifier.
ERC-777 / ERC-1820 callback tokens listed as supported collateral
Tokens that implement tokensReceived or tokensToSend hooks expand the attack surface of any protocol that uses them. Auditors should explicitly check whether any listed collateral asset implements ERC-1820 interfaces. If so, the CEI analysis above becomes critical rather than advisory.
Comptroller liquidity check does not account for in-flight borrows
The Comptroller's getAccountLiquidity() reads from accountBorrows, which is only updated after the transfer. A reentrant caller will always see the pre-transfer liquidity snapshot. This is a systemic design flaw: the liquidity oracle and the state-update mechanism operate on different points in the call stack.
No integration tests covering ERC-777 tokens
The test suite covered standard ERC-20 semantics. A single fuzzing scenario — borrow() on a token whose transfer triggers a reentrant borrow() — would have exposed the race condition immediately. Protocol-scope audits should always include adversarial mock tokens in the test harness.
The Fix: Two Lines and a Modifier
The remediation required no architectural changes — only a correct application of CEI and the addition of a reentrancy lock. Move the state writes above doTransferOut(), and add the nonReentrant modifier as a defense-in-depth backstop:
// FIXED: Correct CEI ordering + reentrancy guard
// import OpenZeppelin ReentrancyGuard and inherit it in CToken
function borrowFresh(
address payable borrower,
uint borrowAmount
) internal nonReentrant returns (uint) {
// ^^^^^^^^^^^^ defense-in-depth: blocks re-entry at the EVM level
// 1. CHECKS
uint allowed = comptroller.borrowAllowed(
address(this), borrower, borrowAmount
);
require(allowed == 0, "comptroller rejected borrow");
// 2. EFFECTS — write state before touching external contracts
// Now any reentrant call will see the updated borrow balance
// and the Comptroller will correctly reject a second borrow.
accountBorrows[borrower].principal = vars.accountBorrows + borrowAmount;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrows + borrowAmount;
// 3. INTERACTIONS — external call happens last, state is consistent
doTransferOut(borrower, borrowAmount); // ← safe to call now
emit Borrow(borrower, borrowAmount, ...);
return NO_ERROR;
}Defense in Depth
CEI alone would have stopped this exploit. The nonReentrant modifier is an additional safeguard: even if a future code change accidentally reintroduces a CEI violation, the mutex prevents reentrant execution entirely. Use both — they are complementary, not alternatives.
Lessons for DeFi Protocol Auditors
The CREAM / Compound V2 reentrancy exploit is a canonical case study because it illustrates how a vulnerability can be invisible under normal ERC-20 assumptions and catastrophic under ERC-777 assumptions. The protocol was forked and deployed with an extended token allowlist without auditing the implications of each token's transfer semantics.
Here is the auditor checklist that would have caught this before deployment:
- 1
For every function that calls an external contract or transfers tokens: verify that all local state mutations precede the call. Map the execution path explicitly — do not rely on assumptions.
- 2
Check whether the nonReentrant modifier is applied to all external functions that modify balances, borrow state, or liquidity. Compound V2's borrow() was missing it entirely.
- 3
For every listed collateral and borrow asset: check the token contract for ERC-777 hooks (tokensReceived, tokensToSend) and ERC-1820 interface registrations. Note which tokens can re-enter the protocol mid-transfer.
- 4
Trace the Comptroller's getAccountLiquidity() call sites relative to state-update call sites. In a CEI-violating flow, these two functions can observe inconsistent world-states during the same transaction.
- 5
Write adversarial ERC-20 mocks — tokens that call back into the target protocol during transfer — and run borrow(), repay(), and liquidate() against them in your test suite.
- 6
Review fork differentials: if the protocol is a fork, enumerate every added token and configuration change since the upstream audit. Forks inherit audit coverage only for unchanged code paths.
The broader lesson is that token-agnostic lending protocols carry implicit assumptions about transfer semantics that are only valid for a subset of ERC-20 tokens. Every time a new collateral asset is listed — even via governance, long after the initial audit — the listing should trigger a fresh review of the token's interface, callback behavior, and interaction with the core lending primitives.
Protocol security does not end at the audit report. It is an ongoing process that must be repeated whenever the attack surface changes. Listing an ERC-1820 token on a Compound V2 fork is a surface change. Passing a governance proposal that touches comptroller parameters is a surface change. Deploying an upgraded implementation is a surface change. Each one warrants a targeted review.
NanoLab Smart Contract Audit
Don't ship until you've checked.
NanoLab audits cover CEI violations, reentrancy paths, ERC-777 callback risks, oracle manipulation, flash loan attack surfaces, and 35+ additional vulnerability categories. Every audit includes a severity-ranked findings report with remediation guidance.
See the full scope in our Compound V2 sample audit report — a real audit of the exact codebase discussed in this article.