Reproduced Exploit

Subsquid: workers withdraw their bond without deregistering

Jan 1970Otheruntagged3 min read

Chain

Other

Category

untagged

Date

Jan 1970

Source

AuditVault

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. 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/logic

Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable code is reproduced verbatim (marked @>) with faithful minimal doubles; local deploy, no fork.

Root cause#

A worker registers and immediately withdraws its bond without ever calling deregister() and without serving the lock period, because withdraw only checks block.number >= deregisteredAt + lockPeriod() and deregisteredAt is still 0; the worker is never removed from activeWorkerIds, so each cycle plants a dangling ghost entry (unbounded-loop / DoS vector) at zero net cost while the full bond is returned

SOLIDITY
        uint256 workerId = workerIds[peerId];
        require(workerId != 0, "Worker not registered");
        Worker storage worker = workers[workerId];
        require(!isWorkerActive(worker), "Worker is active");
        require(worker.creator == msg.sender, "Not worker creator");
        require(block.number >= worker.deregisteredAt + lockPeriod(), "Worker is locked"); // @> VULN (this line)

Why it's exploitable here#

A worker registers and immediately withdraws its bond without ever calling deregister() and without serving the lock period, because withdraw only checks block.number >= deregisteredAt + lockPeriod() and deregisteredAt is still 0; the worker is never removed from activeWorkerIds, so each cycle plants a dangling ghost entry (unbounded-loop / DoS vector) at zero net cost while the full bond is returned

Attack path#

flowchart TD S0["Setup: deregister event declared"] S1["Setup: epoch boundary helper"] S2["Setup: worker-active predicate"] S3["Setup: worker registers a bond"] S4["Withdraw requires only active worker"] H["A worker registers and immediately withdraws its bond withou"] S0 --> S1 S1 --> S2 S2 --> S3 S3 --> S4 S4 --> H

Marked-line walkthrough (Playground)#

The EVM Playground pins each step to the exact executed source line in 0xce01759b82…:

  1. L112 — Setup: deregister event declared: Setup: the registry emits WorkerDeregistered on a proper exit — the path this attack skips entirely.
  2. L132 — Setup: epoch boundary helper: Setup: registration timestamps are snapped forward to the next epoch boundary.
  3. L144 — Setup: worker-active predicate: Setup: a worker counts as active once its registration epoch has started.
  4. L158 — Setup: worker registers a bond: Setup: the attacker registers a worker, locking a bond and joining activeWorkerIds.
  5. L174 — Withdraw requires only active worker: withdraw checks the worker is active but never requires that it was actually deregistered first.
  6. L206 — Bond returned without deregister: Root cause: the lock check reads deregisteredAt (still 0) so it passes at once; the worker struct is deleted and the bond returned though it never deregistered.
  7. L208 — Ghost entry left in active set: The bond is transferred back but the worker is never removed from activeWorkerIds, planting a dangling entry that bloats every iteration.

PoC#

Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):

BASH
cd 58247-h-03-workers-could-withdraw-without-deregister-and-waiting-f_exp
forge test -vvv

The browser Playground replays the same synthetic opcode-for-opcode and measures the harm. 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

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.