XRING: Crashing XQUIC with spec-compliant QPACK instructions
Sébastien Féry
During recent research into the different QUIC stacks for our active TLS scanner, JA4Scan, I found a deterministic remote crash in XQUIC, Alibaba's QUIC and HTTP/3 library, dubbed XRING.
XQUIC enables HTTP/3 support for Tengine, the Nginx-based web server Alibaba runs across its cloud and CDN infrastructure, including sites like Taobao or AliPay.
A remote, unauthenticated client sends spec-compliant HTTP/3 operation traffic and the server process terminates. The crash requires only 260 bytes of client traffic.
Every XQUIC version is impacted. There is no patch available.
Setting SETTINGS_QPACK_MAX_TABLE_CAPACITY to 0 disables the responsible feature.
The bug is a single wrong variable on a single line. It first causes a 64-byte heap out-of-bounds read, then feeds an underflowed length into `memcpy`, turning the same resize into an oversized heap write that terminates the process.
In this post, we'll walk through how QPACK's dynamic table can be implemented and how it can go wrong.
Background
HTTP/3 compresses headers using QPACK, so repeated headers like `user-agent` can be sent as a small index instead of the full string, saving bandwidth. It uses two lookup tables: a static table, a hardcoded set of common fields defined by the specification, and a dynamic table, built up at runtime between a client and server as new headers are seen. Old entries get evicted when the table exceeds its capacity.
The client drives the server’s dynamic table through a dedicated QUIC stream: the encoder stream. By sending instructions to the server, the client can insert entries, evict old ones and resize the table at will.
It is that resize operation that interests us today.
How XQUIC stores the table
XQUIC stores the dynamic table’s raw bytes in a ring buffer:
Figure 2: The ring buffer struct (xqc_ring_mem.c)
Two indices track the start and end of live data. When an entry reaches the buffer boundary, its bytes wrap: XQUIC calls this a truncated layout. When the data fits without wrapping, it is continuous.
When the client sends the Set Dynamic Table Capacity instruction with a value larger than the current one, `xqc_ring_mem_resize` allocates a new buffer, rounded up to the next power of two, and copies the live bytes from the old buffer into the new one.
This logic has four branches, depending on the old and new buffer layouts. The problematic one is where both the old and new buffers contain truncated data.

Figure 3: Resize decision tree.
Three branches are correct but the fourth uses the wrong variable.
Cause
The variable `ori_sz1` should answer "how many bytes of old data sit in the tail of the old buffer." The code uses `mcap`, which is the new capacity. It should use the original capacity:
My best guess is that this is copy-paste from the line above with the wrong variable left in.
Impact
Remember that `mcap` is the new capacity. When the table grows from 64 to 65, XQUIC rounds the allocation up to 128. So `ori_sz1` ends up much larger than it should be: the "old tail" size becomes larger than the old buffer itself.
That oversized value feeds into the `memcpy` calls. The last one computes `used - ori_sz1` for its length. Given `ori_sz1` is bigger than `used` and `size_t` is unsigned, this wraps.
So concretely, what does it look like? If we ask the server for a 64-byte dynamic table and advance the write cursor to offset 58, then request a resize to 65 (doubling to 128):
The oversized `ori_sz1` cascades through the three `memcpy` calls: the second reads 64 bytes past the old allocation, and the third underflows to a near-maximum `size_t`, copying until the process faults.
To reach this branch, an attacker needs truncated data in both the old and new buffer during a resize.
Per spec, QPACK accounts each entry at `name_len + value_len + 32 bytes` so a 64-byte table only holds one 2-byte entry at a time.
Each insert evicts the previous one and advances the ring cursor by two bytes. After 61 repetitions, the cursor is therefore at offset 58, 6 bytes before the boundary. Inserting a 10-byte entry writes 6 bytes at the tail and wraps the remaining 4 to the front, making the layout truncated. Bumping this capacity to 65 doubles the allocation to 128 while the data stays truncated in the new buffer, entering the buggy branch.

Every value in the payload is within QPACK's advertised limits. XQUIC sets SETTINGS_QPACK_MAX_TABLE_CAPACITY to 16 KiB by default and the payload asks for 64 bytes, then 65, well under the limit.
Here is what ASAN reports when configured to continue past the first error, via `-fsanitize-recover=address`:


In a release build, the second `memcpy` silently reads heap-adjacent memory. The third calls `memcpy` with the underflowed size. On Ubuntu 26.04, glibc's `_FORTIFY_SOURCE=2` catches the invalid length and terminates the process. Without that check, the same call is an oversized heap-buffer write from the old allocation into and past the new allocation.
Conclusion
No malformed packet is needed. A remote client only needs to drive the dynamic table into the wrapped resize case. The bug has been present since XQUIC's first public release (Jan 2022) and no fix is currently available.
Until then, setting SETTINGS_QPACK_MAX_TABLE_CAPACITY to 0 disables the dynamic table and prevents this issue.
Finding affected servers
JA4Scan-TLS and JA4Scan-QUIC are FoxIO’s latest additions to the JA4+ suite of fingerprinting methods. Together, they fingerprint TLS and QUIC libraries running on any given server, enabling one to uncover the running stack of a server and its configuration. You can think of JA4Scan as a more up-to-date and capable version of JARM which was created by FoxIO founder John Althouse and his team at Salesforce in 2020.
JA4Scan is currently integrated in Security Scorecard’s Driftnet.io and coming soon to Palo Alto Networks’s Cortex Xpanse, Censys, Validin, Hunt and other External Attack Surface Management (EASM) tools.
To find impacted publicly facing servers in your fleet, here are the JA4Scan-QUIC fingerprints that map to XQUIC servers in different builds:
03000hlss20n_788388523d2d_000000fc76c9_sd10s05771b8
030000lss000_ae14dbf670a5_6bf37dd3fbbc_0d100cb1cc7c
031000lss20n_a579e07d2e11_a86b11fc76c9_001000c966a5
A full JA4Scan blog will be released soon. If you would like access to the JA4Scan tool, please contact us at info@foxio.io.
Timeline
April 7th 2026 - Alibaba contacted via email following their SECURITY.md - No response
April 17th - Alibaba contacted via Github vulnerability report form - No response
April 22nd - Follow-up email - No response
May 6th - Follow-up email - No response
May 9th - Follow-up Github message - No response
July 3rd - Publication notice
July 8th - Blog Published
References
RFC 9204 - QPACK: Field Compression for HTTP/3
RFC 9114 - HTTP/3
XQUIC - XQUIC’s Github repository
POC
Reproduction code is available at https://github.com/FoxIO-LLC/xring-poc
