[SECURITY] io_uring: pbuf length signed truncation causes false commit

From: Andres Berbescu

Date: Wed Sep 16 2026 - 17:45:36 EST


Hello,

I am reporting an io_uring provided-buffer length conversion that falsely
commits buffer ownership and permits a post-CQE overwrite in the ring owner's
userspace pbuf pool.

Root cause

struct io_uring_buf.len is __u32. The single-buffer selector zero-extends the
descriptor length through size_t and then passes it to
io_kbuf_commit(..., int len, ...). For descriptor length 0x80000000, the
implicit conversion produces INT_MIN.

io_kbuf_commit() first clears REQ_F_BUFFERS_COMMIT, treats the negative value
as successful completion, and returns without advancing bl->head. Request A
retains its imported ITER_UBUF while request B can select and publish the same
descriptor, address and bid. After B's CQE, A can resume and overwrite the
same byte.

Affected range

The exact root begins at
ae98dbf43d755b4e111fcd086e53939bef3e9a1a, first included in v6.12-rc1 and
the v6.12 final release. It remains present through stable v6.12.110 and tested
mainline f6e7b42bf05b2427fb8a7a1d1c387a86638bb413. I found no fixed release.
The parent of ae98dbf43d75 always advanced the non-incremental head and does
not contain this exact false-success/no-head-advance transition.

Validation

- Debian 6.12.107: 20/20 overlaps.
- Upstream stable 6.12.110: 20/20 overlaps.
- Tested mainline f6e7b42bf05b: 20/20 overlaps.
- Stable with a diagnostic single-selector MAX_RW_COUNT cap: 0/20 overlaps.
- Stock MAX_RW_COUNT control: 0/20 overlaps.

A ten-value boundary sweep is sharp: all values through INT_MAX take the safe
head-advance path, while INT_MAX+1, INT_MAX+2, UINT32_MAX-1 and UINT32_MAX
all take the negative false-commit path and overlap.

An instrumented kernel records A selecting len=2147483648, entering the signed
helper with len=-2147483648, clearing the commit flag with head 0 -> 0, and B
then selecting the same group, list, address and bid before advancing head.

All runs used UID/EUID 1000, group 1000 only, CapEff=0, and no namespace,
capability, SQPOLL, module, device or privileged socket operation.

Observed impact

The demonstrated result is a deterministic post-CQE overwrite at the
userspace address deliberately registered by the ring owner. B publishes bid
2087 after writing byte 0x62; A later writes 0x61 to the same byte and
publishes the same bid.

I am not claiming kernel-memory corruption, arbitrary write, confidentiality
or availability impact, cross-principal mutation, or privilege escalation.

Duplicate assessment

I found no public commit, lore discussion or Linux CNA record with this same
single-selector signed conversion and ownership transition. CVE-2021-3491,
CVE-2025-39816, and commit 0b88f4705899 concern different triggers or selector
paths. My current classification is LIKELY NOVEL.

Reproducer and evidence

A standalone source reproducer, clean-guest startup/exploit scripts, full raw
evidence, manifests, conversion and ownership traces, boundary CSV, and the
diagnostic patch are available privately on request.

AI assistance was used during analysis and report preparation. In accordance
with the current kernel reporting guidance, I am treating the issue as public
and am not publishing the reproducer.

Please let me know what additional testing or final patch direction would be
most useful.

I will attach full report, please if you consider that full PoC + evidence is needed just let me know. 

Thanks,
  
# KRN-2026-004 — io_uring oversized provided-buffer selection falsely commits and permits post-CQE overwrite

Track: `io_uring`

## Summary

Linux/Debian 6.12.107 accepts a legal provided-buffer ring descriptor length of
`0x80000000`. The single-buffer selector carries that value as `size_t`, then
passes it to `io_kbuf_commit(..., int len, ...)`. On x86-64 the narrowing yields
`INT_MIN`. `io_kbuf_commit()` clears the request's sole pending-commit token,
treats every negative length as a successful terminal commit, and returns
without advancing the provided-buffer ring head.

