CyrusTreasury Protocol: Price Manipulation via Spot Price Oracle in Exit Function

On March 22, 2026, the CyrusTreasury protocol on BNB Chain was exploited through a price manipulation attack against its withdrawUSDTFromAny function, which is called internally by exit(). The vulnerable contract (CyrusTreasury, 0xb042ea7b35826e6e537a63bb9fc9fb06b50ae10b) reads the live PancakeSwap V3 pool slot0 price to determine how much liquidity to remove from managed LP positions, with no TWAP or manipulation-resistant oracle. By flash-borrowing 1,798 ETH and executing a large ETH→USDT swap that moved the ETH/USDT price dramatically, the attacker forced the protocol to remove the LP position almost entirely in the high-ETH-price direction, collecting approximately 1,827 ETH and 1,707 USDT from two exit() calls against a single Cyrus position NFT. After restoring the price via a reverse swap and repaying the flash loan, the attacker netted ~28.14 ETH and ~454,169 USDT (approximately $524,500 total) at the expense of the protocol’s PancakeSwap V3 liquidity pool.

Root Cause

Vulnerable Contract

  • Name: CyrusTreasury
  • Address: 0xb042ea7b35826e6e537a63bb9fc9fb06b50ae10b
  • Proxy: No — direct implementation
  • Source type: Verified (CyrusTreasury.sol)

Vulnerable Function

  • Function: withdrawUSDTFromAny(uint256 usdtAmountWithSlippage, address to)
  • Selector: Internal (called by exit() at selector 0x7f8661a1)
  • File: project/contracts/CyrusTreasury/CyrusTreasury.sol, lines 236–307

Vulnerable Code

function withdrawUSDTFromAny(
    uint256 usdtAmountWithSlippage,
    address to
) internal {
    // ...
    for (uint256 i = 0; i < len && totalWithdrawn < usdtAmountWithSlippage; i++) {
        uint256 index = (startIndex + i) % len;
        uint256 tokenId = tokenIds[index];

        (
            , address operator, address token0, address token1,
            uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity,
            , , ,
        ) = PancakePositionManager.positions(tokenId);

        // ...

        (uint160 sqrtPriceX96,,,,,,) = IPancakePool(pool).slot0(); // <-- VULNERABILITY
        uint160 sqrtRatioAX96 = PancakeSwapUtil.getSqrtRatioAtTick(tickLower);
        uint160 sqrtRatioBX96 = PancakeSwapUtil.getSqrtRatioAtTick(tickUpper);
        (uint256 amount0, uint256 amount1) = PancakeSwapUtil.getAmountsForLiquidity(
            sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity
        );

        uint256 availableUSDT = isToken0USDT ? amount0 : amount1;
        // ...

        uint256 remaining = usdtAmountWithSlippage - totalWithdrawn;
        uint128 liquidityToUse = liquidity;
        if (availableUSDT > remaining) {
            liquidityToUse = uint128((uint256(liquidity) * remaining) / availableUSDT); // <-- VULNERABILITY
        }

        uint256 minAmount = (remaining * 995) / 1000;

        if (isToken0USDT) {
            usdtReceived = decreaseLiquidity(tokenId, liquidityToUse, minAmount, 0, to);
        } else {
            usdtReceived = decreaseLiquidity(tokenId, liquidityToUse, 0, minAmount, to); // <-- VULNERABILITY
        }
        // ...
    }
}

Why It’s Vulnerable

Expected behavior: Before removing liquidity from a Uniswap V3-style concentrated liquidity position, the amount of each token available should be determined by a manipulation-resistant oracle (e.g., a TWAP) so that an adversary cannot temporarily distort the pool price to skew the composition of tokens removed.

Actual behavior: The function calls IPancakePool(pool).slot0() at the current block to read sqrtPriceX96 — the live, instantaneous spot price. This spot price is then used in two ways:

  1. To compute the current token breakdown of the LP position (getAmountsForLiquidity), determining how much USDT is “available.”
  2. To compute liquidityToUse — the proportion of liquidity to burn — by dividing the USDT needed by the availableUSDT figure derived from the manipulated spot price.

When an attacker pushes the ETH price dramatically upward (ETH becomes very expensive in USDT terms), a V3 concentrated liquidity position in the ETH/USDT range skews toward holding nearly all ETH and almost no USDT. This makes availableUSDT very small. The formula liquidityToUse = liquidity * remaining / availableUSDT then produces a value near or equal to the full liquidity, causing virtually all liquidity to be removed. After the price manipulation is reversed, the removed liquidity contains mostly ETH, which the attacker keeps.

