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

kern.ttys sysctl leaks kernel function/heap pointers to unprivileged users

Field Value
ID DF-0006
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
CWE CWE-200 Exposure of Sensitive Information to an Unauthorized Actor
File sys/kern/tty.c
Lines 2891-2924
Area kern
Confidence certain
Discovered 2026-06-29
Reported pending

Summary

The kern.ttys sysctl handler (sysctl_kern_ttys) copies each struct tty verbatim to userspace. struct tty embeds kernel function pointers (t_oproc, t_stop, t_param, t_unhold), kernel heap object pointers (t_pgrp, t_session, t_sigio, t_sc, t_slsc), per-clist data buffer pointers (t_rawq/t_canq/t_outq .c_data), and an embedded lwkt_token. Only t_dev is sanitized. Because sysctl reads are not privilege-gated in DragonFlyBSD (sysctl_root checks privilege only for writes), any unprivileged local user can dump all of these addresses, defeating KASLR and revealing kernel heap layout.

Root cause

sysctl_kern_ttys (sys/kern/tty.c:2891-2921) builds a local copy t = *tp; of the entire struct tty and emits it with SYSCTL_OUT(req, (caddr_t)&t, sizeof(t)). The only field rewritten before copyout is t_dev, via devid_from_dev (tty.c:2912-2913). Every pointer field in sys/sys/tty.h is copied raw: t_pgrp, t_session, t_sigio, t_oproc/t_stop/t_param/t_unhold, t_sc/t_slsc, and the short *c_data in each of t_rawq/t_canq/t_outq, plus the embedded t_token. The OID is registered SYSCTL_PROC(_kern, OID_AUTO, ttys, CTLTYPE_OPAQUE|CTLFLAG_RD, ...) (tty.c:2923-2924); sysctl_root (kern_sysctl.c) applies its privilege/securelevel checks only when req->newptr is set (writes), so a plain read sets no newptr and any user may read kern.ttys. (Compare kern_descrip.c, which exports file data through a purpose-built, sanitized kinfo_file struct rather than the raw struct filedesc β€” tty.c does not follow that pattern.)

Threat model & preconditions

  • Attacker position: any local unprivileged user.
  • Privileges gained or impact: information disclosure. Exposes (a) kernel .text addresses via the function pointers (for ptys t_oproc/t_stop/ t_unhold point to ptsstart/ptsstop/ptsunhold in tty_pty.c) β€” a precise KASLR-relocation primitive; (b) kernel heap addresses (pgrp, session, sigio, per-queue c_data buffers) usable to refine heap grooming for a separate heap-corruption bug; (c) token internals. No arbitrary memory contents are disclosed.
  • Required config or capabilities: none; default kernel.
  • Reachability: sysctlbyname("kern.ttys", ...) as any user.

Proof of concept

PoC source: findings/poc/DF-0006/leak_ttys.c

Reads the kern.ttys blob and scans it for pointer-sized values that look like kernel addresses, proving the leak without depending on the exact arch-dependent struct tty layout.

Build & run

cc -o leak_ttys findings/poc/DF-0006/leak_ttys.c
./leak_ttys        # as a non-root user

Expected output

got 4320 bytes from kern.ttys
  blob offset    16 (word     2): 0xffff8000xxxxxxxx
  ...
total kernel-range pointer-sized values leaked: N

Non-zero N confirms the leak.

Impact

Lowers the bar for exploiting any future local kernel memory-corruption bug in the tty or adjacent subsystems by defeating KASLR and revealing heap layout. Information disclosure only (addresses, not arbitrary memory); rated Low.

Sanitize every pointer field of the local copy before SYSCTL_OUT, or better, export a dedicated, pointer-free kinfo_tty structure (mirroring the kinfo_file pattern in kern_descrip.c). Minimal diff that zeros all kernel pointers while preserving the fields pstat(8) actually uses:

--- a/sys/kern/tty.c
+++ b/sys/kern/tty.c
@@ -2911,6 +2911,18 @@ sysctl_kern_ttys(SYSCTL_HANDLER_ARGS)
        t = *tp;
        if (t.t_dev)
            t.t_dev = (cdev_t)(uintptr_t)devid_from_dev(t.t_dev);
+       /* Do not leak kernel pointers to userspace. */
+       bzero(&t.t_token, sizeof(t.t_token));
+       t.t_pgrp = NULL;
+       t.t_session = NULL;
+       t.t_sigio = NULL;
+       t.t_rawq.c_data = NULL;
+       t.t_canq.c_data = NULL;
+       t.t_outq.c_data = NULL;
+       t.t_oproc = NULL;
+       t.t_stop = NULL;
+       t.t_param = NULL;
+       t.t_unhold = NULL;
+       t.t_sc = NULL;
+       t.t_slsc = NULL;
        error = SYSCTL_OUT(req, (caddr_t)&t, sizeof(t));
        if (error)
            break;

A stronger long-term fix is to define a struct kinfo_tty containing only the non-pointer fields pstat(8) needs and copy those out.

References

Timeline

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0006 Β· 17 files
FileTypeDescriptionSize
leak_ttys.c trigger-source reads kern.ttys blob, scans + dumps kernel pointers and raw blob 3.5 KB view raw
build.sh build-script cc -o leak_ttys leak_ttys.c 155 B view raw
run.sh run-script runs leak_ttys as unprivileged user 207 B view raw
build.log build-log PoC compile (final successful build) 67 B view raw
run.log run-log UNPATCHED #0 baseline: 102 kernel pointers leaked (decisive) 3.2 KB view raw
run.2.log run-log 2nd unpatched run (variance): identical 102 3.2 KB view raw
run.3.log run-log 3rd unpatched run (variance): identical 102 3.2 KB view raw
leak_sample.txt leak-sample leaked function/heap pointers + nm /boot/kernel/kernel cross-ref 4.6 KB view raw
fix.diff suggested-fix IMPROVED git-apply-able fix: zero ALL pointer fields incl. t_list (TAILQ_ENTRY) + t_rkq/t_wkq before SYSCTL_OUT (closes 102->0) 1.2 KB view raw
fix_build.log build-log full nativekernel output of the single-fix build, rc=0 (35599 lines) 5.6 MB ↓ download
fix_run.log run-log PATCHED #1 kernel PoC run: 0 leaked, exit 2 (x3 deterministic) 133 B view raw
env.txt environment guest uname (#0 baseline), cc version, maxx identity 372 B view raw
VERDICT.md verdict full mechanism, field-offset table, before/after proof, fix-validation, install-procedure note 8.0 KB ↓ raw
README.md readme build/run/expected for humans 2.3 KB ↓ raw
manifest.json manifest this catalog 3.6 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected for humans
↓ download raw

DF-0006 β€” PoC

leak_ttys.c β€” unprivileged leak of kernel function/heap pointers via the kern.ttys sysctl.

The issue

sysctl_kern_ttys() (sys/kern/tty.c:2891-2921) emits each struct tty verbatim:

t = *tp;                                  /* tty.c:2911  whole struct */
if (t.t_dev) t.t_dev = devid_from_dev(..);/* only t_dev sanitized */
SYSCTL_OUT(req, &t, sizeof(t));           /* tty.c:2914 */

struct tty (sys/sys/tty.h) carries kernel .text function pointers (t_oproc/t_stop/t_param/t_unhold β†’ exact KASLR-defeat), kernel heap object pointers (t_pgrp/t_session/t_sigio/t_sc/t_slsc), per-clist c_data buffer pointers, and an embedded lwkt_token. Only t_dev is sanitized. The OID is CTLTYPE_OPAQUE|CTLFLAG_RD (tty.c:2923) and sysctl reads are not privilege-gated (kern_sysctl.c:1446-1450 gates only writes), so any unprivileged local user can dump them all.

Build

cc -o leak_ttys leak_ttys.c      # or: ./build.sh

Run

As an unprivileged user (e.g. maxx, uid 1001):

./leak_ttys       # or: ./run.sh

Expected output (bug present)

got 3760 bytes from kern.ttys
raw blob written to ttys.bin (3760 bytes)
  blob offset   256 (word    32): 0xffffffff80b8b800   <- t_oproc = scstart
  blob offset   264 (word    33): 0xffffffff806b7eb0   <- t_stop  = nottystop
  blob offset   272 (word    34): 0xffffffff80b86930   <- t_param = scparam
  ...
total kernel-range pointer-sized values leaked: 102

The 0xffffffff80???????? values match nm /boot/kernel/kernel symbols exactly (see leak_sample.txt) β€” proving real kernel .text addresses are leaked and KASLR is defeated. The 0xfffff8?????????? values are live slab / direct-map heap addresses (clist buffers, pgrp/session objects). Stable across runs in the same boot (run.log/run.2.log/run.3.log). On a fixed kernel, zero kernel-range pointers appear and the program exits 2.

Cross-referencing against nm (optional, reproduces the leak_sample.txt table)

nm -n /boot/kernel/kernel > kernel.nm        # world-readable
./leak_ttys                                  # writes ttys.bin
# then for each candidate value, find the nearest nm symbol:
python3 -c "import bisect,struct; ..."       # see leak_sample.txt for results
VERDICT.md verdict full mechanism, field-offset table, before/after proof, fix-validation, install-procedure note
↓ download raw

DF-0006 β€” kern.ttys sysctl leaks kernel function/heap pointers to unprivileged users

Verdict

REPRODUCED β†’ FIX VALIDATED. kern.ttys is world-readable and copies each struct tty verbatim to userland, leaking 102 kernel-range pointer-sized values per read (including exact .text function-pointer addresses that defeat KASLR). Confirmed on the unpatched audit-source kernel DragonFly 6.5-DEVELOPMENT #0 (Thu Jul 2 06:02:54 UTC 2026). The improved fix.diff in this folder, built as a single-fix kernel (#1), reduces the leak to 0 (deterministic across 3 runs) β€” the previous aggregate-fix run that dropped 102β†’20 is now fully closed.

Mechanism (confirmed by source + run)

sysctl_kern_ttys (sys/kern/tty.c:2891-2921) iterates the global tty_list and, for each struct tty, does:

t = *tp;                                      /* tty.c:2911  whole-struct copy */
if (t.t_dev)
    t.t_dev = (cdev_t)(uintptr_t)devid_from_dev(t.t_dev);   /* ONLY t_dev sanitized */
error = SYSCTL_OUT(req, (caddr_t)&t, sizeof(t));            /* tty.c:2914 */

The OID is registered CTLTYPE_OPAQUE|CTLFLAG_RD (tty.c:2923-2924) β€” a plain read. sysctl_root (sys/kern/kern_sysctl.c:1446-1450) applies its privilege check only when req->newptr is set (a write). A read sets no newptr, so no privilege check runs; any unprivileged user reads kern.ttys.

struct tty (sys/sys/tty.h) carries, all copied raw, these pointer-bearing fields (intra-struct offsets observed in the leaked blob in parentheses):

field tty.h line intra-off kind
t_token (lwkt_token, has t_ref/t_desc) 74 8, 24 token internals (.rodata ptr)
t_rawq.c_data / t_canq.c_data / t_outq.c_data (short *) 45-51 48, 80, 112 clist heap buffers
t_pgrp / t_session / t_sigio 86-88 160, 168, 176 slab objects
t_rkq / t_wkq (kqinfo = klist ptr) 89-90 (NULL on console ttys) knote list
t_oproc / t_stop / t_param / t_unhold 94-99 256, 264, 272, 280 .text func ptrs (KASLR defeat)
t_sc / t_slsc (void *) 100-101 ~288, ~296 driver/disc state
t_list (TAILQ_ENTRY: tqe_next/tqe_prev) 112 352, 360 global list linkage
t_dev (struct cdev *) 82 136 already sanitized via devid_from_dev

(struct lwkt_token is { long t_count; struct lwkt_tokref *t_ref; long t_collisions; const char *t_desc; } β€” sys/sys/thread.h:159; struct kqinfo is { struct klist ki_note; } β€” sys/sys/event.h:160, a single SLIST head pointer.)

Why the original fix.diff left a 102β†’20 residual

The first fix.diff zeroed t_token, the three clist.c_data, t_pgrp, t_session, t_sigio, t_oproc/t_stop/t_param/t_unhold, t_sc, t_slsc β€” but omitted t_list (the TAILQ_ENTRY linkage). During the sysctl walk the handler inserts a stack-resident marker after each tp (tty.c:2905-2908), so every snapshotted tty has:

  • t.t_list.tqe_next == &marker (identical kernel-stack address across all ttys)
  • t.t_list.tqe_prev β†’ the previous tty (or &tty_list.tqh_first for the head)

Those are 2 kernel pointers per tty Γ— 10 ttys = exactly the 20-pointer residual seen on the prior aggregate all-25-fixes kernel. The improved fix.diff adds t.t_list.tqe_next = NULL; t.t_list.tqe_prev = NULL; and, for defense-in-depth, bzero(&t.t_rkq, ...) / bzero(&t.t_wkq, ...) (the klist pointer inside each kqinfo, which is NULL on unwatched console ttys but would leak a knote * if any process held a kqueue on the tty).

Proof (before / after)

Unpatched #0 baseline (./leak_ttys as maxx, uid 1001, not in wheel):

got 3760 bytes from kern.ttys
raw blob written to ttys.bin (3760 bytes)
  blob offset    24 (word    3): 0xffffffff80c64d8d   <- t_token.t_desc (.rodata ptr)
  blob offset    48 (word    6): 0xfffff8008bbbb400   <- t_rawq.c_data (heap)
  blob offset    80 (word   10): 0xfffff8008dcf1000   <- t_canq.c_data (heap)
  blob offset   112 (word   14): 0xfffff8008d202600   <- t_outq.c_data (heap)
  blob offset   160 (word   20): 0xfffff8004f1c6190   <- t_pgrp (slab)
  blob offset   168 (word   21): 0xfffff8008bb1f320   <- t_session (slab)
  blob offset   256 (word   32): 0xffffffff80b8c0f0   <- t_oproc = scstart (.text)
  blob offset   264 (word   33): 0xffffffff806b87a0   <- t_stop  = nottystop (.text)
  blob offset   272 (word   34): 0xffffffff80b87220   <- t_param = scparam (.text)
  blob offset   352 (word   44): 0xfffff801182eb600   <- t_list.tqe_next = &marker
  blob offset   360 (word   45): 0xffffffff810e5200   <- t_list.tqe_prev = &tty_list
  ...
total kernel-range pointer-sized values leaked: 102
RUN_EXIT=0

Single-fix #1 kernel (improved fix.diff, same PoC, same user):

got 3760 bytes from kern.ttys
raw blob written to ttys.bin (3760 bytes)
total kernel-range pointer-sized values leaked: 0
RUN_EXIT=2

Reproduced 3Γ— on the patched kernel β€” 0 every time (deterministic, not masking). Full logs: unpatched baseline in run.log; patched runs in fix_run.log.

Impact

Information disclosure: precise kernel .text base + kernel heap layout to any local unprivileged user. Not a memory-corruption primitive itself, but a KASLR-defeat and slab-grooming enabler that escalates the practical severity of any local heap/stack corruption bug into reliable exploitation. Rated Low standalone (info disclosure, no integrity/availability impact).

PoC changes

The leak_ttys.c trigger is unchanged from the prior run (it already detects the full canonical-upper-half range and dumps the raw blob). The only change this run is the improved fix.diff: - added t.t_list.tqe_next = NULL; t.t_list.tqe_prev = NULL; (the TAILQ_ENTRY that caused the 102β†’20 residual); - added bzero(&t.t_rkq, sizeof(t.t_rkq)); bzero(&t.t_wkq, sizeof(t.t_wkq)); (the embedded kqinfo klist pointers β€” defense-in-depth, NULL on console ttys but leakable when a kqueue watches the tty); - kept the existing zeroing of t_token, the three clist.c_data, t_pgrp, t_session, t_sigio, the four .text function pointers, t_sc, t_slsc, and the t_dev β†’ devid rewrite.

The verified fix.diff in this folder supersedes the finding markdown's initial proposal (which, like the first fix.diff, omitted t_list). It is a minimal, targeted change: zero every pointer-bearing field of the local copy before SYSCTL_OUT at sys/kern/tty.c:2911, preserving t_dev sanitization and every field pstat(8) reads. A stronger long-term fix is a dedicated pointer-free struct kinfo_tty mirroring the kinfo_file pattern in kern_descrip.c.

Fix validation (Phase 8)

  • Baseline (#0, unpatched audit-source kernel): 102 kernel pointers leaked via sysctl kern.ttys as uid 1001 β€” bug reproduced.
  • Single-fix kernel (#1, only this finding's improved fix.diff applied to clean /usr/src, built make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0): 0 pointers leaked, deterministic across 3 runs β€” leak gone.
  • Patched-kernel kern.version: DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 08:12:37 UTC 2026; /boot/kernel/kernel sha256 d462dfb3... (matches the installed kernel.stripped).
  • Build log: fix_build.log (35599 lines, full nativekernel output, clean β€” no errors). Patched run: fix_run.log.
  • Note on install procedure: the first reboot attempt failed at the loader (Unable to load /kernel/kernel / EFTYPE). Root cause was NOT a bad kernel β€” the built kernel.stripped is structurally identical to the original /boot/kernel/kernel (same ELF64/ET_EXEC/x86-64, identical entry point 0xffffffff802aad90, identical program headers). The failure was an unclean shutdown: vm.sh down force-killed qemu at its 90s timeout while the 119 MB kernel.debug copy was still syncing to HAMMER, leaving the disk inconsistent. Fix: sync explicitly (and wait) after the copies, before reboot. After that the #1 kernel booted cleanly.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. leak_ttys (as uid 1001) leaked 102 kernel-range pointers on the unpatched 6.5-DEVELOPMENT #0 baseline (run.log, exit 0) and leaks 0 pointers on the single-fix #1 kernel built ONLY from the improved fix.diff (fix_run.log, exit 2, deterministic x3) => fix closes the leak completely. The prior aggregate all-25-fixes run had dropped 102->20 because that fix omitted the t_list TAILQ_ENTRY; adding t_list zeroing (plus t_rkq/t_wkq) closes it to 0. Build: make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0, clean (no errors).

baseline (#0): 'total kernel-range pointer-sized values leaked: 102' RUN_EXIT=0  |  patched (#1 single-fix): 'total kernel-range pointer-sized values leaked: 0' RUN_EXIT=2 (x3)  |  nativekernel build rc=0  |  fix_kernel kern.version='6.5-DEVELOPMENT #1: Thu Jul 2 08:12:37 UTC 2026', kernel sha256=d462dfb3...
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 08:12:37 UTC 2026

Confirmed kernel references

Detail

Exploit chain

info leak of kernel .text/heap pointers via sysctl kern.ttys readable by any unprivileged local user (102 pointers/read: .text function-pointer addresses defeat KASLR, slab/clist addresses reveal heap layout for grooming a separate heap-corruption bug). No memory-corruption primitive, so no escalation chain; impact ceiling is information disclosure (KASLR-defeat + heap-layout enabler).

Evidence (decisive lines)

UNPATCHED #0 baseline (run.log): 'got 3760 bytes from kern.ttys' ... 'blob offset 256 (word 32): 0xffffffff80b8c0f0' (.text), 'offset 352 (word 44): 0xfffff801182eb600' (t_list.tqe_next=&marker), 'offset 360 (word 45): 0xffffffff810e5200' (t_list.tqe_prev=&tty_list region; tty_list global at 0xffffffff810e5280) ... 'total kernel-range pointer-sized values leaked: 102' RUN_EXIT=0. SINGLE-FIX #1 kernel (fix_run.log, x3 deterministic): 'got 3760 bytes from kern.ttys' / 'total kernel-range pointer-sized values leaked: 0' RUN_EXIT=2. Fix kernel kern.version='DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 08:12:37 UTC 2026', /boot/kernel/kernel sha256=d462dfb3... (matches installed kernel.stripped).

PoC changes

leak_ttys.c trigger unchanged. Rewrote fix.diff (findings/poc/DF-0006/fix.diff) to close the 102->20 residual: ADDED 't.t_list.tqe_next = NULL; t.t_list.tqe_prev = NULL;' (the TAILQ_ENTRY linkage at intra-off 352/360 that the original fix omitted) and 'bzero(&t.t_rkq, sizeof(t.t_rkq)); bzero(&t.t_wkq, sizeof(t.t_wkq));' (kqinfo ki_note klist pointers, NULL on console ttys but leakable when a kqueue watches the tty). Kept the existing zeroing of t_token, the three clist c_data, t_pgrp/t_session/t_sigio, the four .text function pointers (t_oproc/t_stop/t_param/t_unhold), t_sc/t_slsc, and the t_dev->devid rewrite.

Verified recommended fix

In sysctl_kern_ttys at sys/kern/tty.c:2911, after 't = *tp;' and the t_dev devid rewrite, bzero/NULL every pointer-bearing field of the local copy t before SYSCTL_OUT: t_token (lwkt_token: t_ref, t_desc), the three clist c_data, t_pgrp/t_session/t_sigio, t_rkq/t_wkq (kqinfo klist), t_oproc/t_stop/t_param/t_unhold (.text), t_sc/t_slsc, and crucially t_list.tqe_next/tqe_prev (TAILQ_ENTRY). This SUPERSEDES the finding markdown's initial proposal (which, like the first fix.diff, omitted t_list and left a 20-pointer residual). Full git-apply-able diff in findings/poc/DF-0006/fix.diff. Long-term: a dedicated pointer-free struct kinfo_tty mirroring kinfo_file.

Verdict

REPRODUCED then FIX VALIDATED. sysctl_kern_ttys (sys/kern/tty.c:2891-2921) copies each struct tty verbatim to userland (t = *tp at tty.c:2911, SYSCTL_OUT at tty.c:2914) sanitizing ONLY t_dev; the OID is CTLFLAG_RD and sysctl_root gates only writes (kern_sysctl.c:1446-1450), so uid 1001 reads it. On the unpatched #0 kernel the PoC leaks 102 kernel-range pointer-sized values per read, including .text-segment function-pointer addresses (t_oproc/t_stop/t_param at intra-offsets 256/264/272, all inside the kernel .text segment 0xffffffff802aabb0-0x80fce540 => KASLR defeat) and slab/direct-map heap pointers (clist c_data, t_pgrp/t_session, t_list linkage). The first fix.diff zeroed 12 pointer fields but missed the TAILQ_ENTRY t_list (intra-off 352/360); during the walk the handler inserts a stack marker after each tp (tty.c:2905-2908) so t_list.tqe_next==&marker for all ttys and t_list.tqe_prev points at the prior tty / &tty_list.tqh_first => exactly the 2-ptrs/tty x 10 = 20-pointer residual seen on the prior aggregate-fix kernel. The improved fix.diff adds t.t_list.tqe_next/tqe_prev=NULL plus bzero(&t_rkq/t_wkq) (kqinfo klist ptrs, defense-in-depth). Built as a single-fix #1 kernel (make -j6 nativekernel, rc=0), the SAME PoC leaks 0 pointers, deterministic across 3 runs => leak fully closed.