On March 9, 2026, an attacker flash-loaned 1,700 WETH and walked away with a clean profit by exploiting a single ordering mistake in EtherFreakers — an on-chain NFT game where each token ("Freaker") holds withdrawable ETH. The bug wasn't reentrancy. It wasn't an oracle. It was a transfer hook reading stale accounting data during the same transaction that was supposed to settle it.
This is a pattern hiding in hundreds of NFT and GameFi contracts. Let's tear it apart and build the defenses.
How EtherFreakers Works
EtherFreakers is a dividend-pool game. When certain actions happen, ETH gets distributed across all Freakers proportionally via a global accumulator called freakerIndex. Each token tracks:
energyOf(tokenId) = basic + (freakerIndex - tokenIndex) × fortune
The critical invariant: freakerIndex should only grow when real ETH enters the system. If the index grows without corresponding ETH backing, tokens can redeem more than the contract holds — classic phantom inflation.
Players can attack() one Freaker with another. On a successful capture, the attacker's token seizes the defender's token plus its energy. Here's where it goes wrong.
The Bug: Pay First, Transfer Second, Settle Never (In Time)
The attack() function executed these steps:
1. Pay targetCharge (target's full energy) to defender as ETH ← energy spent
2. _transfer(defender, capturer, targetId) ← moves NFT
└─ _beforeTokenTransfer() hook fires
└─ _dissipateEnergyIntoPool(0.1% of energyOf(targetId)) ← reads STALE value
3. _dissipateEnergyIntoPool(sourceSpent) ← normal game logic
4. Update energyBalances for both tokens ← too late
See the problem? Step 1 pays out the target's energy. Step 4 updates the accounting. But step 2 fires a transfer hook that reads energyOf(targetId) between the payout and the update. The hook sees the pre-payout balance — energy that's already been spent — and feeds part of it into the dividend pool.
The same energy gets counted twice: once as a direct payout, once as pool input. Each loop inflates freakerIndex without new ETH backing.
The Attack: Looped Inflation
The attacker's playbook was elegant:
- Flash loan 1,700 WETH
- Mint two Freakers (token 590, token 591)
-
Loop
attack(590, 591)— each successful capture inflatesfreakerIndexvia the stale-state hook - After each capture, transfer token 591 back to reset for the next round
- Once the index is pumped high enough, discharge a batch of pre-controlled Freakers (tokens 496–520) — each redeems 0.278 ETH from an inflated pool
- Repay flash loan, keep ~7.5 WETH profit
Total loss: ~$25K. Small in DeFi terms, but the pattern is everywhere.
The Deeper Problem: Transfer Hooks Are Implicit State Readers
This isn't just an EtherFreakers bug. It's a class of vulnerability affecting any contract that:
- Has economic state tied to tokens (balances, dividends, staking rewards, energy, yield)
- Uses ERC-721/ERC-1155 transfer hooks (
_beforeTokenTransfer,_afterTokenTransfer,onERC721Received) - Performs settlements and transfers in the wrong order
The transfer hook is invisible to casual code review. It fires implicitly during _transfer(), and developers often forget that their custom hooks will read whatever state exists at that moment — settled or not.
Where Else This Lurks
- Staking NFTs with auto-claim on transfer: if rewards aren't settled before the transfer, the hook can double-count
- Yield-bearing NFTs (ERC-4626 style): transfer hooks that adjust share accounting before the burn/mint cycle completes
- GameFi with token energy/stats: any game where token attributes affect global pools during transfers
- LP position NFTs (Uniswap V3 style): custom wrappers that redistribute fees on transfer
Five Defense Patterns
1. Checks-Effects-Interactions (Yes, for Internal Calls Too)
The classic CEI pattern applies to internal state mutations, not just external calls:
// ✅ CORRECT: Settle accounting BEFORE transfer
function attack(uint256 sourceId, uint256 targetId) external {
uint256 targetEnergy = energyOf(targetId);
// EFFECTS: Update ALL accounting first
energyBalances[targetId].basic = 0;
energyBalances[targetId].index = freakerIndex;
energyBalances[sourceId].basic -= sourceSpent;
// Now the hook will read zeroed-out energy
_transfer(defender, capturer, targetId);
// INTERACTIONS: Pay out
payable(defender).transfer(targetEnergy);
}
2. Transfer Hook Guards: Snapshot Before, Validate After
If your hook must read economic state during transfers, snapshot it before and validate after:
uint256 private _preTransferSnapshot;
bool private _inSettlement;
modifier duringSettlement() {
_inSettlement = true;
_;
_inSettlement = false;
}
function _beforeTokenTransfer(
address from, address to, uint256 tokenId
) internal override {
if (_inSettlement) {
// Skip economic logic during settlement
return;
}
// Normal hook logic here
}
3. The "Dirty Flag" Pattern
Mark tokens as being settled so hooks know to skip economic reads:
mapping(uint256 => bool) private _settling;
function attack(uint256 sourceId, uint256 targetId) external {
_settling[targetId] = true;
// ... do payout and transfer ...
_settling[targetId] = false;
// ... update accounting ...
}
function _beforeTokenTransfer(
address, address, uint256 tokenId
) internal override {
require(!_settling[tokenId], "mid-settlement transfer");
}
4. Post-Transfer Settlement (Move Hooks to After)
If using OpenZeppelin 4.x+, prefer _afterTokenTransfer for economic operations. The NFT has already moved, and you can design your accounting to expect the new owner:
function _afterTokenTransfer(
address from, address to, uint256 tokenId
) internal override {
if (from != address(0)) {
_claimDividends(from, tokenId);
}
_resetTokenIndex(to, tokenId);
}
5. Invariant Tests: The Global Solvency Check
The ultimate backstop. After every state-mutating function, assert:
// In Foundry invariant test
function invariant_solvencyHolds() public {
uint256 totalClaimable;
for (uint256 i = 0; i < totalFreakers; i++) {
totalClaimable += game.energyOf(i);
}
assertLe(
totalClaimable,
address(game).balance,
"phantom inflation detected"
);
}
This invariant would have caught the EtherFreakers bug immediately — total claimable energy exceeded contract balance after the first attack loop.
The Solana Parallel: CPI and Stale Account Reads
If you're building on Solana, the same class exists. Cross-Program Invocations (CPIs) can read account data that your program hasn't finished updating:
// ❌ DANGEROUS: CPI reads account before we've updated it
invoke(&transfer_ix, &[source.clone(), dest.clone()])?;
// ... update our PDA accounting AFTER the CPI ...
// ✅ SAFE: Update PDA first, then CPI
let pda_data = &mut ctx.accounts.game_state.load_mut()?;
pda_data.player_energy = 0; // Settle first
drop(pda_data); // Release borrow
invoke(&transfer_ix, &[source.clone(), dest.clone()])?;
In Anchor programs, use realloc constraints and explicit reload() calls after CPIs to ensure your program sees fresh data.
Audit Checklist: Transfer Hook Safety
Before your next deploy, walk through this:
- 🔍 Hook inventory: List every
_beforeTokenTransfer/_afterTokenTransfer/onERC721Receivedhook. What state does each read? - 📊 State dependency: For each hook, does the read state get modified in the same function that triggers the transfer?
- ⏰ Settlement order: Is ALL accounting settled BEFORE
_transfer()/safeTransferFrom()is called? - 🔒 Guard flags: If settlement order can't be guaranteed, does the hook check a "mid-settlement" flag?
- 🧪 Invariant test: Does your test suite verify that no state-mutating function can inflate claimable values beyond actual backing?
- 🔁 Loop exposure: Can the same transfer be triggered repeatedly in one transaction (flash loan + loop)?
Key Takeaways
-
Transfer hooks are implicit function calls. Every
_transfer()is a potential state-reading side channel. - CEI applies to internal state, not just external calls. Settle your accounting before touching any function that triggers hooks.
- Flash loans amplify stale-state bugs from rounding errors to pool-draining exploits. If a bug inflates state by 0.1% per loop, 1000 loops = 100% inflation.
- Invariant tests are non-negotiable for any contract with token-denominated economics. Total claims ≤ total backing, always.
- The same pattern exists in Solana CPI. Stale account reads during cross-program calls are the Solana analog of stale transfer hooks.
The EtherFreakers loss was $25K. The next time this pattern gets hit — in a yield-bearing NFT vault or a GameFi with real liquidity — the number will have more zeros. Check your hooks.
This is part of the DeFi Security Research series. Follow for weekly deep-dives into smart contract vulnerabilities, audit techniques, and defense patterns across EVM and Solana.
Top comments (0)