dTRINITY dLEND cbBTC Liquidity Index Manipulation

On 2026-03-18, the dTRINITY dLEND lending protocol (an Aave v3 fork deployed on Ethereum mainnet) was exploited through a flash loan abuse combined with a logic error in the flash loan repayment accounting. An attacker manipulated the cbBTC reserve’s liquidity index from 1.0 RAY to 6,226,622 RAY in a preparatory transaction, then used that inflated index to borrow 257,328 dUSD against phantom collateral in the exploit transaction. The protocol lost **257,328.63 dUSD ($257,000)** in outstanding undercollateralized debt; the cbBTC aToken is additionally insolvent by 7.86 cbBTC ($786,000 at $100k/BTC) due to extraction of phantom cbBTC during the exploit. The attacker’s net cost was approximately 0.1245 cbBTC ($12,500) paid as a flash loan premium in the setup transaction, yielding a net profit of approximately $257,000 in dUSD transferred to the attacker EOA.

Root Cause

Vulnerable Contract

  • Contract: dLEND Pool (Aave v3 fork)
  • Proxy Address: 0x6598dad18bda89a0e58a1f427c8cebc0de90f153
  • Implementation Address: 0xfda3a0effe2f3917aa60e0741c6788619ae19e84
  • Source Type: verified (Etherscan)
  • The pool uses a transparent proxy pattern; all storage is in the proxy, all logic in the implementation. DELEGATECALL is used for all pool operations.

Vulnerable Function

  • Function: _handleFlashLoanRepayment
  • Signature: _handleFlashLoanRepayment(DataTypes.ReserveData storage, DataTypes.FlashLoanRepaymentParams memory) internal
  • File: FlashLoanLogic.sol
  • Called by: executeFlashLoanSimple — triggered via external flashLoanSimple(address,address,uint256,bytes,uint16) (selector 0x42b0b77c)

Vulnerable Code

// FlashLoanLogic.sol
function _handleFlashLoanRepayment(
    DataTypes.ReserveData storage reserve,
    DataTypes.FlashLoanRepaymentParams memory params
) internal {
    uint256 premiumToProtocol = params.totalPremium.percentMul(params.flashLoanPremiumToProtocol);
    uint256 premiumToLP = params.totalPremium - premiumToProtocol;
    uint256 amountPlusPremium = params.amount + params.totalPremium;

    DataTypes.ReserveCache memory reserveCache = reserve.cache();
    reserve.updateState(reserveCache);
    reserveCache.nextLiquidityIndex = reserve.cumulateToLiquidityIndex(
        IERC20(reserveCache.aTokenAddress).totalSupply() +         // <-- VULNERABILITY: uses nominal ERC20 totalSupply
            uint256(reserve.accruedToTreasury).rayMul(reserveCache.nextLiquidityIndex),
        premiumToLP                                                 // <-- VULNERABILITY: large premium / tiny supply = index explosion
    );
    // ...
}

// ReserveLogic.sol
function cumulateToLiquidityIndex(
    DataTypes.ReserveData storage reserve,
    uint256 totalLiquidity,
    uint256 amount
) internal returns (uint256) {
    // new_index = (amount / totalLiquidity + 1) * old_index
    uint256 result = (amount.wadToRay().rayDiv(totalLiquidity.wadToRay()) + WadRayMath.RAY).rayMul( // <-- VULNERABILITY: no minimum totalLiquidity guard
        reserve.liquidityIndex
    );
    reserve.liquidityIndex = result.toUint128();  // <-- stored permanently; monotonically increasing
    return result;
}

Why It’s Vulnerable

Expected behavior: When a flash loan is repaid with a small premium, cumulateToLiquidityIndex distributes that premium pro-rata to all current liquidity providers. The totalLiquidity denominator ensures the index increases proportionally to the existing pool size. For a pool with $10M in liquidity and a $90 premium, the index should increase by 0.0009% — imperceptible.

Actual behavior: There is no guard on the minimum value of totalLiquidity. An attacker can reduce the pool to a near-zero scaled total supply (by depositing then withdrawing, leaving just 1–2 scaled units), then repay a flash loan with a premium that is large relative to that residual supply. The formula amount / totalLiquidity then produces a massive multiplier.