The minAmount slippage parameter is calculated as (remaining * 995) / 1000 — but remaining is the USDT target, not the ETH component. So the slippage check only protects the USDT side, not the ETH side, allowing the ETH value to be extracted without slippage protection.

Normal flow: A user exits their Cyrus position NFT, the protocol removes proportional LP liquidity based on fair market price, and the user receives USDT approximately equal to their deposited value.

Attack flow: The attacker manipulates the ETH/USDT price upward before calling exit(). At the inflated price, almost all LP liquidity is removed (since there is barely any USDT available in the range). The removed tokens are mostly ETH. After restoring the price via a reverse swap, the attacker holds ETH that was worth far more USDT at fair price than the protocol intended to release.

Attack Execution

High-Level Flow

  1. Attacker EOA (0xf96eb14171b71ac16200013753dff3e91043b63b) calls the pre-deployed attacker contract (0x938dbbb69e71d00f52d5ed5d69ba892fa1448a7b) with encoded parameters specifying the flash loan pool, token addresses, position NFT ID (15505), exit manager, and 1,798 ETH borrow amount.
  2. The attacker contract borrows 1,798 ETH via flash() from the PancakeSwap V3 ETH/WBNB pool (0xd0e226f674bbf064f54ab47f42473ff80db98cba).
  3. Inside the flash callback, the attacker approves the PancakeSwap V3 SmartRouter for ETH and USDT, then performs safeTransferFrom to transfer Cyrus position NFT #15505 from address 0x1737d386bffbbea81dab7bfd32d4c796b76ffa3 to the attacker contract, making the attacker contract the NFT owner.
  4. The attacker swaps all 1,798 ETH → USDT through the ETH/USDT PancakeSwap V3 pool (exactInputSingle), crossing 92 price ticks and dramatically inflating the ETH price (ETH becomes very expensive in USDT, so USDT reserves in the range are nearly exhausted).
  5. The attacker calls exit(15505) on CyrusTreasury. The contract reads the manipulated spot price from slot0, calculates near-zero USDT availability in the position, and removes almost all LP liquidity — receiving ~1,289.35 ETH + 1.19 ETH (fee portion to 0x6cd7...) and ~1,707.06 USDT + 1.45 USDT (fee portion) from the pool’s underlying PancakeSwap V3 NFT position.
  6. The attacker swaps ~760,000 USDT back to ETH (reverse swap) through the same pool, restoring the price to near-market levels and crossing the same ticks back.
  7. After exit() returns, the attacker performs the reverse swap (exactInputSingle USDT→ETH) at decoded_calls index 144. The exit() call at index 111 contains two internal withdrawUSDTFromAny invocations (first for the performance fee to 0x6cd7, second for the main amount to the attacker), but only one decreaseLiquidity+collect per invocation. The two slot0() reads and two decreaseLiquidity calls in the trace (indices 120/134 and 121/135) reflect these two separate calls to withdrawUSDTFromAnynot two passes through the tokenIds loop. The reverse swap (USDT→ETH, decoded_calls index 144) is what transfers ~537.86 ETH from the pool to the attacker as the swap output, not a second decreaseLiquidity iteration.

Note: Call flow derived from on-chain trace. The High-Level Flow step numbering has been corrected to reflect that ~537.86 ETH originates from the reverse swap, not a second loop iteration of withdrawUSDTFromAny.

  1. After the callback completes, the attacker contract repays the flash loan: 1,799.0788 ETH (1,798 borrowed + 1.0788 ETH fee, 0.06% flash fee).
  2. Remaining ETH (~28.14 ETH) and USDT (~454,169 USDT) are transferred to the attacker EOA as profit.

Detailed Call Trace

