Wonderland CTF 2026: Fixed Deposits Challenge Results by Runtime Verification

Posted on April 8th, 2026 by Runtime Verification
Last updated on April 8th, 2026

ctf.png
Earlier this week, our team at Runtime Verification participated in Wonderland’s CTF, providing one of the challenges to snatch a piece of the $30,000 prize pool. We want to thank everyone who joined us and worked tirelessly to solve all the challenges (and congrats to the winning teams!).

 

In this post, we walk through the intended solution to our challenge and the reasoning behind it. And a small reminder about a special announcement we made during the CTF about https://www.simbolik.dev/. If you create an account before the end of the week, you will get 3 months for free on our premium feature (the rest of the tool is free to use forever). And if you already had an account before the CTF, drop us a message on https://www.simbolik.dev/#contact to get your free trial too. 

 

Now, let’s get into the challenge!

Background

Fixed Deposits is an on-chain fixed-term deposit protocol built across two contracts: a DepositVault that custodies all funds, and a DepositManager (Challenge.sol) that tracks deposits in a sorted linked list and handles batch settlement.

Users deposit ERC20 tokens for a fixed duration and earn 10% annualised interest on maturity. The settlement function removeCompleted() can only be called 4 times, and users can also withdraw their principal early without interest via withdrawDeposit() or withdrawAll().

The vault starts with 500,000 tokens as an interest reserve, and the player receives 20,000 tokens. The goal is to drain more than half the vault's initial balance and call isSolved().

Understanding the Setup

Before looking for bugs, it's worth mapping out how the protocol works at a high level.

Deposits are stored in a singly-linked list, sorted ascending by owner address. Each node holds the owner, amount, start timestamp, and maturity timestamp. When removeCompleted() is called, the contract walks the list, accumulates matured deposits belonging to the same owner into a batch, and flushes a single vault.release() call per owner. Nodes are deleted from the list as they're processed.

The Vulnerability

The core bug is that previousNode is unconditionally updated in each iteration, even after a deletion. Look at the traversal loop in removeCompleted():