In the dTRINITY cbBTC pool, the attacker reduced the total supply to 2 scaled units (≈2 cbBTC atoms = 0.00000002 cbBTC), then paid a premium of 12,453,242 atoms (0.1245 cbBTC). This yielded:

new_index = (12,453,242 / 2 + 1) × 1.0 RAY = 6,226,622 RAY

The liquidity index was permanently inflated 6,226,622-fold — from approximately 1.0 RAY to 6,226,622 RAY. Because Aave v3 liquidity indexes are monotonically increasing and never decrease, this inflation is permanent and persists across future transactions.

Why this matters: The liquidity index is used to compute every aToken balance via scaledBalance × index / RAY. An inflated index makes every scaled token appear to hold a vastly larger amount of underlying, creating phantom collateral. Any user who held aToken positions before the inflation (specifically: the attacker’s own 1-scaled-unit position from the setup transaction) will see enormous balanceIncrease credited to them on the next deposit, which passes unchecked through getUserAccountData’s collateral calculation.

Normal flow vs Attack flow:

ScenarioPool cbBTC totalLiquidityFlash loan premiumIndex change
Normal use (10M cbBTC pool, 0.09% fee on 10 cbBTC loan)~10,000,000 cbBTC atoms~9 atoms+0.000001 RAY
Attack (pool drained to 2 atoms, 0.1245 cbBTC premium)2 cbBTC atoms12,453,242 atoms+6,226,621 RAY

Attack Execution

High-Level Flow

Setup Transaction (attacker EOA nonce 1, separate from exploit tx):

  1. Attacker deposits 124.52 cbBTC into the near-empty dLEND cbBTC pool, becoming a liquidity provider.
  2. Attacker withdraws almost all their position, leaving exactly 1 scaled dLEND-cbBTC unit.
  3. Attacker calls dLEND’s flashLoanSimple for cbBTC. In the flash loan callback the attacker holds the borrowed amount temporarily.
  4. Attacker repays the flash loan with a premium of 12,453,242 cbBTC atoms (0.1245 cbBTC). With only 2 scaled units of supply remaining (attacker’s 1 + an existing depositor’s 1), cumulateToLiquidityIndex sets the cbBTC liquidity index to 6,226,622 RAY.

Exploit Transaction (0xbec4c8ae...): 5. Attacker contract takes a zero-fee Morpho flash loan of 11,366 cbBTC (0x108a3789e97 = 1,136,613,957,271 atoms). 6. Attacker contract deposits 7.72 cbBTC (772,101,004 atoms) into dLEND at the inflated index. The attacker’s pre-existing 1-scaled-unit position accrues a phantom balanceIncrease of 6,226,621 atoms, and 124 new scaled units are minted. Total attacker aToken position: 125 scaled units → nominal balance 778,327,625 atoms (7.78 cbBTC). 7. Attacker calls borrow(dUSD, 257,328.63 dUSD) against the collateral. This succeeds because getUserAccountData prices the 7.78 cbBTC collateral using a Chainlink oracle, yielding sufficient borrow capacity (LTV ~33.7%). 8. Attacker contract deploys helper contract (0x149ad6f80e14be0f591c02a8f476fea160ffcea7) via CREATE and approves it for cbBTC. 9. Helper executes 127 deposit/withdraw cycles: deposits cbBTC, receives back more cbBTC than deposited each time (because the inflated index assigns disproportionate scaled-to-underlying ratios). Net extraction: 7.86 cbBTC from the aToken reserve. 10. Attacker repays Morpho flash loan using the remaining flash-loaned cbBTC (11,358 cbBTC not deposited) plus helper’s extracted cbBTC (7.86 cbBTC), totaling 11,366 cbBTC. 11. Attacker EOA receives 257,328.63 dUSD; the attacker contract retains 7.78 cbBTC aToken position and an outstanding 257,328.63 dUSD variable-rate debt — both undercollateralized.

Detailed Call Trace

