OOM kill block operates on bigproc without p_token and without liveness revalidation (TOCTOU vs concurrent exit)
| Field | Value |
|---|---|
| ID | DF-2688 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-362 Race Condition, CWE-667 Improper Locking |
| File | sys/vm/vm_pageout.c |
| Lines | 1826-1837 |
| Area | vm |
| Confidence | speculative |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
When swap is exhausted, vm_pageout_scan_cache's once-per-second OOM
kill block runs allproc_scan(vm_pageout_scan_callback) and then
dereferences the selected bigproc β p_nice store,
p_usched->resetpriority(FIRST_LWP_IN_PROC(bigproc)), killproc β with
no p_token held and no re-check of p_stat, across a window widened by
an intervening kprintf. The selecting callback itself takes p_token and
filters p_stat (:1856-1865); the kill block is the outlier.
FIRST_LWP_IN_PROC is an un-serialized RB_FIRST over p_lwp_tree racing
lwp_rb_tree_RB_REMOVE in lwp_exit (under p_token), and
dfly_resetpriority reads/writes lp fields on the result. Detailed
teardown analysis shows PHOLD keeps the proc/master lwp/lwp_thread
alive through the window β freed-memory UAF was NOT demonstrable; the
realistic worst case is a panic via a torn rb-tree walk or an
empty-tree NULL deref β local DoS under total swap exhaustion.
Recommended fix
Retake p_token, revalidate p_stat β {SACTIVE,SSTOP,SCORE}, NULL-check FIRST_LWP_IN_PROC before resetpriority (tokens are source-reentrant so holding p_token across killproc is safe). Verified git-apply-clean diff in findings/poc/DF-2688/.
Timeline
- 2026-08-30 Discovered during pass-2 audit of vm_pageout.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2688 Β· 6 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.7 KB | β raw | |
| VERDICT.md | β | 4.3 KB | β raw | |
| df2688_trigger.c | β | 2.5 KB | view raw | |
| run.sh | β | 492 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| verdict.json | β | 2.5 KB | view raw |
DF-2688 β OOM kill block operates on bigproc without p_token and without re-validating liveness
File: sys/vm/vm_pageout.c:1812-1838 (vm_pageout_scan_cache), helper
vm_pageout_scan_callback at sys/vm/vm_pageout.c:1841-1891.
Status
UNTESTED (Low / speculative impact). Not verified on the QEMU guest:
the finding is neither Critical/High nor in the memcorrupt/privesc bucket,
and triggering requires full swap exhaustion (swap_pager_full) plus a
microsecond-scale race against process exit at the pagedaemon's once-per-second
kill cadence β not a deterministic local program. Pack contains a seed
trigger for a future verify-mode run.
Build / run (seed, UNVERIFIED)
cc -O2 -o df2688_trigger df2688_trigger.c ./df2688_trigger # as an unprivileged user, on a system with swap configured
Expected (if the race ever fires)
Kernel panic in dfly_resetpriority (NULL/garbage lp) called from
vm_pageout_scan_cache, e.g. NULL-deref at
lp->lwp_qcpu (sys/kern/usched_dfly.c:1108), or a torn
FIRST_LWP_IN_PROC rb-tree read racing lwp_rb_tree_RB_REMOVE in
lwp_exit() (sys/kern/kern_exit.c:773).
Why it is only Low/speculative
Analysis (VERDICT.md) shows the callback's own PHOLD keeps the proc, its
master lwp, and the lwp's thread allocated through the race window
(non-master lwps are unlinked from p_lwp_tree before being freed, and
the master-exit lwp is never unlinked while the proc is held), so the
most likely outcome of the missing lock is a benign priority update on an
exiting lwp, with a panic only via a torn rb-tree walk. The lock-discipline
defect itself is certain by inspection; the exploitable consequence is not
demonstrated. See VERDICT.md for the full kill-chain analysis.
DF-2688 VERDICT β OOM kill block TOCTOU / missing p_token
Status: untested. Reproduced: no. Impact ceiling: local DoS (panic), speculative.
The defect (certain, by inspection)
vm_pageout_scan_cache() runs the out-of-swap OOM kill once per second when
swap_pager_full && pass > 1 && isep == 0 && avail_shortage > 0 &&
vm_paging_target1() (sys/vm/vm_pageout.c:1812-1817):
allproc_scan(vm_pageout_scan_callback, &info, 0); // :1826
if (info.bigproc != NULL) {
kprintf("Try to kill process %d %s\n", ...); // :1828 <- can block on serial console
info.bigproc->p_nice = PRIO_MIN; // :1830 <- no p_token
info.bigproc->p_usched->resetpriority(
FIRST_LWP_IN_PROC(info.bigproc)); // :1831-1832 <- un-tokened rb-tree walk
atomic_set_int(&info.bigproc->p_flags, P_LOWMEMKILL);
killproc(info.bigproc, "out of swap space"); // :1834
...
}
The selecting callback (vm_pageout_scan_callback, :1856) explicitly takes
lwkt_gettoken(&p->p_token) while examining each proc and filters on
p_stat β {SACTIVE, SSTOP, SCORE} (:1862), then takes its own PHOLD
(:1883) so the proc survives the scan. But between the callback returning
and the kill block executing, no lock is held and p_stat is never
re-checked. The kprintf in between can itself block for milliseconds on
a serial console, widening the window.
What races:
- FIRST_LWP_IN_PROC(p) is RB_FIRST(lwp_rb_tree, &p->p_lwp_tree)
(sys/sys/proc.h:413) read without p_token, concurrent with
lwp_rb_tree_RB_REMOVE(&p->p_lwp_tree, lp) in lwp_exit()
(sys/kern/kern_exit.c:773, under p_token) β a torn traversal is possible.
- dfly_resetpriority(lp) (sys/kern/usched_dfly.c:1091) dereferences
lp->lwp_qcpu, lp->lwp_proc, and writes lp->lwp_thread->td_upri
(:1158) on the returned lwp with no token and no lwp reference.
Compare vm_daemon_callback (:2859-2890), which does the same class of
per-proc work strictly under p_token β the kill block is the outlier.
Why the impact collapses to Low/speculative
Traced teardown paths (sys/kern/kern_exit.c):
PHOLD(p)from the callback keeps the proc struct allocated and on its list;wait*()cannot complete the reap.- Non-master lwps unlink themselves from
p_lwp_tree(:773) before being placed ondeadlwp_listfor async reaping β so at any instant the tree only references allocated lwps; a torn read yields an exiting-but-still-allocated lwp, not freed memory. - The master-exit lwp is deliberately left on
p_lwp_treeand disposed synchronously by the reaper (comment at :756-760), so under our PHOLD the tree retains a valid lwp andFIRST_LWP_IN_PROCshould not return NULL for an ordinary single- or multi-threaded exit.
Residual hazards: (a) a torn rb-tree walk during concurrent removal could
return a concurrently-exiting lwp whose lwp_qcpu/scheduler fields are in
flux β dfly_resetpriority's remote-cpu spinlock loop (:1103-1113) then
operates on a moving target; (b) edge cases where the tree is empty
("UNDEAD" state mentioned at sys/kern/kern_exit.c:156-158) would give
resetpriority(NULL) β NULL deref β panic. Neither was demonstrated.
killprocβksignal is self-protecting ("Don't try to deliver a generic
signal to an exiting process", sys/kern/kern_sig.c lwpsignal prologue).
Preconditions an attacker would need: fill all swap + memory
(swap_pager_full) β feasible for an unprivileged user only by exhausting
system swap; then win a Β΅s-scale race against the once-per-second kill
window. Realistic worst case: kernel panic (local DoS) under swap
exhaustion. No path to controlled memory corruption was found because the
concurrent-teardown objects stay allocated under PHOLD.
Fix
Retake p_token, revalidate p_stat, and NULL-check the lwp before
touching it β see fix.diff (authored against the read-only sys/ tree;
not applied anywhere).
Decision not to run on the guest
- Not Critical/High, not memcorrupt/privesc bucket β outside the mandatory verification set; the trigger is a timing race needing total swap exhaustion on the single-tenant guest, with a real chance of wedging it for downstream users (OOM killer firing at sshd/getty).
- Left the QEMU guest untouched (
guest_dirty: 0, guest wasupand clean).
Fix verification
not_testableConfirmed kernel references
Detail
Evidence (decisive lines)
['sys/vm/vm_pageout.c:1824-1837 (kill block, no p_token, no p_stat recheck)', 'sys/vm/vm_pageout.c:1841-1891 (callback takes p_token + PHOLD, filters p_stat)', 'sys/kern/kern_exit.c:766-780 (lwp unlinked from p_lwp_tree before async reap; master lwp left on tree)', 'sys/kern/usched_dfly.c:1091-1158 (resetpriority reads lp->lwp_qcpu/lwp_proc, writes lp->lwp_thread->td_upri)', 'findings/poc/DF-2688/VERDICT.md (full teardown/impact analysis)', 'findings/poc/DF-2688/fix.diff (token + revalidation + NULL-check fix)']
PoC changes
Authored seed trigger df2688_trigger.c (unverified): swap-exhaustion hoggers plus a repeatedly-exiting biggest process to race the once-per-second kill window.
Verified recommended fix
Retake p_token, revalidate p_stat in {SACTIVE,SSTOP,SCORE}, and NULL-check FIRST_LWP_IN_PROC before resetpriority in the OOM kill block.
Verdict
Lock-discipline defect is certain by inspection: the swap-full OOM kill block (sys/vm/vm_pageout.c:1824-1837) dereferences bigproc fields and walks FIRST_LWP_IN_PROC()'s rb tree without p_token and without re-validating p_stat, racing concurrent lwp teardown (kern_exit.c:773 lwp_rb_tree_RB_REMOVE) across a window widened by an intervening kprintf. However, teardown analysis shows the callback's PHOLD keeps the proc, master lwp, and lwp_thread allocated through the window, so no demonstrable UAF of freed memory exists; worst realistic outcome is a panic via a torn rb-tree walk / empty-tree NULL deref in dfly_resetpriority, under total swap exhaustion. Not run on the guest: Low severity timing race outside the mandatory verification set; executing it would intentionally wedge the single-tenant guest at swap_pager_full.
No comments yet.