Governance Attack: Flashloan Vote Manipulation
FutureSwapX Governance Attack: Flashloan Vote Manipulation
On December 14, 2025, a critical vulnerability was exploited in the FutureSwapX governance system, resulting in an estimated loss of approximately $500,000. The attacker exploited the snapshot mechanism of the FST token in conjunction with the governance proposal creation flow, allowing them to use flashloaned tokens for voting power.
Overview
Attacker: 0xcd7c839c6814234601fe7719da21a980c1a8184e
Vulnerable Contracts:
FST Token: 0x0e192d382a36de7011f795acc4391cd302003606
Governance: 0x0a7f8161605acc552fa38fdb8ee7d8177c9ac22a
Exploit TX: 0x23c6a1e3fa409fcf17b4a6c385924a17546772ce77b314d001cbf0dab9469ba3
Exploit Analysis
The FutureSwapX governance system uses an ERC20 token (FST) with a snapshot mechanism for voting. When a proposal is created, the contract takes a snapshot of token balances to determine voting power. The vulnerability lies in the order of operations during proposal creation.
Attack Flow
The attack was executed in a single atomic transaction:
Step 1: Flashloan FST Tokens
The attacker borrowed approximately 3.6 million FST tokens via flashloan. At this point, the attacker’s balance is inflated but only temporarily.
State after this step:
_balanceOf[attacker]= 3,600,000 FSTGlobal
_snapshot= 52Attacker’s last checkpoint = None or older snapshot
Step 2: Create Proposal - Snapshot is Taken
The attacker called createProposal() on the governance contract. This function performs two critical operations in sequence. First, it calls snapshot() on the FST token:
require(bool(_votingToken.code.size));
v48, /* uint256 */ v49 = _votingToken.snapshot().gas(msg.gas);
require(bool(v48), 0, RETURNDATASIZE());
require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
_proposals[_proposalCount].field5 = v49; // Save snapshot ID for this proposal
The snapshot() function in FST token increments the global snapshot counter:
function snapshot() public payable {
_snapshot += 1; // 52 → 53
emit Snapshot(_snapshot);
return _snapshot;
}
State after snapshot:
Global
_snapshot= 53 (just incremented)Attacker’s last checkpoint = Still at 52 or older
_balanceOf[attacker]= 3,600,000 FST (unchanged)
Step 3: Create Proposal - Fee Collection Triggers the Trap
Immediately after the snapshot, the governance contract calls transferFrom() to collect the 100 FST proposal stake:
MEM[MEM[64] + 36] = msg.sender;
MEM[MEM[64] + 68] = address(this);
MEM[MEM[64] + 100] = 10 ** 20; // 100 FST stake (18 decimals)
0x12d8(132 + MEM[64], 0x23b872dd00000000000000000000000000000000000000000000000000000000, _votingToken);
The FST token’s transferFrom calls the internal transfer function:
function transferFrom(address sender, address recipient, uint256 amount) public payable {
require(msg.data.length - 4 >= 96);
0xa94(amount, recipient, sender); // Internal transfer
v0 = _SafeSub('ERC20: transfer amount exceeds allowance', amount, _allowance[sender][msg.sender]);
0x9a8(v0, msg.sender, sender);
return True;
}
The internal transfer function 0xa94 triggers checkpointing before modifying balances:
function 0xa94(uint256 varg0, uint256 varg1, uint256 varg2) private {
0xe45(varg2); // ← Checkpoint sender BEFORE balance change
0xe45(varg1); // ← Checkpoint recipient BEFORE balance change
require(address(varg2), Error('ERC20: transfer from the zero address'));
require(address(varg1), Error('ERC20: transfer to the zero address'));
v0 = _SafeSub('ERC20: transfer amount exceeds balance', varg0, _balanceOf[address(varg2)]);
_balanceOf[address(varg2)] = v0;
v1 = _SafeAdd(varg0, _balanceOf[address(varg1)]);
_balanceOf[address(varg1)] = v1;
emit Transfer(address(varg2), address(varg1), varg0);
return ;
}
The checkpoint function 0xe45 is where the vulnerability materializes:
function 0xe45(address varg0) private {
v0 = varg0;
if (mapping_68[v0].length) {
assert(mapping_68[v0].length - 1 < mapping_68[v0].length);
v1 = v2 = mapping_68[v0].field0[mapping_68[v0].length - 1]; // Get last checkpoint snapshot ID
} else {
v1 = 0;
}
if (v1 < _snapshot) { // attacker's last (52) < current (53) = TRUE!
mapping_68[v0].length = mapping_68[v0].length + 1;
mapping_68[v0].field0[mapping_68[v0].length] = _snapshot; // Record snapshot 53
mapping_68[v0].length = mapping_68[v0].length + 1;
mapping_68[v0].field1[mapping_68[v0].length] = _balanceOf[varg0]; // Record 3.6M FST!
}
return ;
}
Critical Issue: The checkpoint records the attacker’s current balance (3.6M flashloaned tokens) as their historical balance for snapshot 53, before the 100 FST fee is deducted.
State after fee collection:
Attacker’s checkpoint for snapshot 53 = 3,600,000 FST (permanently recorded!)
_balanceOf[attacker]= 3,599,900 FST (after 100 FST fee)
Step 4: Cast Vote with Inflated Power
The attacker immediately calls vote() on the governance contract:
function vote(uint256 proposalId, bool support) public payable {
require(msg.data.length - 4 >= 64);
require(proposalId < _proposalCount, Error('Nonexisting proposal'));
v0 = v1 = block.timestamp <= _proposals[proposalId].field3;
if (block.timestamp <= _proposals[proposalId].field3) {
v0 = v2 = !_proposals[proposalId].field9_0_0;
}
require(v0, Error('vote is not open'));
require(!_proposals[proposalId].field8[msg.sender], Error('already voted'));
_proposals[proposalId].field8[msg.sender] = 1;
require(bool(_votingToken.code.size));
v3, /* uint256 */ v4 = _votingToken.balanceOfAt(msg.sender, _proposals[proposalId].field5).gas(msg.gas);
require(bool(v3), 0, RETURNDATASIZE());
require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
if (!support) {
v5 = _SafeAdd(_proposals[proposalId].field7, v4);
_proposals[proposalId].field7 = v5;
} else {
v6 = _SafeAdd(_proposals[proposalId].field6, v4); // v4 = 3,600,000 FST!
_proposals[proposalId].field6 = v6;
}
emit VoteCasted(msg.sender, proposalId, v4, support);
}
The token’s balanceOfAt returns the recorded checkpoint balance:
function balanceOfAt(address account, uint256 snapshotId) public payable {
require(msg.data.length - 4 >= 64);
require(snapshotId > 0, Error('ERC20Snapshot: id is 0'));
require(snapshotId <= _snapshot, Error('ERC20Snapshot: nonexistent id'));
if (mapping_68[account].length) {
// ... binary search logic to find snapshot ...
v0 = v1 = mapping_68[account].length;
v2 = v3 = 0;
while (v2 < v0) {
// ... search for matching snapshot ID ...
}
}
if (v2 != mapping_68[account].length) {
v8 = v9 = 1;
assert(v2 < mapping_68[account].length);
v8 = v10 = mapping_68[account].field1[v2]; // Returns recorded balance: 3,600,000 FST
} else {
v8 = 0;
}
if (!v8) {
v8 = v11 = _balanceOf[account]; // Fallback to current balance
}
return v8;
}
Result: The attacker’s vote is counted with 3.6 million FST voting power.
Step 5: Return Flashloan
The attacker returns the flashloaned tokens to the flashloan provider. The snapshot balance remains permanently recorded in the token contract’s history, and the proposal passes with artificially inflated votes.
Conclusion
This attack demonstrates a critical design flaw in governance systems that use same-transaction snapshots. The vulnerability arises because:
Snapshot and fee collection happen atomically: The attacker can hold flashloaned tokens when the snapshot is taken.
Checkpointing on first touch: The token contract records the current balance as historical data on the first transfer after a new snapshot, capturing the inflated flashloan balance.
Recommendations
Use historical snapshots: Governance proposals should reference a snapshot taken in a previous block (e.g.,
block.number - 1), not one created in the same transaction.Timelock on voting: Require a delay between proposal creation and when voting can begin.
Minimum hold time: Implement a mechanism to verify tokens were held before the snapshot, not just at the moment of the snapshot.
Furthermore, conducting a security audit is strongly recommended for all projects, including smart contracts, backends, wallets, and dApps. Governance mechanisms in particular require careful review due to the high-value decisions they control.


