All incidents

Conic ETH Oracle Reentrancy

Share
Jul 21, 2023 10:35 UTCAttackLoss: 1,800.07 WETHPending manual check1 exploit txWindow: Atomic
Estimated Impact
1,800.07 WETH
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Jul 21, 2023 10:35 UTC → Jul 21, 2023 10:35 UTC

Exploit Transactions

TX 1Ethereum
0x8b74995d1d61d3d7547575649136b8765acb22882960f0636941c44ec7bbe146
Jul 21, 2023 10:35 UTCExplorer

Victim Addresses

0xbb787d6243a8d450659e09ea6fd82f1c859691e9Ethereum

Loss Breakdown

1,800.07WETH

Similar Incidents

Root Cause Analysis

Conic ETH Oracle Reentrancy

1. Incident Overview TL;DR

On Ethereum mainnet block 17740955, transaction 0x8b74995d1d61d3d7547575649136b8765acb22882960f0636941c44ec7bbe146 let an unprivileged adversary turn Conic Finance's ETH omnipool into an over-withdrawal target. The sender EOA 0x8d67db0b205e32a5dd96145f022fa18aae7dc8aa drove the attack through contract 0x743599ba5cfa3ce8c59691af5ef279aaafa2e4eb, used public Balancer and Aave flash loans, deposited 8498000000000000000000 wei of WETH into the Conic ETH pool, then withdrew 10298071317678691497281 wei and finished with 1723726595625401118794 wei of net native profit after repayments and gas.

The root cause is Conic's reliance on CurveLPOracleV2::getUSDPrice inside ConicEthPool.withdraw(uint256,uint256) and ConicEthPool.handleDepeggedCurvePool(address). CurveLPOracleV2 prices Curve LP tokens from live balances() and totalSupply() during in-progress Curve liquidity operations, and its balance check only tests get_dy(0, i, 1 unit of coin0). During read-only-reentrant Curve callbacks, those transient reads overstated LP value and let the attacker both zero pool weights through false depeg handling and redeem more WETH than the burned Curve LP justified.

2. Key Background

Conic ETH pool 0xbb787d6243a8d450659e09ea6fd82f1c859691e9 is an omnipool that allocates WETH across three Curve pools: stETH/WETH 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022, cbETH/WETH 0x5FAE7E604FC3e24fd43A72867ceBaC94c65b404A, and rETH/WETH 0x0f3159811670c117c372428D4E69AC32325e4D0F.

  • ConicEthPool.getTotalAndPerPoolUnderlying() values each held Curve LP position through _curveLpToUnderlying, which calls controller.priceOracle().getUSDPrice(curveLpToken_).
  • GenericOracleV2 at 0x286ef89cd2da6728fd2cb3e1d1c5766bcea344b0 routes unsupported LP pricing to CurveLPOracleV2 at 0x7b528b4fd3e9f6b1701817c83f2ccb16496ba03e.
  • CurveLPOracleV2 sums current Curve pool balances and divides by LP totalSupply(), then calls CurvePoolUtils.ensurePoolBalanced, which checks only get_dy(0, i, 1 unit of coin0).
  • ConicEthPool.handleDepeggedCurvePool(address) is permissionless. If the current LP price differs from the cached LP price by more than depegThreshold, it sets that pool's weight to zero.

Snippet origin: Conic ETH pool withdrawal and depeg handling code.

function withdraw(uint256 conicLpAmount, uint256 minUnderlyingReceived)
    public
    override
    returns (uint256)
{
    (
        uint256 totalUnderlying_,
        uint256 allocatedUnderlying_,
        uint256[] memory allocatedPerPool
    ) = getTotalAndPerPoolUnderlying();
    uint256 underlyingToReceive_ = conicLpAmount.mulDown(_exchangeRate(totalUnderlying_));
    if (underlyingBalanceBefore_ < underlyingToReceive_) {
        uint256 underlyingToWithdraw_ = underlyingToReceive_ - underlyingBalanceBefore_;
        _withdrawFromCurve(allocatedUnderlying_, allocatedPerPool, underlyingToWithdraw_);
    }
    ...
}

function handleDepeggedCurvePool(address curvePool_) external override {
    require(isRegisteredCurvePool(curvePool_), "pool is not registered");
    require(weights.get(curvePool_) != 0, "pool weight already 0");
    address lpToken_ = controller.curveRegistryCache().lpToken(curvePool_);
    require(_isDepegged(lpToken_), "pool is not depegged");
    _setWeightToZero(curvePool_);
}

