DEV Community

ohmygod
ohmygod

Posted on

The EtherFreakers Exploit: Why ERC-721 Transfer Hooks That Read Economic State Are Ticking Time Bombs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Flash loan 1,700 WETH
  2. Mint two Freakers (token 590, token 591)
  3. Loop attack(590, 591) — each successful capture inflates freakerIndex via the stale-state hook
  4. After each capture, transfer token 591 back to reset for the next round
  5. 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
  6. 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:

  1. Has economic state tied to tokens (balances, dividends, staking rewards, energy, yield)
  2. Uses ERC-721/ERC-1155 transfer hooks (_beforeTokenTransfer, _afterTokenTransfer, onERC721Received)
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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()])?;
Enter fullscreen mode Exit fullscreen mode

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 / onERC721Received hook. 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

  1. Transfer hooks are implicit function calls. Every _transfer() is a potential state-reading side channel.
  2. CEI applies to internal state, not just external calls. Settle your accounting before touching any function that triggers hooks.
  3. 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.
  4. Invariant tests are non-negotiable for any contract with token-denominated economics. Total claims ≤ total backing, always.
  5. 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)