# 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`.