Snippet origin: Conic oracle routing and Curve LP pricing code.

function getUSDPrice(address token) external view virtual returns (uint256) {
    if (chainlinkOracle.isTokenSupported(token)) {
        return chainlinkOracle.getUSDPrice(token);
    }
    if (address(customOracles[token]) != address(0)) {
        return customOracles[token].getUSDPrice(token);
    }
    return curveLpOracle.getUSDPrice(token);
}

function getUSDPrice(address token) external view returns (uint256) {
    ...
    uint256 balance = _getBalance(pool, i);
    value += balance.convertScale(uint8(decimals[i]), 18).mulDown(price);
    ...
    CurvePoolUtils.ensurePoolBalanced(...);
    return value.divDown(IERC20(token).totalSupply());
}

3. Vulnerability Analysis & Root Cause Summary

This was a protocol bug, not a pure MEV unwind. Conic converted external oracle reads directly into both solvency-sensitive withdrawal math and permissionless emergency state transitions. The vulnerable oracle, CurveLPOracleV2, reads raw Curve balances and LP supply during live Curve execution, so the returned LP price is not guaranteed to reflect a settled, realizable state. The downstream Conic code assumes the price is safe for both _underlyingToCurveLp and _curveLpToUnderlying, which means a transiently inflated LP price reduces the LP amount Conic decides to burn for a target WETH withdrawal. The same oracle path also feeds _isDepegged, so the attacker can force handleDepeggedCurvePool(address) while the Curve pool is inside a callback window. The explicit invariant is that LP prices used by Conic must remain stable and realizable during external liquidity operations; the concrete breakpoint is CurveLPOracleV2::getUSDPrice reading transient balances() / totalSupply() and Conic immediately consuming that result in handleDepeggedCurvePool and _underlyingToCurveLp.

4. Detailed Root Cause Analysis

The exploitable call chain is:

ConicEthPool.withdraw
  -> getTotalAndPerPoolUnderlying / _underlyingToCurveLp
  -> controller.priceOracle().getUSDPrice(curveLpToken_)
  -> GenericOracleV2.getUSDPrice
  -> CurveLPOracleV2.getUSDPrice

CurveLPOracleV2 violates the required pricing invariant in two ways. First, it prices LP from live pool balances and LP supply instead of a settled withdrawal quote. Second, CurvePoolUtils.ensurePoolBalanced only checks get_dy(0, i, 1 unit of coin0), which does not bound the actual remove_liquidity_one_coin path Conic later uses to redeem WETH.

Snippet origin: seed trace during the stETH callback window.

└─ ← [Return] 2163275888017846844152 [2.163e21]
├─ 0xBb787d6243a8D450659E09ea6fD82F1C859691e9::handleDepeggedCurvePool(
│    0xDC24316b9AE028F1497c275EB9192a3Ea0f67022
│  )
├─ emit HandledDepeggedCurvePool(
│    0xDC24316b9AE028F1497c275EB9192a3Ea0f67022
│  )

The validated root cause already records the cached LP price as 2033184303435690244360 and the manipulated callback read as 2163275888017846844152, which is above Conic's 3% depeg threshold. The trace confirms that this inflated read is immediately followed by a successful permissionless handleDepeggedCurvePool call on the stETH pool. The same pattern is later repeated for the cbETH pool, and both weights are driven to zero.

Snippet origin: seed trace showing the accounting mismatch on the rETH/WETH withdrawal path.

emit Withdraw(provider: 0x989AEb4d175e16225E39E87d0D97A3360524AD80, value: 466543208091409354292)
Vyper_contract::remove_liquidity_one_coin(
  466543208091409354292,
  0,
  0,
  false,
  0xBb787d6243a8D450659E09ea6fD82F1C859691e9
)
emit Deposit(provider: 0x0f3159811670c117c372428D4E69AC32325e4D0F, value: 982904064388661242457)

This is the decisive accounting breakpoint. Conic decided that burning 466543208091409354292 Curve LP was enough to withdraw the desired WETH amount, yet the subsequent remove_liquidity_one_coin returned 982904064388661242457 wei, or about 2.106780352477196 WETH per LP. That mismatch proves the oracle-derived LP valuation no longer bounded the realizable one-coin withdrawal path. Because Conic trusted the manipulated price in _underlyingToCurveLp, it burned too little LP for too much WETH.

