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

in6_pcbconnect() reads sin6_addr before sa_len validation: heap OOB read past the user-copied sockaddr length decides connect()'s errno (DF-3357/DF-3372 read-before-validate family, v6 pcb core)

Summary

in6_pcbconnect() evaluates IN6_IS_ADDR_V4MAPPED(&sin6->sin6_addr) - reading sin6_addr offsets 8..23 - before the sockaddr is validated; the sa_len/family checks only run later inside in6_pcbladdr(). getsockaddr() kmallocs exactly the user-supplied namelen, so any len<28 makes the read an OOB heap read past the allocation (up to 16 bytes for len=2). VERIFIED on guest as uid 1001: with a 17-byte connect() sockaddr on an AF_INET6 UDP socket, the 3 stale heap bytes at offsets 17..19 (outside the allocation, groomed via a preceding rejected bind(24) sharing the 24-byte M_SONAME slab zone) deterministically flip the errno between EADDRNOTAVAIL and EINVAL: 20/20 vs 0/20 across 3 independent runs. The OOB content never reaches userspace and in6_pcbladdr still rejects the short sockaddr - ceiling is a comparison oracle on stale heap bytes (same class as DF-3357/DF-3372). Fix authored (validate before the field read, mirroring in6_pcbbind's discipline), apply-checked.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3415 Β· 12 files
FileTypeDescriptionSize
in6_v4mapped_oob.c β€” 5.2 KB view raw
build.sh β€” 124 B view raw
run.sh β€” 123 B view raw
build.log β€” 9 B view raw
run.log β€” 714 B view raw
run.2.log β€” 651 B view raw
env.txt β€” 286 B view raw
VERDICT.md β€” 5.0 KB ↓ raw
README.md β€” 2.3 KB ↓ raw
fix.diff β€” 683 B view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.7 KB view raw

DF-3415 β€” in6_pcbconnect() reads sin6_addr before sa_len validation

File: sys/netinet6/in6_pcb.c:343-345 (DF-3357/DF-3372 family, v6 pcb twin)

Build (on the guest)

cd /tmp/df3415
cc -O2 -Wall -o in6_v4mapped_oob in6_v4mapped_oob.c

Run (unprivileged)

su -m maxx -c ./in6_v4mapped_oob

Expected output (decisive lines)

A(groom 00 ff ff) first errno=49 (Can't assign requested address)
B(groom 00 00 00) first errno=22 (Invalid argument)
summary over 20 rounds per variant:
  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 -- connect(2) error code is chosen by heap bytes OUTSIDE
        the supplied sockaddr length (read before sa_len validation).

What it proves

getsockaddr() kmallocs exactly the user-supplied sockaddr length (2..255 bytes) and copies that many bytes. A connect() on a UDP6 socket with a 17-byte sockaddr reaches:

before any sa_len == 28 validation (that check only runs later, in in6_pcbladdr() at sys/netinet6/in6_pcb.c:282). The predicate reads sin6_addr file-offsets 8..19; with a 17-byte sockaddr, bytes 17..19 are past the allocation and contain recycled heap bytes.

The PoC grooms the 24-byte M_SONAME slab zone (sizes 17..24 all round to the 24-byte chunk class; kfree() only overwrites bytes 0..7 with the free list link and does not scrub the rest) with a preceding rejected bind(), then immediately connect()s with a 17-byte sockaddr. The stale groom bytes alone decide whether the kernel returns EADDRNOTAVAIL (mapped pattern 00 00 ff ff completed by the OOB bytes) or EINVAL (validation finally reached). Variant A vs B differ only in heap bytes that were never part of the connect() sockaddr β€” a 20/20 vs 0/20 differential.

Impact ceiling

The OOB-read content does not flow back to userspace; it selects an errno (comparison oracle on 3+ stale heap bytes per attempt). Low severity, kernleak bucket β€” consistent with the filed family (DF-3357 Medium had the garbage address used as a packet destination; here it only gates two error returns because in6_pcbladdr() still rejects the short sockaddr).

VERDICT.md
↓ download raw

VERDICT β€” DF-3415

status: reproduced | impact: leak | confidence: certain

The finding

in6_pcbconnect() (sys/netinet6/in6_pcb.c:337) reads sin6->sin6_addr at line 344 (IN6_IS_ADDR_V4MAPPED) before the sockaddr is validated. The validation (nam->sa_len != sizeof(*sin6), sin6->sin6_family != AF_INET6) lives in in6_pcbladdr() (sys/netinet6/in6_pcb.c:282-285) and is only reached at line 351, after the address-field read.

getsockaddr() (sys/kern/uipc_syscalls.c:1513-1532) allocates kmalloc(len, M_SONAME) with the exact user-supplied len (2 <= len, len <= SOCK_MAXADDRLEN) and copies exactly len bytes. Any len < 28 therefore makes every read of sin6_addr (file offsets 8..23) an out-of-bounds read past the allocation β€” here up to 16 bytes past a 2-byte allocation, or 3 bytes past a 17-byte one for the IN6_IS_ADDR_V4MAPPED window (offsets 8..19, sys/netinet6/in6.h:316-318).

Live unprivileged path (no UDP6 pru_preconnect exists β€” only v4 udp_preconnect, sys/netinet/udp_usrreq.c:1975):

connect(2) β†’ getsockaddr(kmalloc(len))      uipc_syscalls.c:1513
  β†’ soconnect β†’ so_pru_connect              uipc_socket.c:685
  β†’ udp6_connect                            udp6_usrreq.c:583
      reads sin6_addr at udp6_usrreq.c:596  (same family, first reader)
  β†’ in6_pcbconnect                          in6_pcb.c:337
      reads sin6_addr at in6_pcb.c:344      ← THIS FINDING
  β†’ (only on false) in6_pcbladdr            in6_pcb.c:275
      validates sa_len == 28 at in6_pcb.c:282

Reproduction (guest, DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, uid 1001)

Slab mechanics that make the groom deterministic (sys/kern/kern_slaballoc.c):

  • zoneindex() (8-byte granularity below 128): sizes 17..24 all 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) and pushes the chunk on the per-zone LIFO list z->z_LChunks; use_weird_array (free-scrub) defaults to 0 β€” bytes 8..23 survive the free.
  • Both sys_bind (uipc_syscalls.c:177) and sys_connect allocate/free the soname on the syscall thread, same CPU β†’ LIFO reuse is immediate.

IN6_IS_ADDR_V4MAPPED needs bytes 8..19 == 00*8 || 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 each, 100% stable):