EOA (0x08cfdff8) → AttackerContract (0xba5e1e36) [CALL, selector 0xeb37187a]
  ├─ cbBTC.balanceOf(Morpho) [STATICCALL, 0x70a08231]
  ├─ Morpho (0xbbbbbbbb).flashLoan(cbBTC, 1136613957271, data) [CALL, 0xe0232b42]
  │   ├─ cbBTC.transfer(attacker, 1136613957271) [CALL, 0xa9059cbb]
  │   ├─ AttackerContract.onMorphoFlashLoan(1136613957271, data) [CALL, 0x31f57072]
  │       ├─ cbBTC.approve(pool, max) [CALL, 0x095ea7b3]
  │       ├─ pool.getReserveData(cbBTC) [STATICCALL, 0x35ea6a75]
  │       ├─ console.log(0x5f02bd = 6226621) [precompile 0x000...000]
  │       ├─ console.log(0x80 = 128) [precompile 0x000...000]
  │       ├─ pool.deposit(cbBTC, 772101004, attacker, 0) [CALL, 0xe8eda9df]  ← deprecated deposit() alias
  │       │   → aToken.mint(attacker, attacker, 772101004, 6226622_RAY)
  │       │   → _mintScaled: balanceIncrease=6226621, amountScaled=124, Transfer(0→attacker, 778327625)
  │       ├─ pool.getUserAccountData(attacker) [STATICCALL, 0xbf92857c]  ← confirms inflated collateral
  │       ├─ pool.borrow(dUSD, 257328632216555425511184, 2, 0, attacker) [CALL, 0xa415bcad]
  │       │   → variableDebtToken.mint(attacker, attacker, 257328.63 dUSD, dUSD_variableBorrowIndex)
  │       │   → dUSD aToken.transfer(attacker, 257328.63 dUSD)
  │       ├─ CREATE → helper (0x149ad6f8)
  │       ├─ cbBTC.approve(helper, max) [CALL, 0x095ea7b3]
  │       ├─ helper.0x1fa49765(pool, aToken, cbBTC, 6226621) [CALL]
  │       │   ├─ cbBTC.transferFrom(attacker, helper, 12453242) [CALL, 0x23b872dd]
  │       │   ├─ cbBTC.approve(pool, max) [CALL, 0x095ea7b3]
  │       │   └─ [127 cycles:]
  │       │       ├─ pool.deposit(cbBTC, X, helper, 0) [CALL, 0xe8eda9df]  ← deprecated deposit() alias
  │       │       ├─ pool.withdraw(cbBTC, type(uint256).max, helper) [CALL, 0x69328dec]
  │       │       └─ [cycle: deposit 3175576 atoms, receive 9339930 atoms — net +6164354/cycle]
  │       │   └─ cbBTC.transfer(attacker, helper_balance) [CALL, 0xa9059cbb] ← returns 7.86 cbBTC
  │       └─ cbBTC.approve(pool, max) [CALL, 0x095ea7b3]  ← cleanup
  ├─ dUSD.balanceOf(attacker) [STATICCALL, 0x70a08231]
  └─ dUSD.transfer(EOA, 257328.63 dUSD) [CALL, 0xa9059cbb]

Note on selector verification:

  • 0xe8eda9df = deposit(address,uint256,address,uint16) (confirmed via cast sig). The call trace labels this as pool.supply(...) but the actual on-chain selector is deposit() — the deprecated Aave v3 compatibility alias. Both supply() (0x617ba037) and deposit() (0xe8eda9df) call SupplyLogic.executeSupply internally; the attacker used the deprecated deposit() selector.
  • 0x69328dec = withdraw(address,uint256,address) (confirmed via cast sig)
  • 0xa415bcad = borrow(address,uint256,uint256,uint16,address) (confirmed via cast sig)
  • 0x42b0b77c = flashLoanSimple(address,address,uint256,bytes,uint16) (the dLEND pool function used in the SETUP transaction; the report previously cited 0xb7b3e83b which does not correspond to any known selector — corrected here)
  • 0x1fa49765 = helper’s custom drain function (unverified contract, recovered from TAC [approximation])