A forced-async read A can therefore select the entry and park on an empty pipe
while retaining an `ITER_UBUF` over the selected userspace address, even though
the shared head still points to that entry. Ready read B then selects the same
entry, copies byte `b`, advances the head, and publishes bid 2087 in its CQE.
When A is woken afterward, its retained iterator copies byte `a` over B's
already released userspace byte and publishes the same bid 2087 a second time.

Status: **CONFIRMED — 4.8 Medium, owner-userspace integrity only**.

```text
CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
```

Final validation reproduced the exact transition in 20/20 fresh Debian
6.12.107 boots, 20/20 stable v6.12.110 boots, and 20/20 current-mainline boots.
A 20-run MAX_RW_COUNT control and a 20-run stable kernel with the diagnostic
single-selector cap produced no overlap. A ten-value sweep changes behavior
sharply between `INT_MAX` and `INT_MAX+1`, and an instrumented kernel records
the wide length, `INT_MIN` helper argument, cleared commit token, unchanged
head, and same-address reassignment. The earlier immutable three-candidate and
two-control stock evidence and its independent review remain preserved.

This is the dynamically confirmed form of HYP-0805's large-length early-commit
root, strengthened by HYP-0827's exact producer/consumer schedule. It is one
merged finding, not a second HYP-0805 finding.

## Affected target

- Project: Linux kernel
- Subsystem: `io_uring` provided-buffer rings
- Confirmed kernel: `6.12.107+deb13-cloud-amd64`, Debian `6.12.107-1`
- Confirmed latest tested stable: v6.12.110 (`fa5b06866b64`)
- Confirmed tested mainline: `f6e7b42bf05b2427fb8a7a1d1c387a86638bb413`
- Confirmed architecture: x86-64 with 4096-byte pages
- Confirmed attacker: local UID/EUID 1000 with `CapEff=0`
- io_uring setting: `kernel.io_uring_disabled=0`
- Library used by the reproducer: liburing
- Sanitizer requirement: none; all confirmation used the stock kernel
- Network/socket-family requirement: none; the final harness uses ordinary pipes

The root begins at `ae98dbf43d755b4e111fcd086e53939bef3e9a1a`, first
present in v6.12-rc1 and first final release v6.12. No fixed release was found
through stable v6.12.110 or tested mainline v7.3-rc3 plus 64 commits.

## Security impact and limit

The demonstrated integrity transition is:

1. A acquires descriptor `(P, 0x80000000, bid=2087)` but falsely discharges its
commit token without moving the shared head.
2. A parks with no CQE while retaining an `ITER_UBUF` capable of writing `P`.
3. B acquires the same descriptor, writes `P[0]=0x62`, advances the head to 1,
and publishes bid 2087 to userspace.
4. A resumes after B's CQE, writes `P[0]=0x61` through its retained iterator,
and publishes bid 2087 again while the head remains 1.

The corruption target is exactly the provided-buffer address deliberately
chosen by the ring owner. The iterator remains `ITER_UBUF`; `access_ok()`, user
copy, and GUP boundaries do not turn it into a kernel-address iterator.
Different submitter address spaces resolve the same numeric address in their
own `mm`; only a mapping intentionally shared beforehand names shared pages.
Registration does not pin the payload address, while direct I/O obtains its own
page references. Unregister/re-register cannot create a stale
`io_buffer_list` dereference because A's local list pointer is already NULL.
Fixed-buffer and zero-copy operations use different selectors.

Accordingly this package claims no confidentiality impact, availability impact,
kernel-memory corruption, kernel object overwrite, arbitrary kernel write,
cross-principal mutation, durable privilege, privilege escalation, LPE, High,
or Critical severity. It claims only a timed post-CQE integrity overwrite in
the ring owner's chosen userspace pool.

## Preconditions and reachability

The confirmed path requires:

