Reproduced Exploit
Squid Router Module Exploit — Caller-Supplied Delegate on the Permissionless Axelar Express Path
1. SquidRouterModule is an enabled module on a Gnosis Safe. It can drive the Safe (execTransactionFromModule) to approve tokens and route swaps — but only after it checks that some delegate holds the relevant permission on the Safe (_checkPermission, BaseModule.sol#L133-L142).
Loss
0.25361701 WBTC + 0.293599251 wTAO + ~0.0221 ETH (wrapped) + 0.000000000000001215 WETH — all converted to 0.5…
Chain
Ethereum
Category
Access Control
Date
May 2026
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-05-SquidRouterModule_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SquidRouterModule_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/access-control/fake-account-substitution · vuln/bridge/missing-validation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. It forks Ethereum mainnet at block 25,170,474 from a local anvil snapshot (anvil_state.json), so no public archive RPC is needed. Full verbose trace: output.txt. Verified vulnerable source: contracts_modules_SquidRouterModule.sol and its base contracts_modules_BaseModule.sol, reached through Axelar's AxelarExpressExecutableWithToken.sol.
Key info#
| Loss | 0.25361701 WBTC + 0.293599251 wTAO + ~0.0221 ETH (wrapped) + 0.000000000000001215 WETH — all converted to 0.568933475584054988 u delivered to the drained Safe; the Safe's productive assets were swept into a worthless token. Attack tx 0x2d52984706…902abfeb |
| Vulnerable contract | SquidRouterModule — 0x1f1d37a3Bf840e35c6a860c7C2dA71Fe555123ca (a Safe module on the Axelar express path) |
| Victim | Safe (Gnosis Safe proxy) — 0xc52950d522034a558903CC409c8bbF1f4Decc62e; singleton impl 0x41675C099F32341bf84BFc5382aF534df5C7461a |
| Permissioned delegate (abused) | 0x352C6a9f59357457b83D97e33cE28B333a7a1F3c — an address holding wildcard permissions on the victim Safe |
| Attacker EOA | 0x9BDC730183821b6bb2B51BE30B77C964FA645b91 |
| Attacker contract | 0xe1d5FCfBba4d46F4937de369De415dD7E2D3265a |
| Chain / block / date | Ethereum mainnet (chainId 1) / fork block 25,170,474 / May 2026 |
| Compiler | Solidity v0.8.30+commit.73712a01, optimizer enabled, 200 runs (per _meta.json) |
| Bug class | Trust-boundary / authorization bypass — a public, unauthenticated entry point that takes the delegate identity from caller-supplied calldata and uses it as the authority for Safe permission checks |
TL;DR#
-
SquidRouterModuleis an enabled module on a Gnosis Safe. It can drive the Safe (execTransactionFromModule) to approve tokens and route swaps — but only after it checks that some delegate holds the relevant permission on the Safe (_checkPermission, BaseModule.sol#L133-L142). -
On the same-chain path the delegate is
msg.sender(_getDelegate, BaseModule.sol#L144-L149) — so the caller can only act with their own permissions. -
On the cross-chain / express path the delegate is instead decoded out of the bridge payload (
_processPayload, SquidRouterModule.sol#L159-L175). The express entry pointexpressExecuteWithTokenisexternal payable virtual(AxelarExpressExecutableWithToken.sol#L111-L148) — anyone can call it, and it invokes_executeWithToken(and therefore_processPayload) without any gateway proof. -
So the attacker simply crafts a payload
abi.encode(module, victimSafe, delegate, actions)wheredelegateis an address that already holds wildcard (*) permissions on the victim Safe (0x352C6a9f…). The module dutifully checkshasPermission(victimSafe, 0x352C6a9f…, APPROVE/SWAP/WRAP)— which all returntrue(output.txt:136-137, output.txt:170-171, output.txt:335-336) — and executes the actions as the Safe. -
The attacker scripts three express calls, one per asset (SquidRouterModule_exp.sol#L145-L149): approve Permit2, Permit2-approve the UniversalRouter, then UniV3-swap the Safe's entire WBTC, wTAO and (wrapped-)WETH balances into the
utoken via the UniversalRouter (SquidRouterModule_exp.sol#L159-L211). -
After the three express calls the Safe's WBTC, wTAO, WETH and native ETH balances are all 0 (output.txt:14-18) and it holds
0.568933475584054988 u(output.txt:477). The valuable assets are gone; the Safe is left holding a low-value token. The PoC asserts each pool received exactly the Safe's prior balance (output.txt:438, output.txt:442, output.txt:446).
The single root flaw: the authority used for permission checks is attacker-controlled
calldata on a permissionless entry point. Nothing binds the delegate in the payload
to anything the protocol trusts.
Background — what SquidRouterModule does#
SquidRouterModule
(source)
is a Safe (Gnosis Safe) module that lets a Squid/Axelar integration drive a user's
Safe to perform DeFi actions — token approvals, Permit2 approvals, native wrap/unwrap,
and Uniswap V2/V3 swaps — on the user's behalf, both same-chain and as the destination
leg of an Axelar cross-chain bridge.
Every privileged action funnels through _handleAction
(SquidRouterModule.sol#L195-L219),
and each handler begins with _checkPermission(safe, delegate, <PERMISSION>). The
permission model is a string-keyed allowlist managed by an external
IPermissionsManager: a (safe, delegate, {module, permissionName}) tuple either has a
permission or it does not. The named permissions are defined in Permissions
(Permissions.sol#L4-L16):
"*" (ALL), "APPROVE", "SWAP", "WRAP", "UNWRAP", "DEPOSIT", etc. A delegate
holding "*" passes every hasPermission check.
The module has two distinct ways to identify the delegate whose permissions are checked:
| Entry path | How delegate is determined | Trust |
|---|---|---|
Same-chain (executeSameChainActions) | _getDelegate() → msg.sender (or delegateBundler.currentDelegate()) | Bound to the actual caller — safe |
Cross-chain bridge (executeSquidRouterBridgeWithActions) | _getDelegate() on the source leg; payload re-encodes it for the destination | Source-bound |
Axelar express / _executeWithToken | delegate is abi.decoded from the inbound payload | Attacker-controlled |
On-chain state at the fork block (block 25,170,474), read directly from the trace:
| Parameter | Value | Source |
|---|---|---|
| Safe WBTC balance | 25,361,701 (8 dec ⇒ 0.25361701 WBTC) | output.txt:67 |
| Safe wTAO balance | 293,599,251 (9 dec ⇒ 0.293599251 wTAO) | output.txt:74 |
| Safe WETH balance | 1,215 wei (18 dec ⇒ ~1.2e-15 WETH) | output.txt:81 |
| Safe native ETH | 22,100,612,795,637,319 (⇒ ~0.0221 ETH) | output.txt:92 |
Safe u balance (before) | 0 | output.txt:88 |
hasPermission(Safe, 0x352C6a9f…, APPROVE) | true (1) | output.txt:136-137 |
hasPermission(Safe, 0x352C6a9f…, SWAP) | true (1) | output.txt:170-171 |
hasPermission(Safe, 0x352C6a9f…, WRAP) | true (1) | output.txt:335-336 |
WBTC-u pool WBTC reserve (before) | 86,384,348 (~0.864 WBTC) | output.txt:102 |
wTAO-u pool wTAO reserve (before) | 157,819,544,503 (~157.8 wTAO) | output.txt:104 |
WETH-u pool WETH reserve (before) | 60,843,492,938,318,422,244 (~60.84 WETH) | output.txt:106 |
The permission check is structurally correct — it does verify the delegate. The fatal detail is which delegate: on the express path it is whatever address the caller put in the payload.
The vulnerable code#
1. The permission check is only as trustworthy as the delegate it is given#
function _checkPermission(
address safe,
address delegate,
string memory permissionName
) internal view virtual {
require(
permissionsManager.hasPermission(safe, delegate, _getPermissionEntry(permissionName)),
PermissionDenied(safe, delegate, permissionName)
);
}
function _getDelegate() internal view returns (address) {
return
msg.sender == address(delegateBundler)
? delegateBundler.currentDelegate()
: msg.sender;
}
For same-chain actions the delegate always comes from _getDelegate(), i.e. it is
bound to msg.sender. A caller can therefore only ever exercise their own
permissions on the Safe. This is the correct, safe pattern.
2. The express path takes delegate from caller-supplied calldata#
function _processPayload(
IERC20 bridgedToken,
uint256 bridgedTokenAmount,
bytes calldata payload
) internal {
(address module, address safe, address delegate, ActionsExecutionParams memory params) = abi.decode(
payload,
(address, address, address, ActionsExecutionParams)
);
require(module == address(this), InvalidModuleAddress(module));
// Send all bridged tokens to the safe
bridgedToken.safeTransfer(safe, bridgedTokenAmount);
_handleActions(safe, delegate, params); // ⚠️ delegate came from the payload
}
(SquidRouterModule.sol#L159-L175)
The only validation here is module == address(this). The safe and — crucially — the
delegate are taken verbatim from the inbound payload and forwarded straight into
_handleActions(safe, delegate, params), which calls _checkPermission(safe, delegate, …)
for every action. There is no check that the caller is the delegate, that the delegate
authorized this call, or that the call genuinely originated from a remote Squid router.
3. The entry that reaches _processPayload is fully permissionless#
_processPayload is reached only through _executeWithToken:
function _executeWithToken(
bytes32,
string calldata,
string calldata sourceAddress,
bytes calldata payload,
string calldata tokenSymbol,
uint256 amount
) internal override {
// Verify source chain sender address
address srcAddress = Strings.parseAddress(sourceAddress);
require(srcAddress == squidRouter, InvalidSourceAddress(srcAddress));
IERC20 token = IERC20(_getTokenAddress(tokenSymbol));
_processPayload(token, amount, payload);
}
(SquidRouterModule.sol#L142-L157)
The only gate is srcAddress == squidRouter. But sourceAddress is a string argument
supplied by the caller, not a value attested by the Axelar gateway. And _executeWithToken
is invoked directly by the express entry point, which performs no gateway proof:
function expressExecuteWithToken(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload,
string calldata symbol,
uint256 amount
) external payable virtual {
if (gatewayWithToken().isCommandExecuted(commandId)) revert AlreadyExecuted();
address expressExecutor = msg.sender;
address gatewayToken = gatewayWithToken().tokenAddresses(symbol);
bytes32 payloadHash = keccak256(payload);
...
IERC20(gatewayToken).safeTransferFrom(expressExecutor, address(this), amount);
_executeWithToken(commandId, sourceChain, sourceAddress, payload, symbol, amount);
}
(AxelarExpressExecutableWithToken.sol#L111-L148)
Unlike executeWithToken (which calls gatewayWithToken().validateContractCallAndMint(...)
at AxelarExpressExecutableWithToken.sol#L54-L63),
the express variant validates nothing about message authenticity. It is designed for
a relayer to front a bridge fulfilment and be reimbursed later — so it only pulls
amount of the bridged token from the caller and then runs the actions. With
amount = 0 that transfer is a no-op (output.txt:126-128), so the attacker
pays nothing and the express path becomes a free, public proxy into _processPayload.
Root cause — why it was possible#
The exploit is a single authorization-design failure with two reinforcing parts:
-
The authority is attacker-controlled data.
_processPayloaddecodesdelegatefrom the payload and uses it as the subject of every_checkPermissioncall. The correct same-chain pattern (_getDelegate()→msg.sender) guarantees a caller can only act with their own permissions; the express path discards that guarantee. The module never verifies thatmsg.senderis the decodeddelegate, nor that the decodeddelegateconsented to this specific call. -
The entry point is permissionless and unauthenticated.
expressExecuteWithTokenisexternal payable virtualand reaches_executeWithToken→_processPayloadwithout any Axelar gateway proof. The lonesrcAddress == squidRoutercheck is defeated trivially:sourceAddressis a caller-supplied string, so the attacker just passes the Squid router's own address as the string ("0xce16F69375520ab01377ce7B88f5BA8C48F8D666", SquidRouterModule_exp.sol#L40, seen in the trace at output.txt:112).
Composed, these mean any address can invoke the module as if it were a legitimate
cross-chain fulfilment carrying any delegate it likes. The attacker chooses a
delegate that already holds wildcard (*) permissions on the target Safe
(0x352C6a9f…). From there, every hasPermission(Safe, 0x352C6a9f…, …) returns true
(output.txt:137, output.txt:171, output.txt:336),
and the module drives the Safe to approve and swap away its entire balance.
The permission manager worked exactly as designed — it was simply asked the wrong question, about an attacker-named delegate, on behalf of an attacker-initiated call.
Preconditions#
- A Safe with
SquidRouterModuleenabled. The module must be an enabled Safe module soexecTransactionFromModulesucceeds (it does — seeExecutionFromModuleSuccessat output.txt:145). - A delegate with broad permissions on that Safe. The attack needs some address
for which
hasPermission(Safe, delegate, APPROVE/SWAP/WRAP)istrue. The victim Safe had granted0x352C6a9f…wildcard-style permissions; the attacker only had to name it in the payload — they never needed its key. - No authenticity binding on the express path. Because
expressExecuteWithTokenis public and_executeWithTokentrusts a caller-suppliedsourceAddressstring, the attacker can reach_processPayloaddirectly. No bridged tokens are required: the PoC usessymbol = "WETH", amount = 0, so the gatewaysafeTransferFromis a 0-value no-op (output.txt:126-128). - No working capital. Unlike AMM-invariant drains, this attack moves no value through the attacker's own funds; it spends the victim's assets. The attacker contract just needs gas.
Attack walkthrough (with on-chain numbers from the trace)#
The attacker EOA 0x9BDC73… (pranked, output.txt:107) calls
attack() on the attack contract 0xe1d5FC… (output.txt:109), which fires
three expressExecuteWithToken calls — one per asset — each carrying a payload that names
delegate = 0x352C6a9f… and a list of approve+swap actions. All amounts are raw integers
as printed by the trace; human approximations follow in parentheses.
| # | Step | Token amount (raw) | ~Human | State change | Trace |
|---|---|---|---|---|---|
| 0 | Initial Safe balances | WBTC 25,361,701 · wTAO 293,599,251 · WETH 1,215 · ETH 22,100,612,795,637,319 | 0.2536 WBTC · 0.2936 wTAO · ~0 WETH · 0.0221 ETH | Safe holds productive assets; u = 0 | output.txt:8-12 |
| 1 | Express call #1 (WBTC) — expressExecuteWithToken(0x0,"",squidRouterStr,payload,"WETH",0) | amount 0 (no token pulled) | — | Permissionless entry into _processPayload with payload delegate 0x352C6a9f… | output.txt:112 |
| 1a | hasPermission(Safe, 0x352C6a9f…, APPROVE) ⇒ 1 | — | — | Authorization passes on attacker-named delegate | output.txt:136-137 |
| 1b | Safe approve(WBTC → Permit2, max) via execTransactionFromModule | uint256 max | — | Permit2 allowed to move Safe's WBTC | output.txt:148-157 |
| 1c | Safe Permit2.approve(WBTC → UniversalRouter, max, exp) | 1,461,501,637…542,975 | uint160 max | UniversalRouter allowed to pull WBTC via Permit2 | output.txt:163-165 |
| 1d | UniV3 swap Safe's WBTC → u (recipient = Safe) | in 25,361,701 → out 253,489,591,314,883,698 | 0.2536 WBTC → 0.2535 u | WBTC leaves Safe; pool WBTC reserve grows | output.txt:175-201 |
| 1e | WBTC-u pool WBTC reserve after | 111,746,049 | ~1.117 WBTC (was 0.864) | Pool gained exactly the Safe's 25,361,701 WBTC | output.txt:437 |
| 2 | Express call #2 (wTAO) — same shape, token = wTAO | amount 0 | — | Second permissionless entry, same delegate | output.txt:212 |
| 2a | hasPermission(Safe, 0x352C6a9f…, APPROVE) ⇒ 1 | — | — | Passes again | output.txt:236-237 |
| 2b | Safe approves Permit2 + Permit2-approves UniversalRouter for wTAO | max | — | Allowances set | output.txt:248-265 |
| 2c | UniV3 swap Safe's wTAO → u | in 293,599,251 → out 293,354,590,493,101,066 | 0.2936 wTAO → 0.2933 u | wTAO leaves Safe | output.txt:275-300 |
| 2d | wTAO-u pool wTAO reserve after | 158,113,143,754 | ~158.1 wTAO (was 157.8) | Pool gained exactly 293,599,251 wTAO | output.txt:441 |
| 3 | Express call #3 (native + WETH) — adds a NATIVE_WRAP action first | amount 0 | — | Third permissionless entry, same delegate | output.txt:311 |
| 3a | hasPermission(Safe, 0x352C6a9f…, WRAP) ⇒ 1 | — | — | WRAP permission also held by named delegate | output.txt:335-336 |
| 3b | Safe WETH.deposit{value: 22,100,612,795,637,319}() | 22,100,612,795,637,319 | wraps 0.0221 ETH | Native ETH → WETH inside the Safe | output.txt:337-343 |
| 3c | Safe approves Permit2 + Permit2-approves UniversalRouter for WETH | max | — | Allowances set | output.txt:350-377 |
| 3d | UniV3 swap Safe's WETH (incl. wrapped) → u | in 22,100,612,795,638,534 → out 22,089,293,776,070,224 | 0.0221 WETH → 0.0221 u | WETH leaves Safe | output.txt:387-412 |
| 3e | WETH-u pool WETH reserve after | 60,865,593,551,114,060,778 | ~60.866 WETH (was 60.843) | Pool gained exactly 22,100,612,795,638,534 WETH | output.txt:445 |
| 4 | Final Safe balances | WBTC 0 · wTAO 0 · WETH 0 · ETH 0 · u 568,933,475,584,054,988 | all productive assets 0; 0.5689 u | Safe fully swept into u | output.txt:14-18, output.txt:477 |
The PoC's assertions confirm the sweep mechanically: each pool received exactly the
Safe's prior balance of that token —
assertEq(poolWBTCΔ, 25,361,701) (output.txt:438),
assertEq(poolWTAOΔ, 293,599,251) (output.txt:442),
assertEq(poolWETHΔ, 22,100,612,795,638,534) (output.txt:446) — and the Safe's
WBTC/wTAO/WETH/ETH all assert to 0 (output.txt:424-435), with
assertGt(u, 0) passing at 568,933,475,584,054,988 (output.txt:450).
Profit / loss accounting#
This is an asset-substitution theft, not an AMM round-trip. The victim Safe's productive
holdings are forcibly swapped into the low-value u token; the attacker's gain is the
victim's loss of liquid value. Accounting in the Safe's balances:
| Asset | Safe before (raw) | ~Human | Safe after | Trace |
|---|---|---|---|---|
| WBTC | 25,361,701 | 0.25361701 WBTC | 0 | output.txt:8 → output.txt:14 |
| wTAO | 293,599,251 | 0.293599251 wTAO | 0 | output.txt:9 → output.txt:15 |
| WETH | 1,215 | ~1.2e-15 WETH | 0 | output.txt:10 → output.txt:16 |
| native ETH | 22,100,612,795,637,319 | ~0.0221 ETH | 0 | output.txt:12 → output.txt:18 |
u (received) | 0 | 0 | 568,933,475,584,054,988 (~0.5689 u) | output.txt:11 → output.txt:17 |
The three swaps minted u to the Safe: 0.253489591314883698 + 0.293354590493101066 + 0.022089293776070224 = 0.568933475584054988 u — matching the final balance to the wei
(output.txt:477). All of WBTC, wTAO, WETH and ETH (collectively the
"0.25 WBTC + 0.29 wTAO + 0.02 WETH" of the @KeyInfo header) were extracted from the
Safe and dumped into the three thin u pools.
Diagrams#
Sequence of the attack#
Asset / Safe-state evolution#
The flaw inside _processPayload / _checkPermission#
Trust boundary: same-chain path vs. express path#
Why each magic number#
payload = abi.encode(SQUID_ROUTER_MODULE, VICTIM_SAFE, PERMISSIONED_DELEGATE, params)(SquidRouterModule_exp.sol#L154): the four decoded fields of_processPayload.modulemust equal the module address to pass themodule == address(this)check;safeis the victim;delegateis the attacker's chosen authority0x352C6a9f…, the address that already holds wildcard permissions on the Safe;paramsare the approve+swap actions.sourceAddress = "0xce16F69375520ab01377ce7B88f5BA8C48F8D666"(SquidRouterModule_exp.sol#L40, output.txt:112): the Squid router's address as a string, supplied solely to satisfyStrings.parseAddress(sourceAddress) == squidRouter. It attests nothing — it is attacker calldata.symbol = "WETH", amount = 0(SquidRouterModule_exp.sol#L41, SquidRouterModule_exp.sol#L156): the express entry pullsamountof the gateway token from the caller before running actions. Withamount = 0, thesafeTransferFromis a no-op (output.txt:126-128), so the attack costs nothing in bridged tokens."WETH"is just a symbol the gateway can resolve to a token address (output.txt:117-120).type(uint256).max(ERC20 approve) andtype(uint160).max(Permit2 approve) (SquidRouterModule_exp.sol#L188, SquidRouterModule_exp.sol#L197): unlimited allowances so the UniversalRouter can pull the Safe's entire balance of each token. Seen on-chain as1,461,501,637…542,975(uint160 max) approvals (output.txt:163-165).POOL_FEE = 500(SquidRouterModule_exp.sol#L42): the 0.05% UniV3 fee tier, packed into the V3 pathtokenIn | 0x0001f4 | u(SquidRouterModule_exp.sol#L208). Theutoken0xe6Ff0FE0…3512is the swap output.amountIn = balanceOf(VICTIM_SAFE)/amountOutMin = 0(SquidRouterModule_exp.sol#L162, SquidRouterModule_exp.sol#L207-L209): swap the Safe's whole balance and accept any output — the attacker does not care about price, only about emptying the Safe.
Remediation#
- Never derive the permission subject from caller-supplied calldata. On the express
path, do not
abi.decodeadelegateout of the payload and trust it. Either bind the delegate to the authenticated cross-chain sender, or require that the express caller IS the delegate (e.g. checkmsg.sender == delegate/_getDelegate() == delegate), as the same-chain path already does via_getDelegate(). - Authenticate the express path.
expressExecuteWithTokenperforms no gateway proof. A Safe-draining module must not be reachable through an unauthenticated public entry. Gate_executeWithToken/_processPayloadso they can only run aftervalidateContractCallAndMint(the standardexecuteWithTokenpath), or restrict the express relayer to a trusted allowlist and have it later reconcile against a real gateway-validated message. - Do not trust a caller-supplied
sourceAddressstring.srcAddress == squidRouteris meaningless whensourceAddressis an argument the caller controls. Source identity must come from gateway-attested message metadata, not from parsing a calldata string. - Scope and cap module permissions. Wildcard (
*) permissions on a Safe module that can approve arbitrary spenders and route arbitrary swaps make a single authorization slip catastrophic. Grant only the specific permissions needed, add per-token / per-tx spending caps, and require explicit, per-call delegate consent (e.g. a signed intent). - Constrain swap targets and outputs. The handler accepts any UniversalRouter path
and
amountOutMin = 0. Even with correct auth, enforce slippage bounds and an output-token allowlist so a compromised flow cannot dump assets into a worthless token.
How to reproduce#
The PoC was extracted into a standalone Foundry project and runs offline against a
local anvil fork served from the bundled snapshot (anvil_state.json), so no public
archive RPC is required:
_shared/run_poc.sh 2026-05-SquidRouterModule_exp --mt testExploit -vvvvv
- The harness starts a local anvil on
127.0.0.1:8545seeded from anvil_state.json; the test callsvm.createSelectFork("http://127.0.0.1:8545", 25_170_474)(SquidRouterModule_exp.sol#L80-L83) and thenvm.roll(25_170_475)/vm.warp(1_779_689_879). foundry.tomlsetsevm_version = 'cancun'; the attack usesvm.etchto install the exploit executor at the historical attack-contract address (SquidRouterModule_exp.sol#L109-L110).- Result:
[PASS] testExploit()— the Safe's WBTC/wTAO/WETH/ETH are all swept to0and it is left holding0.568933475584054988 u.
Expected tail (from output.txt:4-18 and output.txt:484-486):
Ran 1 test for test/SquidRouterModule_exp.sol:ContractTest
[PASS] testExploit() (gas: 897947)
Logs:
=== Before exploit ===
WBTC Balance: 0.25361701
wTAO Balance: 0.293599251
WETH Balance: 0.000000000000001215
u Balance: 0.000000000000000000
ETH Balance: 0.022100612795637319
=== After exploit ===
WBTC Balance: 0.00000000
wTAO Balance: 0.000000000
WETH Balance: 0.000000000000000000
u Balance: 0.568933475584054988
ETH Balance: 0.000000000000000000
...
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.29s (15.21s CPU time)
Reference: Defimon Alerts — https://t.me/defimon_alerts/3045 (SquidRouterModule caller-supplied-delegate exploit, Ethereum mainnet, May 2026; tx 0x2d52984706d5ac567d554d40a62beeeda9e3901dd3847e93dd2a3117902abfeb).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-05-SquidRouterModule_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SquidRouterModule_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Squid Router Module Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.