while (currentNode != NULL\_NODE) {     bytes32 next = depositsList.deposits\[currentNode].next;     if (block.timestamp >= Timestamp.unwrap(...maturity)) {         // ...batch logic...         deleteNode(currentNode, previousNode);     }     previousNode = currentNode;  // ← BUG: always updated, even after deletion     currentNode = next; }

After a node is deleted, previousNode is unconditionally set to the deleted node's ID. On the next iteration, if that node also needs to be deleted, deleteNode is called with a stale previousNode — one that is no longer part of the list. The result is that depositsList.deposits[ghost].next gets written instead of the real previous node in the list. This can cause deleted nodes to actually not be deleted and stay on the list triggering duplicate vault.release() payouts.

For instance, supposing we have the following linked list:

1.png

When trying to delete Node B, the delete function will update the next pointer of Node A, which will point to Node C. Then, current and previous nodes will move forward:

2.png

If Node C is also meant to be deleted, then Node B's next pointer will be updated to NULL, instead of Node A's next pointer. The result is that the final list will have Node A and Node C, instead of just Node A

Since the removeCompleted() function releases the amount of tokens in each node before deletion, it is possible to call withdraw(NodeC_ID, NodeA_amount)withdrawAll() or removeCompleted() and get Node C’s amount twice.

Solving the Challenge

Now that the vulnerability is known, let’s dive into how we can exploit it within the protocol's constraints. 
First, notice that the vulnerability only manifests if the node to be removed is not the head of the list. Therefore, the first step is to create a deposit with a maturity somewhere in the future

Also, notice that from the second node to the end of the list, nodes are removed alternately. This means that if we create the following list of deposits:

3.png

If we call removeCompleted(), we get to the following state:

4.png

At this point, we got transferred the sum of the amounts that were in Nodes 2, 3, 4 and 5, but Node 3 and 5 are still in the list. If we call removeCompleted() again, we get the sum of the amounts in Node 3 and 5 a second time, but only Node 3 will be removed. We can call removeCompleted() again, or withdraw the depAmount in Node 5 a third time. With this approach, we were able to steal 2 times depAmount from the vault. 

 

The simple example above shows that, if we make minimum deposits of 1 token in nodes [1-4] and the remaining balance in node 5, we can 2x the player’s initial balance from the vault. But with only 4 calls to removeCompleted() available, we need to be more deliberate. The key insight is that we can compound the effect across rounds: after each call, survivor nodes remain in the list and will be paid out again on the next call. If we re-deposit our proceeds between calls, we can grow the stolen amount exponentially. The following strategy extracts approximately 13 times the player’s initial balance from the vault — enough to clear the 250,000 threshold — using all 4 calls optimally.

 

Throughout this section, X = 20,000 — the full token balance the attacker receives at the start of the challenge (we can neglect the amount of tokens necessary for the deposits with 1 token). The attacker's goal is to turn this initial balance into enough vault withdrawals to drain over 250,000 tokens.

Setup:

The attacker deposits 9 nodes — node 1 with a far-future maturity acts as a permanent anchor, nodes 2–8 each hold amount 1 (matured), and node 9 holds amount X (matured). Node 1 is critical: it ensures the head of the list is never removed, which is required for the bug to fire on all subsequent nodes.

5.png

The intentionally chosen list size of  9 (= 2³ + 1) plays a key role. Given  2^n eligible nodes following the anchor, the node at position 2^(n+1) will persist through n executions of the removeComplete() function. This is critical because, as we will demonstrate, it allows for the extraction of the required funds from the vault.

removeCompleted 1 + deposit:

All 9 nodes are paid out. Even-position nodes (2,4,6,8) are correctly deleted; odd-position survivors (3,5,7,9) remain due to the bug. The attacker immediately deposits three amt=1 nodes and one amt=X node, restoring the list to 9 nodes and ensuring X sits at position 9 again.

6.png

The refill of three amt=1 nodes plus one amt=X node is also deliberate. It restores the list to 9 nodes, placing the new X deposit at position 9. This guarantees that in the next call, the alternating deletion pattern will again preserve both X-amount nodes (at positions 5 and 9) while discarding the cheap filler nodes at even positions.

removeCompleted 2 + deposit:

The same alternating pattern fires across the full 9-node list. Nodes 3,7,10,12 are deleted; survivors 5,9(X),11,13(X) remain. The attacker now deposits just one amt=1 node and one amt=2X node — enough to create the right odd/even alignment for the next call without wasting tokens on unnecessary fillers.

7.png

Notice that only 1 filler node is needed this time rather than 3. After calling removeComplete (RC#2), there are already two amt=1 survivors (nodes 5 and 11) occupying even positions in the new list. Adding just one more filler plus the 2X deposit creates a 7-node list where the three X-amount nodes land at odd positions 3, 5, and 7 — all of which will survive the next removeComplete call.

removeCompleted 3 + deposit:

With 7 nodes, positions 2,4,6 are deleted (5,11,14). Three X-amount survivors remain: 9(X), 13(X), 15(2X). The attacker deposits 4X as node 16 — the final deposit before removeCompleted calls are exhausted.

8.png

No filler nodes are needed now. Depositing only 4X as node 16 creates a 5-node list and the next (and last) removeComplete call will delete positions 2 and 4, triggering the bug on positions 3 and 5.

removeCompleted 4 + withdrawAll:

The 5-node list 1, 9, 13, 15, 16 is the endgame state that the whole strategy was building toward. The last removeComplete call deletes nodes 9 and 15 (positions 2 and 4), but because previousNode is stale after each deletion, nodes 13 and 16 are never actually removed from the list — they are only paid out. This means the vault releases X+X+2X+4X = 8X, but nodes 13(X) and 16(4X) remain accessible. Since all 4 removeCompleted() calls are now exhausted, the attacker calls withdrawAll() to extract the survivors' principal: X + 4X = 5X. Total: 13X = 260,000 tokens, clearing the 250,000 threshold.

9.png

The strategy works because each round serves a dual purpose: it extracts tokens from the vault via the bug, and it repositions the surviving nodes so the next round's deletions hit only cheap filler while leaving the high-value deposits intact. The halving of filler nodes each round (8 → 4 survivors → 2 survivors → 0 fillers needed) reflects the shrinking list size, while the doubling of the large deposit (X → 2X → 4X) ensures the final RC call pays out the maximum possible amount. The withdrawAll() at the end is the cleanup step — it recovers the principals of the two survivor nodes that the bug left permanently stranded in the list.

In Conclusion

What made this challenge interesting wasn't the bug itself, a single misplaced assignment in a loop, but everything that follows once you find it. A stale pointer after deletion is easy enough to spot, but translating that into a concrete drain strategy under tight constraints (only 4 settlement calls, a fixed starting balance, and a sorted list you have to carefully pre-load) is a different skill entirely. The gap between "this contract is exploitable" and "here is the exact sequence of deposits that extracts 13× your starting balance" is where real security analysis lives.
That gap also matters for severity assessment. A vulnerability that requires a precise, multi-round setup with compounding deposits looks very different on paper from one that can be triggered in a single transaction, even if both ultimately drain the vault. Understanding the mechanics deeply enough to construct the optimal exploit is what lets you answer the question that actually matters in practice: given the constraints, what's the worst this could do? In this case, the answer was 260,000 tokens from a standing start of 20,000 (maybe, as a bonus challenge, you can spot how to extract even more tokens?). We hope working through that question was as rewarding to solve as it was to design.