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 selector0x7f8661a1) - 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:
- To compute the current token breakdown of the LP position (
getAmountsForLiquidity), determining how much USDT is “available.” - To compute
liquidityToUse— the proportion of liquidity to burn — by dividing the USDT needed by theavailableUSDTfigure 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
- 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. - The attacker contract borrows 1,798 ETH via
flash()from the PancakeSwap V3 ETH/WBNB pool (0xd0e226f674bbf064f54ab47f42473ff80db98cba). - Inside the flash callback, the attacker approves the PancakeSwap V3 SmartRouter for ETH and USDT, then performs
safeTransferFromto transfer Cyrus position NFT #15505 from address0x1737d386bffbbea81dab7bfd32d4c796b76ffa3to the attacker contract, making the attacker contract the NFT owner. - 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). - The attacker calls
exit(15505)on CyrusTreasury. The contract reads the manipulated spot price fromslot0, calculates near-zero USDT availability in the position, and removes almost all LP liquidity — receiving ~1,289.35 ETH + 1.19 ETH (fee portion to0x6cd7...) and ~1,707.06 USDT + 1.45 USDT (fee portion) from the pool’s underlying PancakeSwap V3 NFT position. - 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.
- After
exit()returns, the attacker performs the reverse swap (exactInputSingleUSDT→ETH) at decoded_calls index 144. Theexit()call at index 111 contains two internalwithdrawUSDTFromAnyinvocations (first for the performance fee to0x6cd7, second for the main amount to the attacker), but only one decreaseLiquidity+collect per invocation. The twoslot0()reads and twodecreaseLiquiditycalls in the trace (indices 120/134 and 121/135) reflect these two separate calls towithdrawUSDTFromAny— 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.
- 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).
- 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 indecoded_calls.json), butwithdrawUSDTFromAnyinternally iterates and callsdecreaseLiquidity+collecttwice on the same PancakeSwap V3 position NFT (tokenId = 0x61047b). This is because the protocol’s USDT target (calculated frompositionInfo.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 ID15505; the former is a PancakeSwap LP position controlled by CyrusTreasury.
Financial Impact
Based on funds_flow.json (attacker_gains and net_changes):
| Item | Amount |
|---|---|
| Flash loan borrowed | 1,798 ETH from ETH/WBNB pool |
| Flash loan fee paid | 1.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 inselectors.json,resolved_from: "abi")slot0()→0x3850c7bd✓ (confirmed inselectors.json)decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))→0x0c49ccbe✓
Key trace confirmations:
decoded_calls.jsonindex 111:AttackerContract → CyrusTreasury :: exit(15505)at depth 3, confirming the attacker contract (now owner of NFT #15505) is the caller.decoded_calls.jsonindex 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.jsonindices 120 and 134:CyrusTreasury → PancakeV3Pool_ETH_USDT :: slot0()— the spot price reads that form the core vulnerability.- Both
decreaseLiquiditycalls (indices 121 and 135) reference PancakeSwap NFT tokenId0x61047b(the treasury’s LP position), confirming the same LP position is burned twice. funds_flow.jsonnet_changes["0x9f599f3d64a9d99ea21e68127bb6ce99f893da61"]: ETH/USDT pool lost −30.40 ETH and −454,170.66 USDT.funds_flow.jsonattacker_gains: 28.14 ETH + 454,169.22 USDT sent to EOA0xf96eb14171b71ac16200013753dff3e91043b63b.trace_prestateTracer.jsonstorage 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