
Executive Summary
Two separately-disclosed nginx vulnerabilities turn out to be the same bug wearing different clothes. The rewrite module’s script engine computes an is_args flag during a length pass and consumes it during a copy pass, and the two passes can disagree. Aim that mismatch at a set variable and you get rift (CVE-2026-42945), a forward heap overflow. Aim it at r->args and you get PoolSlip (CVE-2026-9256), a heap over-read. Each is interesting alone; neither is sufficient alone. rift can write, but only URL-safe bytes and with no idea where anything lives. PoolSlip can read, but cannot corrupt anything.
The write-up chains them into a full remote system() on the stock official nginx:1.30.0 Docker image — Debian 13, glibc 2.41, a stripped release binary, no allocator tuning — behind a plausible API-gateway configuration. The pivotal insight is a refusal to write a full pointer. Because rift emits only URL-safe bytes, a complete 48-bit heap address is writable roughly 0.9% of the time under real ASLR, a figure the author measured across twenty live worker restarts and confirmed by Monte Carlo. Instead of fighting that, the exploit overwrites only the low two bytes of a pointer that is already valid: the cleanup field that limit_conn installs on every request pool. The ASLR-random high bytes ride along untouched, the pointer stays inside its own 64 KB block, and the chain becomes ASLR-independent with nothing hardcoded. On connection teardown, ngx_destroy_pool walks the redirected cleanup list straight into system(cmd), landing about 90% of the time per fresh worker.
TL;DR
- Target: the stock official
nginx:1.30.0Docker image (Debian 13, glibc 2.41, stripped release binary) behind an API-gateway config that contains the trigger (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 is rift (CVE-2026-42945) — a forward heap overflow that can only write URL-safe bytes; aimed atr->argsit is PoolSlip (CVE-2026-9256), an$argsheap over-read. - The chain: PoolSlip leaks live libc and heap addresses (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 about 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), roughly 90% per fresh worker, with no nginx restart required.

Summary
By the author’s account this is the first public chain of these two recently-disclosed nginx rewrite-engine bugs into a single ASLR-independent remote system() on the stock official nginx:1.30.0 Docker image running a realistic API-gateway configuration that contains the rewrite-engine trigger. Both bugs are the same is_args two-pass mismatch pointed at two different sinks: PoolSlip leaks live libc and heap addresses, and rift then performs the 2-byte partial overwrite of a limit_conn cleanup pointer so that the cleanup walk in ngx_destroy_pool calls system(cmd). Nothing is hardcoded, the worker is never restarted, and it lands roughly 90% of the time per fresh worker.
Both CVEs are already patched. The write-up is presented as a technique demonstration — evidence that a full, ASLR-independent system() is achievable — and the chain requires a configuration that specifically triggers the is_args bug. The advice is to patch regardless of whether your configuration looks like the trigger.
- CVE-2026-42945 “rift” — vulnerable 0.6.27 – 1.30.0; fixed in 1.30.1 / 1.31.0.
- CVE-2026-9256 “PoolSlip” — vulnerable 0.1.17 – 1.30.1 (and 1.31.0); fixed in 1.30.2 / 1.31.1.
Upgrade to nginx 1.30.2 (stable) or 1.31.1 (mainline); note 1.30.1 fixes rift but is still vulnerable to PoolSlip, so only 1.30.2 closes both. NGINX Plus: R36 P5 / R32 P7 / R37.0.1.1.
y198, Verichains Blog
The two primitives being chained are:
- CVE-2026-42945 “rift”: an
is_argslength/value heap overflow. This is the write primitive. - CVE-2026-9256 “PoolSlip”: an args-inflation heap over-read. This is the info-leak primitive.
Everything in the original is reproduced live under gdb against the stock release binary, with offsets and addresses taken from real runs. Because the release image is stripped, the gdb probes read struct fields by raw byte offset — the layout is identical to a source build, only the symbols are gone.
PoC + config: Nginx-chain-Rift-Poolslip
The complete proof of concept and the triggering configuration are published at y198nt/Nginx-chain-Rift-Poolslip.
1. The Two Bugs (Root Cause)
Both bugs stem from the same nginx quirk: the rewrite module’s script engine carries an is_args flag, and that flag is computed in one pass but consumed in another.
When nginx compiles rewrite and 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 invoked only 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, turning + into %2B and so on). That mismatch is the root of both bugs.
1.1 rift: is_args heap overflow (the write primitive)
The trigger is a location block whose rewrite replacement contains a question mark, followed by a set that copies a capture:
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 executes with a split personality:
- 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 properties of this write primitive shape everything that follows:
- It writes only URL-safe bytes verbatim. Any byte that needs escaping becomes
%XX(three bytes), which shifts everything after it. To land a precise value at a precise offset, every byte must be URL-safe — roughly 79 of 256 possible byte values. - The overflow length is 2 × (number of escape-needing input bytes). Padding
Abytes do not inflate, so NAs followed by K+s writes N + 3K bytes and overflows by 2K. That gives precise, arithmetic control over how far past the buffer the write reaches.
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 then set from the post-overflow engine position — that is, past the allocation. Anything that later reflects $args therefore reads adjacent heap memory. In this configuration the reflection is a 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 NUL and control bytes, so proxying $args to a real backend simply produces a 502 on the upstream request, which the author verified.
2. Why Chain Them (and Not Just Use rift)
rift on its own is a clean write primitive, but it carries no info-leak, and the public PoC (DepthFirstDisclosures/Nginx-Rift) simply hardcodes the bases:
HEAP_BASE = 0x555555659000
LIBC_BASE = 0x7ffff77ba000
SYSTEM_ADDR = LIBC_BASE + 0x50d70
That only works with ASLR disabled, or against a precisely known image. The author explicitly did not want a “works on my machine” exploit, so PoolSlip was bolted on as the leak primitive: it discloses live libc and heap pointers, the bases are derived 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 precisely because they are the same engine quirk pointed at two different sinks — a set variable versus r->args — so one realistic configuration 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 one the public PoC uses: 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 performs its cleanup walk and ultimately calls system(cmd).
But the cleanup pointer is a full 48-bit heap address, and 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.
The author proves this rather than asserting it. The set of byte values rift can place at a target is exactly nginx’s URI-escape bitmap — the bytes that are not percent-encoded. Reproducing it from the source bitmap gives 79 of 256 values, a per-byte probability of about 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 six low bytes of the target address to fall in that set. Twenty real ASLR heap bases were sampled by restarting the worker, the actual entropy was measured, and the full-address target was Monte-Carlo’d over it using that SAFE set and thirty 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 about 0.9% of the time, and even trying every spray landing the per-run ceiling is roughly 3% — while in twenty real restarts, exactly zero had a usable landing. End-to-end, a full-address overwrite fires reliably with ASLR off but only around 1% under real ASLR, matching the single-target bound and further eroded by spray-landing precision. The high bytes simply cannot 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 (as in 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:

limit_conn cleanup pointer that the partial overwrite retargets. Source: original article.So instead of writing a full address, the exploit overwrites only the low two bytes of that pointer with a URL-safe value, YYYY. The ASLR-random high bytes ride along untouched, so the redirected pointer stays inside the same 64 KB block and no random byte is ever written — which is what makes the technique ASLR-independent.
The redirected pointer is aimed 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, since only the low 16 bits are controlled. So that block must be full of fake-struct records at URL-safe offsets.
The trick is to make the victim itself a held POST /api/upload carrying a tiled body of {system, cmd_ptr, 0} records at a 24-byte stride. Its body lives in its own request pool, so the cleanup pointer’s block is densely packed with records. A handful of early sprays and post-victim sprays tile the neighbouring blocks as well, so that for any ASLR base several record runs straddle the pointer’s block. pick_yyyy() then scans the measured runs and selects an in-block, URL-safe record.
cmd_ptr points at a “command holder” spray — an ordinary 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)
Request a stays alive, parked on the upstream, so that its own pool is not freed before v fires. The destroy order matters — see section 5.5.
4. The Info Leak
The release binary is stripped, so the script engine is read by raw offset: e is $rdi at function entry, e->is_args is bit 3 of the dword at +0x40, and e->buf.data (referred to as $buf) sits 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 roughly 700-byte query the 503 body comes back as about 2100 bytes of raw heap.
After a short warm-up of about forty /search hits to 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:

