Originally published at panda-security.xyz
On 24 October 2026 at 14:11:47 UTC, a single Ethereum transaction turned a flash loan into a governance majority at X100, then used that majority to make the project's own treasury sell 400,000 X for roughly 36 USDT. The attacker walked away with about 4.75 million USDT from that one transaction. Follow-on transactions using the same path pushed the total realized to roughly $4.85M, according to Blockaid.
Nothing here required a stolen key or a compiler bug. The governance system did exactly what it was written to do. It just measured voting power at the one moment an attacker could rent it.
TL;DR
-
Protocol: X100, an ERC-20 token project (ticker
X) whose "Senate" decides where the project's liquidity sits. - Chain: Ethereum mainnet.
- Loss: 361,799.999998 X left the Freezer/DexTreasury in the main transaction; the attacker realized about 4.75M USDT. Blockaid reported about 4.85M USDT and roughly 550,000 X taken across three transactions.
- Root cause: anyone could join the Senate instantly for a 100 X fee, seats went to the largest current frozen balances, and proposals could be voted and executed in the same transaction. No snapshot, no stake age, no timelock.
-
Amplifier: the treasury swap accepted
minOut = 0into a pool the attacker had created earlier in the same transaction.
How X100 Governance Was Supposed to Work
X100 describes its Senate as the body that controls where protocol liquidity is held, with smart contracts carrying out its decisions. Holders lock ("freeze") X in a contract called the Freezer, and the three largest frozen positions among Senate applicants hold the three seats. The Freezer also acts as a treasury, holding X and Uniswap V3 positions, and exposes a v3Swap function that the Senate can direct.
Before the attack, per Coindaro's reading of the Senate's on-chain ranking events, the seats were held by stakes of 20,500 X, 19,000 X and 1,000 X. In other words, the whole treasury was guarded by roughly 40,500 frozen tokens. At a price around $50 per X, that is about $2M of stake protecting many times that amount in treasury assets. That ratio alone should have been a red flag.
Root Cause: Voting Power Measured in the Present Tense
Three design choices combined into the exploit. Each one shows up regularly in audit findings on its own.
1. Instant, permissionless Senate entry
ExVul's analysis notes that applyForSenate() let anyone become a candidate immediately for a 100 X fee. There was no waiting period between applying, freezing tokens and taking a seat.
2. Seats ranked by live balances
Seats were assigned from current frozen balances. There was no checkpoint of voting power at an earlier block and no requirement that tokens had been frozen for any length of time. If you could hold enough X for one transaction, you could be a senator for that transaction.
3. Same-transaction propose, vote and execute
Once two of three seats were captured, a proposal could be created, approved and executed immediately. ExVul reports that proposal #7 auto-counted the proposer's vote, the second helper voted, and execute() ran straight away. A timelock of even a few hours would have broken the flash loan, since the loan must be repaid in the same transaction.
The simplified sketch below is our own illustration of this pattern, not X100's verified source code:
// Simplified illustration of the vulnerable pattern (not verbatim X100 code)
function applyForSenate() external {
xToken.transferFrom(msg.sender, treasury, 100e18); // entry fee
isCandidate[msg.sender] = true; // effective immediately
}
function _rankSenate() internal {
// Top 3 candidates by CURRENT frozen balance, no snapshot
seats = topThreeBy(frozenBalance);
}
function propose(bytes calldata action) external onlySenator returns (uint256 id) {
id = ++proposalCount;
proposals[id].action = action;
proposals[id].votes = 1; // proposer votes automatically
}
function vote(uint256 id) external onlySenator {
proposals[id].votes++;
}
function execute(uint256 id) external {
require(proposals[id].votes >= 2, "No majority");
// No delay between approval and execution
(bool ok, ) = address(this).call(proposals[id].action); // e.g. v3Swap(...)
require(ok);
}
function v3Swap(address router, uint24 fee, uint256 amountIn, uint256 minOut)
external
onlySelf
{
// minOut chosen by the proposal, so it can be zero
_swapExactInput(router, address(xToken), USDT, fee, amountIn, minOut);
}
Attack Walkthrough
The following sequence is assembled from BlockSec Phalcon, ExVul and Coindaro's on-chain reconstruction. We confirmed the transaction hash, sender, block (26,263,022), timestamp and the victim and coordinator contracts against a public Ethereum node.
Step 1: Rent the stake
A contract deployed by the attacker wallet in the attack transaction itself borrowed about 1.94M USDT in a flash loan and bought roughly 38,200 X from the main Uniswap V3 X/USDT pool.
Step 2: Outbid the incumbents by one raw unit
The X was split between two helper contracts. Each paid the 100 X application fee and froze 19,000.000001 X, one millionth of a token more than the 19,000 X incumbent. The two helpers took seats two and three, pushing out the 19,000 X and 1,000 X senators.
Step 3: Prepare a pool the attacker controls
The attacker created a new PancakeSwap V3 X/USDT pool with a 0.01% fee tier and seeded it with a tiny amount of liquidity. Coindaro reports about 100 USDT on the USDT side. This pool is where the treasury would be forced to sell.
Step 4: Pass and execute the proposal
One helper submitted a proposal calling the Freezer's v3Swap with 400,000 X in, minimum output 0, routed through the new PancakeSwap pool. The other helper voted, and the proposal executed in the same call. ExVul measured the treasury's proceeds at 35.675313 USDT for 400,000 X.
Step 5: Pull the liquidity and cash out
The attacker withdrew their liquidity from the PancakeSwap pool, which now held the treasury's X, and sold part of it into the real Uniswap V3 pool. Coindaro puts that sale at 131,703 X for about 6.69M USDT. After repaying the flash loan, about 4,749,726 USDT went to the attacker wallet, with roughly 268,000 X left in the attack contract.
Follow-on transactions
Blockaid flagged the exploit at 14:49 UTC and described it as ongoing. A second actor burned the Freezer's Uniswap V3 positions, and a third used the same Senate route to pull a position tied to about 550,000 X. Chainalysis reported the stablecoins being swapped to ETH and split across four wallets holding 400 ETH each. Several outlets reported that roughly 12.2M X still sat in the Freezer at the time of Blockaid's update.
Why This Keeps Happening
Flash-loan governance capture is not new. The Beanstalk attack in 2022 used the same idea: borrow voting power, pass a proposal, execute, repay. What makes these incidents repeat is that governance code is often reviewed as "admin logic" rather than as a market. If voting power can be bought, then governance is a price, and the right question is how much it costs to control the treasury for one block.
At X100 that cost was a flash loan fee plus some slippage, against a treasury worth millions. The minOut = 0 swap made it worse, but it was not the root problem. Even with a slippage check, a majority that can call arbitrary treasury functions immediately will find another way to move funds. We covered a related failure mode, contracts trading at whatever price someone else sets, in our post on flash loan attack prevention.
How to Prevent Governance Takeovers
Snapshot voting power in the past
Use checkpointed balances (for example ERC20Votes with getPastVotes) taken at a block before the proposal was created. Tokens bought in the same block, or the same transaction, should carry zero weight.
Require stake age for seats
For seat-based systems like a Senate, require that the qualifying stake has been frozen for a minimum period before it counts, and make the seat change effective only after that delay.
Put a timelock between approval and execution
A timelock is the single most effective control here. It makes flash loans useless, and it gives holders and monitoring services time to react to a malicious proposal.
Constrain what governance can call
Treasury functions reachable from governance should have hard limits: allowlisted pools and routers, per-period outflow caps, and an oracle-based minimum output rather than a caller-supplied one. A proposal that sells 400,000 X for 36 USDT should revert no matter who approved it.
Price the attack, not just the code
During an audit, compare the cost of acquiring a governance majority with the value governance can move. If capturing the vote is cheaper than the treasury, the design is exploitable even when every line of code is correct.
For a broader pre-launch review list, see our smart contract security checklist.
Key Addresses and Transactions
- Attack transaction:
0x38c60844...4d81d0 - Attacker wallet:
0x7ec2afdf3ec5964899894f8d58afa95cbb8ffd63 - Victim (X100 Freezer / DexTreasury):
0x79f35a33fe2813414851c4ae8daeacd14d822a46 - Exploit coordinator (per ExVul):
0x37541fc4a6f1ec86889ba506f51dc39f2bedd10a
Sources
- BlockSec Phalcon alert on X: loss estimate, root cause and attack flow
-
ExVul alert on X:
applyForSenate()details, seat amounts, proposal #7, swap parameters and addresses - Blockaid alert on X: detection time, follow-on attackers and total realized
- Chainalysis summary on X: fund movements after the attack
- Coindaro report: on-chain timeline, incumbent stakes and swap amounts
- TokenPost report: Blockaid's later update and X100's governance model
- Transaction status, block and timestamp, read by us from a public Ethereum RPC node
Closing Thoughts
X100's Senate had three seats, and two of them were for sale for the length of one transaction. Governance that reads live balances and executes instantly is not governance; it is an open function with an entry fee. Snapshots, stake age and a timelock would each have stopped this attack on their own.
Launching a DAO, a treasury or any token with on-chain voting? Contact Panda Security for a governance and economic review before it controls real funds, or check our audit pricing.
Top comments (0)