Calculated from recorded token losses using historical USD prices at the incident time.
0x8accf43dd31dfcd4919cc7d65912a475bfa60369Arbitrum0x0032f5e1520a66c6e572e96a11fbf54aea26f9beArbitrum0x9e058177a854336d962d5ae576e9a46cfd8374d7Arbitrum0x72f7101371201cefd43af026eef1403652f115eeArbitrumOn Arbitrum, an unprivileged attacker conditioned Rodeo Finance's unshETH collateral oracle by trading around two public OracleTWAP.update() keeper calls, then opened a leveraged borrow that Rodeo accepted at the manipulated mark. The terminal exploit transaction 0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a removed 400000000000 USDC units from Rodeo pool 0x0032f5e1520a66c6e572e96a11fbf54aea26f9be while the protocol still reported the position as healthy.
The root cause is that Rodeo valued unshETH collateral through a reserve-based spot oracle wrapped by a four-sample snapshot average, not a manipulation-resistant TWAP. The concrete breakpoint is the path OracleUniswapV2Eth.latestAnswer() -> OracleTWAP.latestAnswer() -> StrategyHelper.value() -> StrategyLong._rate() -> InvestorActor._life(), which let attacker-shaped Camelot reserves become accepted collateral value.
Rodeo's borrow acceptance depends on strategy share value, and that strategy share value depends directly on oracle output for the underlying collateral token. The relevant victim-side code is:
function price(address ast) public view returns (uint256) {
IOracle oracle = IOracle(oracles[ast]);
if (address(oracle) == address(0)) revert UnknownOracle();
return uint256(oracle.latestAnswer()) * 1e18 / (10 ** oracle.decimals());
}
function value(address ast, uint256 amt) public view returns (uint256) {
return amt * price(ast) / (10 ** IERC20(ast).decimals());
}
That snippet shows StrategyHelper converting oracle output into collateral value. StrategyLong then uses that value for the whole inventory:
0x32c5257ae03c2bbf2736b9f73f58bb15c769144a063d4f9fe27878483cbae6c50x9a462209e573962f2654cac9bfe1277abe443cf5d1322ffd645925281fe65a2e0x4b13c9347b32acce386309003df81120ac6f9716b1715441011c756e046fbc6a0x219d4c9c4ea4aa352647bb6a8d815b3bcbdcee3b360a6237aeede1454edb6c930xefaf90ac027b432f59386daac67bb7284e76ac7a3f6ffcf24e68b808d59cd9d30x39f8e47e363aeb654ee9bb31ec820f1851551c39e33dcecb08de9030fb2ca3b80xaef2104804cf0393a6eb78dcc188722356c4a769c822acaecd292429eebd62520x6171e54917c74935ed071c74f30cabc4a28fac00aa749c9e2067729e2a8215020x1d06f766c3e9bd4b892347dee91697ebb2fd286d2af7b49f378f36f2f8a47bfc0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25afunction _rate(uint256 sha) internal view override returns (uint256) {
uint256 val = strategyHelper.value(address(token), token.balanceOf(address(this)));
return sha * val / totalShares;
}
InvestorActor turns that manipulated strategy rate into the health check gating borrow acceptance:
function _life(uint256 id, uint256 sha, uint256 bor) internal view returns (uint256) {
(, address pol, uint256 str,,,,) = investor.positions(id);
IPool pool = IPool(pol);
if (bor == 0) return 1e18;
uint256 value = (IStrategy(investor.strategies(str)).rate(sha) * pool.liquidationFactor()) / 1e18;
uint256 borrow = _borrowValue(pool, bor);
return value * 1e18 / borrow;
}
The collected supporting facts show that unshETH was configured to use OracleTWAP at 0xf3721d8a2c051643e06bf2646762522fa66100da, whose underlying source was OracleUniswapV2Eth at 0x2d9985e57591def5ef939542e28169c8ea5d05a2 for Camelot pair 0x29fc01f04032c76ca40f353c7df685f4444c15ed.
The vulnerability is an attack-class oracle manipulation issue, not a privileged-only flow. OracleUniswapV2Eth derives price from live Camelot reserves, and OracleTWAP merely stores four discrete snapshots and averages them. That design means a public attacker can buy before a keeper update, let the manipulated reserve ratio be recorded, and unwind immediately after the update while the inflated mark remains inside the four-sample average. Rodeo then consumes that average as if it were realizable collateral value. The protocol does not enforce a manipulation-resistant oracle source, an unwind-realizability check, or any other control that would reject a borrow backed by attacker-shaped marks. Because the keeper updates were public and the trading venue was permissionless, the sequence was anyone-can-take.
The attack started from Arbitrum block 110025246, before the first keeper snapshot used by the exploit. The attacker cluster consisted of EOA 0x2f3788f2396127061c46fc07bd0fcb91faace328 and helper contract 0xe9544ee39821f72c4fc87a5588522230e340aa54, which the EOA had deployed earlier in transaction 0x3eace33baf649103e091a49a9aa3c2f06747728be3665785d06efe90ef531d86.
The first manipulation cycle was:
0x5f16637460021994d40430dadc020fffdb96937cfaf2b8cb6cbc03c91980ac7c: the helper bought 46.382820245732301534 unshETH for 49.961938783663539467 aeWETH.0x4164e14add41403d8cc02b68a5a272ba976748791ae35425d0b810189e55588c: public keeper 0x3b1f14068fa2af4b08b578e80834bc031a52363d called OracleTWAP.update().0x32c5257ae03c2bbf2736b9f73f58bb15c769144a063d4f9fe27878483cbae6c5: the helper sold the same unshETH back for 49.923921758587463120 aeWETH.The second cycle repeated the same pattern:
0x9a462209e573962f2654cac9bfe1277abe443cf5d1322ffd645925281fe65a2e: the helper bought 46.348904090322488851 unshETH for 49.923921758587463120 aeWETH.0x4b13c9347b32acce386309003df81120ac6f9716b1715441011c756e046fbc6a: the same public keeper called OracleTWAP.update() again.0x219d4c9c4ea4aa352647bb6a8d815b3bcbdcee3b360a6237aeede1454edb6c93: the helper sold the second inventory back for 48.234158715323887220 aeWETH.The collected oracle state at exploit block 110043453 shows why the later borrow passed:
{
"last_timestamp": "1689058862",
"prices": [
"2639236641922531347490",
"4973350465837606916071",
"4994765044571862817590",
"4269731359394042641877"
],
"latest_answer": "4219270877931510930757"
}
That is an averaged mark above $4,000 per unshETH, derived from attacker-shaped reserve snapshots. The attacker then staged additional unshETH inventory into the helper through mint transactions 0x39f8e47e363aeb654ee9bb31ec820f1851551c39e33dcecb08de9030fb2ca3b8 and 0xaef2104804cf0393a6eb78dcc188722356c4a769c822acaecd292429eebd6252, followed by transfer transactions 0x6171e54917c74935ed071c74f30cabc4a28fac00aa749c9e2067729e2a821502 and 0x1d06f766c3e9bd4b892347dee91697ebb2fd286d2af7b49f378f36f2f8a47bfc. The helper entered the terminal transaction with exactly 47294222088336002957 unshETH.
The exploit transaction 0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a then executed the actual Rodeo borrow. The collected execution trace shows:
Investor::earn(0xE9544Ee39821F72c4fc87A5588522230e340aa54, 0x0032F5E1520a66C6E572e96A11fBF54aea26f9bE, 41, 0, 400000000000, ...)
emit Borrow(param0: 0x6ff76f4794e4A6122734A11d680dBb351533Ae95, param1: 400000000000, param2: 382349705467)
OracleTWAP::latestAnswer() [staticcall]
The corresponding balance diff confirms the result:
{
"pool_usdc_delta": "-400000000000",
"strategy_unsheth_minted": "105710624227042722164",
"helper_ae_weth_delta": "144219004344154985727",
"helper_unsheth_delta": "-47294222088336002957"
}
The safety invariant that should have held is: a new borrow must only be accepted when the collateral value used by InvestorActor._life() reflects a manipulation-resistant and realizable value. Rodeo violated that invariant because it accepted a borrow using an averaged reserve mark that the attacker could cheaply shape around public update calls.
The adversary flow is deterministic and end-to-end complete:
OracleTWAP.Investor.earn(...) to create the leveraged position and borrow 400000000000 USDC from the pool.144.219004344154985727 aeWETH in the helper while the pool loses 400000000000 USDC and the strategy receives 105.710624227042722164 unshETH under the manipulated valuation.This sequence is ACT because each action is public and permissionless: Camelot trading, observing or bracketing keeper calls, and opening a Rodeo position through Investor.earn(...).
The measured protocol loss is 400000000000 raw USDC units, or 400,000 USDC at 6 decimals, removed from Rodeo pool 0x0032f5e1520a66c6e572e96a11fbf54aea26f9be. The exploit transaction also left the helper with 144.219004344154985727 aeWETH. The immediate victimized components were the Rodeo USDC pool, the Rodeo investor flow that accepted the borrow, and the strategy valuation path that marked manipulated unshETH inventory as healthy collateral.
0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a0x5f16637460021994d40430dadc020fffdb96937cfaf2b8cb6cbc03c91980ac7c, 0x4164e14add41403d8cc02b68a5a272ba976748791ae35425d0b810189e55588c, 0x32c5257ae03c2bbf2736b9f73f58bb15c769144a063d4f9fe27878483cbae6c5, 0x9a462209e573962f2654cac9bfe1277abe443cf5d1322ffd645925281fe65a2e, 0x4b13c9347b32acce386309003df81120ac6f9716b1715441011c756e046fbc6a, 0x219d4c9c4ea4aa352647bb6a8d815b3bcbdcee3b360a6237aeede1454edb6c93/workspace/session/artifacts/auditor/iter_3/supporting_facts.json/workspace/session/artifacts/collector/seed/42161/0xb1be5dee3852c818af742f5dd44def285b497ffc5c2eda0d893af542a09fb25a/balance_diff.jsonStrategyHelper.sol, StrategyLong.sol, InvestorActor.sol in the collected contract sources