Ethereum
Validation gap

X100's $4.75M Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
October 24, 2026

At 14:11:47 UTC on October 24, 2026, a single Ethereum transaction bought two seats on X100's three-member Senate, used them to order the project's own treasury to sell 400,000 X for 35.675313 USDT, and walked away with 4,749,726 USDT. The attacker never held a single X token before the block started. Everything they needed, from the voting power to the price, was rented, created and returned inside one atomic call.

The proposal that did it carried a description the attacker wrote into the chain themselves: "testnet flash governance proof." It was a fair summary. X100's governance measured voting power at the exact moment an attacker could borrow it, counted votes from whoever sat in the seats at that instant, and let a passed proposal execute with no delay at all. Blockaid later traced roughly $4.85M taken across all of the exploit's transactions, but the first transaction alone drained 4.75M USDT out of the X/USDT market, which is the figure in the title.

A Treasury Run by Its Three Biggest Lockers

X100 is an ERC-20 token project on Ethereum built around a contract called the Freezer. Holders freeze X in tiers of one, three or five years to earn rewards, and the same contract doubles as the project's treasury, holding X and the protocol's Uniswap V3 and PancakeSwap V3 liquidity positions. Deciding what happens to that treasury falls to the Senate, a small council made up of the largest frozen balances among the accounts that have applied for a seat.

The Senate's powers were real. Through the DexTreasury layer, a passed proposal could mint, grow or shrink liquidity positions and call v3Swap, which sells any allowed token held by the Freezer through a Uniswap V3 or PancakeSwap V3 pool. The proposal chose the venue, the fee tier, the amount and the minimum output. Any proposal that reached a majority executed with the Freezer itself as the caller, so the onlySenate guard on those treasury functions passed automatically.

Just before the attack, a read of the Freezer's ranking at block 26,263,021 shows three listed accounts, holding 20,500 X, 19,000 X and 1,000 X frozen, and a senate size of three. About 40,500 frozen tokens stood between the outside world and a treasury holding at least 400,000 unfrozen X, the amount Proposal #7 would later sell. At the roughly $50 per X the attacker paid on the day, the whole Senate was worth about $2M of stake.

Voting Power in the Present Tense

The Freezer's verified source on Blockscout shows how membership worked. Applying for the Senate is a single external call that any address can make:

function applyForSenate() external {
    _register(_msgSender());
}

Registration collects the 100 X candidacy fee and immediately ranks the caller by stake. The Freezer defines that stake as the caller's frozen balance at the moment of the call:

function _stakeOf(address account) internal view override returns (uint256) {
    return frozenOf(account);
}

Nothing in either function asks how long the tokens have been frozen or where they came from. A balance that arrived one internal call earlier counts exactly the same as one that's been locked for a year. Seats then go to the top of a linked list sorted by those live balances, and isSenator() walks that list at whatever moment it's asked.

Proposals follow the same present-tense logic. When a senator calls propose(), the contract stamps the time and sets a deadline, then records the proposer's own vote in the same step:

proposal.createdAt = block.timestamp;
proposal.deadline = block.timestamp + votingPeriod;
proposal.data = data;
proposal.description = description;

_votes[id][sender] = Vote.For;

The voting period, three days by default, is a closing time and nothing more. It tells the contract when a proposal stops being open. It never tells the contract to wait. The execute() function only checks that the proposal is still open and that it has enough support right now:

