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)
PoC verification
Evidence pack
findings/poc/DF-3415 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
sys/netinet6/udp6_usrreq.c:596andsys/netinet6/in6_pcb.c:344(IN6_IS_ADDR_V4MAPPED(&sin6->sin6_addr))
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 β 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 β groombind(24)and triggerconnect(17)share it._kfree()local path writes onlychunk->c_Next(bytes 0..7) and pushes the chunk on the per-zone LIFO listz->z_LChunks;use_weird_array(free-scrub) defaults to 0 β bytes 8..23 survive the free.- Both
sys_bind(uipc_syscalls.c:177) andsys_connectallocate/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
(
EADDRNOTAVAILvsEINVAL) β 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_testablefix.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/)
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).
No comments yet.