soreceive() zero-progress restart with MSG_PEEK: recv() on a zero-byte AF_UNIX record spins forever in kernel mode β unprivileged, unkillable, permanent whole-system DoS
Summary
soreceive retries via the zero-progress restart (orig_resid==resid -> goto restart). With MSG_PEEK nothing is ever consumed, so a head-of-queue record delivering zero bytes makes orig_resid==resid on every iteration: an infinite kernel-mode loop that never sleeps, never reaches a signal-delivery point, kill -9 ineffective. One thread permanently starves all userland (sshd never runs, ssh fails at banner exchange, realtime root observer freezes) on the 6-vCPU guest until power-cycle. Trigger records reachable by any local user: AF_UNIX SOCK_STREAM sendmsg with SCM_RIGHTS control and zero-length data (sosend explicitly allows it; no PR_ADDR so the orig_resid=0 suppression does not fire; controlp==NULL plain recv; cc>=lowat keeps the blocking check from sleeping) or AF_UNIX SOCK_SEQPACKET zero-length datagram (sbappendrecord permits zero-length records; PR_ATOMIC makes the blocking condition always false). Reproduced 3/3 fresh boots as uid 1000; recovery requires force-killing the VM. MSG_PEEK guard in soreceive+sorecvtcp fix proposed; no memory corruption (holds only the ssb token), no uid0 path.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5806 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| peekspin.c | β | 3.0 KB | view raw | |
| build.sh | β | 90 B | view raw | |
| run.sh | β | 213 B | view raw | |
| build.log | β | 62 B | view raw | |
| run.log | β | 3.2 KB | view raw | |
| env.txt | β | 407 B | view raw | |
| VERDICT.md | β | 5.7 KB | β raw | |
| fix.diff | β | 1.2 KB | view raw | |
| fix-run.log | β | 1.8 KB | view raw | |
| manifest.json | β | 1.6 KB | view raw | |
| verdict.json | β | 5.3 KB | view raw |
DF-5806 β VERDICT
Status: reproduced (impact: dos β permanent, unkillable, unprivileged local DoS) Confidence: certain
Root cause (code-level, all path:line into sys/kern/uipc_socket.c unless noted)
soreceive() ends with a retry block:
1596 if (orig_resid == resid && orig_resid &&
1597 (flags & MSG_EOR) == 0 && (so->so_state & SS_CANTRCVMORE) == 0) {
1598 ssb_unlock(&so->so_rcv);
1599 goto restart;
1600 }
The restart re-acquires ssb_lock (1324) and re-runs the head-of-queue
blocking check (1340-1346), then walks the record again. With MSG_PEEK
set, nothing is ever consumed: the soname walk is a pure m = m->m_next
(1413), control mbufs are only m_copym'd (1424-1425), and data mbufs are
just advanced past (1504-1506). So a record that contributes zero bytes
to the reader leaves orig_resid == resid forever and the loop never
terminates. Critically it also never sleeps β the only tsleep paths
(ssb_wait at 1388/1569) are guarded by the blocking condition at
1340-1346, which for the trigger records is always false (see below), so
the thread spins in kernel mode holding one so_rcv token hold, and never
reaches a signal-delivery point: kill -9 is ineffective.
Two unprivileged trigger shapes (both confirmed reachable by source analysis; variant 0 exercised on the guest):
- AF_UNIX SOCK_STREAM, control-only record:
- sosend explicitly allows zero-data-with-control sends
(829-830:
!(resid == 0 && clen != 0)bypass). - sosend buildstop= a single 0-length mbuf (863-895). - uipc_send STREAM path appends [control][0-len data] via ssb_appendcontrol (sys/kern/uipc_usrreq.c:756-760). - AF_UNIX stream protosw has NO PR_ADDR (sys/kern/uipc_proto.c:53), so theorig_resid = 0suppression at 1409 does not fire; recv() (not recvmsg) passes controlp == NULL, so the suppression at 1446-1449 does not fire either. - Blocking check can't sleep: ssb_cc = 24 (cmsg bytes) >= ssb_lowat (=1, set in soreserve, sys/kern/uipc_socket2.c:673-674), MSG_WAITALL not set β second conjunct false β dontblock directly. - AF_UNIX SOCK_SEQPACKET, plain zero-length datagram: - sbappendrecord explicitly "permits zero length records" (sys/kern/uipc_sockbuf.c:196-198). - PR_ATOMIC is set for seqpacket (uipc_proto.c:61-62) β the whole blocking condition at 1340-1346 is false whenever the queue head exists, even with cc == 0.
UDP / unix-dgram are NOT affected (PR_ADDR forces orig_resid = 0 at 1409); TCP (sorecvtcp) is not affected (sbcompress drops zero-length mbufs, so a TCP sockbuf head always has m_len >= 1, and its restart at 1926-1930 therefore always makes progress or sleeps). The parallel guard is still added there for robustness.
Guest reproduction (3 consecutive runs, fresh vm.sh reset with-src boots)
Run 1 (variant 0, 1 spinner):
- spinner launched as unprivileged user maxx; parent returned.
- the FIRST subsequent command (ps auxww over ssh) hung >120 s with
zero output; every later ssh died with
"Connection timed out during banner exchange" (TCP completes via qemu
slirp; guest sshd never gets scheduled to send its banner).
- vm.sh status β down (ssh probe) while the qemu process kept running.
Run 2 (variant 0, 1 spinner, observer pre-positioned): - a root observer running at realtime priority (rtprio 0) wrote ps snapshots to /dev/console; it froze mid-loop as soon as the wedge took hold (serial log captured in boot.log.excerpt). - ssh dead again.
Run 3 (variant 0, 1 spinner, timed probes): - guest wedged so fast that even the launching ssh session could not drain its output buffer before the spinner pinned the machine.
Observation mapping: unprivileged local user β guest-wide permanent denial of service; RUN/timeout behaviour matches the README's hang/loop expectation (probe timeouts, guest never recovers, VM must be power-cycled). Per the honest-status table this is status=reproduced / impact=dos.
Impact ceiling
Not memory corruption; no escalation path identified (the loop holds only the per-socket ssb token). But as a DoS it is maximal for the class: one syscall from any local user permanently removes the machine from service (all userland starves, network stacks keep answering at the netisr level, so the host looks half-alive), and the offending thread cannot be killed by any means short of reboot.
Fix
fix.diff (git-apply-able, validated on the guest): never take the zero-progress restart when MSG_PEEK is set β in soreceive() (1596-1600) and, defensively, in sorecvtcp() (1926-1930). A peek of a zero-byte record then returns a short read (0 bytes), which matches the non-spinning behavior of other BSD stacks. Non-PEEK behavior is unchanged: every restart still consumes at least the soname/control mbufs of the record it scanned, so no non-PEEK spin is possible.
Fix validation
Baseline (stock kernel, 3 runs): guest wedged within seconds (see run.log). Patched (guest /usr/src + fix.diff, nativekernel rebuild): see fix-run.log β the same PoC returns immediately, repeated peeks keep working, full 6-spinner barrage leaves the guest responsive (details in fix-run.log).
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
fixedBaseline on stock kernel: single unprivileged spinner permanently wedged the whole guest (3/3 fresh-boot runs; ssh banner-exchange timeouts; realtime-priority root observer frozen). Patched kernel (guest /usr/src + fix.diff, make nativekernel + installkernel, kernel #1 2026-10-13): the identical PoC - single spinner and a 12-spinner barrage of both variants - exits instantly every time and the guest remains fully responsive (load 0.16). The livelock is eliminated with no behavioral regression observed (non-PEEK paths untouched; every non-PEEK restart still consumes at least the soname/control mbufs of the scanned record, so no new spin is possible).
findings/poc/DF-5806/fix-run.log (verbatim session output of patched-kernel runs), fix.diff (git-apply checked), VERDICT.md fix section; build+install RC=0 lines and patched-kernel uname recorded in fix-run.log
Confirmed kernel references
- sys/kern/uipc_socket.c:1596
- sys/kern/uipc_socket.c:1599
- sys/kern/uipc_socket.c:1340
- sys/kern/uipc_socket.c:1409
- sys/kern/uipc_socket.c:1446
- sys/kern/uipc_socket.c:829
- sys/kern/uipc_socket.c:863
- sys/kern/uipc_socket.c:1926
- sys/kern/uipc_sockbuf.c:196
- sys/kern/uipc_usrreq.c:756
- sys/kern/uipc_proto.c:53
- sys/kern/uipc_socket2.c:673
Detail
Exploit chain
unpriv user -> socketpair(AF_UNIX, SOCK_STREAM) -> sendmsg(SCM_RIGHTS control, 0-byte data) creating [control][0-len] record -> recv(sv[1], buf, N>=1, MSG_PEEK) with no control buffer -> permanent kernel-mode spin -> all userland starves -> guest-wide permanent DoS (no kernel memory corruption; no uid0 path identified - the loop holds only the per-socket ssb token)
Evidence (decisive lines)
run.log (3 baseline runs: ssh banner-exchange timeouts, vm.sh status down with qemu alive, root realtime console observer frozen mid-loop), fix-run.log (patched kernel: 12 simultaneous spinners all exit instantly, guest stays responsive, load 0.16), VERDICT.md for the full line-cited analysis, fix.diff applied+buildkernel+installkernel BUILD-RC=0/INSTALL-RC=0 with uname confirming kernel #1 2026-10-13
PoC changes
PoC authored fresh for this finding (no seed): fork-per-spinner design so the wedging recv runs in children while the parent reports; two variants (STREAM+SCM_RIGHTS control-only record; SEQPACKET zero-length datagram) covering both reachable zero-byte record shapes.
Verified recommended fix
In soreceive() and sorecvtcp(), never take the zero-progress restart when MSG_PEEK is set: add (flags & MSG_PEEK) == 0 to the restart conditions (uipc_socket.c:1596-1600 and 1926-1930) so a zero-byte peek returns a short read instead of spinning; see evidence-pack fix.diff (validated: baseline wedges, patched returns instantly).
Verdict
soreceive() (sys/kern/uipc_socket.c:1596-1600) takes a zero-progress goto restart whenever orig_resid == resid; with MSG_PEEK nothing is ever consumed, so a receive-queue record that delivers zero bytes (AF_UNIX SOCK_STREAM control-only record via SCM_RIGHTS sendmsg with zero-length data, or an AF_UNIX SOCK_SEQPACKET zero-length datagram) makes recv(MSG_PEEK) spin forever in kernel mode. The loop never sleeps, so the thread is unkillable (kill -9 is never delivered) and permanently monopolizes a CPU; empirically a single unprivileged spinner starved ALL userland on the 6-vCPU guest (sshd included - ssh fails at banner exchange) within seconds, 3/3 fresh-boot runs, recovery only by VM power-cycle. AF_UNIX stream lacks PR_ADDR (uipc_proto.c:53) so the orig_resid=0 suppression at uipc_socket.c:1409 does not apply, and controlp==NULL (plain recv) bypasses the suppression at 1446-1449; cc=24 (cmsg) >= lowat=1 keeps the head-of-queue blocking check (1340-1346) from ever sleeping. UDP/dgram (PR_ADDR) and TCP (sorecvtcp; sbcompress drops zero-length mbufs) are not affected.
No comments yet.