# Patch Analysis — CVE-2026-XRING (Alibaba XQUIC HTTP/3/QPACK ring buffer resize underflow)

## Status of the fix

There is **no upstream patch**. The disclosure (FoxIO XRING, Jul 8 2026) states that every
published XQUIC version is affected and no patch is available; a web/GitHub check on 2026-07-09
confirms **v1.9.4 (commit `96155cffbde7f062fe45ac3f6899f47e25709d30`, released 2026-06-23) is the
latest of the 26 releases** and contains the vulnerable line. The "fix" analyzed here is the
**one-line negative-control patch** applied by the repro stage (and re-used by this variant stage)
on top of v1.9.4:

```c
/* src/common/utils/ringmem/xqc_ring_mem.c, inside xqc_ring_mem_resize(), both-truncated branch */
- size_t ori_sz1 = mcap - soffset_ori;              /* BUG: sizes old buffer block with NEW capacity */
+ size_t ori_sz1 = rmem->capacity - soffset_ori;    /* FIX: size first block of ORIGINAL buffer with OLD capacity */
```

## What the fix changes

`xqc_ring_mem_resize(rmem, cap)` grows the ring-memory backing buffer when `cap > rmem->capacity`.
It allocates a new power-of-two buffer `mcap = xqc_pow2_upper(cap)`, then copies the `rmem->used`
live bytes from the old buffer into the new one. The copy has four shape cases selected by whether
the data is truncated (wrapped) in the old and/or new buffer. In the **both-truncated** case the
first block of the *original* buffer must be sized with the **old** capacity
(`rmem->capacity - soffset_ori`, since `soffset_ori = rmem->sidx & rmem->mask` is an offset inside
the old buffer). The buggy code instead used the **new** capacity (`mcap - soffset_ori`), making
`ori_sz1` too large by exactly `mcap - rmem->capacity`. The one-line fix corrects that single
computation.

`ori_sz1` is consumed in **both** sub-branches of the both-truncated case:

```c
if (new_sz1 >= ori_sz1) {   /* sub-branch A: new first block >= old first block */
    xqc_memcpy(buf + soffset_new,            rmem->buf + soffset_ori,             ori_sz1);
    xqc_memcpy(buf + soffset_new + ori_sz1,  rmem->buf,                            new_sz1 - ori_sz1);
    xqc_memcpy(buf,                          rmem->buf + new_sz1 - ori_sz1,        rmem->used - new_sz1);
} else {                     /* sub-branch B: new first block  < old first block (PoC lands here) */
    xqc_memcpy(buf + soffset_new,            rmem->buf + soffset_ori,             new_sz1);
    xqc_memcpy(buf,                          rmem->buf + soffset_ori + new_sz1,   ori_sz1 - new_sz1);  /* OOB read */
    xqc_memcpy(buf + ori_sz1 - new_sz1,      rmem->buf,                           rmem->used - ori_sz1); /* underflow */
}
```

Because both sub-branches read the **same** `ori_sz1` variable, changing its definition fixes both
at once. ASAN confirms sub-branch B is the one reached in practice (see Reachability below).

## Assumptions the fix relies on

1. **Single buggy line.** The only old-vs-new-capacity confusion in `xqc_ring_mem_resize` is the
   `ori_sz1` computation. The fix assumes nothing else in the function is wrong.
2. **`rmem->capacity` and `rmem->mask` still describe the OLD buffer during the copy.** They are
   only overwritten (`rmem->capacity = mcap; rmem->mask = mcap - 1;`) *after* the copy block, so
   using `rmem->capacity`/`rmem->mask` inside the copy is correct. The fix relies on this ordering.
3. **The both-truncated branch is the only buggy branch.** The other three copy cases are assumed
   correct (audit below confirms this).

## What the fix explicitly covers

- The **both-truncated** branch of `xqc_ring_mem_resize`, both sub-branches A and B.
- **All callers** of `xqc_ring_mem_resize`, because the fix is inside the function itself. The only
  production caller is `xqc_dtable_set_capacity` (`src/http3/qpack/dtable/xqc_dtable.c:602`), which
  is reached from:
  - the **decoder** path: `xqc_decoder_set_dtable_cap` ← `xqc_qpack_on_encoder_ins` on
    `XQC_INS_TYPE_ENC_SET_DTABLE_CAP` (the QPACK *Set Dynamic Table Capacity* encoder-stream
    instruction). **This is the only attacker-controlled trigger** and the PoC path.
  - the **encoder** path: `xqc_encoder_set_dtable_cap` ← `xqc_qpack_set_dtable_cap`, reached from
    `xqc_h3_conn_on_settings_entry_received` (peer `SETTINGS_QPACK_MAX_TABLE_CAPACITY`) and the
    local `xqc_h3_set_qpack_dtable_cap` app API.
- **All ring offsets and all grow amounts.** The fix replaces `mcap` with `rmem->capacity`, which is
  independent of the grow factor `mcap` and of the wrap position `soffset_ori`. Empirically
  confirmed across mcap=128 (sidx=122, sidx=118) and mcap=256 (sidx=246) — see the variant matrix.

## What the fix does NOT cover (gaps) — none found