function execute(uint256 id) external {
    Proposal storage proposal = _requireOpen(id);

    uint256 size = senateSize;
    (uint256 support, address[] memory seats) = _tally(id);
    require(_isPassed(support), NotPassed(id, support, size));

    proposal.executed = true;

    (bool ok, bytes memory result) = address(this).call(proposal.data);

And "enough support" is a simple majority of the seats:

function _isPassed(uint256 support) private view returns (bool) {
    return support * 2 > senateSize;
}

With three seats, two votes pass anything. Put those pieces together and the Senate behaves like a door with a cash register in front of it. Anyone who can hold the second and third largest frozen balances for a few internal calls gets two votes, the proposer's vote is free, and execution can follow in the very next line of the same transaction. The only post-execution check, _afterExecute(), confirms the Freezer still holds at least as much X as users have frozen. The treasury's own unfrozen X was fair game.

Five Moves Inside One Block

The attack contract was created by the attacker's wallet at 0x7ec2...fd63 in the exploit transaction itself, and it ran the attack through a coordinator contract at 0x3754...d10a. The token transfers in block 26,263,022 lay out the sequence exactly.

Step 1, rent the stake. The coordinator flash-borrowed 1,942,457.637245 USDT from Aave V3's USDT reserve and spent 1,942,356.637245 USDT of it buying 38,200.000002 X from the main Uniswap V3 X/USDT pool. That's an average price of about $50.85 per token.

Step 2, outbid the incumbents by one raw unit. The coordinator sent 19,100.000001 X to each of two helper contracts. Each helper froze 19,000.000001 X in the shortest tier, paid the 100 X candidacy fee and called applyForSenate(). X has six decimals, so each helper's stake beat the 19,000 X senator by exactly one millionth of a token. The Freezer's Ranked events show both helpers slotting in below the 20,500 X incumbent, pushing the 19,000 X and 1,000 X holders out of seats two and three.

Step 3, build the exit. The coordinator sent 100 USDT into a fresh PancakeSwap V3 X/USDT pool at 0x9a03...f075 and received position NFT 23375 for it. With only a sliver of USDT and no X on the other side, that pool would pay almost nothing for whatever X came in. Panda Security's write-up identifies it as a 0.01% fee tier pool, a tier the main market didn't use.

Step 4, pass and execute Proposal #7. The first helper proposed a v3Swap call, which recorded its own vote automatically. The second helper voted for it. The coordinator then called execute(), and the ProposalExecuted event records two votes for out of a senate size of three. The Freezer sent 400,000 X into the attacker's pool and received 35.675313 USDT back.

Step 5, cash out and repay. The coordinator pulled its liquidity out of the PancakeSwap pool, collecting 399,986.799998 X and 64.32468 USDT. It sold 131,702.921165 X into the real Uniswap V3 pool for 6,693,089.638357 USDT, repaid Aave 1,943,428.866064 USDT covering principal and the flash loan fee, and sent the remaining 4,749,726.096973 USDT to the attack contract, which forwarded it straight to the attacker's wallet. Roughly 268,000 X stayed with the coordinator.

Every number in that list comes from the transaction's own transfer log and events. The two seats only had to exist for the few internal calls between applyForSenate() and execute(), which is exactly as long as a flash loan lasts.

The Drain Kept Going

The first transaction wasn't the last. According to Blockaid, a separate transaction destroyed the Uniswap V3 liquidity position held by the Freezer, and Blockaid put the total taken across the exploit at about 4.85M USDT plus roughly 550,000 X. The Defiant reported a third exploit transaction in which a different sender, using the same Senate route, moved about 549,916 X and 1,909 USDT to a new beneficiary while the affected pool held less than 200 USDT.

The money moved quickly after that. Chainalysis traced the stolen stablecoins into ether, and The Defiant reported four wallets holding 400 ETH each, with a distribution wallet keeping another 423 ETH.

Bitcoin News reported that X100 hadn't acknowledged the breach on its social channels at the time of its reporting. As of October 25, 2026, the team hadn't published a post-mortem.

Why Each Line Looked Reasonable

Read one function at a time, nothing in the Senate looks broken. A candidacy fee is a normal anti-spam measure. Ranking by frozen balance matches the project's idea that the most committed holders should govern. A proposer voting for their own proposal is common, and a majority threshold is standard. Even the voting deadline is a sensible guard against proposals lingering forever.

The trouble lives between the functions. Each one reads the current state, and the current state is the one thing a flash loan can manufacture. Seat eligibility, the tally and the execution check all sample the same instant, so nothing ever compares the governance state at proposal time with the state at execution time, or the stake an account held yesterday with the stake it holds now. That's a property of the whole system, and it only shows up when someone asks what a single caller can do across several entry points in one transaction.

The economics made it worse. Two seats cost the attacker about $1.94M of borrowed capital and roughly $971 in flash loan fees, and those seats controlled a treasury worth several times that. Panda Security's analysis makes the scope point directly: governance code tends to get reviewed as admin logic, when it behaves like a market, and the useful question is what it costs to control the treasury for one block. Reviews are a necessary layer, and a bug that lives in the interaction between entry points is exactly the kind that continuous, system-level testing is built to catch alongside them.

How Olympix Catches a Senate That Can Be Rented

Olympix reads a contract the way an attacker does, as a set of states, transitions and entry points. In X100's Freezer, the states that matter are the ranked list of frozen balances, the votes recorded against each proposal, and the treasury balances those votes can move. The entry points that change them are freeze(), applyForSenate(), refresh(), propose(), vote() and execute(), and every one of them is reachable by an outside caller in any order.

From that model, Olympix takes in context to infer the invariants the design depends on. The Freezer's tiers lock tokens for years, and the Senate is meant to be run by the holders who made that commitment. That implies a rule the code never states: voting power that controls treasury transfers must reflect stake that existed before the decision was made, so a balance acquired, frozen and used to pass and execute a treasury action in the same transaction must never be enough to move treasury funds.

Olympix's static analysis is what surfaces the signals that point at that rule. It flags that applyForSenate() and the stake calculation read live balances with no checkpoint or minimum age, that the tally in execute() reads the same live ranking, and that nothing in the path from propose() to execute() enforces a gap in blocks or time. It also flags that the selector allowlist in _isProposable() routes passed proposals straight into v3Swap with a caller-chosen minimum output. Those signals narrow the search from every path through the contract to the feasible paths, and then to the ones that touch the treasury invariant.

BugPoCer takes it from there. It runs Counterexample-Guided Invariant Refutation from the system outward, starting from the treasury rule and searching for a sequence of calls that breaks it. Because every step in the X100 path is an ordinary public call, the search doesn't need anything exotic. A fresh actor acquires X, splits it across two addresses, freezes slightly more than the second-ranked senator in each, applies for the Senate, proposes a treasury swap, votes, and executes, all before the transaction ends. The treasury's unfrozen X leaves at a price the actor sets.

The output is a counterexample, written out in full. It lists the sequence of state changes from the first freeze() to the final swap, states the minimal conditions that make it work (two stakes above the current second and third seats, and a swap target pool the actor controls), and ships as a generated Forge test the team can run locally. A finding like that is concrete evidence of one way the invariant breaks, which gives developers something specific to fix and a test to keep after the fix lands.

The fix it points to is small. Measuring stake at a checkpoint before the proposal was created, requiring frozen tokens to age before they count toward a seat, or putting a timelock between a passed vote and execute() would each have broken this path on its own, because a flash loan can't wait for any of them. Olympix has flagged closely related patterns before, such as governance execution paths with no minimum wait period, and running this analysis at every merge and before release is how a change to the Senate or the treasury functions gets tested against the same rule every time.

Governance Is a Market

X100's Senate did exactly what its code said. The code just let voting power be bought, used and returned in one transaction, and it let a passed vote spend the treasury in the same breath. For any protocol that hands a treasury to on-chain governance, the safest assumption is that someone will price the vote. Build every check so that price includes time.

If your contracts route treasury actions through governance, Olympix can map every path from a new stake to an executed proposal and hand back the counterexample for any path that breaks your invariants before the code ships. To see it on your own contracts, book a technical walkthrough.

On-Chain References

Exploit tx: 0x38c6...81d0

Victim (X100 Freezer / DexTreasury): 0x79f3...2a46

Attacker wallet: 0x7ec2...fd63

Attack contract (created in exploit tx): 0x89e9...e2c9

Exploit coordinator: 0x3754...d10a

Helper 1 (proposer): 0xe081...9f37

Helper 2: 0x176d...619c

Attacker-seeded PancakeSwap V3 pool: 0x9a03...f075

Uniswap V3 X/USDT pool: 0x0d85...2fa0

Flash loan source (Aave V3 aUSDT): 0x2387...086a

X token: 0x100a...a100

Sources

ExVul: X100 Freezer/DexTreasury Senate takeover alert

Panda Security: X100 Exploit, A Flash-Loaned Senate Drained $4.75M

Phemex / Blockaid: X100 under sustained attack, ~4.85M USDT drained

The Defiant: Third X100 exploit transaction pulls 550,000 X from Uniswap pool

The Defiant: X100 exploit proceeds split into four 400-ETH wallets

Bitcoin News: X100 Global loses millions as three-seat Senate is hijacked

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.