# Variant RCA Report — CVE-2026-XRING (Alibaba XQUIC HTTP/3/QPACK ring buffer resize underflow)

## Summary

No bypass and no distinct uncovered alternate trigger was found. The root cause is a single
old-vs-new-capacity confusion in `xqc_ring_mem_resize()` (`src/common/utils/ringmem/xqc_ring_mem.c:129`):
the "both-truncated" resize branch sizes the first block of the *original* ring buffer with the
**new** capacity (`mcap`) instead of the **old** capacity (`rmem->capacity`), producing a heap
out-of-bounds read and a `size_t` underflow into `memcpy`. The one-line fix
(`ori_sz1 = rmem->capacity - soffset_ori`) corrects that single computation, which is consumed by
both sub-branches of the only reachable case and applies to every caller. This stage confirmed
empirically (ASAN, both vulnerable and fixed builds) that the fix eliminates the OOB read across
multiple materially different ring offsets and grow factors, and statically that there is no second
independent capacity-confusion bug, no other attacker-controlled resize trigger, and no unreachable
sub-branch that the fix misses. Result: **fix is complete; no bypass.**

## Fix Coverage / Assumptions

- **Invariant the fix relies on:** `rmem->capacity`/`rmem->mask` still describe the *old* buffer
  during the copy (they are overwritten only after the copy block), so sizing the original-buffer
  block with `rmem->capacity - soffset_ori` is correct.
- **Code paths explicitly covered:** the both-truncated branch of `xqc_ring_mem_resize`, both its
  `new_sz1 >= ori_sz1` and `new_sz1 < ori_sz1` sub-branches (they share the single `ori_sz1` line),
  and therefore every caller — the decoder `Set Dynamic Table Capacity` path (the only attacker
  trigger) and the encoder SETTINGS/app-API path.
- **What the fix does NOT cover:** nothing. The other three copy cases (continuous-new via the
  `xqc_ring_mem_copy` helper; continuous-old/truncated-new; and the dead sub-branch A) were audited
  and are correct; none contains a second capacity confusion. There is no gap.

## Variant / Alternate Trigger

Three payloads were tested (entry point identical for all: a remote unauthenticated QUIC/HTTP3
client opening the HTTP/3 control stream + QPACK encoder unidirectional stream and sending
spec-compliant encoder-stream instructions to the XQUIC `demo_server`):

| Payload | Instructions | mcap | ring sidx | Role |
|---|---|---|---|---|
| P1 | SetCap64, 61×Ins `x:y`, Ins `AAAAA:BBBBB`, SetCap65 | 128 | 122 | original PoC |
| P2 | SetCap64, 59×Ins `x:y`, Ins `AAAAA:BBBBB`, SetCap65 | 128 | 118 | alternate wrap state |
| P3 | SetCap64, 123×Ins `x:y`, Ins `AAAAA:BBBBB`, SetCap200 | 256 | 246 | alternate grow factor |

P2 and P3 are materially different data paths (different ring offsets and, for P3, a different grow
factor / different `mcap`) that still reach the same sink:
`xqc_h3_stream_process_uni` → `xqc_qpack_process_encoder` → `xqc_qpack_on_encoder_ins`
(`XQC_INS_TYPE_ENC_SET_DTABLE_CAP`) → `xqc_decoder_set_dtable_cap` (`xqc_decoder.c:243`) →
`xqc_dtable_set_capacity` (`xqc_dtable.c:602`) → `xqc_ring_mem_resize` both-truncated branch
(`xqc_ring_mem.c:129/139`).

A second, distinct *entry point* to the same sink exists — the encoder dtable resize triggered by a
remote peer's `SETTINGS_QPACK_MAX_TABLE_CAPACITY` (`xqc_h3_conn.c:722` → `xqc_qpack_set_dtable_cap`
→ `xqc_encoder_set_dtable_cap` → `xqc_dtable_set_capacity` → `xqc_ring_mem_resize`). It is covered
by the fix (the fix is in the function) but is **not triggerable for the bug**: at SETTINGS-exchange
time the encoder dtable is empty (`rmem->used == 0`), so `xqc_ring_mem_resize` skips the copy block
entirely, and SETTINGS can be sent only once. The both-truncated grow precondition cannot be met.

Other encoder-stream instructions (`INSERT_NAME_REF`, `INSERT_LITERAL`, `DUPLICATE`) evict entries
but never call `xqc_ring_mem_resize`, so they cannot reach the sink.