A capacity-confusion audit of every `memcpy`/copy in `xqc_ring_mem_resize` and its helper
`xqc_ring_mem_copy` shows **no second independent bug**:

| Branch | Condition | Old/new capacity use | Correct? |
|---|---|---|---|
| A: continuous new | `soffset_new < eoffset_new` | uses `xqc_ring_mem_copy` helper, which reads with `rmem->mask`/`rmem->capacity` (OLD) | yes |
| B: continuous old, truncated new | `soffset_ori < eoffset_ori` | `sz = mcap - soffset_new` (new cap + new offset, consistent); reads bounded by `used < sz` (new-truncated ⇒ `used > mcap-soffset_new`) | yes |
| C: both-truncated, sub-branch A | `new_sz1 >= ori_sz1` | uses buggy `ori_sz1` | **fixed** |
| C: both-truncated, sub-branch B | `new_sz1 < ori_sz1` | uses buggy `ori_sz1` | **fixed** (PoC path) |

`xqc_ring_mem_copy` (used by branch A) consistently uses the old `rmem->mask`/`rmem->capacity` when
reading the old buffer, so it is correct.

### Reachability note (why sub-branch A is dead code in this context)

In the both-truncated case the new buffer must be truncated, which forces
`soffset_new >= mcap - used >= mcap - rmem->capacity` (since `used <= rmem->capacity` always, and
`mcap = k·rmem->capacity` for `k ≥ 2`). That forces
`soffset_ori = soffset_new mod rmem->capacity = soffset_new - (k-1)·rmem->capacity < soffset_new`,
hence `ori_sz1 = mcap - soffset_ori > mcap - soffset_new = new_sz1`, i.e. **`new_sz1 < ori_sz1`
always** (sub-branch B). Sub-branch A (`new_sz1 >= ori_sz1`) is unreachable for any legal grow, so
the PoC's sub-branch B is the only one that fires. The fix still covers A defensively (shared
`ori_sz1`).

## Encoder SETTINGS path — a distinct entry point, but not triggerable for the bug

`xqc_h3_conn_on_settings_entry_received` (`src/http3/xqc_h3_conn.c:722`) calls
`xqc_qpack_set_dtable_cap` → `xqc_encoder_set_dtable_cap` → `xqc_dtable_set_capacity` →
`xqc_ring_mem_resize` when a remote peer sends `SETTINGS_QPACK_MAX_TABLE_CAPACITY`. This **is** an
attacker-reachable entry point to the same sink, and it **is covered by the fix** (the fix is in the
function). However it is **not triggerable for the memory-safety bug**: at SETTINGS-exchange time
the encoder dynamic table is empty (`rmem->used == 0`), and `xqc_ring_mem_resize` skips the entire
copy block (`if (rmem->used != 0)`). SETTINGS may be sent only once per connection, so the
both-truncated grow precondition (wrapped, non-empty ring + grow) can never be met via this path.
The other encoder-resize caller (`xqc_h3_set_qpack_dtable_cap`) is a local application API, not
attacker-controlled.

## Other QPACK encoder-stream instructions

In `xqc_qpack_on_encoder_ins`, only `XQC_INS_TYPE_ENC_SET_DTABLE_CAP` calls
`xqc_decoder_set_dtable_cap` (resize). `INSERT_NAME_REF`, `INSERT_LITERAL`, and `DUPLICATE` call
decoder insert/duplicate routines that **evict** entries but never resize the ring memory. So no
other encoder-stream instruction reaches the sink.

## Behavior before vs after the fix

| | Vulnerable (v1.9.4) | Fixed (v1.9.4 + one-line patch) |
|---|---|---|
| P1 PoC (mcap=128, sidx=122) | ASAN `heap-buffer-overflow READ of size 64` at `xqc_ring_mem_resize:139`; glibc fortify abort exit 134 | ASAN-clean, server alive |
| P2 alt wrap (mcap=128, sidx=118) | ASAN `heap-buffer-overflow READ` at `:139`; crash exit 134 | ASAN-clean, server alive |
| P3 alt grow (mcap=256, sidx=246) | ASAN `heap-buffer-overflow READ of size 192` (= mcap−cap_old) at `:139`; crash exit 134 | ASAN-clean, server alive |

The OOB-read size scales as `mcap - rmem->capacity` (64 for mcap=128; 192 for mcap=256), which is
the exact signature of the `mcap`-vs-`rmem->capacity` confusion; the fix removes it for every mcap.

## Target threat-model scope

XQUIC ships no `SECURITY.md`/threat-model document excluding this class. The bug is a remote,
unauthenticated, network-protocol-triggered memory-safety defect in the default configuration
(dynamic table enabled, `SETTINGS_QPACK_MAX_TABLE_CAPACITY` default 16384), squarely in scope.

## Conclusion

The one-line fix is **complete**: it corrects the sole capacity-confusion defect at the single
point that matters, covers both sub-branches of the only reachable (both-truncated) case, and
applies to every caller (decoder and encoder). No bypass and no distinct uncovered alternate
trigger was found. The fix should still be hardened with regression tests (wrapped-ring grow under
ASAN/UBSAN and `_FORTIFY_SOURCE`) and ideally an upstream release, since none exists today.
