sDOLA Curve LlamaLend Oracle Manipulation

On March 2, 2026 at 03:00:11 UTC (block 24566937), an attacker exploited an oracle misconfiguration in the Curve LlamaLend sDOLA/crvUSD market on Ethereum. The root cause was the CryptoFromPoolVaultWAgg oracle contract (0x88822ee5) calling sDOLA.convertToAssets() as a spot price feed, which could be atomically manipulated via large redemptions and re-deposits within a single transaction. By flash-loaning approximately 10M USDC and 15,986 WETH from Morpho Blue (0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb), the attacker manipulated the sDOLA vault exchange rate, triggered mass liquidations of borrowers in the sDOLA/crvUSD LlamaLend market, and extracted approximately 240,000 USD in profit (6.74 WETH + 227,326 DOLA).

Root Cause

Vulnerable Contract

CryptoFromPoolVaultWAgg – the price oracle used by the sDOLA/crvUSD LlamaLend AMM.

  • Address: 0x88822ee517bfe9a1b97bf200b0b6d3f356488ff2
  • Proxy: No
  • Source type: verified (Vyper 0.3.10)

This oracle was set as the price_oracle_contract for the LlamaLend AMM at 0x0079885e248b572cdc4559a8b156745e2d8ea1f7 (LLAMMA - crvUSD AMM). The AMM calls price_oracle_contract.price() and price_oracle_contract.price_w() to determine collateral pricing for liquidation thresholds. The corresponding LlamaLend Controller (which handles liquidate() calls) is at 0xad444663c6c92b497225c6ce65fee2e7f78bfb86.

Note: Call flow labels corrected from pre-fetched data. On-chain source confirms 0x0079885e = LLAMMA (AMM, has exchange/price_oracle calls) and 0xad444663 = Controller (has liquidate). Similarly, 0xa920de414ea4ab66b97da1bfe9e6eca7d4219635 = crvUSD Controller (WETH market, receives create_loan/repay) and 0x1681195c176239ac5e72d9aebacf5b2492e0c4ee = LLAMMA AMM (WETH market).

Vulnerable Function

  • Function: price() / price_w() / _raw_price()
  • Selectors: 0xa035b1fe (price()), 0xceb7f759 (price_w())
  • File: 0x88822ee517bfe9a1b97bf200b0b6d3f356488ff2.sol (CryptoFromPoolVaultWAgg)

Vulnerable Code

# CryptoFromPoolVaultWAgg (0x88822ee517bfe9a1b97bf200b0b6d3f356488ff2)

interface Vault:
    def convertToAssets(shares: uint256) -> uint256: view

VAULT: public(immutable(Vault))

@internal
@view
def _raw_price() -> uint256:
    p_borrowed: uint256 = 10**18
    p_collateral: uint256 = 10**18

    if NO_ARGUMENT:
        p: uint256 = POOL.price_oracle()
        if COLLATERAL_IX > 0:
            p_collateral = p
        else:
            p_borrowed = p
    else:
        if BORROWED_IX > 0:
            p_borrowed = POOL.price_oracle(BORROWED_IX - 1)
        if COLLATERAL_IX > 0:
            p_collateral = POOL.price_oracle(COLLATERAL_IX - 1)

    return p_collateral * VAULT.convertToAssets(10**18) / p_borrowed  # <-- VULNERABILITY

@external
@view
def price() -> uint256:
    return self._raw_price() * AGG.price() / 10**18

@external
def price_w() -> uint256:
    return self._raw_price() * AGG.price_w() / 10**18

Why It’s Vulnerable

Expected behavior: The oracle SHOULD use a time-weighted or manipulation-resistant price for the sDOLA/DOLA exchange rate (e.g., a TWAP, an EMA, or a delayed oracle), so that the price cannot be moved significantly within a single transaction.

Actual behavior: The oracle directly calls VAULT.convertToAssets(10**18) which reads the spot exchange rate from the sDOLA ERC4626 vault. The convertToAssets() function computes shares * totalAssets() / totalSupply, and totalAssets() is determined by DolaSavings.balanceOf(sDOLA) – which changes immediately when DOLA is staked or unstaked.

The sDOLA contract itself warns about manipulation risk in its source code:

// sDola.sol -- contract comment:
// WARNING: While this vault is safe to be used as collateral in lending markets,
// it should not be allowed as a borrowable asset.
// Any protocol in which sudden, large and atomic increases in the value of an
// asset may be a security risk should not integrate this vault.

