Cyrus Finance's $517K Loss and How Olympix Would Have Prevented It
Date: March 22, 2026
Chain: BNB Chain
Loss: about $517K in ETH and USDT
Class: withdrawal sized from a manipulable slot0() spot price, paid out in both pool tokens
Cyrus Finance position NFT #15505 was cashed out at 19:21:24 UTC on March 22, 2026. The treasury paid it 1,707.06 USDT, enough to pass the function's own check that the owed USDT had arrived, and in the same call handed over 1,289.35 ETH that the check never looked at. By the end of the transaction the attacker who'd borrowed the NFT for a few seconds had kept about $517,000.
The money came from a PancakeSwap V3 liquidity position owned by the CyrusTreasury contract. Its withdrawal routine decided how much liquidity to pull by valuing the position at the pool's live spot price, read from slot0(), a number anyone with enough capital can move inside a single transaction, and then sent the user everything the withdrawal produced, ETH included.
A Yield Product Backed by a Liquidity Position
Cyrus Finance sold fixed-term yield on BNB Chain. According to BlockSec's weekly roundup, users deposited USDT and received CYRP position NFTs, and once a position's term finished they could call exit() to withdraw their principal plus rewards. CyrusTreasury, deployed on December 13, 2025, held concentrated liquidity positions on PancakeSwap V3, including one in the ETH/USDT pool, and paid exits by withdrawing USDT from those positions on demand.
That design means every exit is a small liquidity withdrawal. A concentrated liquidity position holds a mix of two tokens that depends on where the pool's price sits relative to the position's range, so to pay out a fixed amount of USDT the treasury has to work out how much of its liquidity corresponds to that amount at the moment of the exit.
Asking the Pool What Its Position Is Worth
From the verified CyrusTreasury source on Sourcify (contracts/CyrusTreasury/CyrusTreasury.sol), these lines inside withdrawUSDTFromAny() size the withdrawal:
The first line reads sqrtPriceX96 from the pool's slot0(), which is the price right now, as set by the last trade. The next four lines turn the position's liquidity into token amounts at that price, and availableUSDT is the USDT half of that answer. The last block decides how much liquidity to remove: if the position holds more USDT than the exit needs, it removes the matching fraction, and otherwise it removes all of it.
The treasury then calls decreaseLiquidity() and collect() on PancakeSwap's position manager with the treasury's caller as the recipient, so both tokens the withdrawal frees, USDT and ETH, go straight to the user. Only the USDT is counted toward totalWithdrawn, and the final check only confirms that at least 99.5% of the owed USDT arrived.
CertiK's incident analysis treats that last part as the primary flaw, since a function meant to pay out USDT was also sending the position's ETH to the withdrawer, and the spot-price sizing as the related weakness that made the ETH side large. BlockSec's roundup leads with the slot0() reading. The two work as a pair. If the pool's price is pushed so the position holds very little USDT, availableUSDT shrinks, liquidityToUse grows toward the whole position, and the ETH that comes out with it is never weighed against anything.
The builder lesson is that a pool's current price is a quote, valid for one trade, and a payout sized from it is a payout the next trade can resize. It's like a cashier who works out your change by asking the customer in front of you what a dollar is worth today.
Borrowed ETH, a Borrowed NFT and One Exit
Every step happened inside transaction 0x2b7e...9524, which BNB Chain's RPC places in block 88130290 at 19:21:24 UTC on March 22, 2026. It was sent by 0xf96e...b63b to an attack contract at 0x938d...8a7b. According to CertiK, the attacker's wallet had been funded through Tornado Cash on January 17, 2026.
The attack contract borrowed 1,798 ETH from a PancakeSwap pool at 0xd0e2...8cba. In the same transaction, CYRP position NFT #15505 moved from 0x0173...ffa3 to the attack contract, which satisfied the ownership check at the top of exit(). The contract then sold all 1,798 ETH into the ETH/USDT V3 pool at 0x9f59...da61 for 1,212,462.16 USDT, pushing the pool's ETH price down sharply.
With the price moved, it called exit(15505) on CyrusTreasury. The exit first paid the performance fee through the same withdrawal routine, which sent 1.19 ETH and 1.44 USDT to the fee receiver, and then paid the position itself: 1,289.35 ETH and 1,707.06 USDT to the attack contract. The USDT figure cleared the treasury's 99.5% check, and the ETH rode along uncounted.
The contract swapped 760,000 USDT back into the pool for 537.86 ETH, repaid 1,799.08 ETH to the lender, and sent the remainder, 28.14 ETH and 454,169.22 USDT, to the attacker's wallet. CertiK values the loss at $516,840, made up of 30.4 ETH and 454,170.66 USDT, and SlowMist's database lists the same figure. BlockSec's roundup puts it at about $512K.
Where the Money Went
CertiK reports that the proceeds were sent to Tornado Cash in batches, and SlowMist's Hacked database says nine batches. Neither CertiK's nor BlockSec's analysis cites a statement from the Cyrus team.
Why One Exit Could Move the Whole Position
Each piece of withdrawUSDTFromAny() looks reasonable alone. Reading the pool's own price is the standard way to convert liquidity into token amounts, and PancakeSwap's helper libraries are built to take exactly that input. Sending collect() output to the user saves a transfer. The slippage check protects the user from getting too little USDT. What the function never asks is whether the user got too much of anything, and its sizing step trusts a number the caller can set moments before calling.
Audits are necessary and they cover the scope they're given. Treasury plumbing like this often sits beside the product logic a review concentrates on, and the risk only shows when the price input and the payout path are considered together.
How Olympix Weighs a Payout Against a Price the Caller Just Set
Olympix reads a contract the way an attacker does, as states, transitions and entry points, and it uses the context around the code, here a treasury that exists to pay fixed USDT amounts out of shared liquidity, to infer what the system depends on. Olympix's static analysis surfaces the signal: a value read from a pool's slot0() that flows into an amount of liquidity removed and paid to an external caller, in a function reachable from a public entry point, with no time-weighted price or deviation bound in between. Olympix has flagged this class before in other codebases, where a spot price set inside the same transaction decided how much value left a contract.
From that signal the engine narrows to feasible paths, any holder of a matured position calling exit(), and then to the paths that touch the invariant. In one sentence: an exit should never transfer more value out of the treasury's liquidity than the USDT the position is owed, whatever the pool's price is at that moment.
BugPoCer runs Counterexample-Guided Invariant Refutation against that rule. It works outward from the treasury and the pool as one system and executes sequences of trades and exits until the invariant breaks. The counterexample it produces here is a matured position exited right after a large swap moves the ETH/USDT price, with the state changes showing the liquidity removed, the USDT counted and the ETH that left uncounted. It comes with the minimal conditions, a matured position and enough capital to move the pool, and a generated Forge test that replays it on a fork. That's the March 22 transaction, found in development.
Because Olympix runs on every merge and before release, the finding would have attached to the change that introduced withdrawUSDTFromAny(), long before the treasury held a position worth draining.
Count Everything That Leaves
Cyrus's treasury checked the one token it meant to pay and never looked at the other one going out the same door, and it sized the door from a price the person walking through it had just set. Payouts drawn from AMM liquidity need a price the caller can't move and an accounting of every asset that leaves, including the ones the function was never written to pay. If your contracts pay users out of liquidity positions, book a technical walkthrough.
On-Chain References
Protocol: Cyrus Finance (CyrusTreasury)
Chain: BNB Chain
Date: March 22, 2026, 19:21:24 UTC
Loss: $516,840 per CertiK (attacker wallet received 28.14 ETH and 454,169.22 USDT)
Attacker EOA: 0xf96e...b63b
Attack contract: 0x938d...8a7b
CyrusTreasury: 0xb042...e10b
ETH/USDT PancakeSwap V3 pool: 0x9f59...da61
Flash source pool: 0xd0e2...8cba
Position NFT #15505 prior holder: 0x0173...ffa3
Exploit tx: 0x2b7e...9524
Block: 88130290
Sources
CertiK: Cyrus Finance Incident Analysis
BlockSec: Weekly Web3 Security Incident Roundup, Mar 23 to Mar 29, 2026
More post-mortems
See every post-mortem →See what Olympix finds on your own code
Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.




