Welcome to our research page, where we provide you with the latest news, trends, and insights in the fast-paced world of tech. Join us on this exciting journey and discover the transformative power of technology today.
//
·
Use-after-free in the QPACK encoder of nginx HTTP/3
nginx is one of the most widely deployed pieces of infrastructure on the Internet, fronting millions of websites and serving as the default reverse proxy for countless cloud platforms. Since version 1.25.0, nginx supports HTTP/3 directly in the core rather than through an out-of-tree patch, which means the entire new attack surface of QUIC and QPACK runs straight inside the worker process that handles every connection.
While reviewing the QPACK handling in nginx HTTP/3, I found a use-after-free (UAF) that a remote, unauthenticated attacker can trigger right after completing a QUIC handshake. At the basic level it crashes the worker process. At a higher level, with a little heap grooming, it yields a controlled write (write-what-where) that operates before any application logic runs at all.
The root of the bug is a lifetime mismatch. A pointer that belongs to the HTTP/3 session, which lives for the duration of the connection, ends up holding memory that belongs to a unidirectional stream that lives only for a moment. When that stream closes, the memory is freed, but the session-level pointer is still there and is still treated as valid. As F5 describes it in the advisory, the attacker uses a specially crafted HTTP/3 session to reopen a QPACK encoder stream, which triggers the use-after-free in the worker.
The vulnerability was published by F5 on June 17, 2026 as CVE-2026-42530, internal ID NPS-8, rated High under CVSS v3.1 (8.1) and Critical under CVSS v4.0 (9.2), and fixed in NGINX Open Source 1.31.2. It is purely a data plane issue, and the advisory explicitly states there is no control plane exposure.
CyStack Security Advisory — CVE-2026-42530: Use-after-free in the nginx HTTP/3 QPACK encoder (ngx_http_v3_get_insert_buffer). Fixed in nginx 1.31.2.
Background
HTTP/3 compresses headers with QPACK (RFC 9204), similar to HPACK in HTTP/2 but redesigned to tolerate QUIC delivering streams out of order. QPACK maintains a dynamic table shared across the whole connection: a list of header name/value pairs that both ends can reference by index instead of resending the full string.
Updating the dynamic table is not tied to individual requests. Instead, the side sending headers opens a dedicated unidirectional stream called the QPACK encoder stream (stream type 0x02) and pushes table-update instructions over it. With nginx acting as the server, the client is the encoder and nginx is the decoder. The instructions nginx has to process include Set Dynamic Table Capacity, Insert With Name Reference, Insert With Literal Name, and Duplicate.
The crux is the lifetime of the objects involved. In nginx the dynamic table belongs to the HTTP/3 session, stored in the ngx_http_v3_session_t structure (referred to as h3c). This session lives as long as the QUIC connection exists. Each unidirectional stream, on the other hand, is an independent ngx_connection_t with its own memory pool. When the stream closes, its pool is destroyed. These two lifetimes diverge, and that is the root of the vulnerability.
Root cause
When nginx processes an Insert instruction, it needs a scratch buffer to assemble the entry before placing it into the table. That buffer comes from ngx_http_v3_get_insert_buffer() in src/http/v3/ngx_http_v3_table.c:
Note the allocation line. dt->insert_buffer is a field of the dynamic table, so it lives with the h3c session. But the buffer is created from c->pool, where c is the connection of the first encoder stream to reach this function. A unidirectional stream’s pool has a far shorter lifetime than the session. In other words, a session-level pointer is now holding memory that belongs to a stream.
When the encoder stream closes, ngx_http_v3_close_uni_stream() in src/http/v3/ngx_http_v3_uni.c destroys its pool:
ngx_destroy_pool() returns the whole pool block to the allocator. The ngx_buf_t that dt->insert_buffer points to lives inside that block, so it is now freed memory. Yet nothing resets dt->insert_buffer = NULL. The pointer is still there, still non-NULL, and still treated as valid.
When a second encoder stream then sends another Insert instruction, nginx calls ngx_http_v3_get_insert_buffer() again. This time dt->insert_buffer is non-NULL, so the allocation branch is skipped. The function goes straight to dt->insert_buffer->last = dt->insert_buffer->pos, dereferencing a dead ngx_buf_t. This is a textbook use-after-free: a long-lived object holding a pointer into short-lived memory.
Reproducing the basic bug
The trigger sequence is very short. After completing the QUIC handshake, the client does three things:
Open the first encoder stream, send Set Dynamic Table Capacity followed by an Insert instruction. This first Insert forces nginx to allocate insert_buffer from the stream’s pool.
Let the first stream close, which destroys its pool and frees the ngx_buf_t.
Open a second encoder stream and send another Insert instruction, making nginx dereference the dangling pointer.
sequenceDiagram
participant A as Attacker
participant S1 as Encoder stream 1
participant H3C as HTTP/3 session
participant S2 as Encoder stream 2
A->>S1: Set Dynamic Table Capacity + Insert
S1->>H3C: get_insert_buffer allocates ngx_buf_t from stream 1 pool
Note over S1,H3C: insert_buffer points into stream 1 pool
A->>S1: Close stream 1
Note over S1: ngx_destroy_pool frees the pool, ngx_buf_t is freed
Note over H3C: insert_buffer becomes a dangling pointer
A->>S2: Insert, reopen encoder stream
S2->>H3C: get_insert_buffer sees insert_buffer is not NULL
Note over H3C: dereference of freed memory, UAF
The entire payload fits in under 40 bytes of data after the handshake. On an AddressSanitizer build, the read at line 174 produces a clear report:
The allocation and free stacks match the description above exactly: the memory is freed via ngx_destroy_pool called from ngx_http_v3_close_uni_stream, then reused in ngx_http_v3_get_insert_buffer along the encoder-stream parse path. On an ordinary glibc build at -O2 with no sanitizer, the same sequence corrupts the heap allocator’s metadata and the worker dies with corrupted double-linked list. This is already a remote, unauthenticated denial-of-service: each trigger crashes a worker and takes down every connection running on it.
Exploitation
A crash is serious enough, but the structure of this bug points to something worse. insert_buffer is not a flat data region. It is an ngx_buf_t, and an ngx_buf_t is essentially a set of pointers describing a memory span:
Processing an Insert writes the header name and value bytes into the buffer through these very pointers, roughly *buf->last++ = ... until it reaches buf->end. If the attacker controls the contents of the dead ngx_buf_t, they also control the destination of those writes.
This is where heap grooming comes in. The just-freed pool block sits in a specific size class of the allocator. If, right after the free, the attacker makes nginx allocate a region in the same size class with attacker-controlled content, the allocator is likely to hand back the exact block that was just freed, and the attacker’s content lands on top of the ngx_buf_t corpse. A request body of a suitable size is a perfect candidate for this reallocation, because it is copied almost verbatim into a buffer that nginx requests from the pool.
The PoC builds a fake ngx_buf_t by placing values at the right struct offsets: pos and last both point to the target address, start points to the beginning of the target region, and end points to the target plus a writable span:
Once the corpse has been overwritten with the fake buffer, the second encoder stream sends an Insert With Name Reference carrying a literal value. nginx trusts buf->last and buf->end and copies the literal value to the target address. The write length is bounded by large_client_header_buffers.size, that is, up to several kilobytes of attacker-chosen payload placed at an attacker-chosen address. Both the address and the content are in the attacker’s hands.
flowchart TD
A[Encoder stream 1 sends Insert] --> B[insert_buffer allocated from stream 1 pool]
B --> C[Close stream 1, pool is freed]
C --> D[Send request body in same size class as the freed block]
D --> E[Allocator hands back the just-freed block]
E --> F[Body overwrites fake ngx_buf_t, pos last end point to target]
F --> G[Encoder stream 2 sends Insert with literal value]
G --> H[nginx writes literal via buf-last to target address]
H --> I[Controlled write-what-where achieved]
The timing is worth emphasizing. This method operates on the QPACK encoder stream, which is part of the HTTP/3 transport layer. It does not wait for any virtual host, location, or application handler. The write runs right after the handshake, on any configuration with HTTP/3 enabled, regardless of what nginx serves behind it.
Impact
This is a remote, unauthenticated bug that at the basic level already crashes the worker. But with the controlled write described above, the line between “worker crash” and “code execution” is much thinner than it appears.
A write-what-where in the address space of an nginx worker is a very strong precondition for remote code execution. The worker process has all the familiar targets: function pointers in handler structures, GOT entries, other ngx_buf_t objects and cleanup chains on the heap. Overwriting one of them and letting normal processing dereference it is the standard path from turning a “write-what-where” into control of the execution flow.
The remaining obstacle is ASLR. Because this method writes to an absolute address, the attacker needs to know where to write, meaning they need to locate the worker’s heap or code region. The vulnerability itself does not leak addresses, so weaponizing it into full RCE depends on a separate ASLR-defeat step, such as an accompanying info leak or predictable heap layout in a long-running worker. I do not demonstrate a complete RCE chain in this report, but with such a strong technique running before the application layer, this is why the impact scores for Integrity and Availability are both High.
On the scoring, F5 rated the vulnerability High at 8.1 under CVSS v3.1 and Critical at 9.2 under CVSS v4.0. The advisory notes that exploitation requires additional conditions beyond the attacker’s control, which reflects reality: reallocating exactly the just-freed block depends on heap state the attacker does not fully control, so attack complexity is rated high. Even so, this is a remote, unauthenticated bug whose consequences range from service crash to potential code execution, and F5 notes it sits entirely in the data plane and does not touch the control plane.
The fix
nginx fixed the bug in 1.31.2, released on June 17, 2026, and the fix goes straight at the lifetime root cause rather than just treating the symptom.
The core change is inside ngx_http_v3_get_insert_buffer() itself. The buffer is now allocated from the parent QUIC connection’s pool instead of the unidirectional stream’s pool:
c->quic->parent is the main QUIC connection, the one that owns the whole HTTP/3 session, not a transient unidirectional stream. Its pool lives exactly as long as the session, so insert_buffer now has a lifetime that matches the dynamic table holding it. When an encoder stream closes, the destroyed pool no longer contains the buffer, and the pointer never dangles again. This is the structurally correct fix: tie the memory to an object whose lifetime matches the state it serves.
In the patched version, nginx also blocks the reopen path outright. The unidirectional-stream registration function uses a created_streams bitmask to refuse reopening a stream type that has already been created on the same session:
Unlike known_streams, which is cleared when a stream closes, the bit in created_streams stays set for the whole session. A client that tries to reopen the encoder stream after the first one has closed gets an H3_STREAM_CREATION_ERROR instead of reaching get_insert_buffer a second time. This follows the spirit of RFC 9204, which treats each unidirectional stream type as unique per connection, and it removes the very “reopen” step that the F5 advisory refers to.
The nginx 1.31.2 changelog describes it briefly:
Security: use-after-free might occur when using HTTP/3 and processing a specially crafted QUIC session, allowing an attacker to cause worker process memory corruption or segmentation fault in a worker process (CVE-2026-42530). Thanks to Trung Nguyen of CyStack.
Users should upgrade to NGINX Open Source 1.31.2 or later. Products that repackage nginx, such as NGINX Instance Manager, NGINX Gateway Fabric, and NGINX Ingress Controller, are also in scope per the F5 advisory and should track their respective fixes. For systems that cannot upgrade immediately, the temporary mitigation recommended by F5 is to disable HTTP/3 by removing the quic parameter from all listen directives.
Timeline
2026-05-17: bug identified on mainline 1.31.0.
2026-05-18: the ASAN reproducer and the glibc worker-crash reproducer were both stable on 1.31.0 and master. The report was submitted to F5 via GitHub Security Advisory (GHSA-g9jp-cv3q-76p8). F5 SIRT acknowledged it the same day, and the nginx development team later confirmed and reproduced the bug.
2026-06-17: F5 published advisory K000161616, assigned CVE-2026-42530, and released the fix in NGINX Open Source 1.31.2.