[0] EOA (0xf96eb...) → AttackerContract (0x938d...) :: 0xb6b4573e [CALL, depth 0]
  [1] AttackerContract → PancakeV3Pool_ETH_WBNB (0xd0e2...) :: flash(address,uint256,uint256,bytes) [CALL, depth 1]
      Pool checks its own balanceOf(ETH), balanceOf(WBNB), then sends 1,798 ETH to attacker contract.
    [2] PancakeV3Pool_ETH_WBNB → AttackerContract :: pancakeV3FlashCallback(uint256,uint256,bytes) [CALL, depth 2]
      [3] AttackerContract → ETH (0x2170...) :: approve(SmartRouter, MAX) [CALL, depth 3]
      [3] AttackerContract → USDT (0x55d3...) :: approve(SmartRouter, MAX) [CALL, depth 3]
      [3] AttackerContract → CyrusPosition NFT (0xd9a3...) :: safeTransferFrom(0x1737..., AttackerContract, 15505) [CALL, depth 3]
        [4] CyrusPosition → AttackerContract :: onERC721Received(...) [CALL, depth 4]  ← attacker now owns NFT #15505

      --- ROUND 1: Price Manipulation + Exit ---
      [3] AttackerContract → PancakeSmartRouter (0x13f4...) :: exactInputSingle(ETH→USDT, 1798 ETH, fee=100) [CALL, depth 3]
        [4] SmartRouter → SmartRouter_Impl :: getPool(DELEGATECALL)
        [4] SmartRouter → PancakeV3Pool_ETH_USDT (0x9f59...) :: swap(attacker, zeroForOne=true, 1798e18, ...) [CALL, depth 4]
            ← 184 crossLmTick calls = massive price movement, ETH extremely expensive in USDT
            ← attacker receives ~1,212,462 USDT; pool now price-manipulated
      [3] AttackerContract → CyrusTreasury (0xb042...) :: exit(15505) [CALL, depth 3]
        [4] CyrusTreasury → CyrusPosition (0xd9a3...) :: ownerOf(15505) [STATICCALL] ← returns AttackerContract ✓
        [4] CyrusTreasury → CyrusPosition (0xd9a3...) :: getPosition(15505) [STATICCALL]
        [4] CyrusTreasury → CyrusVault (0xc033...) :: getAffiliate(AttackerContract) [STATICCALL]
        [4] CyrusTreasury → CyrusPosition (0xd9a3...) :: updatePosition(15505, {amount:0,...}) [CALL] ← position zeroed
        --- withdrawUSDTFromAny loop iteration 1 ---
        [4] CyrusTreasury → PancakePositionManager :: positions(0x61047b) [STATICCALL]
        [4] CyrusTreasury → PancakePositionManager :: ownerOf(0x61047b) [STATICCALL]
        [4] CyrusTreasury → PancakePositionManager :: isApprovedForAll(owner, CyrusTreasury) [STATICCALL]
        [4] CyrusTreasury → PancakeV3Factory :: getPool(ETH, USDT, 100) [STATICCALL]
        [4] CyrusTreasury → PancakeV3Pool_ETH_USDT :: slot0() [STATICCALL] ← reads MANIPULATED price
        [4] CyrusTreasury → PancakePositionManager :: decreaseLiquidity((0x61047b, liquidity1, ...)) [CALL]
            [5] PancakePositionManager → PancakeV3Pool_ETH_USDT :: burn(...) [CALL]
        [4] CyrusTreasury → PancakePositionManager :: collect((0x61047b, performanceFeeReceiver, MAX, MAX)) [CALL]
            [5] PancakePositionManager → Pool :: burn(0) [CALL]
            [5] PancakePositionManager → Pool :: collect(performanceFeeReceiver, ...) [CALL]
                [6] Pool → ETH (0x2170...) :: transfer(0x6cd7..., 1.185 ETH) [CALL]
                [6] Pool → USDT (0x55d3...) :: transfer(0x6cd7..., 1.445 USDT) [CALL]
        --- withdrawUSDTFromAny loop iteration 2 (same tokenId 0x61047b) ---
        [4] CyrusTreasury → PancakePositionManager :: positions(0x61047b) [STATICCALL]
        [4] CyrusTreasury → PancakePositionManager :: ownerOf(0x61047b) [STATICCALL]
        [4] CyrusTreasury → PancakePositionManager :: isApprovedForAll(...) [STATICCALL]
        [4] CyrusTreasury → PancakeV3Factory :: getPool(...) [STATICCALL]
        [4] CyrusTreasury → PancakeV3Pool_ETH_USDT :: slot0() [STATICCALL] ← reads MANIPULATED price (2nd time)
        [4] CyrusTreasury → PancakePositionManager :: decreaseLiquidity((0x61047b, liquidity2, ...)) [CALL]
            [5] PancakePositionManager → Pool :: burn(...) [CALL]
        [4] CyrusTreasury → PancakePositionManager :: collect((0x61047b, AttackerContract, MAX, MAX)) [CALL]
            [5] Pool → ETH :: transfer(AttackerContract, 1,289.35 ETH) [CALL]
            [5] Pool → USDT :: transfer(AttackerContract, 1,707.06 USDT) [CALL]

      --- ROUND 1 Reverse Swap ---
      [3] AttackerContract → PancakeSmartRouter :: exactInputSingle(USDT→ETH, ~760,000 USDT, fee=100) [CALL, depth 3]
        [4] SmartRouter → PancakeV3Pool_ETH_USDT :: swap(...) [CALL] ← price restored
            ← Pool transfers 537.86 ETH to AttackerContract

      --- Flash Loan Repayment ---
      [3] AttackerContract → ETH :: transfer(PancakeV3Pool_ETH_WBNB, 1,799.0788 ETH) [CALL, depth 3]

      --- Profit Extraction ---
      [0] AttackerContract → ETH :: transfer(EOA, 28.14 ETH) [CALL, depth 0]
      [0] AttackerContract → USDT :: transfer(EOA, 454,169.22 USDT) [CALL, depth 0]