- a local ordinary user with UID/EUID exactly 1000 and zero effective
capabilities;
- Linux x86-64 with 4096-byte pages and raw io_uring enabled for the user;
- a one-entry non-incremental provided-buffer ring;
- a descriptor at a valid low userspace address with `len=0x80000000` and bid
2087;
- two ordinary pipes, one empty forced-async read A and one ready read B;
- B's source byte queued and completed before A's source byte is queued;
- enough open-file and locked-memory limits for one small ring, one pbuf ring,
and two pipes; the packaged launcher requires nofile at least 16 and memlock
at least 64 KiB.

No user namespace, capability, device node, kernel module, privileged socket
option, registered fixed buffer, fixed file, SQPOLL, AF_UNIX behavior, kernel
race, image provisioning, or image modification is required.

The fixed payload address `0x100000000` must be free. Failure to map it or any
unsupported io_uring/pbuf-status API produces `API_BLOCK` and is not a positive
result. The launcher treats every such line as failure.

## Root cause in exact source

The confirmed Debian target source is `/usr/src/linux-source-6.12`.

### 1. Registration accepts the full descriptor length

`io_uring/kbuf.c:697-758` registers user-pinned or kernel-mapped provided-buffer
rings but does not cap each mutable `struct io_uring_buf::len`. The ring owner
may therefore publish the legal `u32` value `0x80000000`.

### 2. Single selection retains the length as `size_t`

`io_ring_buffer_select()` at `io_uring/kbuf.c:170-211` reads the current
provided-buffer descriptor, stores its address and length for the caller, and
sets `REQ_F_BUFFER_RING | REQ_F_BUFFERS_COMMIT`. A zero requested length selects
the descriptor's full `0x80000000` length. On unlocked issue, or a file which
cannot poll, it performs the early commit before the backend runs.

The decisive call forwards the selected `size_t` length into
`io_kbuf_commit()`, whose parameter is signed `int`. No `MAX_RW_COUNT` cap
exists on this single-selector path.

### 3. Signed narrowing produces a false successful commit

At `io_uring/kbuf.c:62-76`, `io_kbuf_commit()` first clears
`REQ_F_BUFFERS_COMMIT`. Its `len < 0` branch returns true without calling the
nonnegative commit logic which mutates the incremental descriptor or advances
the non-incremental head. Conversion of `0x80000000` to the target's 32-bit
`int` yields `INT_MIN`, so the function reports complete ownership discharge
while `bl->head` remains unchanged.

Because the result is true, the selector does not latch `REQ_F_BUF_MORE` and
drops its local `io_buffer_list *`. The request retains ring-selection metadata
but no remaining reservation or commit token can exclude a second consumer.

### 4. Read A retains only the selected userspace iterator

`io_uring/rw.c:89-113` routes buffer-selected `READ` and the relevant `READV`
form through the single selector and imports the selected address as a user
buffer. `io_uring/rw.c:848-970` issues the read. A forced-async read from an
empty pipe reaches `-EAGAIN` and parks on poll. Since the false early commit
cleared the pending token and local list, recycle cannot restore or reserve the
entry. The request nevertheless retains the imported `ITER_UBUF` for retry.

### 5. B publishes, then A overwrites

The shared head still names descriptor 0, so ready read B selects the same
address and bid. Its positive one-byte completion follows the normal terminal
put path, advances the head to 1, writes `b`, and emits a CQE with bid 2087.
Only after that CQE is observed does the harness make A's pipe readable. A's
poll retry reuses its retained iterator rather than reselecting, writes `a` over
B's released byte, and emits another CQE with bid 2087. A's NULL-list put cannot
advance the head again.

The violated invariant is that no second request may select a provided-buffer
range while an earlier request's iterator can still access it. The false
transition is `R+C+L -> R` with unchanged shared head H and a live iterator.

## Exact dynamic confirmation

Canonical immutable evidence is packaged under:

```text
20260913T183033Z-HYP-0827-pbuf-false-commit-stock/
```

The source SHA-256 is:

```text
b8f5348d3c1a138fb2f2533827301023fc43736c36222626ac127cb760f6363a hyp0827_pbuf.c
```

