Reproduced Exploit

Safe approval squat — unlimited ERC-20 allowances point at an empty CREATE address anyone can occupy

On 2026-03-07 the Safe approved type(uint256).max of USDC, WAVAX, WETH.e, and BTC.b to 0x3725bD4D…. At block 98,603,854 that address still has no code, and the four allowances are still max output.txt:385. The address is not random. It is fixed by (Safe Singleton Factory, salt 0, CreateCall initcod…

Oct 2026•Avalanche•untagged•9 min read

Loss

The Safe's entire balance of four tokens output.txt:357. 3,078,123.681646 USDC (3078123681646, 6 decimals). 3…

Chain

Avalanche

Category

untagged

Date

Oct 2026

Source

DeFiHackLabs

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-10-SafeApprovalSquat_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/SafeApprovalSquat_exp.sol.


Vulnerability classes: vuln/access-control · vuln/auth · vuln/logic Reproduction: the PoC compiles and runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Offline pass. anvil_state.json is present. The trace forks <rpc url> at block 98,603,854 output.txt:364. [PASS] testExploit in 16.72ms output.txt:565.


Key info#

LossThe Safe's entire balance of four tokens output.txt:357. 3,078,123.681646 USDC (3078123681646, 6 decimals). 351,374.076087103761073449 WAVAX (351374076087103761073449). 183.747433456727608183 WETH.e (183747433456727608183). 10.77385586 BTC.b (1077385586, 8 decimals). The test's USD headline is ~7.45M. This trace does not price the basket
Vulnerable stateVictim Safe (2-of-7) 0x631FCa4a73AEe699638634479132452ef354b9B8. The allowances, not a Safe bug in execTransaction, are what pay out. Each allowance to the squat address is type(uint256).max output.txt:385
Squat address0x3725bD4D175283108156c3F15f86e1c51266155d. Empty at the fork. Occupied by the 13th CREATE from CreateCall
CreateCall0xB22D635D552eC95142E2Abe3FfB859eA7d7C0316, deployed in the test via Safe Singleton Factory 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7 with salt 0 output.txt:404
TokensUSDC 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E. WAVAX 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7. WETH.e 0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB. BTC.b 0x152b9d0FdC40C096757F570A51E494bd4b943E50
Attacker EOA (historical)0x4c48b79b97516719444056d2bf0dc9564138fa0a. MEV bot that landed the drain 0x36b112875f921e51f5ea8ff39b374960694d21b3
This PoC's attackermakeAddr("attacker") = 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e output.txt:366. No Safe key
TxsApprovals, 2026-03-07 0x6952f80bbbcad4368b020bcda600965cf0a520a8694db431b6b1521842d3829e. Original attack, reverted, front-run 0xf1f87d370290afe269ae8eb30fe8e5e4694ad153914a2de6d980641f4a331289. MEV drain 0xf88a7960a07832e1bf5be64edbd12a9e615b4f63959a4b87a147f0c63c007ed5
Chain / block / dateAvalanche / fork parent 98,603,854, attack block 98,603,855 output.txt:364. Approvals set 2026-03-07. The drain is the 2026-10 incident in the folder name. The trace has no block timestamp
CompilerPoC pragma ^0.8.13, trace compiled with solc 0.8.36 output.txt:1. The embedded CreateCall v1.3.0 initcode ends in metadata solc 0.7.6 (64736f6c63430007060033). sources/ is empty
Bug classAn ERC-20 approval names an address, not a deployed contract. That address is the 13th CREATE of a CreateCall instance that is itself CREATE2 from the Safe Singleton Factory at salt 0. Anyone can deploy that bytecode, burn 12 nonces, and land a sweeper on the approved address

TL;DR#

On 2026-03-07 the Safe approved type(uint256).max of USDC, WAVAX, WETH.e, and BTC.b to 0x3725bD4D…. At block 98,603,854 that address still has no code, and the four allowances are still max output.txt:385. The address is not random. It is fixed by (Safe Singleton Factory, salt 0, CreateCall initcode) and then by CreateCall's nonce: the 13th CREATE lands there.

The PoC is a fresh EOA. It calls the factory with salt 0 and the attacker's CreateCall initcode, gets CreateCall at 0xB22D635D… output.txt:404, runs performCreate(0, 0x60006000f3) 12 times to burn nonces output.txt:407 through output.txt:462, and on the 13th call deploys Sweeper at exactly 0x3725bD4D… output.txt:468. sweep() then transferFroms each of the Safe's balances to the attacker. All four Safe balances end at 0 output.txt:541. No Safe signature is required in the draining transaction. The approval, seven months earlier, was the authorization, and it authorized an address rather than a contract the Safe had audited.

Background#

Safe's approve is a normal ERC-20 approval. The token stores (owner=Safe, spender=0x3725bD4D…, amount=2^256-1). Later, transferFrom checks that the caller is that spender. It does not check that the spender's codehash is one the Safe signed, and it does not check that code existed when the approval was set.

CreateCall v1.3.0 performCreate(value, deploymentData) is a public CREATE. The nth contract it creates is keccak256(rlp(createCall, nonce)). Nonces start at 1, so twelve tiny deployments (0x60006000f3, runtime empty) leave the nonce ready for the 13th. The CreateCall address itself is CREATE2(factory, salt=0, keccak256(CREATECALL_INITCODE)). Salt 0 and this initcode are public. The factory 0x914d7Fec… is the canonical Safe Singleton Factory and is already on Avalanche. The PoC does not etch. The initcode is the hex constant in the test, and the trace shows a real CREATE of CreateCall at the expected address output.txt:404.

The historical race matters only as context. The original attack transaction reverted because a MEV bot landed the same deployment first. This test does not replay either transaction. It repeats the address math from a clean EOA and sweeps the balances that were still on the Safe one block earlier.

The vulnerable code#

The bug is not a function that forgot onlyOwner. It is the approval plus the rule that CREATE addresses are public. The sweeper the PoC lands on the approved address is the whole exploit. From test/SafeApprovalSquat_exp.sol:

SOLIDITY
function sweep() external {
    _pull(USDC);
    _pull(WAVAX);
    _pull(WETHe);
    _pull(BTCb);
}

function _pull(address token) private {
    uint256 bal = IERC20(token).balanceOf(safe);
    require(IERC20(token).transferFrom(safe, recipient, bal), "transferFrom failed");
}

That works only because address(this) after the 13th CREATE is the spender. The trace:

  • USDC allowance Safe → squat is 2^256-1 output.txt:385. The test asserts the same for WAVAX, WETH.e, and BTC.b immediately after.
  • Squat code size is 0 before the deploy. The test asserts that. The 13th performCreate then logs new SquatAddress@0x3725bD4D175283108156c3F15f86e1c51266155d output.txt:468 and the address assert passes output.txt:472.
  • transferFrom(Safe, attacker, 3078123681646) USDC output.txt:479.
  • transferFrom WAVAX 351374076087103761073449 output.txt:490.
  • transferFrom WETH.e 183747433456727608183 output.txt:498.
  • transferFrom BTC.b 1077385586 output.txt:508.

WETH.e and BTC.b emit Approval down by the spent amount, from max uint toward a still-enormous remainder output.txt:500, output.txt:510. The Safe did not have to sign those updates. That is standard ERC-20 bookkeeping on an unlimited allowance.

Root cause#

  1. The spender is an address with no code, and the address is predictable. ERC-20 does not bind an allowance to bytecode. The first transaction that puts code at 0x3725bD4D… becomes the spender. Predictability comes from a public factory, salt 0, a public initcode, and a nonce. Twelve performCreate calls of 0x60006000f3 are in the trace at output.txt:407, output.txt:412, output.txt:417, output.txt:422, output.txt:427, output.txt:432, output.txt:437, output.txt:442, output.txt:447, output.txt:452, output.txt:457, and output.txt:462. The next one is the sweeper.
  2. The allowance is unlimited and was never revoked. Set 2026-03-07, still 2^256-1 at block 98,603,854. A finite allowance equal to a single intended payment would have capped one token. It would not have fixed the other three, and it would not have stopped a sweeper that lands before the intended contract.
  3. No secret is required. The PoC prank is makeAddr("attacker"). The factory call, the nonce burns, and sweep() are all from that EOA output.txt:366. The Safe's owners do not appear.
  4. Front-running is the same bug, not a separate one. Two parties who both know the CREATE sequence race for the nonce. The MEV transaction won historically. The reconstruction shows either of them can empty the Safe. The loser reverts because the address is already occupied, not because the allowance checked a signature.

Preconditions#

  • The four allowances to 0x3725bD4D… are still non-zero. Here each is 2^256-1 output.txt:385.
  • 0x3725bD4D… has no code. The test asserts code.length == 0 before the deploy, and the 13th CREATE succeeds, so it was empty.
  • CreateCall is not already sitting at 0xB22D635D… with a nonce past 13. The factory deploy in this trace creates it fresh output.txt:404, so on this fork the salt-0 CreateCall was not yet deployed. If someone else had deployed it and used nonces, the 13th CREATE would miss the address. That is a race, not an access check.
  • The Safe still holds the balances. It does: the four transferFrom amounts match the constants, and the Safe is then at zero on each token output.txt:541.
  • The attacker needs gas only. No WAVAX balance is funded onto the attacker before the sweep. The swept WAVAX is the profit, not the gas.

Attack walkthrough (with on-chain numbers from the trace)#

#ActionAmountTrace
0Fork the parent of the drainblock 98,603,854output.txt:364
1Read allowances Safe → 0x3725bD4D…each 2^256-1output.txt:381, output.txt:385
2Factory CREATE2, salt 0, CreateCall initcodeCreateCall at 0xB22D635D552eC95142E2Abe3FfB859eA7d7C0316output.txt:404
3Twelve performCreate(0, 0x60006000f3)nonce advanced to 12output.txt:407 through output.txt:462
413th performCreate of Sweepernew contract at 0x3725bD4D175283108156c3F15f86e1c51266155doutput.txt:467, output.txt:468, output.txt:472
5sweep USDC transferFrom3,078,123.681646 USDC to 0x9dF0C6…output.txt:479
6WAVAX351,374.076087103761073449output.txt:490
7WETH.e183.747433456727608183output.txt:498
8BTC.b10.77385586output.txt:508
9Logs. Safe balances asserted 0the four raw amounts aboveoutput.txt:357, output.txt:555, output.txt:541

[PASS] testExploit at output.txt:355. Gas 1,146,578.

Diagrams#

sequenceDiagram participant A as Fresh EOA participant F as Safe Singleton Factory participant C as CreateCall participant S as 0x3725bD4D squat participant Safe as Victim Safe A->>F: CREATE2 salt 0, CreateCall initcode F->>C: deploy 0xB22D635D A->>C: performCreate empty bytecode, 12 times A->>C: 13th performCreate Sweeper C->>S: CREATE lands on the approved address S->>Safe: transferFrom USDC, WAVAX, WETH.e, BTC.b Safe-->>A: 3078123.681646 USDC and the other three balances

Remediation#

  1. Revoke these four allowances now. Set USDC, WAVAX, WETH.e, and BTC.b allowance of 0x3725bD4D175283108156c3F15f86e1c51266155d to 0. Do it before any other transaction that could be ordered behind a deploy of that address. If code is already there, revoke anyway and move the tokens.
  2. Never approve an address that has no code, and never approve a counterfactual CREATE/CREATE2 address unless the deployer key is the Safe's. If a contract must be approved before deployment, deploy it in the same transaction that approves it, and pin the codehash. An unlimited allowance to a public nonce address is a blank check.
  3. Stop using type(uint256).max for treasury tokens. Approve the exact amount in the transaction that spends it, or use a permit that expires. A finite allowance would have limited one race. Combined with (2) it is the actual fix.
  4. Watch allowances, not only Safe signers. The draining call does not look like a Safe execution. Monitoring that only alerts on execTransaction from the Safe misses transferFrom(Safe, …) by a newly deployed spender.

How to reproduce#

Offline, from the committed state. _shared/run_poc.sh loads anvil_state.json, starts anvil, and rewrites the test's http://127.0.0.1:8551 URL.

BASH
# from the registry root
_shared/run_poc.sh 2026-10-SafeApprovalSquat_exp -vvvvv
  • Chain / fork: Avalanche, block 98,603,854 (parent of attack block 98,603,855).
  • Committed result in output.txt (finished in 16.72ms):
CODE
[PASS] testExploit()
  USDC  drained: 3078123681646
  WAVAX drained: 351374076087103761073449
  WETHe drained: 183747433456727608183
  BTCb  drained: 1077385586
Suite result: ok. 1 passed; 0 failed; 0 skipped

Reference: the approval transaction and the MEV drain named in the test header — 0x6952f80b… and 0xf88a7960….

References#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.