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

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/pps0 is 0644 by 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 to ntpd via ntp_adjtime(), and (b) if a privileged ntpd runs with PPS discipline (STA_PPSFREQ and/or STA_PPSTIME), steer pps_freq/ time_offset under the MAXFREQ/MAXPHASE clamps β€” bounded but arbitrary phase/frequency drift and the ability to spoof STA_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 via ppbus); realized clock impact additionally needs a privileged PPS-disciplined ntpd.
  • 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.

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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0022 Β· 15 files
FileTypeDescriptionSize
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
README.md readme original PoC readme
↓ download 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 issues PPS_IOC_KCBIND. On a real PPS box with /dev/pps0 (mode 0644, 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 to uid=1001/gid=1001 before 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.

VERDICT.md verdict full narrative: mechanism, reachability note, fix validation before/after
↓ download raw

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)

  1. Attacker-reachable ioctl. pps_ioctl() (sys/kern/kern_clock.c:1640) handles PPS_IOC_KCBIND at :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.

  2. Missing check. Inside #ifdef PPS_SYNC (:1681), the handler validates only the argument format/consumer/edge (:1684-1689) and then directly sets pps->kcmode = kapi->edge (:1690). There is no caps_priv_check_self(). The in-tree comment at :1683 (/* XXX Only root should be able to do this */) acknowledges the omission.

  3. Effect. With pps->kcmode set, subsequent PPS capture events on that source drive hardpps() (kern_clock.c:1711, kern_ntptime.c), steering the global pps_freq/pps_jitter/pps_tf[]/STA_PPSSIGNAL state used by ntp_update_second(). A privileged ntpd with PPS discipline (STA_PPSFREQ/STA_PPSTIME) then disciplines the clock against attacker-influenced values, bounded by MAXFREQ/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 via ntp_adjtime().)

Reproduction

Two PoC variants ship in this folder:

  • kcbind.c β€” direct-open variant. Opens a PPS device and issues PPS_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 (mode 0644), 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 to uid=1001/gid=1001 (maxx, not in wheel) via setreuid/setregid, and only then issues PPS_IOC_KCBIND. Because caps_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_GENERIC with options PPS_SYNC added to sys/config/X86_64_GENERIC (build log: pps_sync_build.log).
  • Fixed kernel: same config + fix.diff applied to kern_clock.c (build log: fix_build.log). Installed via make 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 added O_NONBLOCK to the open (avoids hanging on DCD carrier wait on serial lines). Added numeric errno to the rejection message for unambiguous EPERM evidence.
  • 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_KCBIND handler (no privilege check; :1683 XXX comment; :1690 pps->kcmode = kapi->edge).
  • sys/kern/kern_clock.c:1644,1681,1692 β€” #ifdef PPS_SYNC gates (compiled out on default GENERIC).
  • sys/config/X86_64_GENERIC β€” no PPS_SYNC (only sys/config/LINT64:791).
  • sys/dev/misc/pps/pps.c:103-104 β€” pps(4) cdev created 0644 (world-openable β€” the real-world unprivileged-open path).
  • sys/dev/misc/pps/pps.c:183 β€” forwards PPS_IOC_KCBIND to pps_ioctl with no upstream privilege check.
  • sys/dev/serial/sio/sio.c:1228-1229 β€” sio's pps_state has ppscap = PPS_CAPTUREASSERT | PPS_CAPTURECLEAR; pps_init() called.
  • sys/dev/serial/sio/sio.c:2165 β€” sio forwards unknown ioctls to pps_ioctl (the path exercised by the PoC).
  • sys/sys/caps.h:175 β€” SYSCAP_NOSETTIME definition.
  • sys/kern/kern_time.c:287,661,753 β€” SYSCAP_NOSETTIME used by settimeofday/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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 18:20:02 UTC 2026 (X86_64_GENERIC + options PPS_SYNC + fix.diff)

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