Reproduced Exploit

Accountable — Cancelling redeem requests permanently blocks the withdrawal queue

1. Redeem requests sit in a FIFO queue keyed by nextRequestId (head). 2. Cancelling a request fully deletes the entry (controller = address(0)) without advancing the head. 3. Processing reads the empty head, _processRequest returns (0, 0, true), and the outer loop does if (shares_ == 0) break befor…

Oct 2025Otherliveness3 min read

Chain

Other

Category

liveness

Date

Oct 2025

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: 62968-cancelling-redeem-requests-permanently-blocks-the-withdrawal. Standalone Foundry PoC and full write-up: 62968-cancelling-redeem-requests-permanently-blocks-the-withdrawal_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/liveness/queue-deadlock · vuln/logic/pointer-not-advanced · frozen-funds

Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only forge-std — no fork, no RPC, no anvil_state. Full trace: output.txt. PoC: test/62968-cancelling-redeem-requests-permanently-blocks-the-withdrawal_exp.sol.

AuditVault taxonomy: severity/high · sector/lending · sector/staking · platform/cyfrin · frozen-funds · permanent · cross-contract-state-consistency


Key info#

ImpactHIGH — withdrawal queue permanently stuck; no subsequent user can withdraw
ProtocolAccountable (credit vaults) — AccountableWithdrawalQueue
Vulnerable codeAccountableWithdrawalQueue::_processUpToSharesif (shares_ == 0) break; before advancing nextRequestId
Bug classEmpty-head queue deadlock (pointer not advanced on deleted entry)
FindingCyfrin 2025-10-16 Accountable v2.0 · #62968 · reporter Immeas
ReportCyfrin Accountable v2.0
SourceAuditVault
StatusAudit finding — fixed by Accountable; verified by Cyfrin. Reproduced as a standalone local PoC.
Compiler^0.8.24 (PoC)

TL;DR#

  1. Redeem requests sit in a FIFO queue keyed by nextRequestId (head).
  2. Cancelling a request fully deletes the entry (controller = address(0)) without advancing the head.
  3. Processing reads the empty head, _processRequest returns (0, 0, true), and the outer loop does if (shares_ == 0) break before ++nextRequestId.
  4. Every later process call deadlocks on the same empty head; tail redeemers never become claimable.

The vulnerable code#

SOLIDITY
(uint256 shares_, uint256 assets_, bool processed_) =
    _processRequest(request_, liquidity, maxShares_, precision_);

if (shares_ == 0) break; // @> VULN: empty head returns (0,0,true) but break skips ++nextRequestId
// FIX: if (shares_ == 0) { if (processed_) { ++nextRequestId; continue; } break; }

Root cause#

Deleted head slots are treated as “processed” but the loop still breaks on shares_ == 0 without advancing the pointer. The cancel path clears storage without moving nextRequestId, so a single dust cancel at the head bricks the entire queue.

Preconditions#

  • Instant (or any) cancel path that fully deletes a request currently at the head.
  • At least one subsequent redeem request enqueued behind the deleted head.

Attack walkthrough#

  1. Alice enqueues a tiny redeem → becomes head (requestId = 1).
  2. Alice cancels; head entry deleted; nextRequestId stays 1.
  3. Charlie enqueues a large, fully funded redeem (requestId = 2).
  4. processUpToShares / processUpToRequestId hit empty head, break, return 0.
  5. Charlie remains fully pending and unclaimable forever.

Diagrams#

flowchart TD A["Alice requestRedeem head id=1"] --> B["Alice cancelRedeem deletes head"] B --> C["nextRequestId still 1"] C --> D["Charlie requestRedeem tail id=2"] D --> E["processUpToShares"] E --> F{"head.controller == 0"} F --> G["shares_ = 0 → break"] G --> H["nextRequestId never advances"] H --> I["Charlie stuck pending forever"]

Impact#

Permanent liveness failure of the withdrawal queue. All subsequent redeemers cannot exit; funds remain locked behind a bricked head pointer.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • 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.