# 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 `withdrawUSDTFromAny`— **not** 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`):
ItemAmountFlash loan borrowed1,798 ETH from ETH/WBNB poolFlash 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 USDTUSDT obtained via price manipulation swap~1,212,462 USDTUSDT 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 ETHETH/USDT pool net USDT loss~454,170.66 USDTPerformance 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).
## Related URLs
– Transaction: https://bscscan.com/tx/0x2b7efdac5f052ee9a8f6de8f966b948027b76f7cc183e4868c98c7afc2d69524
– CyrusTreasury contract: https://bscscan.com/address/0xb042ea7b35826e6e537a63bb9fc9fb06b50ae10b
– Attacker contract: https://bscscan.com/address/0x938dbbb69e71d00f52d5ed5d69ba892fa1448a7b
– Attacker EOA: https://bscscan.com/address/0xf96eb14171b71ac16200013753dff3e91043b63b
