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

ATOHook's $97K Loss and How Olympix Would Have Prevented It

Date: June 4, 2026

Chain: Ethereum

Loss: 55.12 ETH withdrawn by a helper contract that started with no ETH, about $97K at the June 4 ETH price

Class: reentrancy guard storage slot that overlaps a reward balance

At 22:05 UTC on June 3, 2026, a Uniswap v4 hook called ATOHook went live on Ethereum, paying stakers a stream of native ETH rewards and guarding its claim function with a reentrancy lock from Solady, one of the most widely used Solidity utility libraries. Fourteen hours later, a wallet funded through the FixedFloat exchange deployed a small helper contract, and a little over two hours after that the helper started claiming rewards over and over, collecting exactly 0xffffffffffffff wei, roughly 0.072 ETH, on every claim.

By 15:03 UTC the helper, which held no ETH when it was deployed, had withdrawn 55.12 ETH, about $97K at the day's ETH price. SlowMist's alert on June 7 put the drain at 14.41 ETH, roughly $25K, an early count that the on-chain withdrawals later outgrew. Each payout was possible because the lock protecting getReward() and the attacker's reward balance lived in the same storage slot, so the moment the lock engaged, the contract read the lock's own value back as money owed.

A Reward Stream Behind a Borrowed Lock

ATOHook combined two familiar patterns. It was a Uniswap v4 hook, a contract that plugs into the v4 PoolManager and runs code around pool activity, and it ran a Synthetix-style reward stream, the design where each staker's earnings accrue over time and get stored in a balance they can claim. In ATOHook, those balances sit in a public mapping called rewards, declared at base storage slot 17, and stakers collect them by calling getReward().

A reentrancy guard is a lock that stops a function from being entered again before the first call finishes. Solady's version keeps the lock in one fixed storage slot that it writes to directly in assembly, outside the normal variable layout the Solidity compiler assigns. The verified source on Etherscan shows ATOHook inheriting ReentrancyGuard from a copy of Solady at lib/solady, and getReward() carries the nonReentrant modifier. When the modifier runs, it checks the lock slot, writes 0xffffffffffffff into it to mark the function as entered, lets the function body run, and then writes the contract's code size into the slot to release it.

Two Variables Sharing One Slot

Every value a contract stores lives at a numbered slot, and a mapping entry's slot is computed by hashing the key together with the mapping's base slot. For ATOHook, the slot for rewards[address] is keccak256 of that address and the number 17. Solady's published ReentrancyGuard places its lock at 0x929eee149b4bd21268, a short nine-byte value whose source comment explains it's large enough to avoid collisions with lower slots. The copy compiled into ATOHook declares a different constant, the full 32-byte value 0x02215292eb9609279094554c6e223f800950648ddfa3da30329838d6c170928d.

That constant is exactly the storage slot of rewards[0x2441E480F62bf609A08dA09143e4BAf8a817D757], which anyone can confirm by hashing that address with 17. For that one account, the reward balance and the reentrancy lock are the same 32 bytes of storage. When the account calls getReward(), the modifier writes 0xffffffffffffff into the slot, the function's next lines read rewards[msg.sender] and find 0xffffffffffffff, zero it, and send that many wei to the caller. On exit the modifier writes the code size back, the lock reads as open again, and the next call repeats the same payout.

Two pieces of code that each look correct on their own can still write to the same place. A storage slot has no owner, so a library that writes to a raw slot in assembly and a mapping the compiler lays out both assume nobody else will ever use that address, and that assumption only holds if someone checks the combined layout of the contract that actually ships, including every dependency copied into it.

Fourteen Hours Between Deployment and Drain

The address whose reward slot matched the lock didn't exist when ATOHook was deployed. ATOHook's deployment transaction landed in block 25239733 at 22:05:35 UTC on June 3. The attacker's wallet, 0x2D2A...7Ad0, deployed the helper contract at 0x2441...D757 in block 25244005 at 12:24:23 UTC on June 4, sending no ETH with the deployment. A contract deployed with a plain CREATE gets an address determined by its deployer and that deployer's transaction count, so it can be computed before the deployment happens. As of October 19, 2026, no public source had established whether the constant was set deliberately, or by whom.

Between 14:41 and 15:03 UTC, the attacker's wallet sent 22 transactions to the helper, which called ATOHook and received payouts of 72057594037927935 wei each, the decimal form of 0xffffffffffffff. Three of those transactions pulled ETH back out to the wallet: 28.823037615171174 ETH at 14:46, 26.22896422980576834 ETH at 14:55, and 0.072057594037927935 ETH at 15:03. Those three amounts are exactly 400, 364 and 1 payouts, 765 in total, or 55.124 ETH, and since the helper started with nothing, that's the amount it took out of ATOHook. SlowMist and crypto.training reported about 200 claims and 14.4115 ETH in their early write-ups. Blockscout's list of the helper's internal transactions confirms the larger figure from the other side: ATOHook sent the helper 765 successful payouts of 0xffffffffffffff wei across 12 transactions, 55.124 ETH in all, alongside 198 more that were rolled back inside reverted calls.