Key observations from the trace:

  • exit() is called only once (index 111 in decoded_calls.json), but withdrawUSDTFromAny internally iterates and calls decreaseLiquidity + collect twice on the same PancakeSwap V3 position NFT (tokenId = 0x61047b). This is because the protocol’s USDT target (calculated from positionInfo.amount) is large enough to require two passes through the pool position loop.
  • Both slot0() reads (indices 120 and 134) occur while the price is fully manipulated — after the 1,798 ETH→USDT swap but before the reverse swap.
  • The PancakeSwap V3 pool NFT position ID 0x61047b (6,358,139 decimal) is distinct from the Cyrus protocol NFT ID 15505; the former is a PancakeSwap LP position controlled by CyrusTreasury.

Financial Impact

Based on funds_flow.json (attacker_gains and net_changes):

ItemAmount
Flash loan borrowed1,798 ETH from ETH/WBNB pool
Flash loan fee paid1.0788 ETH (0.06%)
ETH extracted from ETH/USDT pool~1,827.22 ETH total (1,290.54 ETH from 2 decreaseLiquidity/collect calls + 537.86 ETH as reverse swap output)
USDT extracted from ETH/USDT pool~1,708.50 USDT
USDT obtained via price manipulation swap~1,212,462 USDT
USDT spent on reverse swap~760,000 USDT
Attacker EOA net gain (ETH)~28.14 ETH
Attacker EOA net gain (USDT)~454,169.22 USDT
Approximate total USD value~$524,500 (at ~$2,500/ETH)
ETH/USDT pool net ETH loss~30.40 ETH
ETH/USDT pool net USDT loss~454,170.66 USDT
Performance fee receiver (0x6cd7…)~1.185 ETH + ~1.445 USDT

The CyrusTreasury protocol’s PancakeSwap V3 LP positions were substantially drained in a single transaction. The victim is the pool of liquidity providers who had deposited USDT into the Cyrus strategy — their underlying ETH/USDT LP was removed and the ETH value extracted. The Cyrus Position NFT #15505 (owned by 0x1737d386bffbbea81dab7bfd32d4c796b76ffa3) had its position record zeroed out by updatePosition, so the victim address shows a zeroed position.

Evidence

Selector verification:

  • exit(uint256)cast sig = 0x7f8661a1 ✓ (confirmed in selectors.json, resolved_from: "abi")
  • slot0()0x3850c7bd ✓ (confirmed in selectors.json)
  • decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))0x0c49ccbe

Key trace confirmations:

  • decoded_calls.json index 111: AttackerContract → CyrusTreasury :: exit(15505) at depth 3, confirming the attacker contract (now owner of NFT #15505) is the caller.
  • decoded_calls.json index 112: CyrusTreasury → CyrusPosition :: ownerOf(15505) returns the attacker contract — the access control check passes legitimately because the NFT was transferred in step 8 of the trace.
  • decoded_calls.json indices 120 and 134: CyrusTreasury → PancakeV3Pool_ETH_USDT :: slot0() — the spot price reads that form the core vulnerability.
  • Both decreaseLiquidity calls (indices 121 and 135) reference PancakeSwap NFT tokenId 0x61047b (the treasury’s LP position), confirming the same LP position is burned twice.
  • funds_flow.json net_changes["0x9f599f3d64a9d99ea21e68127bb6ce99f893da61"]: ETH/USDT pool lost −30.40 ETH and −454,170.66 USDT.
  • funds_flow.json attacker_gains: 28.14 ETH + 454,169.22 USDT sent to EOA 0xf96eb14171b71ac16200013753dff3e91043b63b.
  • trace_prestateTracer.json storage slot 3 of attacker contract = 0x3c91 (15505), and slot 4 = 0x61784331c9a8580000 (1,798 ETH flash amount), confirming the encoded attack parameters.
  • Receipt status: transaction succeeded (the 0.5% slippage check at line 303–306 of CyrusTreasury passed because the USDT received exceeded 99.5% of the USDT target — but the USDT target itself was computed from the manipulated price).