Reproduced Exploit
Subsquid (tSQD): unrestricted `registerTokenOnL2` lets anyone brick the L2 bridge
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: Subsquid-security-review. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/access-control · vuln/frontrun
Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable
registerTokenOnL2function is reproduced verbatim (marked@>) with faithful minimal doubles of Arbitrum's L1 custom gateway and router; local deploy, no fork.
Root cause#
The tSQD L1 token exposes registerTokenOnL2, which sets the token's L2 counterpart in Arbitrum's generic-custom gateway, but declares it public payable with no owner/admin restriction. Arbitrum's gateway records the L1→L2 mapping one-way, so whoever calls this first — including an unprivileged attacker — permanently fixes the L2 address. The vulnerable function, reproduced verbatim:
function registerTokenOnL2(
address l2CustomTokenAddress,
uint256 maxSubmissionCostForCustomGateway,
uint256 maxSubmissionCostForRouter,
uint256 maxGasForCustomGateway,
uint256 maxGasForRouter,
uint256 gasPriceBid,
uint256 valueForGateway,
uint256 valueForRouter,
address creditBackAddress
@>) public payable {
require(!shouldRegisterGateway, "ALREADY_REGISTERED");
shouldRegisterGateway = true;
gateway.registerTokenToL2{value: valueForGateway}(
l2CustomTokenAddress, maxGasForCustomGateway, gasPriceBid, maxSubmissionCostForCustomGateway, creditBackAddress
);
router.setGateway{value: valueForRouter}(
address(gateway), maxGasForRouter, gasPriceBid, maxSubmissionCostForRouter, creditBackAddress
);
shouldRegisterGateway = false;
}
The only guard is the one-shot shouldRegisterGateway boolean — it prevents a second registration but never checks the caller. Arbitrum's design intends the L1 token owner to perform this registration exactly once; leaving it permissionless hands that one-time write to anyone.
Why it's exploitable here#
Following the finding's front-running scenario with concrete values:
- An unprivileged attacker (not the owner) calls
registerTokenOnL2withl2CustomTokenAddress = 0xD00d(a dead address). It succeeds — there is no access control. - The gateway records
tSQD → 0xD00dand enforces the one-way invariantNO_UPDATE_TO_DIFFERENT_ADDR, so the mapping can only ever be re-written to the same address. - The legitimate owner tries to register the correct L2 address
0xC0FFEE; the gateway reverts — the bridge can never be corrected. - An honest user bridges
1000e18tSQD;outboundTransferescrows the tokens and routes them to0xD00d, where the full1000e18is permanently lost. Every subsequent deposit meets the same fate.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xce01759b…:
- L126 — One-time registration guard flag: Setup: shouldRegisterGateway is a one-shot boolean and the only gate on the registration call — nothing here checks who the caller is.
- L152 — Bridge pulls the depositor's tSQD: Setup: transferFrom debits the depositor's allowance here as the gateway escrows their tSQD, the deposit later routed onward to whatever L2 address was registered.
- L159 — The registerTokenOnL2 call begins: The bridge-registration function starts; it sets tSQD's L2 counterpart in Arbitrum's custom gateway that every future deposit will follow.
- L168 — Caller supplies every argument: The final caller-supplied parameter closes an argument list whose l2CustomTokenAddress is chosen entirely by whoever invokes the function.
- L169 — No access control on registration: Root cause: registerTokenOnL2 is public payable with no onlyOwner/admin check, so any unprivileged caller can front-run and register a wrong L2 address.
- L188 — Honest user's gateway wiring: Setup: the honest BridgeUser stores the gateway it deposits through, the deposit whose tSQD ends up stranded at the attacker's dead L2 address.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 58248-h-04-lack-of-access-control-on-tsqds-registertokenonl2-pasho_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: an unprivileged attacker registers a dead L2 address, the owner's corrective registration reverts, and an honest 1000e18 tSQD deposit is stranded at that address permanently. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: Subsquid-security-review.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Subsquid (tSQD): unrestricted
registerTokenOnL2lets anyone brick the L2 bridge". - 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.