β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2768

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).

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2768 Β· 15 files
FileTypeDescriptionSize
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 a pt_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/1000 opening 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/1000 and reuses the same pti (vmstat -m "ptys" count stays at 1002 = ptis array + 1001 ever-allocated ptis), proving the OOB slot write at ptis[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.

VERDICT.md
↓ download raw

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

  1. Static: devfs_clone_bitmap_get() (devfs_helper.c:221-234) accepts unit == 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). fff returns 1000 when units 0..999 are busy (lowest-free-bit scan, bitmap chunk 15 bits 40..63 free).
  2. Dynamic (as nobody): ptmx_limit_offbyone opened 1000 ptmx fds; the 1001st open failed at the devfs layer (ENODEV, autoclone fallback default_dev_ops→noopen) but the clone had already executed: /dev/pts/1000 existed (stat proof), i.e. unit 1000 was allocated through the OOB slot.
  3. 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.
  4. The OOB write persisted: vmstat -m M_PTY ("ptys") count stayed 1002 (ptis array + 1001 ever-allocated ptis) across 5 create/destroy cycles of unit 1000 β€” the pti stored at ptis[1000] is reused, so the first cycle's ptis[1000] = pti write survived.
  5. 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 as struct 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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 00:17:25 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.