All three fresh candidate boots assert this ordered oracle:

```text
CQ_EMPTY stage=A_park_window result=yes
PBUF stage=after_A_park head=0 ... P0=0x00
CQE stage=B_completion ... res=1 ... bid=2087 P0=0x62
PBUF stage=after_B_CQE head=1 ... P0=0x62
CQE stage=A_completion ... res=1 ... bid=2087 P0=0x61
PBUF stage=after_A_CQE head=1 ... P0=0x61
TIMELINE mode=candidate parked_head=0 B_CQE_bid=2087 B_P0=0x62 A_CQE_bid=2087 final_P0=0x61
VERDICT mode=candidate outcome=MATCH failures=0 impact=owner-userspace-integrity-only
```

The output assertion also requires B's CQE to precede A's CQE, both buffer
flags to be present, exactly one matching verdict, process exit 0, and zero
`FALSIFIER` or `API_BLOCK` records.

The three guest boot IDs and QEMU process generations are distinct. All three
harnesses and assertion suites exit 0, all three guests/QEMU processes remain
alive after the non-crashing integrity result, all fault scans are clean, and
all QEMU processes are stopped. `run-index.txt` records the complete matrix.

The final-validation evidence under `final-validation/` adds:

- 20/20 stock Debian 6.12.107 overlaps;
- 20/20 stable v6.12.110 overlaps;
- 20/20 tested-mainline overlaps;
- 0/20 overlaps after the diagnostic cap;
- 0/20 overlaps at MAX_RW_COUNT;
- a ten-value boundary CSV with the exact transition at `INT_MAX+1`;
- a kernel trace linking A and B to one buffer list/address/bid; and
- a clean run of the exact packaged ZIP.

The kernel trace observes A select `len=2147483648`, enter
`io_kbuf_commit()` with `len=-2147483648`, clear the commit token, and return
with `head 0 -> 0`. B then selects the same list, address `0x100000000` and bid
2087 at head 0, commits one byte, and advances head to 1. This proves ownership
reassignment independently of the userspace content oracle.

## Differential controls

Twenty additional fresh stock boots run the generalized verified source with
descriptor length `MAX_RW_COUNT=0x7ffff000`. All 20 controls show:

- A's early commit is nonnegative and advances the head to 1 before parking;
- the A park window still has no CQE;
- B cannot select a buffer and completes `res=-105` (`-ENOBUFS`), flags 0, no
bid, and no write to P;
- A later completes alone with bid 2087 and writes `P[0]=0x61`;
- the head remains 1, the verdict is MATCH, all assertions pass, and no fault
occurs.

The control preserves the same opcode, files, scheduling, group, bid, address,
and target kernel while changing only the descriptor length across the signed
boundary. The earlier two immutable controls show the same result. A separate
ten-value fresh-boot sweep covers MAX_RW_COUNT ±1, INT_MAX ±2, `0x80000000`,
`0x80000001`, and UINT32_MAX ±1. Every value through INT_MAX advances the head
before A parks; every value from INT_MAX+1 has a negative signed interpretation
and overlaps.

Stable v6.12.110 with the one-line diagnostic cap also produces the safe
transition in 20/20 boots, despite the input descriptor remaining
`0x80000000`. The diagnostic result confirms causality but is not presented as
the only acceptable upstream fix.

## Reproduction

Do not run the PoC on a host. Copy `poc.zip` into a disposable isolated VM,
extract it, and from an ordinary UID-1000 shell run:

```sh
./exploit.sh
```

The launcher fails closed unless `systemd-detect-virt --vm` detects a VM. It
then requires Linux x86-64, page size 4096, UID/EUID exactly 1000, zero
`CapEff`, `kernel.io_uring_disabled=0`, sufficient nofile/memlock limits, and
all build/assertion commands. `startup.sh` verifies the preserved source hash
before compiling. The launcher runs only:

```text
build/hyp0827_pbuf candidate
```

