โฌข DragonFlyBSD Kernel Audit
โ† dashboard
DF-0039

ptsopen check-then-use TOCTOU on dev->si_drv1 -> NULL-deref kernel panic (local DoS)

Field Value
ID DF-0039
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-367 Time-of-check Time-of-use (TOCTOU); CWE-476 NULL Pointer Dereference
File sys/kern/tty_pty.c
Lines 313-317 (ptsopen), 279/281 (pti_done), 571-573 (ptcopen contrast)
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

ptsopen() tests dev->si_drv1 for NULL at :313, then re-reads it into pti at :315 with no lock held. A concurrent ptcclose()โ†’pti_done() can null si_drv1 (:279, before destroy_dev at :281) between the two unlocked loads, so the second read observes NULL; the subsequent lwkt_gettoken(&pti->pt_tty.t_token) (:317) dereferences NULL and panics the kernel. Reachable by any local user (/dev/ptmx is 0666, the slave is owned by the master opener, and ptcclose reopens the slave 0666) by racing open(/dev/pts/N) against close(master). The sibling ptcopen (:571-573) reads si_drv1 once into pti โ€” the safe pattern โ€” confirming the defect.

Root cause

sys/kern/tty_pty.c:313-317:

if (dev->si_drv1 == NULL)        /* :313  unlocked load #1 */
    return(ENXIO);
pti = dev->si_drv1;              /* :315  unlocked load #2 (can now be NULL) */

lwkt_gettoken(&pti->pt_tty.t_token);   /* :317  NULL deref if pti==NULL */

pti_done() nulls dev->si_drv1 at :279 under the pti token, but ptsopen does not take that token until :317 โ€” after both unsynchronized reads. The pti structure itself is never freed (:293), so only the si_drv1 field toggles non-NULLโ†’NULL during unix98 termination; a single read captures a stable pointer. Compare ptcopen (:571-573):

pti = dev->si_drv1;              /* single read */
if (pti == NULL)
    return(ENXIO);
lwkt_gettoken(&pti->pt_tty.t_token);

Threat model & preconditions

  • Attacker position: any local unprivileged user.
  • Privileges gained or impact: kernel panic (full-system DoS). No integrity/confidentiality impact. The race is narrow but trivially winnable in a tight fork/thread loop (/dev/ptmx 0666, the user owns the slave).
  • Required config or capabilities: none; default kernel.
  • Reachability: open /dev/ptmx, get the paired /dev/pts/N, race open(slave) (many attempts) against close(master).

Proof of concept

PoC source: findings/poc/DF-0039/pts_race.c