Note: The sDOLA warning specifically describes atomic price increases as the risk (e.g., a donation attack inflating the rate). In this exploit, the attacker caused a price decrease (by mass-redeeming sDOLA to crash convertToAssets()). Both directions demonstrate that the spot convertToAssets() rate is manipulable within a single transaction. The core warning remains relevant: the vault exchange rate should not be used as a real-time oracle feed in lending markets.

Normal flow vs Attack flow:

  • Normal flow: sDOLA holders deposit/withdraw gradually. convertToAssets() changes slowly over time. Oracle returns a fair price. Borrowers’ positions remain healthy.
  • Attack flow: The attacker atomically redeems a massive amount of sDOLA, which calls DolaSavings.unstake() and reduces totalAssets(). With the same totalSupply, convertToAssets(10**18) drops sharply, making the oracle report a significantly lower sDOLA price. This pushes existing borrowers’ collateral value below their liquidation thresholds. The attacker then liquidates these undercollateralized positions for profit, and afterward re-mints sDOLA to restore the rate and repay the flash loan.

The key insight is that CryptoFromPoolVaultWAgg was designed for vaults with donation-attack resistance (as noted in its source: “Only suitable for vaults which cannot be affected by donation attack (like sFRAX)”), but the sDOLA vault’s totalAssets() can be manipulated by any large holder through standard redeem/deposit operations because it reads the live DolaSavings.balanceOf(sDOLA).

Attack Execution

High-Level Flow

  1. Attacker EOA (0x33a0aab2) calls the attacker contract (0xd8e8544e) with attack parameters.
  2. Attacker contract takes a flash loan of 10,000,000 USDC from Morpho Blue (0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb) via flashLoan(address,uint256,bytes) [selector 0xe0232b42].
  3. Inside the USDC callback (onMorphoFlashLoan [0x31f57072]), attacker takes a second flash loan of ~15,986 WETH from Morpho Blue.
  4. Inside the WETH callback, attacker swaps USDC to DOLA via Curve pools, then exchanges DOLA for sDOLA and scrvUSD.
  5. Attacker creates a loan on the WETH market Controller (0xa920de414ea4ab66b97da1bfe9e6eca7d4219635, crvUSD Controller) depositing 15,986 WETH to borrow 25M crvUSD.
  6. Attacker deposits crvUSD into the scrvUSD vault and exchanges scrvUSD/sDOLA into the sDOLA/crvUSD LlamaLend AMM (0x0079885e) to create positions.
  7. Oracle manipulation: Attacker redeems ~10.6M sDOLA via sDOLA.redeem(), which unstakes ~12.6M DOLA from DolaSavings, crashing the convertToAssets() rate.
  8. Attacker re-stakes a small amount (190,777 DOLA) into DolaSavings to partially restore the rate but keep it depressed enough for liquidations.
  9. Attacker deploys a helper contract via CREATE2 (0xc6c2fcdf) and passes it crvUSD and the LlamaLend Controller address. The helper first queries users_to_liquidate() and then calls liquidate(user, 0) 27 times on borrower addresses in the sDOLA/crvUSD market (Controller at 0xad444663), collecting crvUSD and sDOLA collateral.
  10. Attacker re-mints sDOLA and re-stakes DOLA, then opens a second loan on the sDOLA/crvUSD Controller (0xad444663) with ~8.29M sDOLA collateral to borrow ~10.90M crvUSD.
  11. Attacker calls repay(uint256) on the WETH market Controller (0xa920de41) with a 50M crvUSD parameter; the function repays only outstanding debt via min(debt, _d_debt) and returns ~15,986 WETH collateral.
  12. Attacker unwinds remaining positions, swaps to WETH/USDC as needed, repays both Morpho Blue flash loans, and keeps the profit.

Detailed Call Trace

The following call trace is derived from trace_callTracer.json and decoded_calls.json. Selectors are verified with cast sig.

