Root Cause
X100's on-chain 'Senate' governance assigned seats to the addresses with the largest current frozen token balances, accepted new members for a flat fee of 100 X, and allowed proposals to be voted on and executed within the same transaction. There was no balance-snapshot from a prior block, no minimum stake age, and no timelock. A flash loan could therefore temporarily inflate a single address's frozen balance, buy a Senate seat instantly, submit a treasury-drain proposal, vote it through (2/3 majority), execute it, and repay the loan — all atomically.
// Reconstructed Senate seat assignment (from on-chain bytecode analysis)
function joinSenate() external {
require(frozenBalance[msg.sender] >= SENATE_MIN); // checked at call time
// Seat granted based on *current* balance — no snapshot
_assignSeat(msg.sender, frozenBalance[msg.sender]);
}
// No timelock on execution
function executeProposal(uint256 id) external {
require(_senateApproved(id)); // 2-of-3 sufficient
_execute(proposals[id].target, proposals[id].data); // immediate
}
Attack Steps
| # | Action |
|---|---|
| 1 | Flash-borrow large X balance |
| 2 | Freeze X tokens → become top frozen-balance holder |
| 3 | Pay 100 X fee; receive 2 of 3 Senate seats immediately |
| 4 | Submit proposal: transfer 400,000 X from treasury to attacker-controlled LP |
| 5 | Vote yes + execute in same transaction (no timelock) |
| 6 | Sell treasury X into self-created LP → realise ~$4.75M USDT |
| 7 | Second attacker destroys Uniswap V3 Freezer position via same governance path |
| 8 | Third transaction drains ~550,000 X from the depleted Uniswap pool |
| 9 | Repay flash loan; net ~$4.85M USDT |
Impact
- ~$4.85M USDT drained across three separate exploit transactions on Ethereum mainnet
- Treasury emptied, Uniswap V3 liquidity position destroyed
- Multiple independent attackers exploited the same governance flaw within hours
- X100 token price collapsed; project liquidity is effectively gone
Lessons for Auditors
- Governance snapshot required. Voting power must be determined from a balance snapshot taken at a fixed block before the proposal was created — never from the current balance. ERC20Votes (
getPastVotes) is the standard pattern. - Timelock is not optional. Any proposal that moves treasury funds or upgrades contracts must pass through a timelock contract (minimum 24–48 hours for high-value protocols).
- Seat assignment with instant effect. Any governance structure that grants immediate control based on real-time on-chain state is flash-loan attackable. Require stake age (e.g., tokens frozen for ≥ 1 epoch) before seat eligibility.
- Governance + flash loans = critical risk. When reviewing on-chain governance, explicitly test whether a sufficiently large flash loan can reach quorum. If it can, the protocol is broken by design.
- Consider OpenZeppelin Governor. It ships snapshot voting, configurable quorum, and TimelockController as composable, audited primitives.