select/pselect/ppoll pass unvalidated (negative/unnormalized) timeouts into kern_kevent β POSIX EINVAL missing, ~146-year sleeps collapse to immediate or arbitrary-short returns
Summary
sys_select/sys_pselect/sys_ppoll copyin the timeout and hand it to kern_kevent with no range/normalization check: negative values become already-expired targets (immediate success return indistinguishable from a spurious wakeup β long-poll loops and watchdogs spin with no diagnosable error); unnormalized huge sub-second fields overflow the PRECISE ustimeout multiply (kern_event.c:975) or the tick conversion in tvtohz_high, collapsing arbitrarily long sleeps. The nd==0 paths of the same syscalls route through clock_nanosleep1 which DOES validate β proving the missing entry check is the defect. Guest-verified uid=1001: pselect{-1s} rc=0 in 19-87us (POSIX: EINVAL); ppoll{nsec:2^62} returns after 1.7s (intended ~146y); select{usec:2^62} rc=0 in 38-73us; negative usec immediate; controls exact. Complementary to DF-5835. Entry-validation fix (3 hunks) proposed.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5842 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.8 KB | β raw | |
| VERDICT.md | β | 4.4 KB | β raw | |
| timeout_rig.c | β | 4.4 KB | view raw | |
| t8.c | β | 734 B | view raw | |
| build.sh | β | 129 B | view raw | |
| run.sh | β | 119 B | view raw | |
| build.log | β | 200 B | view raw | |
| run.log | β | 506 B | view raw | |
| run.2.log | β | 1.1 KB | view raw | |
| env.txt | β | 351 B | view raw | |
| fix.diff | β | 809 B | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-5842 β select(2)/pselect(2)/ppoll(2) accept invalid timeouts unvalidated; long timers collapse
What
sys/kern/sys_generic.c copies the user timeout into the kernel and passes
it straight into kern_kevent() with no range or normalization check:
sys_select():807-815β timeval, no checksys_pselect():842-849β timespec, no checksys_ppoll():1271-1278β timespec, no check
(sys_poll()'s computed {timeout/1000, (timeout%1000)*1e6} is always
valid, so only the timeout-pointer syscalls are affected.)
Inside kern_kevent() (sys/kern/kern_event.c:872-1000) the raw value is
added to the current uptime and later converted by tstohz_high()
(sys/kern/kern_clock.c:1227+) or, for ppoll (KEVENT_TIMEOUT_PRECISE),
by ustimeout = atx.tv_sec * 1000000 + atx.tv_nsec / 1000
(kern_event.c:975) β both of which overflow/truncate for unnormalized
huge sub-second fields, and both of which treat negative targets as
"already expired".
Contrast: the nd == 0 / nfds == 0 paths of the very same syscalls go to
dotimeout_only() β nanosleep1() β clock_nanosleep1(), which DOES
reject tv_sec < 0 || tv_nsec < 0 || tv_nsec >= 1000000000 with EINVAL
(sys/kern/kern_time.c:467).
Demonstrated (unprivileged, deterministic over 3 runs)
| test | call | POSIX | DragonFly observed |
|---|---|---|---|
| T1 | pselect(pipe, {tv_sec:-1}) |
EINVAL | rc=0 in ~20-90 Β΅s |
| T2 | ppoll(pipe, {tv_nsec: 2^62}) |
EINVAL | rc=0 after ~1.7-2.0 s (intended ~146 years!) |
| T2b | ppoll(pipe, {tv_sec:-1}) |
EINVAL | rc=0 in ~15 Β΅s |
| T3 | select(pipe, {tv_usec:-5}) |
EINVAL | rc=0 in ~20 Β΅s |
| T7 | select(pipe, {tv_usec: 1500000}) |
EINVAL | rc=0, sleeps 1.5 s |
| T8 | select(pipe, {tv_usec: 2^62}) |
EINVAL | rc=0 in ~40-70 Β΅s (intended ~146 years) |
| T4/T5 | controls {2s} / {0.5s} |
β | 2.000 s / 0.500 s (rig sane) |
| T6 | select(0 fds, {tv_sec:-1}) |
EINVAL | EINVAL (nd==0 path validates) |
Impact: any long-intended poll/select/watchdog built on these syscalls can
be made to return immediately or after an arbitrary wrong duration by an
unprivileged caller; POSIX conformance violation. Same observable class as
DF-5835 (kern_clock.c converters) but a distinct defect and fix location:
missing validation at these syscall entries, plus overflow sites
(kern_event.c:975 precise multiply; tvtohz_high huge-usec (int) cast)
that only see garbage because the entries let it through.
Reproduce
build.sh # cc -O -o /tmp/timeout_rig /tmp/timeout_rig.c; cc -O -o /tmp/t8 /tmp/t8.c
run.sh # /tmp/timeout_rig && /tmp/t8 && /tmp/t8
# expect the table above (total runtime ~8 s)
DF-5842 VERDICT β reproduced (timer-semantics corruption, unprivileged)
Bottom line
select(2), pselect(2) and ppoll(2) copy the user-supplied timeout into
the kernel and hand it to kern_kevent() with no validation whatsoever
(sys/kern/sys_generic.c:807-815, :842-849, :1271-1278). Negative and
unnormalized (sub-second field β₯ 1e9/1e6) values flow into
timespecadd/tstohz_high/precise_sleep, where they either mean
"already expired" (immediate return with success) or overflow integer
conversions and produce arbitrary wrong durations. Demonstrated live on the
audit guest as unprivileged uid=1001, deterministic across 3 runs:
pselect(fd, {tv_sec:-1})β rc=0 in 19-87 Β΅s (POSIX: EINVAL)select(fd, {tv_usec: 2^62})β rc=0 in 38-73 Β΅s β a ~146-year intended sleep collapses to immediate return (tick math(int)(sec*hz + howmany(4.6e15 usec, ustick))truncates mod 2^32 to a non-positive tick count;tvtohz_highkern_clock.c:1196-1200)ppoll(fd, {tv_nsec: 2^62})β rc=0 after 1.69-1.98 s (PRECISE path:ustimeout = atx.tv_sec*1000000 + atx.tv_nsec/1000int-overflows, kern_event.c:975, systimer fires after the truncated value)select(0 fds, {tv_sec:-1})β EINVAL: the nd==0 path of the same syscall routes throughnanosleep1()βclock_nanosleep1()which validates (kern_time.c:467) β proving the inconsistency is the missing entry check, not the sleep machinery.
Controls ({2s} β 2.000 s, {0.5s} β 0.500 s) prove the rig measures real
sleeps. Full logs: run.log, run.2.log (3 consecutive full runs + 2 T8
runs, all consistent).
Root cause (path:line)
sys_selectsys_generic.c:807-811:copyin(uap->tv, &ktv)thenTIMEVAL_TO_TIMESPECβ notv_sec<0 / tv_usec<0 / tv_usec>=1000000check.sys_pselectsys_generic.c:842-846andsys_ppollsys_generic.c:1271-1275:copyin(uap->ts, &kts)β notv_sec<0 / tv_nsec<0 / tv_nsec>=1000000000check.kern_keventkern_event.c:872-875:timespecadd(tsp, &ats, tsp)on the raw value (negative target β in the past βEWOULDBLOCKtreated as "no events" β immediate success).kern_keventwait loopkern_event.c:958-1000: PRECISE multiply overflows for unnormalized nsec; non-precisetstohz_high(&atx)(kern_clock.c:1227-1250) castssec*hz + howmany(nsec, nstick)to int β huge nsec wraps to arbitrary/negative ticks (callout clamps to 1 tick β immediate wake, same endpoint as DF-5835 documented).
Why not a duplicate of DF-5835
DF-5835 (kern_clock.c) is the seconds-term overflow inside the four
converters at the sec == INT_MAX/hz boundary; its fix (headroom guard in
the converters) does not stop T1/T2b/T3/T7 (negative and unnormalized
values) and does not cover kern_event.c:975. This finding is the missing
syscall-entry validation in sys_generic.c; the fix mirrors
clock_nanosleep1()'s check (kern_time.c:467) β FreeBSD does exactly this
in kern/select/poll paths. Complementary file, complementary fix.
Impact
Unprivileged local timer-semantics corruption: an application's arbitrarily long select/poll timeout can silently become immediate return or a random short duration (breaking long-poll loops, watchdogs, retry throttles β they spin); POSIX conformance violation. No memory corruption, no info leak β severity Low, impact class dos (same class DF-5835 was filed under).
Fix validation
Not rebuilt: Low-severity semantics finding, non-memory-corruption β
fix.diff is the entry-validation patch (3 hunks, mirrors
clock_nanosleep1's exact predicate). With it, T1/T2/T2b/T3/T7/T8 all become
clean EINVAL and the controls are unchanged. fix_status: not_tested
(no patched-kernel run performed for this Low finding).
Attempts: 2 (initial rig had an unbounded T7 that wedged the first ssh session for its full 4000 s intended sleep β itself confirming that huge normalized positive values are honored; rig was bounded with alarm(30) and T7 re-scoped). Guest stayed up throughout; no panic; no dirty state.
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 (3 hunks) adds the clock_nanosleep1-identical validation to all three entry points; with it T1/T2/T2b/T3/T7/T8 become clean EINVAL and the T4/T5 controls are unchanged. Not kernel-rebuilt: Low-severity non-memory-corruption finding, rebuild not mandated by contract; patch is a direct mirror of the already-proven kern_time.c:467 predicate.
fix.diff (unified, against read-only sys/, never applied); baseline behavior in run.log/run.2.log; validating-path contrast T6 proves EINVAL is the kernel's existing behavior for the same input when routed through nanosleep1.
Confirmed kernel references
Detail
Exploit chain
unprivileged pselect/ppoll/select with negative or unnormalized timeout -> kern_kevent raw timespecadd -> expired-target immediate return or int-truncated tick/us count -> syscall returns success immediately/after arbitrary wrong duration; applications relying on long timeouts spin or misbehave (DoS-class).
Evidence (decisive lines)
run.log + run.2.log: T1 pselect{-1s} rc=0 elapsed=0.000019-0.000087; T2 ppoll{0,2^62ns} rc=0 elapsed=1.694-1.982s; T2b ppoll{-1s} rc=0 elapsed~15us; T3 select{0,-5us} rc=0 elapsed~20us; T7 select{0,1.5e6us} rc=0 sleeps 1.501s; T6 select(0fds){-1s} rc=-1 EINVAL=22 (validating path); T4/T5 controls 2.000s/0.500s. T8 (run.2.log tail) select{0,2^62us} rc=0 elapsed=0.000038-0.000073 across 3 executions.
PoC changes
No seed rig; wrote from scratch. First version had an unbounded T7 (select{0,4e9us}) that legitimately slept 4000s and wedged the ssh session - confirming normalized huge positive values are honored - then bounded every test with alarm(30) and re-scoped T7 to {0,1500000us}; added T8 (t8.c) for the non-precise tstohz_high path.
Verified recommended fix
Validate the timeout at each syscall entry exactly like clock_nanosleep1: reject tv_sec<0 or tv_nsec<0 or tv_nsec>=1e9 (timeval: tv_usec<0 or >=1e6) with EINVAL in sys_select/sys_pselect/sys_ppoll
Verdict
Reproduced unprivileged (uid=1001), deterministic across 3 runs: sys_select (sys_generic.c:807-815), sys_pselect (:842-849) and sys_ppoll (:1271-1278) copy the user timeout and pass it to kern_kevent with no range/normalization check. pselect(ppoll){tv_sec:-1} returns rc=0 in ~20-90us (POSIX: EINVAL); select{tv_usec:2^62} returns rc=0 in ~40-73us - a ~146-year intended sleep collapses to immediate return via the (int) truncation in tvtohz_high's tick math (kern_clock.c:1196-1200); ppoll{tv_nsec:2^62} (PRECISE path) returns after ~1.7-2.0s from the int-overflowing ustimeout multiply at kern_event.c:975; select{tv_usec:1500000} is accepted and sleeps 1.5s. The nd==0 path of the same syscalls routes through nanosleep1->clock_nanosleep1 which DOES validate (kern_time.c:467, returns EINVAL) - select(0fds,-1s)=EINVAL while select(fd,-1s)=success proves the missing entry check is the defect. Complementary to DF-5835 (kern_clock.c converters): DF-5835's converter fix does not stop negative/unnormalized values and does not cover kern_event.c:975. Impact: unprivileged timer-semantics corruption (long polls silently become immediate/arbitrary short returns - watchdog/long-poll spin), POSIX violation; no memory corruption.
No comments yet.