variant stale bytes 17..19 EADDRNOTAVAIL EINVAL
A: groom 00 ff ff complete v4-mapped pattern 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 that lie outside the sockaddr handed to connect(). The errno is therefore chosen by out-of-bounds heap reads that happen before validation. Full logs: run.log, run.2.log.

Attribution

Externally the errno cannot distinguish which of the two premature reads decided (both read the same bytes: udp6_usrreq.c:596 first, then in6_pcb.c:344); the code trace pins both. The finding filed against this file is the in6_pcb.c:344 instance β€” it is unconditional in in6_pcbconnect() and the only live caller (udp6_usrreq.c:608) feeds it unvalidated lengths; tcp_syncache.c:791 passes a kernel-built full-size sockaddr. in6_pcbbind() is not affected (its sa_len check at in6_pcb.c:129 precedes the first field read at line 138).

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 (the predicate is an AND of byte equalities, so per-attempt leakage is sub-bit in practice).
  • No escalation path: in6_pcbladdr() still rejects every short sockaddr, so no connection state is ever established from OOB data.
  • Severity Low (kernleak), consistent with DF-3372 (Low); DF-3357 was Medium only because there the garbage address became the packet destination.

Fix validation

fix.diff moves the sa_len/family validation ahead of the IN6_IS_ADDR_V4MAPPED read (identical shape to the family fix; mirrors the checks already at in6_pcb.c:282-285 and the bind-side discipline at in6_pcb.c:129-135). Not rebuilt/tested in-guest: Low-severity out-of-bounds read whose observable effect is an errno choice; the conditional-verification policy for this batch reserves the full kernel-rebuild cycle for Critical/High or memcorrupt/privesc findings. The patch is a pure reorder β€” after it, every connect() with sa_len != 28 returns EINVAL before any address field is read, killing 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 (validation reorder, identical in shape to the family fix) and verified to apply cleanly with git apply --check; not rebuilt in-guest β€” Low-severity OOB read (errno oracle only) and this batch's conditional-verify policy reserves the kernel rebuild/reboot cycle for Critical/High or memcorrupt/privesc findings. The patch is a pure precondition reorder: with it, every sa_len!=28 connect() returns EINVAL before any address byte is read, eliminating the oracle by construction.

findings/poc/DF-3415/fix.diff ('git apply --check' passes against sys/)
↓ fix.diffper-fix-DF-3415

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 (udp6_usrreq.c:596) then in6_pcbconnect (in6_pcb.c:344) read sin6_addr bytes 8..19 incl. 3 bytes past the allocation -> IN6_IS_ADDR_V4MAPPED true/false selected by attacker-groomed stale heap bytes -> observable errno oracle (EADDRNOTAVAIL vs EINVAL). No path to state establishment or privilege: validation still rejects; impact is leak-class only.

Evidence (decisive lines)

run.log / run.2.log: '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. VERDICT.md: full trace + slab mechanics. in6_v4mapped_oob.c: PoC source.

PoC changes

No seed PoC existed; authored from scratch. First design (len=2 trigger + len=16 groom) was unworkable: zoneindex() maps <=16 to the 16-byte zone where the v4-mapped word2 (bytes 16..19) falls into the adjacent chunk whose byte 0 is always a stale sa_len != 0; switched to the 17-byte trigger / 24-byte groom pair which share the 24-byte zone (sizes 17..24 round to 24) so bytes 17..19 are stale-but-groomable. Verified kfree() preserves bytes 8..23 (only c_Next at offset 0 written; use_weird_array defaults 0) and that UDP6 has no pru_preconnect (only v4 udp_preconnect), so the netisr udp6_connect path runs unvalidated.

Verified recommended fix

Move the sa_len/family validation ahead of the IN6_IS_ADDR_V4MAPPED read in in6_pcbconnect() (mirror in6_pcbbind's checks at in6_pcb.c:129-135); see fix.diff.

Verdict

in6_pcbconnect() (sys/netinet6/in6_pcb.c:344) evaluates IN6_IS_ADDR_V4MAPPED(&sin6->sin6_addr) before any sa_len validation (the check only runs later in in6_pcbladdr(), in6_pcb.c:282, called at :351). getsockaddr() kmallocs exactly the user-supplied sockaddr length, so for len<28 this reads past the allocation (up to 16 bytes for len=2). Verified on the guest as an unprivileged user (uid 1001): grooming the shared 24-byte M_SONAME slab chunk via a rejected bind(24) and 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. Impact ceiling is a comparison oracle on stale heap bytes (they only select an errno; in6_pcbladdr still rejects short sockaddrs, so no connection state is established from OOB data). Severity Low, kernleak bucket, consistent with filed family DF-3357 (Medium: garbage addr used as packet dst) and DF-3372 (Low).