## Impact

- **Package/component affected:** `xquic` — `src/common/utils/ringmem/xqc_ring_mem.c`
  (`xqc_ring_mem_resize`), reached via the QPACK decoder dynamic-table capacity path.
- **Affected versions (as tested):** XQUIC v1.9.4, commit `96155cffbde7f062fe45ac3f6899f47e25709d30`
  (latest release at 2026-07-09; no newer/fixed release exists). Fixed target = v1.9.4 + the
  one-line `ori_sz1` patch.
- **Risk level / consequences:** High. Remote, unauthenticated, deterministic process crash (DoS)
  of any XQUIC HTTP/3 endpoint with the dynamic table enabled (default). Underlying defect is a
  heap OOB read (size scales with `mcap - rmem->capacity`) plus a `size_t` underflow into `memcpy`.

## Impact Parity

- **Disclosed/claimed maximum impact:** remote unauthenticated crash (DoS); the blog also describes
  a 64-byte heap OOB read and a `size_t` underflow into `memcpy`. No code execution claimed.
- **Reproduced impact from this variant run:** ASAN `heap-buffer-overflow READ` in
  `xqc_ring_mem_resize:139` on all three payloads against the vulnerable build
  (READ of size 64 for mcap=128; READ of size 192 for mcap=256), followed by process abort
  (exit 134). The fixed build is ASAN-clean and stays alive on all three. The OOB-read size
  scaling with `mcap - rmem->capacity` directly confirms the `mcap`-vs-`rmem->capacity` root cause.
- **Parity:** `full` for the claimed DoS + the disclosed OOB read, via the claimed network surface,
  across multiple alternate data paths; and `full` for "the fix closes the memory-safety defect"
  (no residual ASAN finding on the fixed build).
- **Not demonstrated:** code execution / arbitrary heap write. Only the OOB read + crash (DoS) are
  demonstrated, matching the claim. No bypass (fixed build never crashes).

## Root Cause

In the both-truncated resize case, `soffset_ori = rmem->sidx & rmem->mask` is an offset inside the
**old** buffer (size `rmem->capacity`), so the first block of the old buffer that must be copied has
size `rmem->capacity - soffset_ori`. The buggy code computes `ori_sz1 = mcap - soffset_ori` (using
the **new** capacity), making `ori_sz1` too large by `mcap - rmem->capacity`. In sub-branch B
(`new_sz1 < ori_sz1`, the only reachable sub-branch — see patch_analysis.md for the reachability
proof) this causes:

1. `xqc_memcpy(buf, rmem->buf + soffset_ori + new_sz1, ori_sz1 - new_sz1)` — reads
   `mcap - rmem->capacity` bytes past the old buffer (the ASAN `READ of size 64/192`);
2. `xqc_memcpy(buf + ori_sz1 - new_sz1, rmem->buf, rmem->used - ori_sz1)` — `rmem->used - ori_sz1`
   underflows (unsigned) → glibc `_FORTIFY_SOURCE` aborts (exit 134) in a non-ASAN build.

The fix replaces `mcap` with `rmem->capacity` in the `ori_sz1` computation. No fix commit exists
upstream; the fix is the locally-applied one-line patch on v1.9.4.

## Reproduction Steps

1. Script: `bundle/vuln_variant/reproduction_steps.sh` (self-contained; reuses the durable project
   cache, otherwise clones/builds XQUIC v1.9.4 + Tongsuo 8.4-stable + a parameterized `quic-go`
   client from source, with AddressSanitizer).
2. What it does: builds ASAN `demo_server` for the vulnerable tree (line intact) and the fixed tree
   (one-line patch), then runs P1/P2/P3 against each server, capturing ASAN output and crash state,
   and writes `bundle/vuln_variant/runtime_manifest.json`.
3. Expected evidence: vulnerable server emits ASAN `heap-buffer-overflow READ` at
   `xqc_ring_mem_resize:139` and crashes (exit 134) on all 3 payloads; fixed server is ASAN-clean
   and alive on all 3. Script exits `1` (no bypass). A bypass would exit `0`.

## Evidence

- `bundle/vuln_variant/reproduction_steps.sh` — the variant reproducer (run 3×, all exit 1).
- `bundle/vuln_variant/runtime_manifest.json` — runtime evidence (`sanitizer_used=true`,
  `vulnerable_crashes=3`, `fixed_bypasses=0`, per-payload metadata, proof-artifact list).