$args[1489]. Source: original article.For libc, the Ubuntu trick — a large POST body that glibc mmaps just below libc — fails on Debian, because large allocations land in the low brk/PIE cluster rather than below libc. Instead, a pointer into the libpcre/regex region, which the loader maps at a fixed 0xb0ad08 below libc, settles at $args[1729] (x/gx $buf+0x6c8), again confirmed against vmmap:

$args[1729], from which libc is derived. Source: original article.So libc_base = $args[1729] + 0xb0ad08 and heap_base = $args[1489] − 0x28610, both confirmed against the live mappings. Nothing is hardcoded, and request_pool_size is the nginx default — there is no allocator tuning in the configuration.
The derivation as implemented in 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 + 0x53110
A live run — the addresses differ on every run, since they are all derived from the leak with 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 placed 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, annotated with byte offsets so the raw dump 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
};

pool->cleanup pointer in memory. Source: original article.So pool->cleanup is a heap address whose high bytes are ASLR-random but whose enclosing 64 KB block is known from the leak. Only the low two bytes of the pointer at pool+0x40 are overwritten, so that it lands on one of the sprayed records in that same block. The legitimate cleanup record’s handler is an nginx .text address (the stripped ngx_http_limit_conn_cleanup) with its data at cleanup+0x18. The chain has a single entry, next = NULL; which handler it legitimately holds is irrelevant, because the partial overwrite replaces the head pointer with the address of the fake record.
5.2 Landing the write on pool->cleanup
The geometry is gdb-measured and deterministic on the Debian heap, with offsets taken from the leaked bases and kept stable by the groom. There is a subtlety: S, the rift’s set buffer, rises as the rift URI grows, so the target offset is a fixpoint — N + 3K must equal the very gap it 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 S
S is e->buf.data of the rift request, captured the same way as in section 4 but driven by /api/v1/…. The payload A×9 + (+)×1005 + YYYY lands the final two bytes 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 two bytes of the victim’s pool->cleanup are overwritten with the URL-safe YYYY while the ASLR-random high bytes ride along untouched. pool->cleanup now points at one of the sprayed {handler=&system, data=cmd_ptr, next=0} records inside the same 64 KB block, where &system is the leaked libc_base + 0x53110 and data is the command-holder spray. Only the low two bytes were ever written by the attacker; the rest is the victim’s own heap base.
The screenshot in section 5.4 captures this live, thanks to a convenient register situation: at the system breakpoint, ngx_destroy_pool has left pool in $rbp and the cleanup record c in $rbx. Both are callee-saved, so they survive into system. In that single 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, which isc->data.
5.4 The cleanup walk calls system
Set break *system, then fire the chain. The stock release binary is stripped, so there are no line numbers and a couple of static frames show as ??, but the call chain is unmistakable: system is reached straight from the cleanup walk in ngx_destroy_pool. The same stop also displays the corrupted cleanup from section 5.3 — $rbx holding the {&system, cmd, 0} record and $rbp+0x40 holding pool->cleanup:

