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, hasexchange/price_oraclecalls) and0xad444663= Controller (hasliquidate). Similarly,0xa920de414ea4ab66b97da1bfe9e6eca7d4219635= crvUSD Controller (WETH market, receivescreate_loan/repay) and0x1681195c176239ac5e72d9aebacf5b2492e0c4ee= 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 spotconvertToAssets()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 reducestotalAssets(). With the sametotalSupply,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
- Attacker EOA (
0x33a0aab2) calls the attacker contract (0xd8e8544e) with attack parameters. - Attacker contract takes a flash loan of 10,000,000 USDC from Morpho Blue (
0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb) viaflashLoan(address,uint256,bytes)[selector0xe0232b42]. - Inside the USDC callback (
onMorphoFlashLoan[0x31f57072]), attacker takes a second flash loan of ~15,986 WETH from Morpho Blue. - Inside the WETH callback, attacker swaps USDC to DOLA via Curve pools, then exchanges DOLA for sDOLA and scrvUSD.
- Attacker creates a loan on the WETH market Controller (
0xa920de414ea4ab66b97da1bfe9e6eca7d4219635, crvUSD Controller) depositing 15,986 WETH to borrow 25M crvUSD. - Attacker deposits crvUSD into the scrvUSD vault and exchanges scrvUSD/sDOLA into the sDOLA/crvUSD LlamaLend AMM (
0x0079885e) to create positions. - Oracle manipulation: Attacker redeems ~10.6M sDOLA via
sDOLA.redeem(), which unstakes ~12.6M DOLA from DolaSavings, crashing theconvertToAssets()rate. - Attacker re-stakes a small amount (190,777 DOLA) into DolaSavings to partially restore the rate but keep it depressed enough for liquidations.
- Attacker deploys a helper contract via CREATE2 (
0xc6c2fcdf) and passes it crvUSD and the LlamaLend Controller address. The helper first queriesusers_to_liquidate()and then callsliquidate(user, 0)27 times on borrower addresses in the sDOLA/crvUSD market (Controller at0xad444663), collecting crvUSD and sDOLA collateral. - 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. - Attacker calls
repay(uint256)on the WETH market Controller (0xa920de41) with a 50M crvUSD parameter; the function repays only outstanding debt viamin(debt, _d_debt)and returns ~15,986 WETH collateral. - 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 selector0xe0232b42=flashLoan(address,uint256,bytes)and callback0x31f57072=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, AMM0x0079885e) 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 Controller0xad444663changed 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).
Related URLs
- Etherscan TX: https://etherscan.io/tx/0xb93506af8f1a39f6a31e2d34f5f6a262c2799fef6e338640f42ab8737ed3d8a4
- sDOLA Token: https://etherscan.io/address/0xb45ad160634c528cc3d2926d9807104fa3157305
- CryptoFromPoolVaultWAgg Oracle: https://etherscan.io/address/0x88822ee517bfe9a1b97bf200b0b6d3f356488ff2
- LlamaLend AMM (sDOLA/crvUSD): https://etherscan.io/address/0x0079885e248b572cdc4559a8b156745e2d8ea1f7
- LlamaLend Controller (sDOLA/crvUSD): https://etherscan.io/address/0xad444663c6c92b497225c6ce65fee2e7f78bfb86
- Attacker Contract: https://etherscan.io/address/0xd8e8544e0c808641b9b89dfb285b5655bd5b6982
- Attacker EOA: https://etherscan.io/address/0x33a0aab2642c78729873786e5903cc30f9a94be2