Off-by-one in unix98 pty clone limit: unit==MAXPTYS accepted, ptis[MAXPTYS] out-of-bounds read (uninitialized pointer) + out-of-bounds pointer write
| Field | Value |
|---|---|
| ID | DF-2768 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L |
| CWE | CWE-787 / CWE-125 / CWE-457 |
| File | sys/kern/tty_pty.c |
| Lines | 176 (limit), 186 (OOB read), 190 (OOB write), 1292 (array) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
ptyclone passes the ptis[] array SIZE (MAXPTYS=1000) as devfs_clone_bitmap_get()'s limit, but that helper only refuses units STRICTLY GREATER than the limit, so unit 1000 is accepted when units 0..999 are busy. ptyclone then reads ptis[1000] (uninitialized memory consumed as a struct pt_ioctl pointer) and writes ptis[1000]=pti (a kernel heap pointer stored past the 8000-byte allocation). Today the OOB read yields NULL only because kmalloc(8000) rounds to an 8192-byte zone chunk whose full-chunk zeroing covers the slot β a non-NULL uninit value would be type-confused as pt_ioctl* (make_only_dev/ make_dev/flag writes through it), and a zone-boundary MAXPTYS (e.g. 1024) puts the slot in the neighboring heap chunk.
Threat model & preconditions
Any local user (opening /dev/ptmx needs no privilege; 1001 fds vs ulimit 32528) executes both OOB accesses on the stock kernel: presently a MAXPTYS guard bypass (functional 1001st pty) plus a latent CWE-125/457/787 that becomes cross-object corruption the moment MAXPTYS hits a zone-size multiple or the allocator's full-chunk zeroing changes.
Proof of concept
VERIFIED unprivileged on the stock INVARIANTS guest (findings/poc/DF-2768/): 1001st ptmx clone β "VULNERABLE: /dev/pts/1000 exists" (rc=2); the OOB slave is functional; 5 create/destroy cycles leave the M_PTY count at 1002 (the ptis[1000] write persisted and is reused). Fixed kernel: refusal at #1001, /dev/pts/1000 absent. uid0 route not pursued (at MAXPTYS=1000 the read is deterministically NULL and the write confined to intra-chunk slack β fully characterized in VERDICT.md).
Recommended fix
unit = devfs_clone_bitmap_get(&DEVFS_CLONE_BITMAP(pty), MAXPTYS - 1);
(pass the highest valid unit number instead of the array size).
Validated on a rebuilt kernel.
Timeline
- 2026-08-30 Discovered during pass-2 audit of tty_pty.c (GLM 5.3); unpriv reproduction + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2768 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.9 KB | β raw | |
| VERDICT.md | β | 3.1 KB | β raw | |
| ptmx_limit_offbyone.c | β | 1.8 KB | view raw | |
| probe_oob_unit.c | β | 2.3 KB | view raw | |
| cycle_oob_unit.c | β | 1.8 KB | view raw | |
| build.sh | β | 262 B | view raw | |
| run.sh | β | 357 B | view raw | |
| run.log | β | 626 B | view raw | |
| run.2.log | β | 431 B | view raw | |
| run.3.log | β | 1.1 KB | view raw | |
| run.fix.log | β | 430 B | view raw | |
| env.txt | β | 289 B | view raw | |
| fix.diff | β | 1.2 KB | view raw | |
| combined_fix.diff | β | 810 B | view raw | |
| fix_build.log | β | 5.7 MB | β download |
DF-2768 β unix98 pty clone-limit off-by-one: ptis[MAXPTYS] OOB read+write
What it is
ptyclone() (sys/kern/tty_pty.c:176) asks for a new unit with
unit = devfs_clone_bitmap_get(&DEVFS_CLONE_BITMAP(pty), MAXPTYS);
but devfs_clone_bitmap_get() only refuses units strictly greater
than its limit (sys/vfs/devfs/devfs_helper.c:224-227):
unit = devfs_clone_bitmap_fff(bitmap);
if (limit > 0 && unit > limit) /* unit == limit passes! */
unit = -1;
With all units 0..999 allocated, fff returns exactly 1000, the check
1000 > 1000 is false, and unit 1000 is accepted. ptis is allocated
with exactly MAXPTYS entries (tty_pty.c:1292:
kmalloc(sizeof(struct pt_ioctl *) * MAXPTYS, ...) = 8000 bytes), so
ptyclone then executes, with unit == 1000:
- tty_pty.c:186
if ((pti = ptis[unit]) == NULL)β out-of-bounds read of uninitialized heap memory, consumed as apt_ioctl *pointer (type-confusion primitive if non-NULL); - tty_pty.c:190
ptis[unit] = ptiβ out-of-bounds write of a kernel heap pointer 8 bytes past the requested allocation size.
Today the OOB read yields NULL by allocator accident: kmalloc(8000)
rounds to an 8192-byte zone chunk and the M_ZERO path bzeros the full
rounded chunk size (kern_slaballoc.c: zoneindex() rewrites the request
to the chunk size; bzero(chunk, size) at the done: label), so the
slack at +8000..8191 is zero. The OOB write therefore lands in dead
slack of ptis' own chunk β no cross-object corruption at MAXPTYS=1000.
If MAXPTYS were a power-of-two multiple (e.g. 1024 β exactly 8192
bytes), ptis[MAXPTYS] would instead read/write the first 8 bytes of
the neighboring heap chunk β a groomable type-confusion/corruption
primitive.
Observable effect on the stock kernel: a functional 1001st pty is
created beyond the enforced limit; the OOB slot persists and is reused
across cycles (ptis is never freed and pti_done() never clears
ptis[unit]).
Reproduce (unprivileged)
cc -O2 -Wall -o ptmx_limit_offbyone ptmx_limit_offbyone.c ./ptmx_limit_offbyone # as ANY user (e.g. nobody) # expected (vulnerable): "VULNERABLE: /dev/pts/1000 exists ...", rc=2
Also in the pack:
probe_oob_unit.cβ holds 1000 ptys, shows the 1001st clone creating/dev/pts/1000, the master open failing (ENODEV via the autoclone fallback),/dev/pts/1000opening successfully as a slave, and the 1002nd open being refused.cycle_oob_unit.cβ cycles the OOB unit 5Γ: each cycle re-creates/dev/pts/1000and reuses the same pti (vmstat -m"ptys" count stays at 1002 = ptis array + 1001 ever-allocated ptis), proving the OOB slot write atptis[1000]persisted.
Success criterion: /dev/pts/1000 exists while 1000 ptys are held β
ptis[1000] OOB taken. Fixed kernel: open #1001 refused (ENODEV),
no /dev/pts/1000, exactly β€1000 concurrent ptys.
Fix
MAXPTYS - 1 is the highest valid unit. See fix.diff.
DF-2768 VERDICT
Status: reproduced (unprivileged, default config, stock INVARIANTS kernel #0). Impact: security-limit bypass + latent out-of-bounds read/write (memcorrupt class); no crash/corruption observable at MAXPTYS=1000 because the OOB lands in allocator slack.
How it was proven
- Static:
devfs_clone_bitmap_get()(devfs_helper.c:221-234) acceptsunit == limit; ptyclone passes the array size MAXPTYS as the limit;ptis[unit]at tty_pty.c:186/190 indexes past the 1000-entry array (8000 bytes, tty_pty.c:1292).fffreturns 1000 when units 0..999 are busy (lowest-free-bit scan, bitmap chunk 15 bits 40..63 free). - Dynamic (as
nobody):ptmx_limit_offbyoneopened 1000 ptmx fds; the 1001st open failed at the devfs layer (ENODEV, autoclone fallbackdefault_dev_opsβnoopen) but the clone had already executed:/dev/pts/1000existed (stat proof), i.e. unit 1000 was allocated through the OOB slot. - The OOB pty is real:
open("/dev/pts/1000", O_RDWR|O_NONBLOCK)succeeded (probe_oob_unit.c) and was close-cycled repeatedly (cycle_oob_unit.c Γ5) β a working slave beyond MAXPTYS. - The OOB write persisted:
vmstat -mM_PTY ("ptys") count stayed 1002 (ptis array + 1001 ever-allocated ptis) across 5 create/destroy cycles of unit 1000 β the pti stored atptis[1000]is reused, so the first cycle'sptis[1000] = ptiwrite survived. - Why it doesn't crash today: the first OOB read returned NULL
(M_PTY count rose by exactly one pti ever), because kmalloc's M_ZERO
bzero covers the full rounded 8192-byte chunk (zoneindex() rounds
8000β8192 and _kmalloc bzeros the rounded size β kern_slaballoc.c
zoneindex +
done:bzero). A non-NULL uninit value would have been used directly asstruct pt_ioctl *by tty_pty.c:202-218 (pti->devc = make_only_dev(...)etc. β wild writes). With MAXPTYS=1024 the slot would fall into the next chunk (cross-object corruption, groomable neighbor).
Exploit chain
Not pursued beyond proof of the OOB: on the current constant (1000) the read is deterministically NULL and the write lands in intra-chunk slack, so the present-day impact is the limit bypass (a 1001st pty exists and is slave-usable) plus a latent CWE-125/457/787 that becomes cross-object the moment MAXPTYS is changed to a zone-boundary multiple or the allocator behavior changes. Classified dos-level impact.
Fix validation
Combined patch (DF-2768 + DF-2769 hunks) built with
make -j6 nativekernel && make installkernel in the guest; both PoCs
re-run: ptmx_limit_offbyone then reported open #1001 refused and
/dev/pts/1000 absent (see fix run logs). fix.diff = the MAXPTYS-1
hunk alone.
Kernel references
- sys/kern/tty_pty.c:176 (limit passed = array size)
- sys/kern/tty_pty.c:186,189-190 (ptis[unit] OOB read/write)
- sys/kern/tty_pty.c:127 (MAXPTYS definition)
- sys/kern/tty_pty.c:1292-1293 (ptis = kmalloc(MAXPTYS entries))
- sys/vfs/devfs/devfs_helper.c:221-234 (unit > limit off-by-one)
- sys/kern/kern_slaballoc.c zoneindex() + _kmalloc done: (why the OOB read is zeroed today: chunk-rounding + full-chunk M_ZERO bzero)
Fix verification
fixedPatched kernel refuses the 1001st ptmx clone (no /dev/pts/1000, rc=0 'not vulnerable') with normal pty operation unaffected.
['run.fix.log', 'fix_build.log (INSTALL-DONE line 37780)', 'combined_fix.diff']
Confirmed kernel references
Detail
Exploit chain
open /dev/ptmx x1001 (unprivileged, keep fds) -> devfs_clone_bitmap_get returns 1000 -> ptis[1000] OOB read (NULL today) -> kmalloc pti -> ptis[1000]=pti OOB write (persists) -> functional pty beyond MAXPTYS; escalation not pursued: at MAXPTYS=1000 the read is deterministically NULL and the write is confined to allocator slack (characterized in VERDICT.md).
Evidence (decisive lines)
["run.log: 'VULNERABLE: /dev/pts/1000 exists' as nobody (rc=2), stock kernel", 'run.2.log: open /dev/pts/1000 SUCCEEDED (functional OOB slave); #1002 refused', 'run.3.log: vmstat -m ptys stays 1002 across 5 OOB cycles -> OOB slot write persisted/reused', "run.fix.log: patched kernel -> 'not vulnerable: /dev/pts/1000 absent', rc=0"]
PoC changes
n/a (pack authored fresh in this run; seed sketch was wrong about the observable - the OOB clone's master open fails ENODEV at the devfs autoclone fallback while the slave /dev/pts/1000 is the working artifact)
Verified recommended fix
Pass MAXPTYS-1 (the highest valid unit) to devfs_clone_bitmap_get() in ptyclone(); alternatively make devfs_clone_bitmap_get() refuse unit >= limit tree-wide.
Verdict
Off-by-one between devfs_clone_bitmap_get()'s 'unit > limit' refusal (devfs_helper.c:224-227) and ptyclone()'s use of the array SIZE MAXPTYS as the limit: unit==1000 is accepted and ptis[1000] is read (uninit pointer, tty_pty.c:186) and written (heap pointer, :190) one element past the 1000-entry array (:1292). Reproduced unprivileged (nobody) on the stock INVARIANTS kernel: the 1001st ptmx clone creates a functional pty /dev/pts/1000 (slave opens/closes repeatedly), the OOB slot write persists (M_PTY count stays 1002 across 5 create/destroy cycles -> pti reuse), proving both OOB accesses execute. No crash/corruption at MAXPTYS=1000 because kmalloc rounds 8000->8192 and M_ZERO bzeros the full chunk (kern_slaballoc.c zoneindex + done: bzero), so the OOB read yields NULL and the write lands in intra-chunk slack; a non-NULL uninit value would be type-confused as struct pt_ioctl * by tty_pty.c:202-218 (make_only_dev/make_dev/flag writes through it), and a zone-boundary MAXPTYS (e.g. 1024) turns the slot into the neighboring chunk. Present-day impact: MAXPTYS guard bypass (1001st pty) + latent CWE-125/457/787.
No comments yet.