Note: Call flow derived from on-chain trace. The Morpho flashLoan call (0xe0232b42) has exactly three sub-calls: (1) cbBTC.transfer(attacker, amount), (2) attacker.onMorphoFlashLoan(amount, data), (3) cbBTC.transferFrom(attacker, Morpho, amount). The diagram previously showed a spurious second cbBTC.transfer before the callback; this has been removed for accuracy.

The 127-Cycle Extraction Mechanism

Each deposit/withdraw cycle exploits the inflated index (6,226,622 RAY) to extract more cbBTC than was deposited:

  • Cycle 1: helper deposits 12,453,242 atoms; pool assigns scaled units at index 6,226,622; helper immediately withdraws max — receives 9,339,930 atoms (less than deposited, consuming its initial cbBTC budget).
  • Cycles 2–126: helper deposits 3,175,576 atoms each cycle; receives back 9,339,930 atoms. Net extraction per cycle: +6,164,354 atoms.
  • Cycle 127 (final cycle): helper deposits 3,175,576 atoms; receives back 7,845,642 atoms. Net: +4,670,066 atoms. The reduced withdrawal reflects partial pool depletion — the pool cannot fund a full 9,339,930 payout for the last cycle.

The excess cbBTC withdrawn per cycle (9.34M vs 3.18M deposited) comes from the phantom cbBTC balance in the aToken contract — specifically the unaccounted pool reserves created by the inflated index (cbBTC that “exists” per pool accounting but is not backed by real deposits at the current index). Over 127 cycles, the helper extracts 7.86 cbBTC (786,048,534 atoms) net.

Financial Impact

Protocol loss (primary): 257,328.63 dUSD in outstanding unbacked variable-rate debt. The dUSD aToken (0x5cc741931d01cb1adde193222dfb1ad75930fd60) sent 257,328.63 dUSD to the attacker contract; a corresponding variable debt token position was minted. The attacker transferred all 257,328.63 dUSD to the attacker EOA (0x08cfdff8ded5f1326628077f38d4f90df6417fd9). This debt is uncollateralizable: the inflated cbBTC aToken position backing it is itself backed by insufficient real cbBTC.

Protocol loss (secondary): cbBTC aToken insolvency of 7.86 cbBTC (~$786,000 at $100k/BTC). After the exploit, the cbBTC aToken contract holds only ~4.7M cbBTC atoms (0.0473 cbBTC) in actual reserves, but its accounting obligations total 790,780,994 atoms (7.91 cbBTC) across all aToken holders. The deficit: 786,048,661 atoms (7.86 cbBTC). Affected parties include the attacker’s own retained position (125 scaled units) and at least one innocent depositor (1 scaled unit).

Funds flow summary (from funds_flow.json):

TokenFlowAmount
cbBTCMorpho → Attacker contract+11,366.14 cbBTC (flash loan)
cbBTCAttacker contract → dLEND aToken−7.72 cbBTC (deposit)
dLEND-cbBTCMinted to attacker+7.78 cbBTC (nominal, includes phantom)
dLEND-variableDebt-dUSDMinted to attacker+257,328.63 dUSD
dUSDdLEND aToken → Attacker contract+257,328.63 dUSD
cbBTCHelper cycles (net extraction)+7.86 cbBTC
cbBTCAttacker contract → Morpho (repay)−11,366.14 cbBTC
dUSDAttacker contract → Attacker EOA+257,328.63 dUSD

Attacker net profit: 257,328 dUSD ($257,000), with setup cost of 0.1245 cbBTC (~$12,500), for a net of ~$244,500. Morpho: net zero (flash loan repaid in full, zero fee). dLEND dUSD liquidity providers: bear the 257,328.63 dUSD loss. dLEND cbBTC liquidity providers: bear the 7.86 cbBTC insolvency.

Evidence

Storage Slot Verification (from trace_prestateTracer.json)