- `bundle/logs/vuln_variant/variant_matrix_summary.txt` — the 3×2 result matrix.
- `bundle/logs/vuln_variant/vuln_P{1,2,3}_server.stderr` — ASAN `heap-buffer-overflow READ` reports
  at `xqc_ring_mem_resize:139` (size 64 for P1/P2; size 192 for P3), region `0 bytes after 64-byte
  region` allocated by `xqc_ring_mem_resize:97` (the old ring buffer).
- `bundle/logs/vuln_variant/fixed_P{1,2,3}_server.stderr` — 0 bytes (ASAN-clean, server alive).
- `bundle/logs/vuln_variant/{vuln,fixed}_P{1,2,3}_result.txt` — `CRASHED exit=134` vs `ALIVE`.
- `bundle/logs/vuln_variant/analysis_notes.md` — static entry-point / sub-branch reachability audit.
- `bundle/logs/vuln_variant/source_revision_summary.txt`, `vulnerable_version.txt`,
  `fixed_version.txt`, `latest_version.txt` — exact tested source revisions.

Key ASAN excerpt (vuln-P1):
```
==24138==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7bd837fea620 ...
READ of size 64 at 0x7bd837fea620 thread T0
    #0 memcpy
    #1 xqc_ring_mem_resize  xqc_ring_mem.c:139
    #2 xqc_dtable_set_capacity  xqc_dtable.c:602
    #3 xqc_decoder_set_dtable_cap  xqc_decoder.c:243
    #4 xqc_qpack_on_encoder_ins  xqc_qpack.c:261   (XQC_INS_TYPE_ENC_SET_DTABLE_CAP)
0x...a620 is located 0 bytes after 64-byte region [0x...a5e0,0x...a620)
allocated by ... xqc_ring_mem_resize xqc_ring_mem.c:97   (old ring buffer from initial SetCap=64)
```

Environment: Ubuntu 26.04, GCC 15.2.0 with `-fsanitize=address`, XQUIC v1.9.4 (`96155cf`),
Tongsuo 8.4-stable (BabaSSL) QUIC TLS backend, `quic-go` v0.60.0 client, Go 1.26.5.

## Recommendations / Next Steps

- **Ship the one-line fix upstream** (`ori_sz1 = rmem->capacity - soffset_ori`) and cut a patched
  release; none exists today (v1.9.4 is latest). The fix is complete as-is — no additional code path
  needs closing.
- **Add regression tests** that fill the QPACK dynamic table until the ring wraps, then grow via
  `Set Dynamic Table Capacity` across multiple `mcap` values (128, 256, …), asserting no OOB read /
  underflow under ASAN/UBSAN and `_FORTIFY_SOURCE`. Cover both decoder (encoder-stream SetCap) and
  the encoder SETTINGS path for defense-in-depth.
- **Deploy-time mitigation (now):** advertise `SETTINGS_QPACK_MAX_TABLE_CAPACITY = 0` to disable the
  QPACK dynamic table, preventing the decoder from ever creating/growing the buggy ring memory.
- **Optional hardening:** in `xqc_ring_mem_resize`, derive all "original buffer" sizes from
  `rmem->capacity`/`rmem->mask` and add `assert(soffset_ori < rmem->capacity)` style bounds checks
  so any future old/new confusion fails closed.

## Additional Notes

- **Idempotency:** confirmed — `reproduction_steps.sh` was run 3 consecutive times; all exited `1`
  with an identical 3×2 matrix (vuln 3/3 ASAN crash, fixed 3/3 ASAN-clean/alive).
- **Bounded search:** the variant search is bounded by the code structure — there is exactly one
  attacker-controlled resize trigger (decoder `Set Dynamic Table Capacity`), exactly one reachable
  sub-branch of the both-truncated case (sub-branch B; sub-branch A is provably unreachable for any
  legal grow), and exactly one buggy line. The encoder SETTINGS path is a real but non-triggerable
  alternate entry point. Additional "attempts" would be the same sink under different byte values
  (same root cause, same fix), so the search was stopped at 3 meaningfully distinct data paths.
- **Limitations:** reproduced against the `demo_server` sample; any XQUIC HTTP/3 server exposing the
  QPACK decoder to remote clients is expected to be similarly affected. Only DoS + the OOB read are
  demonstrated; code execution is not claimed and not shown.