It captures the candidate output and runs the preserved independent assertion
script. Success is only the final line:

```text
candidate oracle: PASS
```

The scripts do not provision packages, change sysctls, raise limits, load
modules, alter capabilities, modify the guest image, or select a weaker mode.

For the differential control, inside the same class of disposable VM:

```sh
./startup.sh
./build/hyp0827_pbuf control
```

## Merged HYP-0805/HYP-0827 identity

HYP-0805 first identified the large-length `size_t -> int` early-commit root on
READ but conservatively closed the source-only path at the owner-local impact
boundary. HYP-0827 enumerated compatible producers and second consumers, built
the deterministic pipe schedule, and supplied the five-boot confirmation.
Those records describe one root and one report identity:

- same unbounded single pbuf descriptor length;
- same narrowing at the early `io_kbuf_commit(int)` call;
- same false clearing of the only commit token;
- same unchanged shared head and duplicate buffer ownership.

KRN-2026-004 therefore merges HYP-0805 and HYP-0827. Their evidence must not be
stacked, counted twice, or represented as independent vulnerabilities.

## Public fixes and duplicate analysis

The exact trigger remains distinct from the relevant public changes:

1. `41b70df5b38bc80967d2e0ed55cc3c3896bba781` commits positive progress before
retry. A has zero progress before parking, and the bug occurs in the earlier
selection-time commit.
2. `418eab7a6f3c002d8e64d6e95ec27118017019af` propagates `BUF_MORE` when
`io_kbuf_commit()` returns false. Here negative narrowing incorrectly makes
it return true, so that repair trusts the false-success result.
3. `6028b543884f8735e057ec9eea4908cd61cab230` corrects MSG_TRUNC terminal
accounting. The pipe READ reproducer uses no socket, truncation, bundle, or
positive partial result.
4. `dcbd1c054848848a1937ca0768ce2bdbc31ae621` returns selected length through
the simple RECV helper and incidentally rejects its oversized conversion. It
does not change direct RECVMSG, READ, READV, or the shared selector root.
5. `b948f9d5d3057b01188e36664e7c7604d1c8ecb5` rejects negative SEND/RECV SQE
lengths. This trigger uses SQE length zero; the oversized value enters later
from the pbuf descriptor.
6. `46800585ae04863c623b1563b03d12e9381089f1` adds selection-time
`access_ok()`. A low `P` plus `0x80000000` remains a numerical userspace
range; this hardening does not repair ownership or raise impact.
7. `0b88f470589960591f1cb3f238124939d0a7331d` caps only the plural/bundle
`io_ring_buffers_peek()` path to `MAX_RW_COUNT`. It does not cap
`io_ring_buffer_select()`, the single-selector root used here.

The same single-selector narrowing remains in tested mainline
`f6e7b42bf05b2427fb8a7a1d1c387a86638bb413`. Targeted upstream/stable git,
lore and Linux CNA searches found no public report or fix describing the same
single-selection negative-int false commit. CVE-2021-3491 involves legacy
`PROVIDE_BUFFERS` and `/proc/<pid>/mem`; CVE-2025-39816 concerns repeated
mutable descriptor reads. Neither has this trigger, corrupted accounting state
or patch.

Novelty classification is therefore **LIKELY NOVEL**, conservatively qualified
because private reports are unknowable. Internal duplicate identity with
HYP-0805 is certain: this submission merges HYP-0805 and HYP-0827 into one
finding and must not be counted twice.

## Diagnostic fix and upstream choices

The tested diagnostic change caps every single ring selection before early
commit and before caller-specific signed storage. In
`io_ring_buffer_select()`, it limits the descriptor to `MAX_RW_COUNT` before
selection:

```c
*len = min_t(size_t, min_t(u32, READ_ONCE(buf->len), MAX_RW_COUNT),
*len ?: MAX_RW_COUNT);
```

That one-line diagnostic kernel removed the overlap in 20/20 boots. The
required invariant is that ownership cannot be reported committed while the
head remains unchanged and a live iterator exists.