system(), with the corrupted record visible in $rbx. Source: original article.The result: the worker executed the supplied 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” meant fighting through five distinct crashes. Each is worth recording, because the fix for each is baked into the exploit or the configuration:
- SIGSEGV in
ngx_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-connection groom evicts that conn pool so the request pool lands right after the rift block, with nothing in between. - SIGSEGV in
ngx_palloc_small(fromngx_http_log_requestintongx_pnalloc): finalizing the victim logs the request, allocating from its smashedr->poolheader before the cleanup walk runs. Fix:access_log off;on/api/upload. - SIGABRT in
free()duringngx_destroy_poolof the rift request’s pool: reaching a distant victim forced a second pool block and smashed a chunk header. Fix: keep requestaparked on the upstream so its pool frees after the victim fires, combined with a minimal overflow. pick_yyyyfinds nothing: on some bases no record run lands inside the cleanup block. Fix: a tiled victim body plus early and post-victim sprays, so record runs straddle the block for any base.- SIGSEGV in
free()duringngx_destroy_pool(Debian-specific): if the rift URI reaches 2 KB or more 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 fixpoint N=9, K=1005 keeps the URI under 2 KB; 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 larger design dead-ends — approaches that seemed right and had to be abandoned. The author considers them the most useful part of the write-up, because each pivot is what actually made the chain work on a stock Debian release build:
- 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 about 0.9% of the time under ASLR, as measured in section 3.1. → The 2-byte partial overwrite of an existing
limit_conncleanup pointer (section 3.2). - Use rift alone: it has no info-leak, and the public PoC simply hardcodes
LIBC_BASE/HEAP_BASE, which only works with ASLR off. → Chain PoolSlip as the leak primitive (section 2). - Leak libc via the “big POST body mmaps just below libc” sled (which worked on Ubuntu): on Debian with glibc 2.41, large allocations land in the low brk/PIE cluster rather than below libc, so no libc-cluster pointer ever appears that way. → The libpcre/regex pointer that the loader maps at a fixed
0xb0ad08below 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 were reused, and freeing large chunks raised glibc’s dynamic mmap threshold, pushing bodies to brk. → The same libpcre pointer, which is deterministic and needs no grooming. - Leak straight off
/searchwith no warm-up: the pointer at offset 1729 is not present on a cold heap. → Roughly forty/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, producing an empty reflection or a worker SIGSEGV. → Keep k=350 (about 2100 bytes, which stays mapped); the offset-1729 pointer is already in range. - Read engine and request structs by name in gdb (
p e->is_args,r->uri): the release image is stripped, so field-by-name access throws and the probes silently captured nothing. → Raw byte offsets plus$rdiat function entry; 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))giving N=9, K=1005, with the URI staying under 2 KB. - A 4-empty groom (the Ubuntu value): on Debian it still left a conn pool wedged between
aandvwhenTARGET_OFF=0x18f9, forcing the URI to 2 KB or more — a regime flip with no fixpoint. → 16 empties, matching Debian conn-pool sizing, evict it sovlands close toS, givingTARGET_OFF=0xbd0. - An auto-retry loop plus phone-home callback to force “100%”: every fire eventually crashes the worker after
system(), so phantomlimit_conncounts cap same-IP retries, and it also diverged from the simple one-shot PoC flow. → One-shot like the lab PoC: leak once, fire once, check the box; about 90% per fire, re-run on a miss, with fresh source IPs making retries independent. - Tight groom sleeps: the connection-ordering race was not settled, giving about 60%. → Letting each phase settle with longer inter-step sleeps raised it to about 90%; going beyond that does not help.
6. Reliability & Notes
- About 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, so the 2-byte write misses
pool->cleanupand the process SIGSEGVs. The jump from an initial ~60% came simply from letting each groom phase settle with longer inter-step sleeps; more sleep beyond that does not 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, because the dead connections’ cleanups never run, so after roughly two attempts the source IP hits the perip-100 cap and further connections are refused. Same-IP retries therefore do not compound — but a networked attacker retrying from fresh source IPs gets independent ~90% shots, converging on 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, so the chain can run again immediately.
- Config credibility: no allocator tuning, and a plausible API-gateway shape —
limit_conn, a v1→v2 migration rewrite, an upload proxy, a degraded search page — rather than a contrived lab config. The caveat the author raises directly: the triggeringrewrite+setshape looks innocuous but is a specific pattern that is uncommon in real configurations. The point is the technique, not that this shape is widespread.
7. Credits & References
Original reports:
Original component PoC: DepthFirstDisclosures/Nginx-Rift (rift). The chained PoC is at y198nt/Nginx-chain-Rift-Poolslip.
Key Takeaways
- One root cause can produce two CVE classes. The
is_argsflag being computed in the length pass and consumed in the copy pass yields a heap overflow when aimed at asetvariable and a heap over-read when aimed atr->args. Triaging the two bugs independently misses that a single configuration naturally exposes both. - Constrained-charset write primitives are not weak primitives. rift can only emit ~79 of 256 byte values, which kills a full-pointer overwrite under ASLR at about 0.9% success — but the constraint disappears entirely once you stop writing whole pointers.
- Partial overwrites neutralize ASLR without leaking the base for the write. Overwriting only the low two bytes of an already-valid pointer keeps the random high bytes intact, confining the redirect to a known 64 KB block. The leak is still needed to choose the target, but no random byte is ever written.
limit_connsupplied the pointer. A rate-limiting directive registers a cleanup on every request pool, which is exactly the already-valid heap pointer the technique needs. A defensive feature became the exploitation foothold.- Spray geometry does the rest. Because the redirect is confined to one 64 KB block, the victim’s own request body is tiled with 24-byte
{system, cmd_ptr, 0}records so that block is dense with valid landing sites regardless of ASLR base. - Distribution details matter more than the bug. The Ubuntu mmap-below-libc leak fails on Debian 13 / glibc 2.41; libc had to come from a libpcre pointer at a fixed
0xb0ad08offset instead. Stripped release binaries also forced raw-offset struct reads throughout. - The write target is a fixpoint, not a constant. Because the
setbuffer moves with URI length,N + 3Kmust equal the gap it itself produces — solved by iteration to N=9, K=1005, which also keeps the URI under the 2 KB large-header-buffer threshold.
Defensive Recommendations
- Upgrade to nginx 1.30.2 (stable) or 1.31.1 (mainline). Do not stop at 1.30.1 — it fixes rift but remains vulnerable to PoolSlip, so only 1.30.2 closes both. For NGINX Plus, the fixed builds are R36 P5, R32 P7 and R37.0.1.1.
- Patch even if your configuration does not look like the trigger. The triggering
rewrite+setshape is uncommon, but “uncommon” is not “absent”, and configurations get edited by people who do not know about this interaction. - Audit configurations for the specific pattern: a
rewritewhose replacement contains?followed by asetthat copies a regex capture in the same location block, and anyrewritethat inflatesr->args. Grep your config tree for both shapes. - Never reflect
$argsin an nginx-generated response body. The degraded-search page returningQuery: $argsis what converted an internal over-read into a remote memory disclosure. Reflect nothing from the request into areturndirective. - Treat degraded-backend error pages as an attack surface. The leak surfaced precisely because an offline upstream fell through to a static nginx-level page. Review every
error_pageand named-location fallback for what request data it echoes. - Do not treat ASLR as the control that stops heap corruption. A partial overwrite of an existing pointer bypasses it entirely. ASLR raises the cost of the naive approach and nothing more.
- Monitor for the crash signature. Every attempt crashes the worker after
system()fires, so repeated worker respawns — especially paired withlimit_connconnection counts that never decay — are a strong post-exploitation indicator worth alerting on. - Watch for the traffic pattern: bursts of
/searchrequests with long runs of+characters, heldPOSTbodies that never complete, and long URIs padded with repeated characters just under 2 KB. Rate-limiting alone will not stop it, since fresh source IPs make retries independent.
Conclusion
The most instructive part of this chain is not that two nginx bugs compose — it is the decision to stop trying to write a pointer. Faced with a write primitive restricted to 79 of 256 byte values, the obvious path is a full-address overwrite, and the author measured that path honestly enough to prove it dead: zero usable landings across twenty live ASLR bases, with a Monte Carlo ceiling of about 3%. The pivot to overwriting two bytes of a pointer that limit_conn had already placed turns a probabilistic mess into a deterministic one, because the bytes that cannot be controlled are simply never touched. Combined with a leak primitive that shares the same root cause, and a spray that makes every landing site in the reachable block a valid target, the result is remote system() on a stock image with nothing hardcoded. Both CVEs are patched; the technique — partial overwrites against constrained-charset write primitives — is not going anywhere.
Original text: “Two bytes to RCE: chaining rift + PoolSlip into an ASLR-independent nginx 1.30.0 exploit” by y198 at Verichains Blog.