cbBTC reserve liquidity index (pool proxy slot 0x89c297050f05308a1ebd800db358705332b6d10d9be0598b08aa6c1829173df8):

  • The slot does NOT appear in the prestateTracer post-state for this tx, confirming it was read but not written. Its value — set in the setup transaction — is 6,226,622 RAY = 0x000132ff04c8189dd459bf042ecaad94... (upper 128 bits of the N+1 reserve data slot).
  • Verification: slot address 0x89c297...df8 confirmed via cast storage at block 24676809 returning 6226622 RAY. Note: the formula keccak256(cbBTC_addr || 52) + 1 does not produce this slot (the pool’s _reserves mapping is not at storage position 52 in this deployment); the slot address is cited directly from on-chain verification.

dUSD reserve liquidity index (pool proxy slot 0x54ad65b5b7967b5b32b72bcf312b2881cf8a6dc4daafeef7ea3694d7a7ae446f):

  • PRE: 1001935482728802100208295786 (≈1.001935 RAY); POST: 1001936352144692645030820724 (≈1.001936 RAY). This is the dUSD reserve, which was updated normally during the borrow operation.
  • PRE liquidity rate: 0.013 RAY (1.3% APY); POST: 0.300 RAY (30% APY) — interest rate correctly jumped after the large dUSD borrow increased utilization.

Attacker contract aToken userState (aToken proxy slot 0xb180679ac08dd5fca797debdf653dc0c9bb699d57aa2113450976403e19bf50b):

  • Slot computed as: keccak256(attacker_contract || 52) matches b180679....
  • PRE: balance=1, additionalData=1.0 RAY — confirms attacker’s pre-existing 1 scaled unit from setup tx with additionalData = 1.0 RAY (set when they last withdrew, before the index was inflated).
  • POST: balance=125, additionalData=6,226,622 RAY — confirms 124 new scaled units minted at the inflated index, and the index stored at interaction time.

Helper contract aToken userState (aToken proxy slot 0xb71a64f703f2ac271ed1f1c70a0e7db8ed20c912e87c44492e0d9f9e3e0f03e4):

  • Slot computed as: keccak256(helper_contract || 52).
  • PRE: absent (helper not yet deployed).
  • POST: balance=1, additionalData=6,226,622 RAY — helper retains 1 scaled unit after its final deposit cycle.

aToken total scaled supply (aToken proxy slot 0x36):

  • PRE: 2 (attacker’s 1 + existing depositor’s 1); POST: 127 (attacker’s 125 + helper’s 1 + existing depositor’s 1, unchanged).

Key Transfer Events

From receipt.json (selected events):

  • Log index 542: Transfer(from=0x0000...0000, to=attacker_contract, amount=778327625) — dLEND-cbBTC mint. Breakdown: 772,101,004 (deposit) + 6,226,621 (phantom balanceIncrease) = 778,327,625. Confirms index inflation in effect at time of deposit.
  • Log index 546: Transfer(from=0x0000...0000, to=attacker_contract, amount=257328632216555425511184) — dLEND-variableDebt-dUSD mint. Confirms borrow executed.
  • Log indices 555–1820: 127 pairs of dLEND-cbBTC mint/burn events (helper deposit/withdraw cycles). Net: -772,101,004 dLEND-cbBTC to helper, confirming the extraction.
  • Log index 1826: Approval(owner=attacker_contract, spender=Morpho, amount=1,136,613,957,271) — Morpho flash loan repayment approval for 11,366 cbBTC.

cumulateToLiquidityIndex Calculation (Setup Tx)

totalLiquidity = aToken.totalSupply() = 2 scaled units × 1.0 RAY = 2 cbBTC atoms
premiumToLP = 12,453,242 atoms

new_index = (12,453,242 / 2 + 1) × 1.0 RAY
           = 6,226,622 × 1.0 RAY
           = 6,226,622 RAY

Setup tx cbBTC allowance deduction confirms: 12,452,007,379 atoms (deposit) + 12,453,242 atoms (premium) = 12,464,460,621 atoms total — matching the observed allowance decrement for the setup transaction (max_uint256 - 12,464,460,621 PRE, max_uint256 - 772,101,004 POST of exploit tx, with the difference being 11,692,359,617 atoms restored by the re-approval in the exploit tx).