Aave V3 Fork Oracle Misconfiguration: USDC Priced as BTC
On February 26, 2026, an attacker exploited a misconfigured Aave V3 fork lending pool on Ethereum mainnet (block 24,538,897). The root cause was a deployment-time oracle misconfiguration in the AaveOracle contract at 0x9dce7a180c34203fee8ce8ca62f244feeb67bd30, where the constructor arguments contained an off-by-one index error that mapped the USDC price feed to the Chainlink BTC/USD aggregator instead of the correct USDC/USD aggregator. This caused the protocol to value 8.879192 USDC as if it were worth 8.879192 BTC ($608,705 at the BTC/USD price of $68,554 at block 24,538,897), allowing the attacker to borrow 187.366746 WETH ($389,811 at $2,080.47/ETH) against effectively zero collateral. The attacker profited approximately 181.75 ETH after fees and flash swap costs.
Root Cause
Vulnerable Contract
- Contract:
AaveOracle(Aave V3 fork) - Address:
0x9dce7a180c34203fee8ce8ca62f244feeb67bd30 - Proxy status: Not a proxy (standalone contract)
- Source type: Verified (Etherscan)
Vulnerable Function
- Function:
getAssetPrice(address asset) - Selector:
0xb3596f07 - File:
@aave/core-v3/contracts/misc/AaveOracle.sol
The getAssetPrice function itself is correct code from the canonical Aave V3 codebase. The vulnerability is in the state of the contract: the assetsSources mapping was misconfigured such that assetsSources[USDC] pointed to the Chainlink BTC/USD aggregator instead of the USDC/USD aggregator.
Vulnerable Code
// From AaveOracle.sol (verified source)
function getAssetPrice(address asset) public view override returns (uint256) {
AggregatorInterface source = assetsSources[asset]; // <-- VULNERABILITY: reads misconfigured mapping
if (asset == BASE_CURRENCY) {
return BASE_CURRENCY_UNIT;
} else if (address(source) == address(0)) {
return _fallbackOracle.getAssetPrice(asset);
} else {
int256 price = source.latestAnswer(); // <-- Returns BTC/USD price (~$68,554 at block 24,538,897) when queried for USDC
if (price > 0) {
return uint256(price);
} else {
return _fallbackOracle.getAssetPrice(asset);
}
}
}
The assetsSources mapping is set via setAssetSources(), which is guarded by onlyAssetListingOrPoolAdmins:
function setAssetSources(
address[] calldata assets,
address[] calldata sources
) external override onlyAssetListingOrPoolAdmins {
_setAssetsSources(assets, sources);
}
function _setAssetsSources(address[] memory assets, address[] memory sources) internal {
require(assets.length == sources.length, Errors.INCONSISTENT_PARAMS_LENGTH);
for (uint256 i = 0; i < assets.length; i++) {
assetsSources[assets[i]] = AggregatorInterface(sources[i]); // <-- VULNERABILITY: no validation of source
emit AssetSourceUpdated(assets[i], sources[i]);
}
}
Why It’s Vulnerable
Expected behavior: getAssetPrice(USDC) should return the USDC/USD price from Chainlink (approximately 1e8, representing $1.00 in 8-decimal base currency units). The assetsSources[USDC] mapping should point to the Chainlink USDC/USD aggregator at 0x8fffffd4afb6115b954bd326cbe7b4ba576818f6.
Actual behavior: At the time of the exploit, assetsSources[USDC] pointed to the Chainlink BTC/USD aggregator at 0xf4030086522a5beea4988f8ca5b36dbc97bee88c, which returned 6,855,405,329,514 (representing ~$68,554 in 8-decimal base currency units at block 24,538,897). This is confirmed by the trace: when getAssetPrice(0xa0b86991c...USDC) is called, the oracle at 0x9dce7a18 makes a STATICCALL to the BTC/USD Chainlink proxy 0xf4030086 (selector 0x50d25bcd = latestAnswer()), not the USDC/USD feed.
Constructor arguments reveal a deployment-time misconfiguration: Decoding the constructor arguments from info.json reveals that the oracle was misconfigured from the moment of deployment, not via a later admin call. The constructor sources array has an off-by-one shift error: USDC (assets[2]) was mapped to the BTC/USD aggregator (sources[2] = 0xf4030086), USDT (assets[3]) was mapped to the USDC/USD aggregator (sources[3] = 0x8fffffd4), and USDe (assets[4]) was mapped to the USDT/USD aggregator (sources[4] = 0x3e7d1eab). WBTC (assets[1]) received 0x0 (no oracle). This was a deployment script bug — the correct oracle addresses were present but associated with the wrong asset addresses due to an index offset error.
Note: The prestate trace (
trace_prestateTracer.json) confirms the AaveOracle’s storage was not modified during this transaction (the contract does not appear in theprediff section), confirming the misconfiguration pre-existed the exploit transaction.
Impact of the misconfiguration: The GenericLogic.calculateUserAccountData() function computes collateral value as scaledBalance * normalizedIncome * assetPrice / assetUnit. With the BTC/USD price (~68,554x the correct USDC/USD price at block 24,538,897), the attacker’s 8.879192 USDC deposit was valued at approximately $608,705 instead of $8.88 – an inflation factor of ~68,554x. This passed the health factor check in ValidationLogic.validateBorrow() and allowed borrowing 187.366746 WETH.
Normal flow vs Attack flow:
- Normal flow: User deposits 8.879 USDC. Oracle returns $1/USDC. Collateral = $8.88. Maximum borrow at 80% LTV = ~$7.10 worth of assets.
- Attack flow: Attacker deposits 8.879 USDC. Oracle returns ~$68,554/USDC (BTC/USD price at block 24,538,897). Collateral = ~$608,705. Maximum borrow =
$487,000 worth of assets. Attacker borrows 187.366746 WETH ($389,811 at $2,080.47/ETH) and walks away.
Attack Execution
High-Level Flow
- Attacker EOA (
0x3885...) calls attacker contract (0x3e47...) using a disguisedapprove()selector with extra calldata encoding the full attack plan. - Attacker contract initiates a Uniswap V2 flash swap of 8.88 USDC from the USDC/WETH pair (
0xb4e1...). - Inside the
uniswapV2Callcallback, the attacker contract approves the LendingPool to spend the 8.88 USDC. - Attacker contract calls
LendingPool.deposit(USDC, 8879192, self, 0)to deposit 8.88 USDC as collateral. - The deposit succeeds and the oracle misconfiguration values the 8.879192 USDC as ~$608,705 worth of collateral (BTC/USD price of ~$68,554 at block 24,538,897).
- Attacker contract calls
LendingPool.borrow(WETH, 187.37e18, 2, 0, self)to borrow 187.37 WETH. - The borrow passes the health factor check because the collateral is massively overvalued due to the oracle returning the BTC price for USDC.
- Attacker contract unwraps the borrowed WETH to ETH, repays ~0.0043 WETH to the Uniswap V2 pair as flash swap fee, sends ~5.61 ETH to the block builder, and transfers ~181.75 ETH profit to the attacker EOA.
Detailed Call Trace
depth 0: EOA 0x3885... -> AttackerContract 0x3e47... [CALL] approve(address,uint256) 0x095ea7b3 (804 bytes input)
depth 1: AttackerContract -> UniV2Pair 0xb4e1... [STATICCALL] token1() 0xd21220a7
depth 1: AttackerContract -> UniV2Pair 0xb4e1... [CALL] swap(uint256,uint256,address,bytes) 0x022c0d9f
depth 2: UniV2Pair -> USDC 0xa0b8... [CALL] transfer(address,uint256) 0xa9059cbb // 8,879,192 USDC to attacker
depth 3: USDC -> USDCImpl 0x4350... [DELEGATECALL] transfer
depth 2: UniV2Pair -> AttackerContract [CALL] uniswapV2Call(address,uint256,uint256,bytes) 0x10d1e85c
depth 3: AttackerContract -> UniV2Pair [STATICCALL] getReserves() 0x0902f1ac
depth 3: AttackerContract -> USDC [STATICCALL] balanceOf(address) 0x70a08231
depth 3: AttackerContract -> USDC [CALL] approve(LendingPool, 0) 0x095ea7b3
depth 3: AttackerContract -> USDC [CALL] approve(LendingPool, 8879192) 0x095ea7b3
depth 3: AttackerContract -> LendingPool 0x7398... [CALL] deposit(USDC, 8879192, self, 0) 0xe8eda9df
depth 4: LendingPool -> PoolLogic_Main 0xb86a... [DELEGATECALL] deposit 0xe8eda9df
depth 5: LendingPool -> PoolLogic_Validation 0x8ce6... [DELEGATECALL] 0x1913f161
depth 6: LendingPool -> StableDebtUSDC [STATICCALL] scaledTotalSupply
depth 6: LendingPool -> OracleProxy_USDC [STATICCALL] getSupplyData
depth 6: LendingPool -> aUSDC [STATICCALL] scaledTotalSupply
depth 6: LendingPool -> InterestRateStrategy_USDC [STATICCALL] calculateInterestRates
depth 5: LendingPool -> USDC [CALL] transferFrom(attacker, aUSDC, 8879192)
depth 5: LendingPool -> aUSDC [CALL] mint(attacker, attacker, 8879192, index)
depth 6: aUSDC -> ATokenImpl [DELEGATECALL] mint
depth 7: aUSDC -> IncentivesController [CALL] handleAction
depth 3: AttackerContract -> LendingPool [CALL] borrow(WETH, 187.37e18, 2, 0, self) 0xa415bcad
depth 4: LendingPool -> PoolLogic_Main 0xb86a... [DELEGATECALL] borrow 0xa415bcad
depth 5: LendingPool -> PoolAddressesProvider [STATICCALL] getPriceOracle
depth 5: LendingPool -> PoolAddressesProvider [STATICCALL] getPriceOracleSentinel
depth 5: LendingPool -> PoolLogic_UserAccountData 0x07b5... [DELEGATECALL] 0x1e6473f9 // calculateUserAccountData
depth 6: LendingPool -> VariableDebtWETH [STATICCALL] scaledTotalSupply
depth 6: LendingPool -> OracleProxy_WETH [STATICCALL] getSupplyData
depth 6: LendingPool -> PriceOracle 0x9dce... [STATICCALL] getAssetPrice(USDC) 0xb3596f07
depth 7: PriceOracle -> Chainlink BTC/USD 0xf403... [STATICCALL] latestAnswer // WRONG FEED!
depth 8: BTC/USD Proxy -> BTC/USD Impl [STATICCALL] latestAnswer
depth 6: LendingPool -> aUSDC [STATICCALL] scaledBalanceOf(attacker)
depth 6: LendingPool -> PriceOracle [STATICCALL] getAssetPrice(WETH) 0xb3596f07
depth 7: PriceOracle -> Chainlink ETH/USD 0x5f4e... [STATICCALL] latestAnswer // Correct
depth 8: ETH/USD Proxy -> ETH/USD Impl [STATICCALL] latestAnswer
depth 5: LendingPool -> VariableDebtWETH [CALL] mint(attacker, attacker, 187.37e18, index)
depth 6: VariableDebtWETH -> DebtTokenImpl [DELEGATECALL] mint
depth 7: VariableDebtWETH -> IncentivesController [CALL] handleAction
depth 5: LendingPool -> InterestRateStrategy_WETH [STATICCALL] calculateInterestRates
depth 5: LendingPool -> aWETH [CALL] transferUnderlyingTo(attacker, 187.37e18) 0x4efecaa5
depth 6: aWETH -> ATokenImpl [DELEGATECALL] transferUnderlyingTo
depth 7: aWETH -> WETH [CALL] transfer(attacker, 187.37e18)
depth 3: AttackerContract -> UniV2Pair [STATICCALL] token1 // get WETH address
depth 3: AttackerContract -> WETH [CALL] transfer(UniV2Pair, 0.004289 WETH) // repay flash swap
depth 2: UniV2Pair -> USDC [STATICCALL] balanceOf(self) // verify K invariant
depth 2: UniV2Pair -> WETH [STATICCALL] balanceOf(self)
depth 1: AttackerContract -> WETH [STATICCALL] balanceOf(self)
depth 1: AttackerContract -> WETH [CALL] withdraw(187.36e18) 0x2e1a7d4d // unwrap WETH to ETH
depth 2: WETH -> AttackerContract [CALL] (ETH transfer, 187.36 ETH)
depth 1: AttackerContract -> FeeRecipient 0x4838... [CALL] (ETH transfer, 5.61 ETH) // builder tip
depth 1: AttackerContract -> AttackerEOA 0x3885... [CALL] (ETH transfer, 181.75 ETH) // profit
Financial Impact
- Total loss: 187.366746 WETH drained from the aWETH pool (
0xd060ebd4f56be8866376a3616b6e5aef87f945d2), representing the lending pool’s WETH reserves (~$389,811 at $2,080.47/ETH at block 24,538,897). - Attacker profit: 181.749547 ETH to the attacker EOA (
0x3885869b0f4526806b468a0c64a89bb860a18cee), plus 5.612910 ETH paid as a builder tip to0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97. - Attacker costs: ~0.004289 WETH Uniswap V2 flash swap repayment + gas costs. Total costs negligible.
- Who lost funds: WETH liquidity providers in the Aave V3 fork pool. The attacker’s debt position of 187.366746 variableDebtWETH is backed by only 8.879192 USDC of actual collateral, making it effectively uncollectable.
- Protocol solvency: The pool lost 187.366746 WETH of liquidity. The bad debt (187.366746 WETH - 8.879192 USDC worth) is irrecoverable unless the protocol has a backstop fund. The pool is likely insolvent for WETH withdrawals.
- Source of attacker_gains (from
funds_flow.json): Attacker gained 181.749547183310220532 ETH net (authoritative figure from on-chain logs).
Evidence
Oracle misconfiguration (trace indices 40-42):
- Index 40:
LendingPool -> PriceOracle.getAssetPrice(0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48)[USDC address] - Index 41:
PriceOracle -> 0xf4030086522a5beea4988f8ca5b36dbc97bee88c.latestAnswer()[Chainlink BTC/USD proxy – WRONG] - Index 42:
0xf4030086 -> 0x4a3411ac2948b33c69666b35cc6d055b27ea84f1.latestAnswer()[BTC/USD aggregator implementation]
Correct WETH price feed (trace indices 45-47):
- Index 45:
LendingPool -> PriceOracle.getAssetPrice(0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2)[WETH address] - Index 46:
PriceOracle -> 0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419.latestAnswer()[Chainlink ETH/USD proxy – CORRECT]
Constructor arguments from info.json reveal the AaveOracle was deployed with a pre-existing off-by-one error in the sources array: USDC (assets[2]) was already mapped to BTC/USD (sources[2] = 0xf4030086), not USDC/USD. The USDC/USD aggregator (0x8fffffd4afb6115b954bd326cbe7b4ba576818f6) was mistakenly mapped to USDT (assets[3]), and the USDT/USD aggregator was mapped to USDe (assets[4]). The prestate trace confirms no setAssetSources() was called during the exploit transaction — the misconfiguration was a deployment-time bug.
Note: The oracle’s
latestAnswer()for USDC (via the BTC/USD feed) returned 6,855,405,329,514 at block 24,538,897, corresponding to a BTC/USD price of ~$68,554 (not ~$86,000 as initially estimated). Actual ETH/USD price was ~$2,080 (not ~$2,700).
Key log events:
- Log index 0: USDC Transfer 8,879,192 from UniV2Pair to AttackerContract (flash swap)
- Log index 5: aUSDC mint 8,879,192 to AttackerContract (collateral deposit)
- Log index 9: variableDebtWETH mint 187,366,746,326,704,993,556 to AttackerContract (borrow)
- Log index 12: WETH Transfer 187.37e18 from aWETH to AttackerContract (borrow disbursement)
Receipt status: 0x1 (success)
Block miner/builder: 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97 (Titan Builder), same address that received the 5.61 ETH tip from the attacker.