Within half an hour the wallet started sending ETH onward in 10 ETH and 15 ETH lots, including two transfers to addresses Etherscan labels as Binance deposit addresses. ATOHook's balance today is effectively zero.

A Library Copy Nobody Diffed

ATOHook's own source contains no assembly at all. The only code that writes to raw storage slots is the vendored ReentrancyGuard, and it's the one file a reviewer would most likely treat as known-good, since Solady is widely audited and used across the ecosystem. Audits and reviews cover the scope they're given, and a library folder is often outside it. The problem lived exactly where an imported dependency met the contract's own storage layout, the seam between two pieces that each look fine when read alone.

How Olympix Maps the Storage a Contract and Its Libraries Share

Olympix reads a contract the way an attacker does, as states, transitions and entry points, and it works on the code a team actually compiles, dependencies included. In ATOHook the state that matters is the full set of storage slots the contract writes, whether the compiler assigned them or assembly wrote to them directly. Laid out side by side, those slots expose the signal: a library modifier writes a hardcoded constant slot in assembly, the same contract keeps a balance mapping whose entries live at hashed slots, and the constant equals the hashed slot of one specific account. The constant also differs from the published upstream version of the same library, a second reason to look closer.

That signal cuts the work from every path in the contract to the feasible ones, and then to the handful that can change the rule ATOHook depends on, which in plain words is that an account's reward balance only ever changes through the reward accounting. Olympix infers that rule from the code itself, since every write to rewards in ATOHook's own source goes through the reward logic, and from the context around it. The engine then attacks the path in two ways. One method starts from that inferred rule and keeps executing paths through getReward() until a balance changes for a reason the accounting never approved, and the other starts from the suspicious slot constant and runs known attack strategies against it, with symbolic execution supporting both.

What comes back is a counterexample: the concrete sequence of state changes, the minimal conditions that trigger it, and a generated Forge test that replays it. For ATOHook the sequence is short. An account at 0x2441...D757 calls getReward(), the lock writes 0xffffffffffffff into its reward slot, the function pays that amount out as a reward, and the same call works again on the next transaction. That's the June 4 drain, produced in development rather than on mainnet, and because Olympix runs automatically in development, at every merge and before release, an edited library file gets checked against the rest of the codebase as soon as it's committed.

Check the Code That Actually Ships

ATOHook's own reward logic was sound, and the loss came from a lock that, in the copy that shipped, sat on top of one account's balance. Builders can treat vendored libraries as part of their storage map, because anything written to a raw slot is shared state whether the code says so or not. To watch Olympix lay out every slot your contracts and their dependencies touch, and hand back a runnable counterexample for any overlap it finds, book a technical walkthrough.

On-Chain References

Protocol: ATOHook (Uniswap v4 hook with an ETH reward stream)

Chain: Ethereum

Date: June 4, 2026, attacker calls between 14:41 and 15:03 UTC

Loss: 55.124059439014870275 ETH withdrawn by the helper contract, about $97K at June 4 prices (SlowMist's early alert reported 14.411518807585587 ETH)

ATOHook (victim, verified source): 0xA10D...2888

ATOHook deployer: 0xa10d...2121

ATOHook deployment tx (June 3, 22:05:35 UTC, block 25239733): 0x618c...1ed6

Attacker EOA: 0x2D2A...7Ad0

Attack contract (its rewards slot equals the guard slot): 0x2441...D757

Attack contract deployment tx (June 4, 12:24:23 UTC, block 25244005): 0x0129...d2c0

Withdrawal tx (28.823 ETH, 400 payouts, block 25244708): 0x76cf...a7ef

Withdrawal tx (26.229 ETH, 364 payouts, block 25244756): 0xd2c9...96ba

Withdrawal tx (0.072 ETH, 1 payout, block 25244791): 0x6c2b...fa97

Guard slot in the deployed copy: 0x02215292eb9609279094554c6e223f800950648ddfa3da30329838d6c170928d

Guard slot in Solady's published source: 0x929eee149b4bd21268

Sources

SlowMist: ATOHook alert on X

crypto.training: ATOHook Solady ReentrancyGuard Storage Collision

Blockchain Stories: Ethereum-Vertrag verliert Millionen durch Speicherkollision

Solady: ReentrancyGuard.sol

More from Olympix:

No items found.

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