A thread loops open(/dev/ptmx)+close(master) (driving pti_done's si_drv1=NULL); forked children loop open(/dev/pts/N). The race wins intermittently โ†’ ptsopen NULL deref โ†’ panic.

Build & run (unprivileged, disposable VM)

cc -pthread -o pts_race findings/poc/DF-0039/pts_race.c
./pts_race

Expected output

Kernel panic (NULL deref in ptsopen).

Impact

Local DoS (race-triggered kernel panic) reachable by an unprivileged user. Medium (AC:H = the race window; A:H = a full panic).

Collapse the double unlocked load into a single read (matching ptcopen); the pti struct is never freed, so the captured pointer stays valid, and the termination race is then serialized by the pti token + pti_hold's PF_TERMINATED check (:233):

--- a/sys/kern/tty_pty.c
+++ b/sys/kern/tty_pty.c
@@ -313,6 +313,5 @@
-   if (dev->si_drv1 == NULL)
+   pti = dev->si_drv1;
+   if (pti == NULL)
        return(ENXIO);
-   pti = dev->si_drv1;

    lwkt_gettoken(&pti->pt_tty.t_token);

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/tty_pty.c.
  • pending Reported to DragonFlyBSD security contact.

PoC verification

Evidence pack

findings/poc/DF-0039 ยท 12 files
FileTypeDescriptionSize
pts_race.c trigger-source race PoC (rewritten to build & run on DragonFly) 3.4 KB view raw
build.sh repro-script exact cc -pthread -O2 build 182 B view raw
run.sh repro-script 60 s race harness 806 B view raw
build.log build-log final successful build output 66 B view raw
run.log run-log decisive 60 s run (42500 iters, no panic) 2.8 KB view raw
ptsopen.kernel.asm kernel-disasm objdump of ptsopen @0xffffffff806ba760 - shows SINGLE load of dev->si_drv1 (the proof) 48.1 KB view raw
ptcopen.kernel.asm kernel-disasm objdump of ptcopen @0xffffffff806b91c0 - same single-load pattern (already safe) 8.3 KB view raw
pti_done.kernel.asm kernel-disasm objdump of pti_done @0xffffffff806b8f70 - shows the NULL store at 0x98(%rdi) 3.3 KB view raw
env.txt environment uname, cc, sysctls 462 B view raw
fix.diff suggested-fix git-apply-able single-read fix matching ptcopen (defense-in-depth) 406 B view raw
VERDICT.md verdict full narrative with the disassembly proof 6.3 KB โ†“ raw
README.md readme human summary 2.1 KB โ†“ raw
README.md readme human summary
โ†“ download raw

DF-0039 โ€” PoC

pts_race.c โ€” exercises the ptsopen check-then-use TOCTOU race on dev->si_drv1 from the unprivileged maxx account.

The bug (source-level)

ptsopen (sys/kern/tty_pty.c:313-317) tests dev->si_drv1 == NULL (:313) then re-reads pti = dev->si_drv1 (:315) with no lock; a concurrent ptcclose()โ†’pti_done() nulls si_drv1 (:279) between the two unlocked loads, so lwkt_gettoken(&pti->pt_tty.t_token) (:317) would dereference NULL โ†’ panic. ptcopen (:571-573) uses the safe single-read pattern.

Verdict (this run)

NOT REPRODUCED on the master DEV kernel (certain). GCC 8.3 at -O2 CSE-fuses the two source-level reads of dev->si_drv1 into a single mov 0x98(%r14),%r12, identical to the safe ptcopen pattern. The race window the finding describes does not exist in the compiled kernel. 34 million race attempts (42 500 iterations ร— 4 ร— 200 opens) produced no panic. See VERDICT.md and ptsopen.kernel.asm for the full proof.

The bug is still worth fixing in source as defense-in-depth โ€” a different compiler/version/flags would resurrect the two-load form. See fix.diff.

Build & run (unprivileged, disposable VM)

./build.sh           # cc -pthread -O2 -o pts_race pts_race.c
./run.sh             # races for 60 s; prints iter count, no panic expected

Expected output

On the current master DEV kernel: a stream of [pts_race] iter=N (no panic) lines, then exit. Guest stays up.

On a hypothetical kernel where the race is reachable: kernel panic (NULL deref in ptsopen).

Files

  • pts_race.c โ€” race PoC (rewritten from the original to actually build and run on DragonFly).
  • ptsopen.kernel.asm / ptcopen.kernel.asm / pti_done.kernel.asm โ€” objdump of the shipped /boot/kernel/kernel showing the CSE-fused single load in ptsopen (the proof).
  • build.log / run.log โ€” full build and decisive 60 s run output.
  • env.txt โ€” guest uname, compiler, sysctls.
  • fix.diff โ€” git apply-able single-read fix matching ptcopen.
  • VERDICT.md โ€” full narrative.
  • manifest.json โ€” artifact catalog.
VERDICT.md verdict full narrative with the disassembly proof
โ†“ download raw

DF-0039 โ€” Verdict

REPRODUCED: NO ยท Status: NOT REPRODUCED ยท Confidence: certain

The TOCTOU exists in the source (sys/kern/tty_pty.c:313-317) but is unreachable in the compiled master DEV kernel because GCC 8.3 at -O2 has CSE-fused the two dev->si_drv1 reads into a single load, producing the same safe pattern that ptcopen uses. Empirically verified: 34 million race attempts across 42 500 iterations produced no panic.

What the finding claims (and the source really does show)

The C source for ptsopen (sys/kern/tty_pty.c:313-317) literally performs two unlocked loads of dev->si_drv1:

if (dev->si_drv1 == NULL)        /* :313  load #1 */
    return(ENXIO);
pti = dev->si_drv1;              /* :315  load #2 (claimed race window) */

lwkt_gettoken(&pti->pt_tty.t_token);   /* :317  NULL deref if pti==NULL */

ptcclose() โ†’ pti_done() nulls dev->si_drv1 at :279 before destroy_dev() at :281, and the pti structure is never freed (:293), so a single captured read would be stable. The sibling ptcopen (:571-573) is already written as a single read. Everything the finding says about the source is correct.

Why it does not reproduce on this kernel (the disassembly proof)

struct cdev::si_drv1 is at offset 0x98 (verified against sys/sys/conf.h:67-104 with SPECNAMELEN=63: si_flags(4)+pad(4)+si_inode(8)+si_uid(4)+si_gid(4)+si_perms(4)+pad(4)+link(16)+si_uminor(4)+si_umajor(4)+si_parent(8)+si_hash(16)+si_hlist(8)+si_name[64] = 152 = 0x98).

Disassembling the actual shipped /boot/kernel/kernel (ffffffff806ba760 <ptsopen>, DragonFly v6.5.0.1712.g89e6a-DEVELOPMENT, built Mon Jun 29 14:18:01 UTC 2026) shows:

ffffffff806ba771:  4c 8b 77 08                mov    0x8(%rdi),%r14      ; r14 = ap->a_head.a_dev (the `dev` arg)
ffffffff806ba775:  4d 8b a6 98 00 00 00       mov    0x98(%r14),%r12     ; r12 = dev->si_drv1   <-- THE ONLY LOAD
ffffffff806ba77c:  4d 85 e4                   test   %r12,%r12           ; NULL check (the :313 test, on the value)
ffffffff806ba77f:  0f 84 ab 01 00 00          je     ffffffff806ba930   ; -> ENXIO return
ffffffff806ba785:  49 8d 44 24 28             lea    0x28(%r12),%rax    ; rax = &pti->pt_tty.t_token (reuses r12!)
...
ffffffff806ba794:  e8 27 e3 fb ff             callq  ffffffff80678ac0 <lwkt_gettoken>

The compiler has fused the :313 check and the :315 assignment into a single mov 0x98(%r14),%r12, then reused %r12 for the token address. There is no second load. Compare to ptcopen (ffffffff806b91c0), which is already a single read in the source and compiles to the same shape:

ffffffff806b91d5:  49 8b 9d 98 00 00 00       mov    0x98(%r13),%rbx    ; rbx = dev->si_drv1   <-- single load
ffffffff806b91dc:  48 85 db                   test   %rbx,%rbx
ffffffff806b91df:  0f 84 db 01 00 00          je     ffffffff806b93c0
ffffffff806b91e5:  4c 8d 7b 28                lea    0x28(%rbx),%r15    ; reuses rbx

Both functions are byte-for-byte the same safe pattern in the compiled kernel. pti_done does still store NULL into 0x98(%rdi) for the slave's cdev (movq $0x0,0x98(%rdi) at ffffffff806b8fc9), but a store can only race with a separate load โ€” and there is no separate load in ptsopen. The race window the finding describes simply does not exist in the generated code.

GCC is permitted to do this: in the C memory model, two reads of a non-volatile lvalue with no intervening observable side-effect may be coalesced (the compiler assumes no concurrent modification absent volatile). This is a perfectly legal optimization, and it is what masks the bug.

Empirical confirmation

pts_race.c (rewritten; the original used the non-existent TIOCSPTLCK ioctl and ptsname without <stdlib.h> and did not build) races open(/dev/pts/N) (ร—4 children, 200 opens each per cycle) against close(master) in a tight loop, reachable as the unprivileged maxx user (uid 1001, not in wheel).

60-second run as maxx:

[pts_race] iter=40500 (no panic)
[pts_race] iter=41000 (no panic)
[pts_race] iter=41500 (no panic)
[pts_race] iter=42000 (no panic)
[pts_race] iter=42500 (no panic)

42 500 iterations ร— 4 ร— 200 = 34 million ptsopen attempts. Guest still up (vm.sh status โ‡’ up), no fatal trap / panic: / Stopped at / db> markers in boot.log. See run.log, ptsopen.kernel.asm, ptcopen.kernel.asm, pti_done.kernel.asm, env.txt.

Classification

(d) Genuinely not reachable on this kernel, with a specific reason that is not "the bug isn't real in source" but rather "the compiler accidentally closes the race". This is fragile: a future GCC upgrade, a different -O level, LTO, or any code change that pokes a function call or memory barrier between the two source lines would re-materialize the two-load form and resurrect the race exactly as the finding describes. It is therefore worth fixing as defense-in-depth even though no current exploit exists.

Collapse the two reads into a single read into pti (matching ptcopen), which makes the source match what the compiler is already generating. See fix.diff โ€” a standalone git apply-able unified diff (passes git apply --check). This matches the finding markdown's ## Recommended fix proposal verbatim.

-   if (dev->si_drv1 == NULL)
-       return(ENXIO);
    pti = dev->si_drv1;
+   if (pti == NULL)
+       return(ENXIO);

Exploit chain

None. No memory-corruption primitive is derivable: the race window that would expose the NULL deref does not exist in the compiled kernel.

PoC changes from the original

  • Replaced the non-existent ioctl(m, TIOCSPTLCK, 0) (DragonFly has no TIOCSPTLCK; unlockpt() is the right libc helper) with unlockpt(m).
  • Added <stdlib.h> (for ptsname / unlockpt) and <errno.h>.
  • Removed the leaky for(;;) opener children (process-table explosion); replaced with bounded bursts (opener_child(buf, 200)).
  • Dropped the redundant master_closer thread (the closer thread in the original operated on unrelated masters and so could never race the slaves being opened).
  • Added iteration logging so the negative result is observable.
  • Added ptsopen.kernel.asm / ptcopen.kernel.asm / pti_done.kernel.asm as the decisive evidence that the compiled kernel has no second load.

Confirmed kernel references

Detail

Exploit chain

none

Evidence (decisive lines)

Decisive proof is ptsopen.kernel.asm (objdump of /boot/kernel/kernel ptsopen @0xffffffff806ba760): a single `mov 0x98(%r14),%r12` loads dev->si_drv1; %r12 is then tested for NULL (je -> ENXIO) and reused via `lea 0x28(%r12),%rax` for &pti->pt_tty.t_token. ptcopen.kernel.asm shows the identical single-load shape (already safe in source). pti_done.kernel.asm confirms the NULL store still happens (`movq $0x0,0x98(%rdi)` @0xffffffff806b8fc9) but cannot race a load that does not exist. run.log shows `[pts_race] iter=42500 (no panic)` after 60s of racing as maxx; guest remained up.

PoC changes

Rewrote pts_race.c: replaced non-existent TIOCSPTLCK ioctl with unlockpt(); added /; dropped the master_closer thread (it raced unrelated masters and so could never hit the slave being opened); replaced unbounded for(;;) opener children (process-table explosion) with bounded opener_child(buf, 200) bursts; added iteration logging to make the negative result observable. Added ptsopen/ptcopen/pti_done .kernel.asm disassembly artifacts (the decisive evidence), build.sh/run.sh repro scripts, env.txt, fix.diff, VERDICT.md, README.md, manifest.json.

Verified recommended fix

Defense-in-depth: collapse the two reads into a single read into pti (matching ptcopen, which the compiler already produces). fix.diff passes git apply --check: -if (dev->si_drv1 == NULL) / return(ENXIO); / pti = dev->si_drv1; becomes pti = dev->si_drv1; / if (pti == NULL) / return(ENXIO);. Matches the finding markdown's ## Recommended fix proposal verbatim.

Verdict

NOT REPRODUCED on the master DEV kernel (v6.5.0.1712.g89e6a-DEVELOPMENT, built 2026-06-29 with GCC 8.3 -O2). The TOCTOU is real in the SOURCE -- sys/kern/tty_pty.c:313 does if (dev->si_drv1 == NULL) then :315 does pti = dev->si_drv1 with no lock, while ptcclose->pti_done nulls si_drv1 at :279 before destroy_dev at :281, and ptcopen (:571-573) uses the safe single read. BUT objdump of the shipped /boot/kernel/kernel shows GCC has CSE-fused the two reads into ONE load: ptsopen @0xffffffff806ba760 is mov 0x98(%r14),%r12 / test %r12,%r12 / je ENXIO / lea 0x28(%r12),%rax -> lwkt_gettoken -- byte-for-byte the same safe pattern ptcopen already uses. There is no second load instruction, so the race window the finding describes does not exist in the compiled kernel (offset 0x98 confirmed as si_drv1 against sys/sys/conf.h:67-104 with SPECNAMELEN=63). Empirically: 42500 race iterations x 4 children x 200 opens = 34 million ptsopen attempts as unprivileged maxx (uid 1001) over 60s produced no panic, guest stayed up, no panic markers in boot.log. The bug is fragile -- a future compiler/version/-O level or an intervening function call between the two source lines would resurrect the two-load form -- so defense-in-depth fix is warranted, but no current exploit exists.