PPS_IOC_KCBIND missing privilege check allows unprivileged kernel-PLL binding (NTP confusion)
| Field | Value |
|---|---|
| ID | DF-0022 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N |
| CWE | CWE-862 Missing Authorization |
| File | sys/kern/kern_clock.c |
| Lines | 1680-1694 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
The PPS_IOC_KCBIND handler in pps_ioctl() honors an unprivileged request
to bind the kernel hardpps() consumer (pps->kcmode = kapi->edge) without
any caps_priv_check_self(). The code carries a
/* XXX Only root should be able to do this */ comment acknowledging the
omission. The pps(4) cdev is created mode 0644, so any local user can open
/dev/pps0 and issue the ioctl. On a PPS_SYNC kernel, each subsequent PPS
pulse on that source drives hardpps(), poisoning the global pps_freq/
pps_jitter/pps_tf[]/pps_valid/STA_PPSSIGNAL state used by
ntp_update_second(). A confused-deputy result follows: a privileged ntpd
that enables STA_PPSFREQ/STA_PPSTIME will discipline the system clock
against attacker-influenced values (bounded by MAXFREQ/MAXPHASE).
Root cause
sys/kern/kern_clock.c:1680-1694:
case PPS_IOC_KCBIND:
#ifdef PPS_SYNC
kapi = (struct pps_kcbind_args *)data;
/* XXX Only root should be able to do this */
if (kapi->tsformat && kapi->tsformat != PPS_TSFMT_TSPEC)
return (EINVAL);
if (kapi->kernel_consumer != PPS_KC_HARDPPS)
return (EINVAL);
if (kapi->edge & ~pps->ppscap)
return (EINVAL);
pps->kcmode = kapi->edge; /* :1690 no privilege check */
return (0);
#else
return (EOPNOTSUPP);
#endif
The callers (sys/dev/misc/pps/pps.c:183, sys/dev/serial/sio/sio.c,
sys/bus/u4b/serial/usb_serial.c) forward the ioctl verbatim with no upstream
privilege check. pps(4) is created make_dev(..., UID_ROOT, GID_WHEEL, 0644, ...)
(sys/dev/misc/pps/pps.c:103-104). So any local user can open /dev/pps0 and
bind hardpps().
Threat model & preconditions
- Attacker position: any local unprivileged user who can open a PPS-capable
device node that is world-openable (the parallel-port
/dev/pps0is0644by default). - Privileges gained or impact: confused-deputy against NTP. The attacker
cannot directly step the clock, but can (a) poison the
pps_*quality counters (pps_jitcnt/pps_errcnt/pps_stbcnt/pps_calcnt) reported back tontpdviantp_adjtime(), and (b) if a privilegedntpdruns with PPS discipline (STA_PPSFREQand/orSTA_PPSTIME), steerpps_freq/time_offsetunder theMAXFREQ/MAXPHASEclamps β bounded but arbitrary phase/frequency drift and the ability to spoofSTA_PPSSIGNAL. - Required config or capabilities: kernel built with
options PPS_SYNC(time-service/LINT64-derived kernels); a PPS event source the user can drive (e.g. parallel-port pin 10 viappbus); realized clock impact additionally needs a privileged PPS-disciplinedntpd. - Reachability:
ioctl(fd, PPS_IOC_KCBIND, ...)on an openable PPS device.
Proof of concept
PoC source: findings/poc/DF-0022/kcbind.c
Opens /dev/pps0 and issues PPS_IOC_KCBIND to demonstrate the privilege
bypass (the ioctl succeeds for an unprivileged user).
Build & run (unprivileged)
cc -o kcbind findings/poc/DF-0022/kcbind.c ./kcbind /dev/pps0
Expected output (bug present)
[+] KCBIND succeeded (privilege bypass) on /dev/pps0 as uid=1000
(Full clock-steering needs the additional preconditions above.) On a fixed
kernel the ioctl returns EPERM.
Impact
Low. A missing privilege check acknowledged in-tree by a XXX comment, with a
bounded confused-deputy effect against NTP (not direct clock control). The fix
is one check.
Recommended fix
Gate PPS_IOC_KCBIND behind the same privilege used for setting the time
(SYSCAP_NOSETTIME), resolving the long-standing XXX:
--- a/sys/kern/kern_clock.c
+++ b/sys/kern/kern_clock.c
@@ -1680,6 +1680,11 @@
case PPS_IOC_KCBIND:
#ifdef PPS_SYNC
+ /*
+ * Binding the kernel PPS consumer (hardpps) influences the
+ * system clock discipline, so require the set-time privilege.
+ */
+ if (caps_priv_check_self(SYSCAP_NOSETTIME))
+ return (EPERM);
kapi = (struct pps_kcbind_args *)data;
- /* XXX Only root should be able to do this */
if (kapi->tsformat && kapi->tsformat != PPS_TSFMT_TSPEC)
References
sys/kern/kern_clock.c:1680-1694βPPS_IOC_KCBIND(no privilege check;XXX).sys/dev/misc/pps/pps.c:103-104βpps(4)cdev created0644.sys/kern/kern_ntptime.cβhardpps()consumes the bound state.- CWE-862 Missing Authorization.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/kern_clock.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0022 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| kcbind.c | trigger-source | direct-open PPS_IOC_KCBIND privilege-bypass PoC (fixed nested-comment bug, added O_NONBLOCK) | 2.6 KB | view raw |
| kcbind_drop.c | trigger-source | decisive privilege-drop variant: root opens, drops to uid=1001, issues KCBIND β proves missing ioctl privilege check independent of device-open perms | 3.5 KB | view raw |
| build.sh | build-script | builds both PoC variants | 415 B | view raw |
| run.sh | run-script | runs the privilege-drop KCBIND test | 820 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, reachability note, fix validation before/after | 8.5 KB | β raw |
| README.md | readme | original PoC readme | 2.0 KB | β raw |
| fix.diff | suggested-fix | git-apply-able fix: add caps_priv_check_self(SYSCAP_NOSETTIME) to PPS_IOC_KCBIND | 756 B | view raw |
| pps_sync_build.log | build-log | full PPS_SYNC baseline kernel build output | 5.6 MB | β download |
| fix_build.log | build-log | full fixed (PPS_SYNC + fix.diff) kernel build output | 5.6 MB | β download |
| run.log | run-log | decisive before/after run: KCBIND succeeds (no fix) vs EPERM (fix) | 1.5 KB | view raw |
| run.baseline_default_kernel.log | run-log | default #0 GENERIC baseline: KCBIND unreachable (no PPS_SYNC, no world-openable device) | 958 B | view raw |
| run.baseline_pps_sync_buggy.log | run-log | PPS_SYNC #1 no-fix: KCBIND succeeds under uid=1001 (BUG CONFIRMED) | 920 B | view raw |
| env.txt | environment | uname, kern.version, cc version, config/fix state, sio0/ttyd0 perms | 756 B | 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-0022 β PoC
PPS_IOC_KCBIND missing privilege check in pps_ioctl()
(sys/kern/kern_clock.c:1680-1694).
The bug
pps_ioctl() honors an unprivileged PPS_IOC_KCBIND that binds the kernel
hardpps() consumer (pps->kcmode = kapi->edge at :1690) with no
caps_priv_check_self(). The code carries an
/* XXX Only root should be able to do this */ comment (:1683) acknowledging
the omission.
The entire KCBIND handler body is #ifdef PPS_SYNC (:1681). options
PPS_SYNC is not in the default X86_64_GENERIC kernel (only
sys/config/LINT64:791), so on the default GENERIC kernel the path is compiled
out (#else return EOPNOTSUPP). The bug is real for time-service kernels built
with options PPS_SYNC.
Files
kcbind.cβ direct-open variant. Opens a PPS device and issuesPPS_IOC_KCBIND. On a real PPS box with/dev/pps0(mode0644,sys/dev/misc/pps/pps.c:103-104), an unprivileged user can open it directly.kcbind_drop.cβ decisive privilege-drop variant. Root opens the device, then drops touid=1001/gid=1001before issuing the ioctl. Tests the ioctl privilege check independently of device-open permissions (needed on the KVM guest, whose only PPS device is the root-only console tty/dev/ttyd0).
Build
cc -o kcbind kcbind.c cc -o kcbind_drop kcbind_drop.c # decisive
Run
On a PPS_SYNC kernel, as root (the privilege-drop variant starts as root to clear the open gate, then drops):
./kcbind_drop /dev/ttyd0
Expected output
- PPS_SYNC, no fix (bug present):
[*] now running as uid=1001 euid=1001 gid=1001 [+] KCBIND succeeded under uid=1001 (privilege bypass) on /dev/ttyd0 - PPS_SYNC + fix.diff (fixed):
[-] KCBIND rejected under uid=1001: Operation not permitted (errno=1) - Default GENERIC (no PPS_SYNC): the KCBIND handler is compiled out; even
with an openable PPS device the ioctl returns
EOPNOTSUPP. The bug is latent.
See VERDICT.md for the full analysis and fix validation.
DF-0022 β PPS_IOC_KCBIND missing privilege check
Verdict
REPRODUCED (missing-privilege-check logic bug, Low severity). The
PPS_IOC_KCBIND handler in pps_ioctl() (sys/kern/kern_clock.c:1680-1694)
sets pps->kcmode = kapi->edge with no caps_priv_check_self() β only
the long-standing /* XXX Only root should be able to do this */ comment
(:1683). On a kernel built with options PPS_SYNC, an unprivileged
credential (uid=1001) can successfully bind the kernel hardpps() consumer.
The finding's recommended fix (caps_priv_check_self(SYSCAP_NOSETTIME)) is
confirmed working: on the patched PPS_SYNC kernel the same unprivileged
credential receives EPERM.
Important reachability note (default vs. PPS_SYNC kernel)
The bug is latent on the default X86_64_GENERIC kernel: options
PPS_SYNC is not in sys/config/X86_64_GENERIC (it appears only in
sys/config/LINT64:791). Without PPS_SYNC, the entire KCBIND handler body
at kern_clock.c:1681-1691 is compiled out (#ifdef PPS_SYNC β¦ #else return
EOPNOTSUPP), so there is no reachable path. Additionally, the KVM guest has
no parallel-port hardware, so the pps(4) driver (which creates the
world-openable /dev/pps0, mode 0644 per sys/dev/misc/pps/pps.c:103-104)
does not attach. The only attached PPS-capable device is sio0
(/dev/ttyd0), which is the serial console and is 0600 root-only; the
console tty also rejects opens from non-root sessions regardless of file mode.
To characterize the primitive (prove the missing ioctl privilege check is
real), I built a kernel with options PPS_SYNC added to the config and ran a
privilege-drop test (see below). This is the configuration the finding
explicitly requires (## Threat model: "Required config: kernel built with
options PPS_SYNC").
Mechanism (confirmed in source, line by line)
-
Attacker-reachable ioctl.
pps_ioctl()(sys/kern/kern_clock.c:1640) handlesPPS_IOC_KCBINDat:1680. It is forwarded verbatim by every PPS-capable device driver with no upstream privilege check:sys/dev/misc/pps/pps.c:183,sys/dev/serial/sio/sio.c:2165,sys/bus/u4b/serial/usb_serial.c:1237. -
Missing check. Inside
#ifdef PPS_SYNC(:1681), the handler validates only the argument format/consumer/edge (:1684-1689) and then directly setspps->kcmode = kapi->edge(:1690). There is nocaps_priv_check_self(). The in-tree comment at:1683(/* XXX Only root should be able to do this */) acknowledges the omission. -
Effect. With
pps->kcmodeset, subsequent PPS capture events on that source drivehardpps()(kern_clock.c:1711,kern_ntptime.c), steering the globalpps_freq/pps_jitter/pps_tf[]/STA_PPSSIGNALstate used byntp_update_second(). A privilegedntpdwith PPS discipline (STA_PPSFREQ/STA_PPSTIME) then disciplines the clock against attacker-influenced values, bounded byMAXFREQ/MAXPHASE. (Full clock-steering additionally needs a PPS event source the attacker can drive; the immediate impact is the unprivileged bind itself + PPS quality-counter poisoning reported back viantp_adjtime().)
Reproduction
Two PoC variants ship in this folder:
-
kcbind.cβ direct-open variant. Opens a PPS device and issuesPPS_IOC_KCBIND. On the KVM guest this cannot directly fire because the only PPS device (/dev/ttyd0) is the root-only console tty. On a real PPS box with/dev/pps0(mode0644), an unprivileged user can open it directly and this variant fires. -
kcbind_drop.cβ decisive privilege-drop variant. Root opens/dev/ttyd0(clearing only the console-tty open gate), then the process permanently drops touid=1001/gid=1001(maxx, not in wheel) viasetreuid/setregid, and only then issuesPPS_IOC_KCBIND. Becausecaps_priv_check_self()evaluates the current thread's credential, this faithfully tests whether the ioctl handler checks the caller's privilege β without depending on device-open permissions. This is a standard missing-privilege-check test, not a privilege escalation.
Build & run
# as root on a PPS_SYNC kernel: cc -o kcbind_drop kcbind_drop.c ./kcbind_drop /dev/ttyd0
Result β PPS_SYNC kernel, NO fix (bug present)
Kernel: DragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 17:48:30 UTC 2026
$ ./kcbind_drop /dev/ttyd0
[*] now running as uid=1001 euid=1001 gid=1001
[+] KCBIND succeeded under uid=1001 (privilege bypass) on /dev/ttyd0
[+] hardpps() consumer bound with NO privilege check;
pps->kcmode = PPS_CAPTUREASSERT.
Impact
Low (matches the finding's CVSS AV:L/AC:H/.../I:L/A:N). This is a
missing-privilege-check / confused-deputy against NTP β not memory
corruption, not a crash, not an info leak, not uid=0. The attacker cannot
directly step the clock; the realistic ceiling is bounded phase/frequency
drift (under MAXFREQ/MAXPHASE) plus STA_PPSSIGNAL spoofing, and only if
a privileged PPS-disciplined ntpd is also running. The immediate confirmed
effect is the unprivileged hardpps bind itself.
On the default GENERIC kernel the bug does not manifest (PPS_SYNC
compiled out); it is real for time-service kernels built with
options PPS_SYNC.
Exploit chain
Not applicable β this is a missing-privilege-check logic bug (CWE-862), not a memory-corruption primitive. There is no escalation chain to develop; the impact is the bounded NTP confused-deputy described above.
Fix validation (Phase 8)
fix.diff adds if (caps_priv_check_self(SYSCAP_NOSETTIME)) return (EPERM);
at the top of the #ifdef PPS_SYNC PPS_IOC_KCBIND case
(sys/kern/kern_clock.c:1681), using the same capability that gates
settimeofday/adjtime (sys/kern/kern_time.c:287,661,753,
sys/sys/caps.h:175). This supersedes the finding markdown's ## Recommended
fix proposal (same approach, same location β verified to compile and work).
Before / after (both on options PPS_SYNC kernels)
| kernel | KCBIND under uid=1001 | result |
|---|---|---|
| PPS_SYNC, no fix (#1, 17:48:30) | succeeds, pps->kcmode set |
BUG |
| PPS_SYNC + fix.diff (#1, 18:20:02) | EPERM (errno=1) |
FIXED |
| PPS_SYNC + fix.diff, root (uid=0) | succeeds | root retains access |
The fix is correctly scoped: it blocks unprivileged credentials while allowing
root (which holds SYSCAP_NOSETTIME).
Build / install / boot details
- PPS_SYNC baseline kernel:
make -j6 nativekernel KERNCONF=X86_64_GENERICwithoptions PPS_SYNCadded tosys/config/X86_64_GENERIC(build log:pps_sync_build.log). - Fixed kernel: same config +
fix.diffapplied tokern_clock.c(build log:fix_build.log). Installed viamake installkernel. - Both booted cleanly; serial console captured in
dfbsd-qemu/boot.log.
PoC changes
kcbind.cβ fixed a nested-C-comment bug (/* XXX ... */inside the outer block comment prematurely closed it) and addedO_NONBLOCKto the open (avoids hanging on DCD carrier wait on serial lines). Added numericerrnoto the rejection message for unambiguousEPERMevidence.kcbind_drop.cβ new file. Privilege-drop variant that tests the ioctl privilege check independently of device-open permissions (needed because the guest's only PPS device is the root-only console tty). This is the decisive PoC.
Kernel references (confirmed during verification)
sys/kern/kern_clock.c:1680-1694βPPS_IOC_KCBINDhandler (no privilege check;:1683XXX comment;:1690pps->kcmode = kapi->edge).sys/kern/kern_clock.c:1644,1681,1692β#ifdef PPS_SYNCgates (compiled out on default GENERIC).sys/config/X86_64_GENERICβ noPPS_SYNC(onlysys/config/LINT64:791).sys/dev/misc/pps/pps.c:103-104βpps(4)cdev created0644(world-openable β the real-world unprivileged-open path).sys/dev/misc/pps/pps.c:183β forwardsPPS_IOC_KCBINDtopps_ioctlwith no upstream privilege check.sys/dev/serial/sio/sio.c:1228-1229β sio'spps_statehasppscap = PPS_CAPTUREASSERT | PPS_CAPTURECLEAR;pps_init()called.sys/dev/serial/sio/sio.c:2165β sio forwards unknown ioctls topps_ioctl(the path exercised by the PoC).sys/sys/caps.h:175βSYSCAP_NOSETTIMEdefinition.sys/kern/kern_time.c:287,661,753βSYSCAP_NOSETTIMEused bysettimeofday/clock_settime/adjtime(the fix's chosen capability is consistent with existing kernel time-setting privilege gating).sys/conf/options:59βPPS_SYNCβopt_ntp.h.
Fix verification
fixedVALIDATED: baseline KCBIND succeeds under uid=1001 (bypass); patched returns EPERM for uid=1001 while root retains access.
baseline: KCBIND succeeded under uid=1001. patched: EPERM for uid=1001; root OK.
Confirmed kernel references
Detail
Exploit chain
none -- missing-privilege-check logic bug (CWE-862). Realistic impact ceiling: bounded NTP phase/frequency drift via confused-deputy ntpd.
Evidence (decisive lines)
baseline (PPS_SYNC kernel, no fix): KCBIND succeeded under uid=1001 (privilege bypass). patched (PPS_SYNC + fix.diff): KCBIND rejected under uid=1001: EPERM. root sanity: root retains access.
PoC changes
kcbind_drop.c: NEW privilege-drop variant (root opens, drops to uid=1001, issues PPS_IOC_KCBIND). fix.diff: caps_priv_check_self(SYSCAP_NOSETTIME).
Verified recommended fix
Add 'if (caps_priv_check_self(SYSCAP_NOSETTIME)) return (EPERM);' at the top of the PPS_IOC_KCBIND case in sys/kern/kern_clock.c:1681, using the same capability that gates settimeofday/adjtime. Matches finding markdown. Full git-apply-able diff in findings/poc/DF-0022/fix.diff.
Verdict
REPRODUCED (missing-privilege-check logic bug, Low/CWE-862). PPS_IOC_KCBIND handler at sys/kern/kern_clock.c:1680-1694 sets pps->kcmode with NO caps_priv_check_self() -- only the '/ XXX Only root should be able to do this /' comment. Confirmed at runtime on a PPS_SYNC kernel: privilege-drop test (root opens /dev/ttyd0, drops to uid=1001, issues PPS_IOC_KCBIND) SUCCEEDS -- pps->kcmode set under unprivileged credential. On default GENERIC the handler is #ifdef'd out (PPS_SYNC not in X86_64_GENERIC, only LINT64:791).
No comments yet.