udp6_connect() reads sin6_addr before any sa_len validation: heap OOB read past the user-copied sockaddr decides connect(2)'s errno (DF-3357/3372/3415 family, udp6_usrreq.c instance)
Summary
udp6_connect() casts the connect(2) sockaddr and evaluates IN6_IS_ADDR_V4MAPPED(&sin6_p->sin6_addr) before any length validation - the first sa_len check on the whole path is in6_pcbladdr()'s, reached only via in6_pcbconnect(), after the read. getsockaddr() kmallocs exactly the user-supplied length, so any len<28 makes the read of sin6_addr (offsets 8..19 for the V4MAPPED predicate) an OOB heap read - up to 16 bytes past a 2-byte allocation. When the stale bytes complete the v4-mapped pattern, EADDRNOTAVAIL is returned and in6_pcbconnect is never reached; when they don't, the syscall falls through to EINVAL from the late sa_len check - an unprivileged comparison oracle on stale heap bytes. VERIFIED on stock guest 20/20 vs 0/20 across 3 runs as unpriv uid: groomed bind(24) alloc/free shares the 24-byte M_SONAME slab chunk; connect(17-byte sockaddr) errno deterministically chosen by bytes 17..19 outside the allocation. Impact ceiling is the errno oracle (no connection state from OOB data) - leak-class Low, the v6 twin of DF-3372 and the udp6 instance of the DF-3357 family (the v4 member is Medium only because it also feeds garbage as the packet destination). Fix: sa_len check before the first field read, mirroring the same file's send path and rip6_bind.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3432 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| udp6_connect_oob.c | β | 4.8 KB | view raw | |
| build.sh | β | 124 B | view raw | |
| run.sh | β | 123 B | view raw | |
| build.log | β | 9 B | view raw | |
| run.log | β | 2.4 KB | view raw | |
| VERDICT.md | β | 5.2 KB | β raw | |
| README.md | β | 1.3 KB | β raw | |
| fix.diff | β | 338 B | view raw | |
| env.txt | β | 188 B | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 5.5 KB | view raw |
DF-3432 β udp6_connect() reads sin6_addr before sa_len validation
DF-3357/DF-3372/DF-3415 read-before-validate family, udp6_usrreq.c
instance. Unprivileged connect(2) on an AF_INET6 SOCK_DGRAM socket
with a 17-byte sockaddr makes the kernel read sin6_addr bytes 8..19 β
3 bytes past the allocation β and pick the errno from them.
Reproduce
build: cc -O2 -Wall -o udp6_connect_oob udp6_connect_oob.c run: su -m maxx -c ./udp6_connect_oob # unprivileged
(or ./run.sh for build + 3 stress runs)
Expected (observed 3Γ20 rounds, 100% stable)
A groom 00 ff ff : EADDRNOTAVAIL=20 EINVAL=0 other=0
B groom 00 00 00 : EADDRNOTAVAIL=0 EINVAL=20 other=0
C no groom : EADDRNOTAVAIL=0 EINVAL=20 other=0
RESULT: VULNERABLE -- udp6_connect()'s errno is chosen by heap bytes
OUTSIDE the supplied sockaddr length
Variant A vs B differ only in heap bytes at offsets 17..19 β outside the 17-byte sockaddr the user handed to connect(2).
Files
udp6_connect_oob.cβ PoC sourcerun.sh,build.log,run.logβ exact commands + full outputVERDICT.mdβ full narrative, attribution, impact ceilingfix.diffβ sa_len validation at top of udp6_connect() (git-apply-check OK)manifest.json,verdict.json,env.txt
VERDICT β DF-3432
status: reproduced | impact: leak | confidence: certain
The finding
udp6_connect() (sys/netinet6/udp6_usrreq.c:581) casts the connect(2)
sockaddr and evaluates IN6_IS_ADDR_V4MAPPED(&sin6_p->sin6_addr) at
line 597 β before any sa_len validation. The first length check on
this entire path lives deep inside in6_pcbladdr()
(sys/netinet6/in6_pcb.c:282), reached only via in6_pcbconnect() at
udp6_usrreq.c:608, i.e. after the premature read.
getsockaddr() (sys/kern/uipc_syscalls.c:1513) kmallocs exactly the
user-supplied length (2 <= len <= 255) and copies exactly len bytes.
For len < 28 every read of sin6_addr (file offsets 8..23) is
out-of-bounds past the allocation β up to 16 bytes past a 2-byte
allocation; the IN6_IS_ADDR_V4MAPPED window is offsets 8..19
(sys/netinet6/in6.h:316-318).
Live unprivileged path (no UDP6 pru_preconnect exists β the v4 twin
udp_preconnect is IPv4-only, sys/netinet/udp_usrreq.c:1975):
connect(2) β getsockaddr(kmalloc(len)) uipc_syscalls.c:1513
β soconnect β so_pru_connect uipc_socket.c
β udp6_connect udp6_usrreq.c:583
reads sin6_addr at udp6_usrreq.c:597 β THIS FINDING (first reader)
true β EADDRNOTAVAIL at :598, return (in6_pcbconnect never runs)
false β in6_pcbconnect at :608
reads sin6_addr at in6_pcb.c:344 (DF-3415, other file)
β in6_pcbladdr validates sa_len==28 in6_pcb.c:282
DF-3415's verification already noted this site ("same family, first
reader") but was filed against in6_pcb.c. This finding is the
udp6_usrreq.c instance β unconditional on every short-sockaddr
connect(2) to an AF_INET6 SOCK_DGRAM socket, and it decides the errno
before in6_pcbconnect() is ever called.
Reproduction (guest, DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, uid maxx)
Slab mechanics (sys/kern/kern_slaballoc.c), identical to DF-3415's
verified groom:
zoneindex(): sizes 17..24 round to the 24-byte chunk zone β groombind(24)and triggerconnect(17)share it._kfree()local path writes onlychunk->c_Next(bytes 0..7); free-scrub (use_weird_array) defaults 0 β bytes 8..23 survive.sys_bindandsys_connectboth alloc/free the soname on the syscall thread, same CPU β immediate LIFO reuse.
IN6_IS_ADDR_V4MAPPED needs bytes 8..19 == 00*10 || 00 00 ff ff; the
17-byte trigger supplies bytes 0..16 (zeros), bytes 17..19 are stale
heap bytes from the groom. Results (3 independent runs Γ 20 rounds,
100% stable):
| variant | stale bytes 17..19 | EADDRNOTAVAIL | EINVAL |
|---|---|---|---|
A: groom 00 ff ff |
v4-mapped pattern completes | 20/20 | 0 |
B: groom 00 00 00 |
pattern broken | 0 | 20/20 |
| C: no groom | whatever the zone held | 0 | 20/20 |
A vs B differ only in heap bytes lying outside the 17-byte sockaddr
handed to connect(). When A fires, the return happens at
udp6_usrreq.c:598 β in6_pcbconnect() (and DF-3415's read) is never
reached, so the deciding read for variant A is the one at :597.
Attribution (honest)
Both premature readers (udp6_usrreq.c:597 here, in6_pcb.c:344 in
DF-3415) read identical bytes with no writer in between, so the errno
oracle alone cannot separate them for the false branch (B falls
through to in6_pcbconnect). For the true branch (A) the short-circuit
at :598 means this finding's read is the one that decided, by code
trace. Both are real OOB reads on every short-sockaddr v6 UDP connect();
DF-3415 covers the in6_pcb.c instance, this covers udp6_usrreq.c.
Impact ceiling (honest)
- The stale bytes never reach userspace; they gate two error returns
(
EADDRNOTAVAILvsEINVAL) β a comparison oracle on 3 heap bytes per attempt (AND of byte equalities β limited per-attempt info). - No connection state is ever established from OOB data: validation in
in6_pcbladdr()still rejects every short sockaddr. - Severity Low, kernleak family β consistent with DF-3372 (Low) and
below DF-3357 (Medium: v4 twin reads garbage as the actual packet
destination, since udp_send there lacks the pre-check that
udp6_sendhas atudp6_usrreq.c:686-693).
Fix
fix.diff β mirror rip6_bind/rip6_connect/in6_pcbbind: validate
nam->sa_len == sizeof(struct sockaddr_in6) at the top of
udp6_connect(), before the first field read. git apply --check
passes against the read-only sys/ tree. Not rebuilt in-guest: Low
severity / leak-class (errno oracle), and this batch's conditional-verify
policy reserves the kernel rebuild/reboot cycle for Critical/High or
memcorrupt/privesc findings (same policy recorded for DF-3415). The
patch is a pure precondition insertion: with it, every short-sockaddr
connect() returns EINVAL before any address byte is read, eliminating
the oracle by construction.
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 authored (pure precondition insertion, identical in shape to the family fix) and verified to apply cleanly with git apply --check against the read-only sys/ tree; not rebuilt in-guest β Low-severity errno-oracle leak, and this batch's conditional-verify policy reserves the kernel rebuild/reboot cycle for Critical/High or memcorrupt/privesc findings (same policy recorded for DF-3415). With the patch, every sa_len!=28 connect() returns EINVAL before any address byte is read, eliminating the oracle by construction.
findings/poc/DF-3432/fix.diff ('git apply --check' passes against sys/)
Confirmed kernel references
Detail
Exploit chain
unprivileged connect(2) on AF_INET6 SOCK_DGRAM with a 17-byte sockaddr -> getsockaddr kmalloc(17) [24-byte M_SONAME chunk reusing a groomed free chunk] -> udp6_connect reads sin6_addr bytes 8..19 incl. 3 bytes past the allocation at udp6_usrreq.c:597 -> IN6_IS_ADDR_V4MAPPED true/false selected by attacker-groomed stale heap bytes -> true: EADDRNOTAVAIL returned at :598 (short-circuit before in6_pcbconnect); false: falls to in6_pcbconnect -> in6_pcbladdr finally validates sa_len -> EINVAL. Observable errno oracle. No path to state establishment or privilege: validation still rejects; leak-class only.
Evidence (decisive lines)
["run.log: three independent runs, each 'A groom 00 ff ff : EADDRNOTAVAIL=20 EINVAL=0' vs 'B groom 00 00 00 : EADDRNOTAVAIL=0 EINVAL=20' β variants differ only in heap bytes outside the connect() sockaddr", 'udp6_connect_oob.c: PoC source (groom bind(24) + trigger connect(17) on 24-byte shared slab zone)', 'VERDICT.md: full code trace (udp6_usrreq.c:596-608), slab mechanics, honest attribution vs DF-3415, impact ceiling', 'fix.diff: sa_len validation at top of udp6_connect(), git apply --check passes']
PoC changes
No seed PoC existed (finding discovered in this pass-2 run). Authored from scratch, reusing the verified DF-3415 groom design (17-byte trigger / 24-byte groom share the 24-byte slab zone; kfree preserves bytes 8..23). Worked first build/run; no source fixes needed.
Verified recommended fix
Validate nam->sa_len == sizeof(struct sockaddr_in6) at the top of udp6_connect() before the first sin6 field read (mirror rip6_bind/rip6_connect/in6_pcbbind); see fix.diff.
Verdict
udp6_connect() (sys/netinet6/udp6_usrreq.c:581) evaluates IN6_IS_ADDR_V4MAPPED(&sin6_p->sin6_addr) at :597 before any sa_len validation β the first length check on the path is in6_pcbladdr()'s (in6_pcb.c:282), reached only via in6_pcbconnect() at udp6_usrreq.c:608, after the read. getsockaddr() kmallocs exactly the user-supplied sockaddr length, so len<28 reads past the allocation (IN6_IS_ADDR_V4MAPPED window = offsets 8..19). Verified on the guest as an unprivileged user (uid maxx): grooming the shared 24-byte M_SONAME slab chunk via a rejected bind(24) then connect(17) makes stale heap bytes 17..19 β outside the 17-byte sockaddr β complete the v4-mapped pattern and deterministically flip connect() from EINVAL to EADDRNOTAVAIL, 20/20 vs 0/20 differential across 3 runs. When the pattern completes, the return happens at udp6_usrreq.c:598, so in6_pcbconnect (and DF-3415's read at in6_pcb.c:344) is never reached: the deciding read for that branch is this finding's. Impact ceiling is a comparison oracle on stale heap bytes (they only select an errno; in6_pcbladdr still rejects short sockaddrs, no connection state is established from OOB data). Severity Low, kernleak family β the udp6_usrreq.c instance of DF-3357/3372/3415 (the v4 twin udp_send lacks the pre-validation that udp6_send has at udp6_usrreq.c:686-693, which is why DF-3357 is Medium and this is Low).
No comments yet.