Home DEFCON Quals 2025 - Memory Bank CTF Challenge Writeup
Post
Cancel

DEFCON Quals 2025 - Memory Bank CTF Challenge Writeup

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.

Memory Bank Challenge

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:

index.js

Dockerfile

run_challenge.sh

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.

Pikachu Surprised

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:

  1. Increasing the size of the signature maximized memory consumption, which was necessary for triggering memory exhaustion.
  2. 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.

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.

This post is licensed under CC BY 4.0 by the author.