October 20, 2026
|
Exploit Postmortem
Cover image for "Spiral's $27K Loss and How Olympix Would Have Prevented It"

Spiral's $27K Loss and How Olympix Would Have Prevented It

Date: September 14, 2026

Chain: Ethereum

Loss: 10.71 ETH, about $27K at the time of the exploit

Class: collateral priced at the pool's spot price, behind a same-block guard keyed to tx.origin

In Ethereum block 25974146, a freshly deployed contract handed six new wallets 0.01 ETH each for gas, and within that same block each of them borrowed against the same pile of SPIRAL tokens. Spiral, a Uniswap V4 hook that runs a token launchpad, a lending market and leveraged trading out of one contract, had a guard meant to stop exactly this: anyone who traded in a block couldn't borrow in that same block. The guard remembered who traded by the wallet that signed the transaction, so the attacker simply signed with seven different ones.

At 07:17:59 UTC on September 14, 2026, one wallet bought 141,059 SPIRAL with 54 ETH and pushed up the pool's price, and six fresh wallets posted those tokens as collateral and borrowed 64.71 ETH against the inflated price, all inside one block. The attacker walked away with 10.71 ETH, about $27,000, more than they put in. SpiralHookV2's borrow() valued collateral at the pool's live spot price with no time-weighted average, and the only thing standing between a price pump and a loan was a check that a second wallet could step around.

One Contract Doing Four Jobs

Spiral describes itself in its own source code as a hook protocol that fuses an AMM, a lending market, a perpetual exchange and a launchpad into a single contract surface. On Ethereum, the canonical SPIRAL token trades against ETH in a Uniswap V4 pool whose liquidity is locked to the hook, so nobody can add or remove it. The verified SpiralHookV2 contract at 0x1725...aacc handles the pool callbacks, leveraged longs, liquidations and a plain borrow: a holder deposits SPIRAL and draws ETH from idle liquidity that sits below the current price, at a loan-to-value of 40%.

That design puts the price and the lender in the same place, since every loan the hook makes is sized from a pool anyone with enough ETH can move.

The Price Read Inside borrow()