The exploit required only public conditions:

  • Access to public Balancer and Aave flash liquidity.
  • A Conic-held Curve LP token priced through CurveLPOracleV2.
  • The ability to call Conic functions from inside Curve remove_liquidity callback windows.
  • A transient LP price move large enough to exceed depegThreshold and unlock handleDepeggedCurvePool.

The violated security principles are also explicit:

  • Oracle reads used for accounting must not depend on transient external state.
  • Permissionless emergency state transitions must not be keyed off manipulable spot prices.
  • The valuation model used for LP pricing must match the protocol's actual withdrawal path.

5. Adversary Flow Analysis

The adversary cluster consists of EOA 0x8d67db0b205e32a5dd96145f022fa18aae7dc8aa and exploit contract 0x743599ba5cfa3ce8c59691af5ef279aaafa2e4eb. The victim-side components are Conic ETH pool 0xbb787d6243a8d450659e09ea6fd82f1c859691e9, Controller 0x013a3da6591d3427f164862793ab4e388f9b587e, GenericOracleV2 0x286ef89cd2da6728fd2cb3e1d1c5766bcea344b0, and CurveLPOracleV2 0x7b528b4fd3e9f6b1701817c83f2ccb16496ba03e.

  1. Permissionless Flash Funding The exploit contract takes public Balancer flash loans of rETH, cbETH, and WETH, plus Aave V2/V3 flash loans of stETH and cbETH. No privileged signer, allowlist, or private state is required.

  2. Mint Conic ETH LP Using the flash-funded WETH, the attacker deposits into Conic ETH seven times and mints 8478045845540324381061 Conic LP tokens. The validated analysis ties this stage to the seed trace and the Conic deposit path.

  3. Trigger Callback Oracle Misreads The attacker enters Curve liquidity-removal flows, then calls back into Conic while Curve accounting is transient. In the stETH callback window the oracle price spikes enough to zero the stETH pool weight; the same pattern later zeroes the cbETH pool weight.

  4. Redeem Under Manipulated Accounting After the false depeg transitions, the attacker calls ConicEthPool.withdraw twice. The first major withdrawal burns 6623698366188315193522 LP from the relevant Curve position and returns 7131846119079586205266 wei into Conic, which Conic then forwards as part of a 9315167253290030254824 wei WETH withdrawal to the attacker contract. The later rETH-path withdrawal repeats the same accounting flaw on a smaller LP amount and returns 982904064388661242457 wei.

  5. Repay Loans And Realize Profit Once the Conic LP balance is fully redeemed, the attacker repays Balancer and Aave inside the same transaction. The seed balance diff shows the sender EOA's native balance increasing by 1723726595625401118794 wei after all premiums and gas, confirming realized profit rather than an unrealized mark-to-market gain.

6. Impact & Losses

The directly measured protocol loss is 1800071317678691497281 wei of WETH from the Conic ETH omnipool. That amount is encoded in the validated loss summary as raw on-chain units with decimal = 18.

The exploit had three concrete impacts:

  • Conic ETH paid out more WETH than the attacker deposited.
  • The stETH and cbETH Curve pool weights were permissionlessly set to zero under false depeg conditions.
  • The attacker sender EOA realized 1723726595625401118794 wei of net native profit after flash-loan repayment and gas.

7. References

  • Seed transaction: 0x8b74995d1d61d3d7547575649136b8765acb22882960f0636941c44ec7bbe146 at block 17740955.
  • Seed transaction metadata: /workspace/session/artifacts/collector/seed/1/0x8b74995d1d61d3d7547575649136b8765acb22882960f0636941c44ec7bbe146/metadata.json
  • Seed execution trace: /workspace/session/artifacts/collector/seed/1/0x8b74995d1d61d3d7547575649136b8765acb22882960f0636941c44ec7bbe146/trace.cast.log
  • Seed balance diff: /workspace/session/artifacts/collector/seed/1/0x8b74995d1d61d3d7547575649136b8765acb22882960f0636941c44ec7bbe146/balance_diff.json
  • Conic ETH pool source: /workspace/session/.tmp/conicethpool/ConicEthPool.sol
  • Curve LP oracle source: /workspace/session/.tmp/conic-extra/CurveLPOracleV2-0x7b528b4fd3e9f6b1701817c83f2ccb16496ba03e.sol
  • Generic oracle source: /workspace/session/.tmp/conic-extra/GenericOracleV2-0x286ef89cd2da6728fd2cb3e1d1c5766bcea344b0.sol
  • Controller source: /workspace/session/.tmp/conic-extra/Controller-0x013a3da6591d3427f164862793ab4e388f9b587e.sol