Rodeo Oracle Shortfall
Exploit Transactions
0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25aVictim Addresses
0x0032f5e1520a66c6e572e96a11fbf54aea26f9beArbitrum0x8accf43dd31dfcd4919cc7d65912a475bfa60369Arbitrum0x9e058177a854336d962d5ae576e9a46cfd8374d7Arbitrum0xf3721d8a2c051643e06bf2646762522fa66100daArbitrumLoss Breakdown
Similar Incidents
Themis Oracle Manipulation
37%Sentiment Balancer Oracle Overborrow
34%dForce Oracle Reentrancy Liquidation
33%DEI burnFrom Allowance Inversion
30%Paribus Redeem Reentrancy
28%Sturdy LP Oracle Manipulation
25%Root Cause Analysis
Rodeo Oracle Shortfall
1. Incident Overview TL;DR
At Arbitrum block 110043453, the attacker used Rodeo Finance's unshETH long strategy to borrow 400,000 USDC from Rodeo's USDC pool and open position 3193, even though the position was already underwater on the protocol's real liquidation route. Rodeo still considered the position healthy because its solvency check trusted an authorized TWAP over a manipulable Camelot reserve ratio instead of executable unwind value.
The decisive post-state is deterministic. Position 3193 carried 382349705467 borrow shares, which equals 399999999999 raw USDC debt at pool index 1046162699436054842, while the configured unwind route unshETH -> WETH -> USDC returned only 200460943117 raw USDC for the position collateral. That left Rodeo with an immediate shortfall of 199539056882 raw USDC while InvestorActor.life(3193) still returned 1059380758710470169.
2. Key Background
Rodeo strategy 41 is a StrategyLong vault over unshETH at 0x9e058177a854336d962d5ae576e9a46cfd8374d7. The strategy does not value collateral from an executable sell quote. Instead, it asks StrategyHelper at 0x72f7101371201cefd43af026eef1403652f115ee for a marked value, and Rodeo's InvestorActor health check uses that marked value to decide whether borrowing is safe.
The price source for unshETH was OracleTWAP at 0xf3721d8a2c051643e06bf2646762522fa66100da. That TWAP stores four samples and averages them, but each sample comes from OracleUniswapV2Eth at 0x2d9985e57591def5ef939542e28169c8ea5d05a2, which computes price directly from the Camelot pair reserves at 0x29fc01f04032c76ca40f353c7df685f4444c15ed. Any trader can skew those reserves by swapping against the pool, and the authorized TWAP updater can then record the skewed spot into Rodeo's trusted collateral price.
By the pre-state sigma_B at block 110043452, the attacker cluster already held 47.294222088336002957 unshETH in contract 0xe9544ee39821f72c4fc87a5588522230e340aa54, and the TWAP still valued unshETH at 4219.270877931510930757 USD. That valuation was far above executable unwind value on Camelot.
3. Vulnerability Analysis & Root Cause Summary
This incident is an ATTACK case and an ACT opportunity. The bug is not missing access control on the borrow path; it is that Rodeo's solvency logic marked collateral with an authorized but manipulable TWAP instead of a manipulation-resistant liquidation price. InvestorActor._life() multiplies StrategyLong.rate() by the pool liquidation factor, and StrategyLong._rate() delegates valuation to StrategyHelper.value(). StrategyHelper.price() then trusts OracleTWAP.latestAnswer(), while OracleUniswapV2Eth.latestAnswer() derives the underlying price from raw Camelot reserves. Because Rodeo borrows first and never re-checks collateral against an executable unwind quote, an attacker can borrow USDC against collateral that is already worth far less on the actual exit path. The invariant that should hold is simple: if life(id) >= 1e18, then the collateral should be liquidatable for at least the borrow value after liquidation-factor haircuts. Position 3193 violated that invariant immediately at creation.
4. Detailed Root Cause Analysis
The victim-side pricing chain is visible directly in code:
// InvestorActor.sol
function _life(uint256 id, uint256 sha, uint256 bor) internal view returns (uint256) {
IPool pool = IPool(pol);
uint256 value = (IStrategy(investor.strategies(str)).rate(sha) * pool.liquidationFactor()) / 1e18;
uint256 borrow = _borrowValue(pool, bor);
return value * 1e18 / borrow;
}
function _checkLife(uint256 id, int256 sha, int256 bor) internal view {
if (_life(id, uint256(int256(psha)+sha), uint256(int256(pbor)+bor)) < 1e18) {
revert Undercollateralized();
}
}
// StrategyLong.sol + StrategyHelper.sol + OracleTWAP.sol + OracleUniswapV2Eth.sol
function _rate(uint256 sha) internal view override returns (uint256) {
uint256 val = strategyHelper.value(address(token), token.balanceOf(address(this)));
return sha * val / totalShares;
}
function price(address ast) public view returns (uint256) {
return uint256(oracle.latestAnswer()) * 1e18 / (10 ** oracle.decimals());
}
function latestAnswer() external view returns (int256) {
int256 price = (prices[0] + prices[1] + prices[2] + prices[3]) / 4;
return price;
}
function latestAnswer() external view returns (int256) {
(uint256 reserve0, uint256 reserve1,) = pair.getReserves();
uint256 price = token0 == weth ? reserve0 * 1e18 / reserve1 : reserve1 * 1e18 / reserve0;
return int256(price) * ethOracle.latestAnswer() / int256(10 ** ethOracle.decimals());
}
Pool.borrow() then finalizes the bad position by transferring USDC out once the borrow request is accepted:
function borrow(uint256 amt) external live auth returns (uint256) {
if (asset.balanceOf(address(this)) < amt) revert UtilizationTooHigh();
uint256 bor = amt * 1e18 / index;
totalBorrow += bor;
push(asset, msg.sender, amt);
return bor;
}
The exploit path is reflected in the trace of tx 0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a:
0x0032F5E1520a66C6E572e96A11fBF54aea26f9bE::borrow(400000000000)
emit Borrow(..., 400000000000, 382349705467)
StrategyLong::rate(105710624227042722164) -> 446021758289122595426003
OracleTWAP::latestAnswer() -> 4219270877931510930757
InvestorActor::life(3193) -> 1059380758710470169
Those values match direct RPC checks at the exploit block. The four stored TWAP samples at block 110043452 were:
2639236641922531347490
4973350465837606916071
4994765044571862817590
4269731359394042641877
Their average is 4219270877931510930757, the exact price Rodeo trusted. The same position collateral, however, only quoted to 200460943117 raw USDC on the configured unshETH -> WETH -> USDC route at block 110043453. Against 399999999999 raw USDC debt, Rodeo therefore created an immediate pool deficit of 199539056882 raw USDC.
The side-profit calculation is also deterministic. Before the exploit tx, the attacker contract held 47.294222088336002957 unshETH, which quoted to 47.326962568178199590 WETH at block 110043452. After execution, the attacker contract held 144.219004344154985727 WETH and no unshETH or USDC. The sender EOA paid 1640222 * 100000000 = 0.000164022200000000 ETH in gas, so the cluster's immediately realizable gain was 96.891877753776786137 ETH. That side calculation is secondary to the main ACT predicate, which is the deterministic Rodeo pool shortfall.
5. Adversary Flow Analysis
The attacker flow has three stages:
- Inventory accumulation and reserve skew. In tx
0x9a462209e573962f2654cac9bfe1277abe443cf5d1322ffd645925281fe65a2eat block110032565, the attacker contract swapped49.923921758587463120WETH for46.348904090322488851unshETH on Camelot, pushing the pool reserve ratio upward. - Oracle snapshot capture. In tx
0x4b13c9347b32acce386309003df81120ac6f9716b1715441011c756e046fbc6aat block110032812, authorized updater0x3b1f14068fa2af4b08b578e80834bc031a52363dcalledOracleTWAP.update(), storing the manipulated sample4269.731359394042641877USD into Rodeo's four-slot average. - Undercollateralized borrow and monetization. In tx
0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a, the attacker opened position3193, borrowed400,000USDC from the Rodeo pool, bought105.710624227042722164unshETH intoStrategyLong, then sold the pre-held47.294222088336002957unshETH and arbitraged the resulting WETH/USDC dislocation. The tx ended with+144.219004344154985727WETH on the attacker contract.
The exploit is ACT because the decisive exploit transaction is a normal Arbitrum type-2 transaction from unprivileged EOA 0x2f3788f2396127061c46fc07bd0fcb91faace328 to attacker contract 0xe9544ee39821f72c4fc87a5588522230e340aa54. No privileged key, governance action, or attacker-only artifact was required.
6. Impact & Losses
The protocol impact is an immediate USDC pool solvency break:
- Rodeo USDC debt created:
399999999999raw USDC - Executable collateral unwind value:
200460943117raw USDC - Immediate pool shortfall:
199539056882raw USDC (199,539.056882USDC)
The attacker-side monetization was also immediate:
- Pre-state executable attacker inventory value:
47.326962568178199590WETH - Post-state attacker balance:
144.219004344154985727WETH - Gas paid by the sender EOA:
0.000164022200000000ETH - Net immediately realizable adversary gain:
96.891877753776786137ETH
Affected public protocol components were the Rodeo USDC pool at 0x0032f5e1520a66c6e572e96a11fbf54aea26f9be, Rodeo Investor at 0x8accf43dd31dfcd4919cc7d65912a475bfa60369, the unshETH StrategyLong at 0x9e058177a854336d962d5ae576e9a46cfd8374d7, and the OracleTWAP at 0xf3721d8a2c051643e06bf2646762522fa66100da.
7. References
- Exploit transaction:
0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a - Reserve-skew transaction:
0x9a462209e573962f2654cac9bfe1277abe443cf5d1322ffd645925281fe65a2e - Oracle update transaction:
0x4b13c9347b32acce386309003df81120ac6f9716b1715441011c756e046fbc6a - Rodeo
InvestorActorsource:/workspace/session/.tmp_sources/0x6ff76f4794e4A6122734A11d680dBb351533Ae95/InvestorActor/src/InvestorActor.sol - Rodeo
StrategyLongsource:/workspace/session/.tmp_sources/0x9E058177A854336D962D5aE576E9A46cFd8374D7/StrategyLong/src/strategies/StrategyLong.sol - Rodeo
StrategyHelpersource:/workspace/session/.tmp_sources/0x72f7101371201CeFd43Af026eEf1403652F115EE/StrategyHelper/src/StrategyHelper.sol - Rodeo
OracleTWAPsource:/workspace/session/.tmp_sources/0xf3721d8A2c051643e06BF2646762522FA66100dA/OracleTWAP/src/oracles/OracleTWAP.sol - Rodeo
OracleUniswapV2Ethsource:/workspace/session/.tmp_sources/oracle_underlying/OracleUniswapV2Eth/src/oracles/OracleUniswapV2Eth.sol - Collector trace for
0xb1be5dee...:/workspace/session/artifacts/collector/seed/42161/0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a/trace.cast.log - Collector balance diff for
0xb1be5dee...:/workspace/session/artifacts/collector/seed/42161/0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a/balance_diff.json