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
.textaddresses via the function pointers (for ptyst_oproc/t_stop/t_unholdpoint toptsstart/ptsstop/ptsunholdintty_pty.c) β a precise KASLR-relocation primitive; (b) kernel heap addresses (pgrp,session,sigio, per-queuec_databuffers) 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.
Recommended fix
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
sys/kern/tty.c:2891-2921βsysctl_kern_ttysraw copyout.sys/sys/tty.hβstruct ttypointer fields.sys/kern/kern_descrip.cβkinfo_filesanitized-export pattern.- CWE-200 Exposure of Sensitive Information to an Unauthorized Actor.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/tty.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0006 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
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_firstfor 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.
Recommended fix
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 viasysctl kern.ttysas uid 1001 β bug reproduced. - Single-fix kernel (
#1, only this finding's improvedfix.diffapplied to clean/usr/src, builtmake -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/kernelsha256d462dfb3...(matches the installedkernel.stripped). - Build log:
fix_build.log(35599 lines, fullnativekerneloutput, 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 builtkernel.strippedis structurally identical to the original/boot/kernel/kernel(same ELF64/ET_EXEC/x86-64, identical entry point0xffffffff802aad90, identical program headers). The failure was an unclean shutdown:vm.sh downforce-killed qemu at its 90s timeout while the 119 MBkernel.debugcopy was still syncing to HAMMER, leaving the disk inconsistent. Fix:syncexplicitly (and wait) after the copies, before reboot. After that the#1kernel booted cleanly.
Fix verification
fixedVALIDATED 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...
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.
No comments yet.