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)
PoC verification
Evidence pack
findings/poc/DF-5099 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 readtimeout[0] == 0, both skipEBUSY, and both storeJOY_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-flightjoyread()(harmless on its own β plain int fields, no tearing on the supported arches, and the read loop snapshotstimeoutonce atjoy.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).
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 byjoy->timeout[i] = JOY_TIMEOUT;β an unsynchronized check-then-set on shared state. No lock, token, or atomic anywhere in the file (includes atjoy.c:31-45).joyclose()βjoy.c:173: unconditionaljoy->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
- sys/dev/misc/joy/joy.c:159-162 (TOCTOU check-then-set in joyopen)
- sys/dev/misc/joy/joy.c:173 (joyclose clears claim unconditionally)
- sys/dev/misc/joy/joy.c:133 (root-only 0600 node β impact bound)
- sys/dev/misc/joy/joy.c:31-45 (no synchronization primitives included)
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_testablefix.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.
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.
No comments yet.