EOA 0x33a0aab2 -> 0xd8e8544e (AttackerContract) [0x8201355f] (CALL)
  |
  +-> 0xbbbbbbbb (Morpho Blue).flashLoan(USDC, 10,000,000 USDC) [0xe0232b42]
  |     |
  |     +-> USDC.transfer(AttackerContract, 10,000,000) [0xa9059cbb]
  |     +-> AttackerContract.onMorphoFlashLoan(10,000,000, ...) [0x31f57072]
  |           |
  |           +-> WETH.balanceOf(MorphoBlue) [0x70a08231] -- check WETH available
  |           +-> MorphoBlue.flashLoan(WETH, 15,986.108 WETH) [0xe0232b42]
  |                 |
  |                 +-> WETH.transfer(AttackerContract, 15,986.108 WETH)
  |                 +-> AttackerContract.onMorphoFlashLoan(15,986.108 WETH, ...) [0x31f57072]
  |                       |
  |                       +-- [Token approvals: crvUSD, sDOLA, DOLA, alUSD, scrvUSD to various contracts]
  |                       |
  |                       +-> 0xb30da237 (Curve Factory).exchange_underlying(USDC -> alUSD) [0xa6417ed6]
  |                       |     Swaps 7,000,000 USDC -> ~6,187,490 alUSD via FRAX/USDC pool
  |                       |
  |                       +-> 0x460638e6 (Curve StableSwap #1).exchange(alUSD -> sDOLA) [0x3df02124]
  |                       |     Swaps 650,000 alUSD -> ~454,997 sDOLA
  |                       |
  |                       +-> WETH.withdraw(15,986.108 WETH -> ETH) [0x2e1a7d4d]
  |                       +-> 0xa920de41 (crvUSD Controller, WETH market).create_loan(15,986 ETH) [0x23cfed03]
  |                       |     Deposits WETH as collateral, borrows 25,000,000 crvUSD
  |                       |     [calls price_oracle chain: 0x966cbdec -> 0x18672b1b -> 0xff530428]
  |                       |
  |                       +-> 0x0655977f (scrvUSD vault).deposit(7,000,000 crvUSD) [0x6e553f65]
  |                       |     Mints ~6,410,905 scrvUSD
  |                       |
  |                       +-> 0x76a96ba6 (Curve StableSwap #2).exchange(scrvUSD -> sDOLA) [0x3df02124]
  |                       |     Swaps 370,000 scrvUSD -> ~327,300 sDOLA
  |                       |
  |                       +-> 0x0079885e (LlamaLend AMM, sDOLA/crvUSD).exchange(crvUSD -> sDOLA) [0x5b41b908]
  |                       |     Deposits ~13,254,734 crvUSD, receives ~9,825,506 sDOLA from AMM
  |                       |
  |                       +--- ORACLE MANIPULATION ---
  |                       |
  |                       +-> 0xb45ad160 (sDOLA).redeem(~10,607,802 sDOLA) [0xba087652]
  |                       |     Burns sDOLA, triggers DolaSavings.unstake(~12,613,130 DOLA)
  |                       |     This CRASHES convertToAssets() rate
  |                       |
  |                       +-> 0xe5f24791 (DolaSavings).stake(190,777 DOLA) [0x7acb7757]
  |                       |     Partially re-stakes to calibrate oracle depression
  |                       |
  |                       +-> sDOLA.convertToAssets(10**18) [0x07a2d13a] -- attacker checks new rate
  |                       |
  |                       +-> 0x0079885e (LlamaLend AMM, sDOLA/crvUSD).exchange() [0x5b41b908]
  |                       |     Exchange at manipulated price
  |                       |
  |                       +--- MASS LIQUIDATIONS ---
  |                       |
  |                       +-> CREATE2 -> 0xc6c2fcdf (Attacker Helper) [0x60806040]
  |                       +-> crvUSD.transfer(Helper, ~4,745,266 crvUSD)
  |                       +-> 0xc6c2fcdf (Helper).attack() [0xdf3644ce]
  |                       |     Inside helper: calls Controller.users_to_liquidate() once, then 27 calls to Controller.liquidate(user, 0) [0xbcbaf487]
  |                       |       -> calls 0xad444663 (LlamaLend Controller, sDOLA/crvUSD market)
  |                       |     Each liquidate call:
  |                       |       -> Controller reads oracle via AMM (0x0079885e) -> CryptoFromPoolVaultWAgg.price_w()
  |                       |       -> Oracle calls sDOLA.convertToAssets(10**18) -- returns depressed value
  |                       |       -> Position deemed undercollateralized, liquidation proceeds
  |                       |       -> Liquidator receives sDOLA collateral + crvUSD discount
  |                       |
  |                       +--- POSITION RESTORATION & PROFIT EXTRACTION ---
  |                       |
  |                       +-> sDOLA.mint(~8,286,547 sDOLA) [0x94bf804d]
  |                       |     Re-deposits DOLA to restore vault rate
  |                       |
  |                       +-> Curve swaps: sDOLA -> alUSD -> USDC via StableSwap pools
  |                       +-> scrvUSD.redeem(~6,413,422 scrvUSD -> ~7,002,748 crvUSD) [0xba087652]
  |                       +-> sDOLA.redeem(~573,665 sDOLA -> ~776,207 DOLA) [0xba087652]
  |                       +-> sDOLA.mint(~8,286,547 sDOLA) to AMM exchange on sDOLA/crvUSD market
  |                       +-> 0xad444663 (sDOLA/crvUSD Controller).create_loan(~8,286,547 sDOLA, ~10,904,020 crvUSD) [0x23cfed03]
  |                       |     Opens second loan after liquidation phase
  |                       +-> 0xa920de41 (crvUSD Controller, WETH market).repay(50,000,000 max) [0x371fd8e6]
  |                       |     Function caps repayment to outstanding debt and retrieves ~15,986 WETH
  |                       |
  |                       +-> Uniswap V2 Router.swapExactTokensForTokens(13,241 USDC -> ~6.737 WETH) [0x38ed1739]
  |                       +-> WETH.deposit() [0xd0e30db0] -- wraps ETH back to WETH
  |                       +-> WETH.approve(MorphoBlue, 15,986 WETH) [0x095ea7b3]
  |
  +-> Morpho Blue: WETH.transferFrom(AttackerContract, MorphoBlue, 15,986 WETH) -- repay WETH flash loan
  +-> Morpho Blue: USDC.transferFrom(AttackerContract, MorphoBlue, 10,000,000 USDC) -- repay USDC flash loan

Note: Call flow labels corrected by validator. Flash loan provider is Morpho Blue (0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb), confirmed by selector 0xe0232b42 = flashLoan(address,uint256,bytes) and callback 0x31f57072 = onMorphoFlashLoan(uint256,bytes). Original report incorrectly labeled this as Balancer Vault.

Financial Impact

  • Attacker profit: 6.74 WETH ($13,242 USD at ~$1,966/ETH) + 227,326 DOLA ($227,326 USD). Total: ~$240,567 USD.
  • Flash loan amounts: 10,000,000 USDC + 15,986.11 WETH (from Morpho Blue 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb, fee-free).
  • Victims: Borrowers in the sDOLA/crvUSD LlamaLend market (Controller 0xad444663, AMM 0x0079885e) who had sDOLA collateral. 27 liquidation calls were executed by the attacker’s helper contract (across 27 borrower addresses).
  • Collateral liquidated: The LlamaLend Controller (0xad444663) lost ~1,577,896 sDOLA in collateral from borrower positions.
  • Who lost: The LlamaLend borrowers lost their sDOLA collateral positions. Their debt was repaid at manipulated prices, but they lost the difference between the fair value and the liquidation discount.
  • Protocol solvency: The LlamaLend protocol itself remains functional; the issue was specific to the sDOLA/crvUSD market’s oracle configuration. The AMM collected ~2,748 crvUSD in admin fees from the liquidation exchange operations.

The funds_flow.json shows the attacker contract (0xd8e8544e) has net token changes of +6.737 WETH and +227,325.57 DOLA. The USDC, crvUSD, scrvUSD, sDOLA, and alUSD balances all net to zero (borrowed and returned). The automated attacker_gains detection returned empty because the tool keyed on the EOA address (0x33a0aab2) rather than the contract address, but the contract-level net changes confirm the profit.

Evidence

Flash loan events (Morpho Blue 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb):

  • Log index 0: FlashLoan event for USDC, amount 0x9184e72a000 = 10,000,000,000,000 raw (10M USDC, 6 decimals)
  • Log index 2: FlashLoan event for WETH, amount 0x3629bcfc8e6ab859739 = 15,986,107,781,121,575,327,545 raw (~15,986.11 WETH)

Oracle price reads (convertToAssets selector 0x07a2d13a):

  • Called 78 times during the transaction (selectors.json confirms seen_count: 78), confirming the oracle continuously reads sDOLA’s spot conversion rate throughout the liquidation process.

Liquidation calls (liquidate(address,uint256) selector 0xbcbaf487):

  • Called 27 times (selectors.json: seen_count: 27), confirming mass liquidation of borrower positions.

Liquidation candidate scan + remaining loans:

  • users_to_liquidate() (0x007c98ab) was called once immediately before batch liquidations.
  • On-chain n_loans() for Controller 0xad444663 changed from 30 at block 24566936 (pre-state) to 4 at block 24566937 (post-state), confirming broad but not total liquidation of market borrowers.

Key ERC-20 Transfer events:

  • Transfer index 17 (log 43): sDOLA burn of 10,607,802.32 sDOLA (the oracle manipulation redemption)
  • Transfer index 16 (log 41): DOLA transfer 12,613,129.72 from DolaSavings to sDOLA (the unstake that crashes the rate)
  • Transfer index 19 (log 46): DOLA stake of 190,777.47 into DolaSavings (partial rate restoration)
  • Transfer index 105 (log 290): WETH 6.737 from Uniswap V2 to attacker (profit extraction)

Receipt status: 0x1 (success).

Block: 24566937, timestamp 1772420411 (2026-03-02 03:00:11 UTC). Transaction index 0 (first transaction in the block, likely a private/builder submission).