All audits
FailedEthereumIndependent

Conic Read-Only Reentrancy (2023)

Smart contract security audit by TierZero · 2023-10-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

1
Critical
1
High
2
Medium
1
Low
1
Informational

Independent research — not a commissioned audit.

What the protocol did

Conic Finance ran "omnipools" on Ethereum: single-asset vaults that auto-allocated deposits across multiple Curve pools to farm yield while managing rebalancing and slippage for depositors. To value its own LP shares and to size withdrawals, Conic's accounting read live state from the underlying Curve pools it held positions in, including Curve's get_virtual_price()-style pricing functions. On July 21, 2023, an attacker drained one of Conic's ETH-denominated omnipools for roughly $3.2 million using a bug class that had been publicly flagged days earlier by Curve's own team and several security researchers: read-only reentrancy in Curve's stable pools.

The vulnerability

The bug is not a classic reentrancy where an attacker re-enters a state-mutating function. It's subtler: a view function returns a value computed from pool state that is temporarily inconsistent mid-transaction, and an external caller trusts that value.

Curve's remove_liquidity/remove_liquidity_one_coin paths, when withdrawing native ETH, send ETH to the caller with a raw call before fully reconciling internal balances. That transfer triggers the recipient's receive()/fallback, handing control back to the attacker while the pool's balances[] and totalSupply are in a stale intermediate state. Any external contract that calls a Curve view function during that window gets a wrong answer, even though the function itself has no nonReentrant violation of its own guard — the guard, if present, only protects state-changing entry points, not the view function.

// Curve-style pool (simplified)
function remove_liquidity_one_coin(uint256 tokenAmount, int128 i, uint256 minAmount) external {
    uint256 dy = _calcWithdrawOneCoin(tokenAmount, i);
    require(dy >= minAmount, "slippage");
    _burn(msg.sender, tokenAmount);
    // external call happens BEFORE balances[] is updated
    (bool ok, ) = msg.sender.call{value: dy}("");
    require(ok);
    balances[uint256(uint128(i))] -= dy; // too late — attacker already re-entered
}

function get_virtual_price() external view returns (uint256) {
    // derives price from balances[] / totalSupply — stale during the callback above
    return totalSupply() == 0 ? 0 : (getD() * PRECISION) / totalSupply();
}
// Consumer (Conic-style) — trusts the pool's live price with no reentrancy awareness
function totalUnderlying() public view returns (uint256) {
    return curvePool.get_virtual_price() * lpBalance / 1e18; // read is reentrant-reachable
}

How the exploit worked

  1. The attacker funded a flash loan and acquired Curve LP tokens for one of the ETH-based pools Conic held a position in.
  2. The attacker's contract called remove_liquidity/remove_liquidity_one_coin on the Curve pool, requesting the ETH leg.
  3. Curve's pool sent ETH to the attacker's contract via a raw call, before finalizing balances[]/totalSupply for that withdrawal.
  4. The attacker's receive() hook fired mid-call and re-entered Conic's contracts, which read get_virtual_price() (or an equivalent internal valuation path) to price the omnipool's Curve LP holdings.
  5. Because the Curve pool's internal state was mid-update, the price read back was distorted relative to its true post-withdrawal value, letting the attacker mint or redeem Conic shares at a mispriced rate.
  6. The attacker completed the original Curve withdrawal, unwound the position, repaid the flash loan, and kept the difference — all inside one atomic transaction, repeated to extract the bulk of the pool's assets.

This is the same class of bug that hit other Curve-adjacent protocols in the same window; Conic was one of the confirmed victims because its valuation logic had no defense against being called mid-callback by an untrusted external contract.

How an audit catches this

We do not treat reentrancy review as "does this function have nonReentrant." For any protocol that consumes external AMM/vault pricing:

  • Map every external call in the dependency's codebase that transfers native ETH or invokes ERC-777/ERC-721/ERC-1155 hooks before internal accounting is finalized. Curve's ETH withdrawal path is a known instance; it is not the only one.
  • Write a proof-of-concept receiver contract whose receive() re-enters the protocol under review and asserts whether any price, exchange-rate, or share-valuation read differs from its value immediately after the outer call completes. A nonzero delta is the finding.
  • Check whether price-reading functions are protected by a shared reentrancy lock with the pool's state-mutating functions (a "locked" flag readable cross-contract), not just an internal modifier on the function itself.
  • Run invariant/fuzz tests (Foundry invariant tests or Echidna) that interleave external calls with view-function reads across the full call graph, not just the top-level entry points.
  • For any protocol pricing its own shares from a third-party pool's spot state, require either a reentrancy-safe read (via the third party's official guard, where one exists) or a fallback to a TWAP/oracle source decorrelated from single-transaction state.

This is core scope in our smart contract audit engagements and in composability-focused code review audits: any dependency on external pricing functions gets traced for reentrancy-reachable state windows, not just checked for the presence of guards on paper.

Remediation

Curve's fix, and the broader ecosystem response, converged on two patterns: expose a reentrancy status check that downstream consumers can query before trusting a price read, and prefer pull-payment (WETH) transfers over raw ETH sends to avoid handing control to the recipient mid-function. Protocols consuming external pool prices should add their own defense in depth: guard valuation-critical view functions with the same lock used on state-changing functions, avoid native ETH transfers in withdrawal paths where a hook can be triggered, and never treat a single spot read of get_virtual_price() — or any AMM-derived price — as safe to consume from within another contract's execution context. This is the same category of cross-contract trust assumption we flag when reviewing external call risk in other ecosystems, including cross-program invocation risk in Solana DeFi and approval-flow abuse patterns like Permit2 gasless approvals and EVM sniping bots. If your protocol reads price or balance data from any external contract mid-transaction, get it reviewed under our smart contract audit process before it becomes the next post-mortem.

Thinking about a review like this for your own contracts? Get a smart contract audit from an independent team.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit