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

joyopen/joyclose exclusive-access protocol is an unsynchronized check-then-set (TOCTOU) β€” EBUSY exclusivity bypass

Summary

No lock/atomic anywhere in the file: two CPUs racing open() both observe timeout==0 and both succeed; a close by one holder drops the claim for all. Root-only 0600 node, plain ints in a never-freed softc - no memory-safety consequence. Atomic-CAS claim fix. Trigger builds clean; needs gameport hw to demo (absent on guest).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5099 Β· 11 files
FileTypeDescriptionSize
README.md β€” 2.0 KB ↓ raw
VERDICT.md β€” 2.6 KB ↓ raw
joy_open_race.c β€” 2.0 KB view raw
build.sh β€” 100 B view raw
run.sh β€” 96 B view raw
build.log β€” 98 B view raw
run.log β€” 553 B view raw
env.txt β€” 887 B view raw
fix.diff β€” 377 B view raw
manifest.json β€” 853 B view raw
verdict.json β€” 2.8 KB view raw

DF-5099 β€” joyopen/joyclose exclusive-access protocol is an unlocked TOCTOU

What

joyopen() implements device exclusivity as check-then-set on the shared joy->timeout[i] flag with no serialization at all (sys/dev/misc/joy/joy.c:159-162):

if (joy->timeout[i])
    return EBUSY;
joy->x_off[i] = joy->y_off[i] = 0;
joy->timeout[i] = JOY_TIMEOUT;

joyclose() clears the same flag (joy.c:173). The file contains no lock, token, or atomic β€” the entire open/close/read/ioctl state machine (timeout[2], x_off[2], y_off[2]) is unsynchronized.

Defect mechanics

  • Two CPUs racing open("/dev/joy0") can both read timeout[0] == 0, both skip EBUSY, and both store JOY_TIMEOUT β€” the exclusive-open guarantee is bypassed.
  • A close() by one holder zeroes the flag while another holder still has the fd open, permanently dropping the claim for the remaining holder.
  • joyioctl() mutates the same ints concurrently with an in-flight joyread() (harmless on its own β€” plain int fields, no tearing on the supported arches, and the read loop snapshots timeout once at joy.c:195).

Security impact

None beyond the broken exclusivity semantics: /dev/joy* is created 0600 root (make_dev(..., 0, 0, 0600, ...) at joy.c:133), so only root reaches any of this, and the raced state is plain ints in a devclass-allocated softc that is never freed (no detach path) β€” there is no lifetime or memory-safety consequence. Filed as Info hardening / correctness.

Build / run (as executed on the audit guest)

cc -O2 -Wall -o joy_open_race joy_open_race.c     # -> BUILD_OK, no warnings
./joy_open_race

Expected on hardware with /dev/joy0: exit 0 with iterations=2000 EBUSY-bypassing iterations=N stray opens=M, N>0. Observed on this guest: exit 2, open /dev/joy0: No such file or directory (errno=2) β€” joy(4) never attaches (no ISA gameport; kldload joy alone creates no node), so the race is not runtime-exercisable here. See run.log, env.txt.

Fix

One-word claim via atomic_cmpset_int() (see fix.diff).

VERDICT.md
↓ download raw

DF-5099 VERDICT

Status: inconclusive (not runtime-exercisable on this guest) β€” defect proven from source; Info hardening finding, PoC skipped per contract.

Reproduced?

No β€” and provably impossible on this guest. The joy(4) driver is module-only (/boot/kernel/joy.ko); it attaches only to ISA/PnP gameport hardware (joy_probe/joy_attach, sys/dev/misc/joy/joy.c:103-135). The audit guest has no such hardware: /dev/joy* is absent, and even after kldload joy no device node appears (see run.log, env.txt). Both trigger binaries build cleanly on the guest (cc 8.3, -Wall, zero diagnostics) and exit 2 at open() with ENOENT.

Why the defect is real anyway (source proof)

  • joyopen() β€” joy.c:159-162: if (joy->timeout[i]) return EBUSY; followed by joy->timeout[i] = JOY_TIMEOUT; β€” an unsynchronized check-then-set on shared state. No lock, token, or atomic anywhere in the file (includes at joy.c:31-45).
  • joyclose() β€” joy.c:173: unconditional joy->timeout[i] = 0; β€” drops the exclusive claim while other holders may still have the device open (post-TOCTOU double-open, or dup'd fds closed out of order).
  • Consequence: two racing open()s both succeed (EBUSY bypassed); exclusivity is advisory and racy. That is the entire impact β€” the raced fields are plain ints in a never-freed devclass softc (no detach method exists), so there is no memory-safety or lifetime consequence.

Impact ceiling

Bounded by the device node's permissions: make_dev(&joy_ops, unit, 0, 0, 0600, "joy%d", unit) at joy.c:133 creates the node root-only, so only root reaches this code at all. Info severity is the correct ceiling.

Why no fix validation

The fix (one-word atomic claim) is trivially correct by inspection but cannot be behaviorally validated on a guest whose kernel never instantiates the device. fix.diff is included untested-by-build; the guest was left clean (kldunload joy, exit=0).

Kernel references

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff is git-apply-clean against the unmodified tree and correct by construction, but behavioral fix validation is impossible on this guest: the joy driver never attaches (no ISA gameport), so neither baseline nor patched kernel ever executes joyopen().

fix.diff; run.log (device absent on guest); git apply --check output in session log.
↓ fix.diffper-fix-DF-5099

Confirmed kernel references

Detail

Evidence (decisive lines)

VERDICT.md (source proof + environment blocker); run.log (ENOENT at open, kldload joy creates no node); build.log (clean -Wall build on guest cc 8.3); env.txt (no ISA gameport, virtio-only PCI); fix.diff (git-apply-clean one-word atomic claim).

PoC changes

None material β€” trigger written fresh for this pass; guest-built heredoc trimmed comments only (functionally identical to pack source).

Verified recommended fix

Claim the device with atomic_cmpset_int(&joy->timeout[i], 0, JOY_TIMEOUT) in joyopen() instead of the unlocked check-then-set (see fix.diff).

Verdict

Defect proven from source: joyopen() implements exclusive access as an unsynchronized check-then-set on joy->timeout[i] (joy.c:159-162) and joyclose() clears the flag unconditionally (joy.c:173); racing opens both succeed past the EBUSY check and a close by one holder drops the claim for the rest. Not runtime-exercisable on this guest: joy(4) is module-only and attaches only to ISA/PnP gameport hardware β€” /dev/joy* is absent even after kldload joy (run.log), so both triggers exit 2 at open() ENOENT. Impact ceiling is Info: the node is 0600 root-only (joy.c:133) and the raced state is plain ints in a never-freed softc, so there is no memory-safety or privilege consequence β€” PoC skipped per contract for Info hardening findings, guest evidence captured instead.