Fixing only RECVMSG leaves READ, READV, and other single-selector callers on the
same root. Maintainers should choose whether the final repair caps/validates the
shared selector, widens the commit interface with explicit error semantics, or
combines both for API consistency. The diagnostic patch is evidence, not a
premature final design choice.

## CVSS 4.0

```text
CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N = 4.8 Medium
```

- AV:L — a local process submits the io_uring operations.
- AC:L — the explicit A-park, B-complete, A-wake order uses ordinary pipes and
succeeded in all candidate boots without a race-dependent allocator state.
- AT:N — no deployment-specific security precondition is required beyond
ordinary io_uring availability.
- PR:L — an ordinary local UID 1000 is required.
- UI:N — no other user's action is required.
- VI:L — the kernel overwrites a byte after its CQE transferred that same bid
back to the ring owner.
- VC:N, VA:N — neither confidentiality loss nor availability loss is shown.
- SC:N, SI:N, SA:N — no Subsequent System impact is shown.

The independent reviewer accepted this exact vector and the owner-userspace
impact ceiling. No LPE, High, or Critical claim is made.

## Evidence integrity and package hashes

The earlier complete timestamped evidence root was copied byte-for-byte under
`prior-evidence/`. Its embedded root `SHA256SUMS`, all five per-run manifests,
all manifest-verification transcripts and the independent review are preserved.
The new `final-validation/` tree contains all counted raw boots, final reports,
sources and diagnostic patch. `validation-scripts/` contains the exact runners.
Archive-root `ARCHIVE_SHA256SUMS` authenticates every packaged file except
itself and was independently verified after extraction.

One immutable scope note is inaccurate: `manifest_scope.txt` says the root
manifest covers all per-run manifests, but `run_stock_matrix.sh:263-266`
generated it with `find ... ! -name SHA256SUMS`, excluding every per-run
`SHA256SUMS` as well as the root file. The primary note remains verbatim. Raw
run files are root-covered; each per-run manifest and its recorded verification
independently verify that run, and the prior package-level manifest covers all
five embedded per-run manifests.

Preserved source:

```text
b8f5348d3c1a138fb2f2533827301023fc43736c36222626ac127cb760f6363a working/poc/src/hyp0827_pbuf.c
b8f5348d3c1a138fb2f2533827301023fc43736c36222626ac127cb760f6363a working/evidence/20260913T183033Z-HYP-0827-pbuf-false-commit-stock/hyp0827_pbuf.c
```

Submission archives:

```text
29c111d3028a72e7985fd91b1e6dbcc21f96c9ca5a7a2d12c10f8e471361eb0d poc.zip
9970f73ad6a8b4a28a676b975be0e21c9bb66b507d1dbfbfeef69878996ef0d8 evidence.zip
```

`poc.zip` has `startup.sh`, `exploit.sh`, `README.md`, `assert_output.py`,
`SHA256SUMS`, and `src/hyp0827_pbuf.c` at its archive root. `evidence.zip` has
`ARCHIVE_SHA256SUMS`, `prior-evidence/`, `final-validation/`, and
`validation-scripts/` at its archive root.

## Limitations

- Dynamic confirmation covers Debian guests on x86-64 with 4096-byte pages and
the exact Debian, stable and mainline kernels listed above. Other
architectures and page sizes were not run.
- The earliest affected commit and release are established; no fixed release
exists at the tested tips. Future/private fixes are outside this report.
- The dynamic harness confirms pipe READ. Other compatible producer/consumer
forms are source-derived and are not separately claimed as dynamic runs.
- The inherited-child test proves that the ring/pbuf registration remains
usable after fork; it does not demonstrate cross-principal or unarranged
cross-mm mutation.
- The fixed address must be free and pbuf-status registration must be supported;
otherwise the harness reports `API_BLOCK`, not a match.
- Only owner-userspace integrity is claimed. No LPE, kernel memory corruption,
cross-principal effect, High, or Critical outcome is part of KRN-2026-004.