All incidents

Raft cbETH Share Inflation

Share
Nov 10, 2023 18:59 UTCAttackLoss: 2,009,226.63 sDAI, 86,430.56 USDCPending manual check1 exploit txWindow: Atomic
Estimated Impact
2,009,226.63 sDAI, 86,430.56 USDC
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Nov 10, 2023 18:59 UTC → Nov 10, 2023 18:59 UTC

Exploit Transactions

TX 1Ethereum
0xfeedbf51b4e2338e38171f6e19501327294ab1907ab44cfd2d7e7336c975ace7
Nov 10, 2023 18:59 UTCExplorer

Victim Addresses

0x9ab6b21cdf116f611110b048987e58894786c244Ethereum

Loss Breakdown

2,009,226.63sDAI
86,430.56USDC

Similar Incidents

Root Cause Analysis

Raft cbETH Share Inflation

1. Incident Overview TL;DR

On Ethereum mainnet block 18543486, transaction 0xfeedbf51b4e2338e38171f6e19501327294ab1907ab44cfd2d7e7336c975ace7 exploited Raft Finance's cbETH interest-rate market at 0x9ab6b21cdf116f611110b048987e58894786c244. The adversary sender EOA 0xc1f2b71a502b551a65eee9c96318afdd5fd439fa routed execution through contract 0x0a3340129816a86b62b7eafd61427f743c315ef8, directly transferred cbETH into the Raft interest-rate position manager, forced a liquidation-driven reindex, then used dust deposits to mint whole raw collateral shares and extract value.

The root cause is a two-part accounting failure. First, Raft reindexed collateral from the manager's raw cbETH balance, so arbitrary direct transfers changed the indexed backing without minting any shares. Second, ERC20Indexable.mint() rounded in the depositor's favor with divUp, so once the index was inflated, a 1 wei deposit minted a full raw share. That let the attacker borrow unbacked R, withdraw inflated cbETH, and finish with positive profit.

2. Key Background

Raft's interest-rate markets represent collateral and debt with indexable ERC-20 tokens. Raw share balances are stored internally, while user-facing balances are computed as raw shares multiplied by the current index. The relevant collateral-side token is 0xd0db31473caad65428ba301d2174390d11d0c788.

The victim contract is the cbETH InterestRatePositionManager at 0x9ab6b21cdf116f611110b048987e58894786c244. It manages user positions through managePosition() and liquidate(), and it updates collateral and debt indexes after liquidation. A liquidatable victim position existed at 0x011992114806e2c3770df73fa0d19884215db85f before the exploit transaction.

Two protocol properties are critical:

  • Anyone can transfer cbETH directly to the position manager outside the protocol's accounted deposit path.
  • The indexable collateral token converts asset amounts to raw shares with fixed-point ceil division, not floor division.

Those properties become unsafe when the index itself is derived from raw ERC-20 balance.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability class is protocol accounting manipulation. Raft's liquidation path recalculated indexed collateral backing from collateralToken.balanceOf(address(this)), which treated unsolicited direct cbETH transfers as legitimate backing. Because the protocol did not tie backing solely to accounted deposits and protocol-owned accruals, the attacker could enlarge the collateral index without minting new raw shares.

Once the index was inflated, the mint path became exploitable. ERC20Indexable.mint() used amount.divUp(storedIndex), so any positive collateral increase smaller than the share value still minted one full raw share. With an index above one cbETH per share by orders of magnitude, each 1 wei managed deposit created an indexed collateral claim worth tens of cbETH.

The exploit therefore breaks the invariant that a position's collateral entitlement may only increase by the amount of real collateral contributed through the accounted deposit path. The concrete breakpoints are:

// InterestRatePositionManager
raftCollateralToken.setIndex(collateralToken.balanceOf(address(this)));

// ERC20Indexable
_mint(to, amount.divUp(storedIndex));
_burn(from, amount == type(uint256).max ? ERC20.balanceOf(from) : amount.divUp(storedIndex));

4. Detailed Root Cause Analysis

The liquidation-time reindex is the first decisive step. The collected victim code shows that _updateDebtAndCollateralIndex() trusts the manager's raw token balance:

function _updateDebtAndCollateralIndex(
    IERC20 collateralToken,
    IERC20Indexable raftCollateralToken,
    IERC20Indexable raftDebtToken,
    uint256 totalDebtForCollateral
) internal {
    raftDebtToken.setIndex(totalDebtForCollateral);
    raftCollateralToken.setIndex(collateralToken.balanceOf(address(this)));
}

The indexable token then computes the fixed-point index and user-facing balances as follows:

function setIndex(uint256 backingAmount) external override onlyPositionManager {
    uint256 supply = ERC20.totalSupply();
    uint256 newIndex = (backingAmount == 0 && supply == 0) ? INDEX_PRECISION : backingAmount.divUp(supply);
    storedIndex = newIndex;
}

