β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3432

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3432 Β· 11 files
FileTypeDescriptionSize
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 source
  • run.sh, build.log, run.log β€” exact commands + full output
  • VERDICT.md β€” full narrative, attribution, impact ceiling
  • fix.diff β€” sa_len validation at top of udp6_connect() (git-apply-check OK)
  • manifest.json, verdict.json, env.txt
VERDICT.md
↓ download raw

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 β€” groom bind(24) and trigger connect(17) share it.
  • _kfree() local path writes only chunk->c_Next (bytes 0..7); free-scrub (use_weird_array) defaults 0 β€” bytes 8..23 survive.
  • sys_bind and sys_connect both 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 (EADDRNOTAVAIL vs EINVAL) β€” 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_send has at udp6_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_testable
baseline reproduced→ patch + rebuild →patched clean

fix.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/)
↓ fix.diffper-fix-DF-3432

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).