From the verified SpiralHookV2 source on Etherscan and Blockscout (contracts/canonical_v2/SpiralHookV2.sol), these are the lines that size a loan:

    function borrow(uint256 collateralSpiral, uint256 minEthOut)
        external nonReentrant noSameBlockSwap
        returns (uint256 positionId, uint256 ethOut)
    {
        if (!poolInitialized) revert PoolNotInitializedErr();
        if (!tradingOpen)     revert TradingClosed();
        if (collateralSpiral == 0) revert CollateralBelowMin();

        require(spiral.transferFrom(msg.sender, address(this), collateralSpiral), "SpiralPullFail");

        (uint160 sqrtP, int24 currentTick,,) = poolManager.getSlot0(_poolId());
        uint256 collateralValue = LDF.spiralValueInETH(sqrtP, collateralSpiral);
        if (collateralValue < MIN_COLLATERAL_VALUE) revert CollateralBelowMin();

        uint256 plannedDebt = (collateralValue * LTV_BPS) / 10_000;

The function pulls the borrower's SPIRAL in, then asks the Uniswap V4 PoolManager for the pool's current state with getSlot0. The value it gets back, sqrtP, is the price at this exact moment, set by whatever the last trade in the pool did. spiralValueInETH turns the deposited SPIRAL into an ETH value at that price, and plannedDebt is 40% of it. Nothing in the path compares the price to a time-weighted average or limits how far it can have moved, so a buy that lifts the price a moment earlier lifts every loan made after it.

The developers knew a pump-then-borrow was the risk and put a guard on borrow() for it. From the shared SpiralStateV2.sol file in the same verified source:

    modifier noSameBlockSwap() {
        if (uint64(block.number) <= lastSwapBlockOf[tx.origin]) revert SwapInSameBlock();
        _;
        lastSwapBlockOf[tx.origin] = uint64(block.number);
    }

The hook's afterSwap callback writes the current block number into lastSwapBlockOf[tx.origin] on every external trade, and the modifier refuses any gated call from a tx.origin that already traded in this block. The code's own comment says this "kills self-sandwich," and it does, for one wallet. tx.origin is the externally owned account that signed the transaction, so the record is per signer. A second signer has a clean record. Another comment elsewhere in the same file says it outright: noSameBlockSwap "only fences the same EOA."

That's the teaching moment in this case. A guard keyed to an identity is only as strong as the cost of a new identity, and on Ethereum a new signer costs nothing. Picture a bank that won't lend to someone who just bid up the price of their own collateral, and checks by asking for the borrower's name. The rule the loan actually depended on was about the price, and the guard was about the person.

Seven Signers, One Block

Etherscan shows the attacker's wallet, 0x859E...a2f0, receiving 25.18 ETH from a FixedFloat hot wallet at 06:36:23 UTC, 29.87 ETH from a second address at 06:37:35, and 3.16 ETH more from FixedFloat at 07:12:11. Everything after that landed in block 25974146, at 07:17:59 UTC.

First, the wallet deployed an attack contract at 0x0C23...6F86 and, in the same deployment, sent 0.01 ETH to each of six new addresses. Next, it sent the contract 54 ETH. The contract swapped that ETH for 141,059.24 SPIRAL through the V4 pool, with 52.92 ETH reaching the pool and 1.08 ETH going to the swap fee, and the afterSwap callback recorded the attacker's main wallet as having traded in this block.

Then came six transactions, each signed by a different one of the funded addresses. All six are EIP-7702 transactions, the type that lets an ordinary account run a contract's code, and each delegated to the attack contract and called its executeBorrow function. Each one posted a slice of the freshly bought SPIRAL as collateral to borrow(), and each passed noSameBlockSwap because its tx.origin had never traded. The slices added up to exactly the 141,059.24 SPIRAL the contract had bought, and the PoolManager paid out 12.30, 22.40, 10.93, 12.12, 3.66 and 3.30 ETH, a total of 64.71 ETH, all valued at the price the 54 ETH purchase had just created.

A final transaction swept the ETH back to the attacker's wallet, and another sent 0.2 ETH to the Titan Builder block builder in the same block. The attacker had spent 54 ETH and borrowed 64.71 ETH, leaving 10.71 ETH ahead and a stack of SPIRAL locked in the hook as collateral that was never going to be worth the debt against it.

From FixedFloat Back to FixedFloat

Three minutes later, at 07:20:59 UTC, the attacker's wallet sent 68.72 ETH to 0xB201...0cdb. According to a trace published by PublicAML, that address put 40 ETH into Tornado Cash in four 10 ETH deposits within about ten minutes and sent the remaining 28.7 ETH back to FixedFloat.

SlowMist's alert went out at 08:11 UTC and named the cause: borrow() valued collateral at the pool's spot price through getSlot0 with no TWAP or price-change limit, and the tx.origin-keyed guard fell to six different EOAs. Later that day, Spiral posted that because of a security incident involving borrow() and the immutable nature of its contracts, it would migrate to a new token with the launch of V2. The migration opened on September 15, and Spiral relaunched its token on September 17, describing the release as having all previous security issues resolved. Replying to a holder that day, the team wrote that liquidity on the old Spiral had been very thin because of the security issue and that it would try to make holders whole using part of its treasury.

How Olympix Follows the Price Into the Loan

Olympix reads a contract the way an attacker does, as states, transitions and entry points, and it takes in the context around the code, including where its prices come from and who can move them. In SpiralHookV2, the state that matters is how much ETH the hook has lent against how much SPIRAL, and the entry points that matter are every external swap that moves the pool's price and every borrow that reads it.

The signal here is a lending path that sizes debt from getSlot0 on a pool any caller can trade in, guarded only by a check keyed to tx.origin. From that signal, the engine narrows from every path in the hook, to the feasible ones that end in a loan, to the sequences where a swap and a borrow can land in the same block, which is where the invariant lives: no account should be able to borrow more against SPIRAL than the price it would hold once a single trade stops pushing on it. Olympix has flagged this class before in other codebases.

The counterexample for this hook is short. One signer swaps a large amount of ETH into the pool, a second signer that hasn't traded calls borrow() in the same block with the bought SPIRAL, and the loan comes out sized at the pumped price, more than the ETH that went in. Olympix returns that sequence of calls and state changes, the minimal conditions that trigger it (a second tx.origin, and a pump large enough that 40% of the pumped collateral value exceeds what the purchase cost), and a generated Forge test that replays it. That test is the September 14 attack, produced in development rather than on mainnet.

Because the engine runs automatically in development, at every merge and before release, the path would have surfaced at the commit that put borrow() and its guard in place, where a fix like a time-weighted price or a cap on how far spot can move before a loan is cheap.

When the Guard Checks the Wrong Thing

Spiral's developers saw the self-sandwich coming and wrote a guard against it, and the guard worked for the attacker it imagined. The loan still trusted a price one trade could set, and the check in front of it counted signers when the risk was in the price. For builders pricing collateral from a pool they also run, the safe default is to treat the spot price as something an attacker controls for the length of a block and to test every guard against an attacker with as many wallets as they like. To see how Olympix traces every price your contracts lend against back to who can move it, and hands you a runnable counterexample where it can be pushed, book a technical walkthrough.

On-Chain References

Protocol: Spiral (SpiralHookV2, canonical SPIRAL/ETH Uniswap V4 hook)

Chain: Ethereum

Date: September 14, 2026, 07:17:59 UTC

Loss: 10.71 ETH net (64.71 ETH borrowed against 54 ETH spent), about $27K

Attacker EOA: 0x859E...a2f0

Attack contract: 0x0C23...6F86

Vulnerable contract (SpiralHookV2): 0x1725...aacc

SPIRAL token (original): 0x6a77...b12b

Uniswap V4 PoolManager: 0x0000...8A90

Attack contract deployment tx: 0xc8da...477e

Pump tx (54 ETH buy): 0x84f2...9925

Borrow tx 1: 0x0599...642e

Borrow tx 2: 0x3172...6fb2

Borrow tx 3: 0x0952...9cfc

Borrow tx 4: 0x129f...f22b

Borrow tx 5: 0xc197...53d5

Borrow tx 6: 0x0dd9...0db1

Sweep tx: 0xe8d3...f682

Block: 25974146

Outflow to laundering address: 0x179f...676a

Sources

SlowMist: SlowMist TI Alert on the Spiral (spir8l) exploit

Spiral: Security incident involving borrow() and migration to V2

Spiral: Reply on thin liquidity and making holders whole

Blockscout: SpiralHookV2 verified source

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.