function mint(address to, uint256 amount) public virtual override onlyPositionManager {
    _mint(to, amount.divUp(storedIndex));
}

function balanceOf(address account) public view virtual override(IERC20, ERC20) returns (uint256) {
    return ERC20.balanceOf(account).mulDown(currentIndex());
}

The seed trace shows the actual exploit sequence. After the attacker primed the manager with direct cbETH, liquidation called setIndex(6003441032036096684181) and emitted the fixed-point index 67454393618383108811022471910112359551:

FiatTokenProxy::balanceOf(InterestRatePositionManager) -> 6003441032036096684181
ERC20Indexable::setIndex(6003441032036096684181)
emit IndexUpdated(newIndex: 67454393618383108811022471910112359551)
emit Liquidation(liquidator: 0x0A3340129816..., position: 0x011992114806e2c3770df73fa0d19884215db85F, ...)

Under Raft's Fixed256x18.divUp, that emitted value is exactly ceil(6003441032036096684181 * 1e18 / 89), so liquidation-time raw supply was 89. One raw share therefore became worth about 67.454393618383108811 cbETH. The trace-derived event summary independently records that the attacker then executed 60 repeated managePosition(..., collateralChange=1, isCollateralIncrease=true, debtChange=0, isDebtIncrease=true, ...) calls.

Those 60 dust deposits minted 60 whole raw shares. Using the emitted index and mulDown, the added indexed claim was:

floor(60 * 67454393618383108811022471910112359551 / 1e18)
= 4047263617102986528661 wei cbETH
= 4047.263617102986528661 cbETH

The final withdrawal amount was 6003441032036096684241, which is exactly 60 wei larger than the liquidation-time 89-share claim of 6003441032036096684181. Because burn() also uses divUp, withdrawing that slightly larger amount rounded up and burned 90 raw shares. The trace confirms the burn:

ERC20Indexable::burn(0x0A3340129816..., 6003441032036096684241)
emit Transfer(from: 0x0A3340129816..., to: 0x0000000000000000000000000000000000000000, value: 90)
storage @ attacker raw shares: 149 -> 59

That reconciles the end state: the attacker started the transaction with 89 raw shares, minted 60 more through dust deposits, and then burned 90 on withdrawal, ending with 59 raw shares while extracting 6003.441032036096684241 cbETH and borrowing 6705028.472545484664692489 R.

5. Adversary Flow Analysis

The adversary flow is deterministic and permissionless:

  1. Flash-borrow 6000 cbETH from Aave via pool 0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2.
  2. Open or extend the attacker's own Raft interest-rate position using existing collateral/debt state and approve the manager for cbETH and R operations.
  3. Directly transfer 6001.446088796812024975 cbETH into the Raft interest-rate position manager, increasing raw token balance without minting new indexed collateral shares.
  4. Liquidate victim position 0x011992114806e2c3770df73fa0d19884215db85f, which forces _updateDebtAndCollateralIndex() and inflates the collateral index.
  5. Call managePosition() 60 times with collateralChange = 1 wei to mint 60 whole raw shares at the manipulated index.
  6. Borrow 6705028472545484664692489 wei of R against the inflated indexed collateral state.
  7. Withdraw 6003441032036096684241 wei of cbETH from the manipulated position, repay the flash-loan legs, and retain positive residual value.

The derived key trace events capture the exploit in order:

{
  "step": 2,
  "summary": "The attacker contract directly transferred 6001.446088796812024975 cbETH into the interest-rate position manager before any share minting."
}
{
  "step": 4,
  "summary": "Liquidation recomputed the indexed collateral backing amount from the raw cbETH balance held by the contract, which now included the attacker's direct transfer."
}
{
  "step": 5,
  "summary": "The attacker called managePosition with a 1 wei collateral increase 60 times after the index jump."
}
{
  "step": 6,
  "summary": "After the dust mints, the attacker borrowed 6705028.472545484664692489 R against the inflated collateral balance."
}

No privileged role, attacker private key reuse, or attacker-specific deployment was required beyond a generic unprivileged executor contract.

6. Impact & Losses

The exploit let the attacker mint unbacked R and withdraw inflated cbETH from Raft's interest-rate market. The observed downstream losses in the collected balance diff were:

  • sDAI: 2009226625525958210315125 raw units (2009226.625525958210315125 sDAI, 18 decimals)
  • USDC: 86430564918 raw units (86430.564918 USDC, 6 decimals)

The sender EOA 0xc1f2b71a502b551a65eee9c96318afdd5fd439fa also finished the transaction with a direct ETH balance increase of 7102367025435392496 wei after gas, confirming positive realized profit in the reference asset used by the ACT success predicate.

7. References