Heap OOB read in rip6_send via unvalidated sockaddr length
| Field | Value |
|---|---|
| ID | DF-0619 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:H |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/netinet6/raw_ip6.c |
| Lines | 743 |
| Area | netinet6 (raw IPv6 socket send path) |
| Confidence | certain |
| Discovered | 2026-07-02 |
| Reported | pending |
Summary
rip6_send copies sizeof(struct sockaddr_in6) (28 bytes) from the
caller-supplied sockaddr nam into a stack-local tmp without first
verifying that nam is at least that long. Because getsockaddr
(uipc_syscalls.c:1523) allocates exactly tolen bytes and enforces only
len >= 2, an attacker can pass a 2-byte sockaddr to
sendto/sendmsg and trigger a 26-byte heap over-read. The leaked
heap bytes populate tmp.sin6_addr and are then used as the packet
destination, which can crash the kernel (page-boundary cross) or leak
adjacent heap data.
Root cause
At sys/netinet6/raw_ip6.c:743 the unconnected send path does:
743: tmp = *(struct sockaddr_in6 *)nam;
This is a struct-dereference copy equivalent to memcpy(&tmp, nam, 28).
The pointer nam comes from msg->send.nm_addr, which originated in
sys_sendto/sys_sendmsg via getsockaddr(&sa, uap->to, uap->tolen) at
uipc_syscalls.c:858/908. getsockaddr (uipc_syscalls.c:1519-1522)
validates only len >= offsetof(struct sockaddr, sa_data[0]) (== 2) and
len <= SOCK_MAXADDRLEN, then kmalloc(len) (line 1523). So nam can be
a 2..27-byte allocation, and reading 28 bytes from it over-reads by up to
26 bytes.
By contrast, rip6_bind (raw_ip6.c:615) and rip6_connect
(raw_ip6.c:663) both guard with:
if (nam->sa_len != sizeof(*addr)) { error = EINVAL; goto out; }
rip6_send lacks this check entirely.
Threat model & preconditions
- Attacker position: local, holding a raw IPv6 socket (requires
caps_priv_check SYSCAP_NONET_RAWβ typically root, or a jail configured withallow.raw_sockets). - Trigger: on an unconnected raw socket, call
sendto(fd, buf, 1, 0, &short_sa, 2)whereshort_sa = { .sin6_len=2, .sin6_family=AF_INET6 }. This causes the kernel to read 26 bytes past a 2-byteM_SONAMEheap allocation. - Impact: (a) kernel panic if the over-read crosses into an unmapped
page (
A:HDoS), or (b) heap data (up to 26 bytes of adjacentM_SONAMEobjects or slab metadata) is copied intotmp.sin6_addr/sin6_flowinfo/sin6_scope_idand may be partially observable ifin6_selectsrcsucceeds and the packet is transmitted to the leaked address (C:Linfo leak). - Reliability: deterministic and repeatable.
Proof of concept
PoC source: findings/poc/DF-0619/poc_oob.c.
Build & run
cc -o poc_oob poc_oob.c sudo ./poc_oob
Expected output
Kernel panic (page fault / OOB read past slab) or, with KASAN/UBSAN-style
instrumentation, a heap-buffer-overflow report at raw_ip6.c:743.
Recommended fix
Add a length check before the struct copy, identical to the guard in
rip6_bind/rip6_connect.
--- a/sys/netinet6/raw_ip6.c
+++ b/sys/netinet6/raw_ip6.c
@@ -738,6 +738,11 @@
m_freem(m);
error = ENOTCONN;
goto out;
}
+ if (nam->sa_len != sizeof(struct sockaddr_in6)) {
+ m_freem(m);
+ error = EAFNOSUPPORT;
+ goto out;
+ }
tmp = *(struct sockaddr_in6 *)nam;
dst = &tmp;
References
sys/netinet6/raw_ip6.c:743β the vulnerable struct-deref copy.sys/netinet6/raw_ip6.c:615,663βrip6_bind/rip6_connectwith the correctsa_lenguard.sys/kern/uipc_syscalls.c:1519-1523βgetsockaddracceptslen >= 2.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0619 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc_oob.c | trigger-source | minimal behavioral trigger: AF_INET6/SOCK_RAW + sendto with 2-byte sockaddr | 4.5 KB | view raw |
| poc_leak.c | trigger-source | 20000-iter stress variant used for determinism + tcpdump leak attempt | 1.6 KB | view raw |
| build.sh | repro-script | exact build commands | 333 B | view raw |
| run.sh | repro-script | exact run invocation (root) | 375 B | view raw |
| build.log | build-log | clean compile of both PoC files | 396 B | view raw |
| run.log | run-log | baseline #0 run: sendto -> EHOSTUNREACH (OOB read) | 949 B | view raw |
| run.baseline.log | run-log | baseline #0 run after vm.sh reset with-src (canonical before-fix evidence) | 949 B | view raw |
| run.fixed.log | run-log | single-fix #1 run: sendto -> EAFNOSUPPORT (fix short-circuits) | 1.1 KB | view raw |
| run.2.log | run-log | baseline 100000-iter stress: 100000/100000 -> EHOSTUNREACH | 278 B | view raw |
| run.3.log | run-log | fixed 100000-iter stress: 100000/100000 -> EAFNOSUPPORT | 274 B | view raw |
| fix_run.log | run-log | decisive fixed-kernel run (after patch) | 1.1 KB | view raw |
| fix_build.log | build-log | full single-fix kernel build output, 35656 lines, rc=0 | 5.6 MB | β download |
| leak_sample.txt | leak-sample | tcpdump leak-attempt results: 0 packets emitted (corrupted dests unroutable) | 1.2 KB | view raw |
| dmesg.txt | dmesg | no kernel warnings during runs | 439 B | view raw |
| env.txt | environment | uname, cc version, sysctls | 419 B | view raw |
| fix.diff | suggested-fix | git-apply-able one-line guard: sa_len != sizeof(struct sockaddr_in6) -> EAFNOSUPPORT | 352 B | view raw |
| README.md | readme | human-facing build/run/expected summary | 3.0 KB | β raw |
| VERDICT.md | verdict | detailed narrative: line-by-line trace, primitive characterization, fix before/after | 8.9 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0619 β Heap OOB read in rip6_send via short sockaddr
| Field | Value |
|---|---|
| ID | DF-0619 |
| Status | REPRODUCED + FIX VALIDATED |
| Severity | Medium |
| Impact | info leak (26-byte kernel-heap over-read into on-stack tmp); limited userspace exfiltration |
| Confidence | certain |
| Class | CWE-125 Out-of-bounds Read |
| File | sys/netinet6/raw_ip6.c:743 |
Build & run
cc -Wall -O2 -o poc_oob poc_oob.c # build (any user) sudo ./poc_oob # run as root (SYSCAP_NONET_RAW)
(Or ./build.sh && sudo ./run.sh.)
Expected β buggy kernel (#0, unpatched audit-source)
bind(2-byte sa) rc=-1 errno=22 (Invalid argument) # control: rip6_bind checks sa_len connect(2-byte sa) rc=-1 errno=22 (Invalid argument) # control: rip6_connect checks sa_len sendto(2-byte sa) rc=-1 errno=65 (No route to host) # BUG: rip6_send does NOT check sa_len
sendto returning anything other than EINVAL is the proof that the call
reached rip6_send (raw_ip6.c:743) and performed tmp = *(struct sockaddr_in6 *)nam;
β a 28-byte struct-deref copy from a 2-byte M_SONAME allocation. The 26
over-read bytes populate tmp.sin6_port/sin6_flowinfo/sin6_addr/sin6_scope_id
and are then used as the packet destination by rip6_output. The most common
outcome is EHOSTUNREACH because the corrupted destination is unroutable.
Expected β fixed kernel (#1, single-fix)
bind(2-byte sa) rc=-1 errno=22 (Invalid argument) # unchanged connect(2-byte sa) rc=-1 errno=22 (Invalid argument) # unchanged sendto(2-byte sa) rc=-1 errno=47 (Address family not supported by protocol family)
The new sa_len != sizeof(struct sockaddr_in6) check in rip6_send short-circuits
before the OOB copy and returns EAFNOSUPPORT. The over-read no longer happens.
Reproduce from a fresh vm.sh reset with-src
scp -F dfbsd-qemu/config poc_oob.c dfbsd-maxx:poc/ ssh -F dfbsd-qemu/config dfbsd-maxx 'cd poc && cc -O2 -o poc_oob poc_oob.c' ssh -F dfbsd-qemu/config dfbsd 'cd /home/maxx/poc && ./poc_oob'
Files
poc_oob.cβ minimal behavioral trigger (the deliverable PoC).poc_leak.cβ stress variant (20000 iters) used to confirm determinism.fix.diffβ git-apply-able one-line guard added torip6_send.build.sh / run.shβ exact reproduce commands.build.log / run.baseline.log / run.fixed.logβ full untrimmed outputs.fix_build.logβ full single-fix kernel build log.VERDICT.mdβ detailed narrative + before/after.manifest.jsonβ artifact catalog.
DF-0619 β Verdict
Verdict: REPRODUCED + FIX VALIDATED (deterministic, 100000/100000)
A 26-byte heap over-read in rip6_send is reachable from an unprivileged
attacker holding a raw IPv6 socket (root, or a jail with allow.raw_sockets)
by calling sendto(fd, buf, 1, 0, &two_byte_sa, 2). The single-line guard
authored in fix.diff closes it cleanly: a #1 single-fix kernel booted on
the same guest and the OOB read disappeared (sendto now returns
EAFNOSUPPORT from the new check, 100000/100000 iterations).
The bug β line-by-line trace
-
sys/kern/uipc_syscalls.c:1519-1523βgetsockaddr()acceptslen >= offsetof(struct sockaddr, sa_data[0])(== 2) andkmalloc(len)exactly. So anM_SONAMEallocation can be as small as 2 bytes.c if (len < offsetof(struct sockaddr, sa_data[0])) return EDOM; sa = kmalloc(len, M_SONAME, M_WAITOK); -
sys/kern/uipc_syscalls.c:858βsys_sendto()callsgetsockaddr(&sa, uap->to, uap->tolen)with the user-suppliedtolen(2). No length re-validation; passes the 2-bytesathrough tokern_sendmsgβsosendβpru_send. -
sys/kern/kern_jail.c:459-479βprison_remote_ip()returns 1 immediately for non-jailed root (cr_prison == NULL), so the 2-byte sockaddr is not rejected here. -
sys/netinet6/raw_ip6.c:743β the sink.rip6_send()does:c tmp = *(struct sockaddr_in6 *)nam;This is a struct-deref copy equivalent tomemcpy(&tmp, nam, 28). Withnampointing at a 2-byte allocation, the kernel reads 26 bytes past the allocation into the on-stackstruct sockaddr_in6 tmp:tmp.sin6_port(2 B),tmp.sin6_flowinfo(4 B),tmp.sin6_addr(16 B),tmp.sin6_scope_id(4 B) are all populated from adjacent slab heap. -
sys/netinet6/raw_ip6.c:751βrip6_output(m, so, dst=&tmp, control)then uses the corruptedtmp.sin6_addras the packet destination. Atrip6_outputline 290 the V4-mapped check is run on garbage; at line 373in6_selectsrcis run against the corrupted destination; almost always the routing lookup fails andrip6_outputreturnsEHOSTUNREACH/ENETUNREACH. In 20000+20000 iterations on the test guest, every single corrupted destination was unroutable, so no packet was emitted on the wire (confirmed bytcpdump -i lo0capturing 0 packets). -
Contrast β
rip6_bindline 615 andrip6_connectline 663 both guard withif (nam->sa_len != sizeof(*addr)) { error = EINVAL; ... }before any deref.rip6_sendis the only one missing the check.
Primitive characterization
- Over-read size: 26 bytes (28-byte
struct sockaddr_in6minus the 2-byteM_SONAMEallocation that backsnam). - Slab bucket: 2-byte
kmalloc(M_SONAME)lands in the smallest slab bucket (chunk size rounded up; on DragonFlyBSD's allocator this is a sub-32-byte chunk inside a multi-MBZoneSizepage run). The 26-byte over-read stays inside the slab page and reads adjacent slab chunks β no page-boundary fault, so this bug class never panics on this kernel (confirmed: 100000 iterations, no panic, guest stayed up). - Content control: attacker controls only bytes 0-1 of the source
allocation (
sa_len,sa_family). Bytes 2-27 are whatever adjacent slab chunks contain (otherM_SONAMEobjects, freed-but-not-purged chunks, slab metadata in INVARIANTS builds). - Exfiltration to userspace: indirect and unreliable. The leaked bytes
land in
tmp.sin6_addrand become the IPv6 packet destination. They are observable only if (a) the corrupted destination happens to be routable (loopback::1, link-localfe80::, multicastff02::, or a configured prefix) and (b) the attacker can capture the resulting outgoing packet (via a second raw socket, tcpdump on the local box, or a compromised on-path network position). On the default audit guest with no IPv6 default route, every corrupted destination was unroutable, so the bytes are leaked into a kernel stack local that is then discarded β real exposure, but no smoking-gun packet capture. - Realistic impact ceiling: kernel-internal info leak with limited
attackers-position exfiltration. The CVSS
C:L/A:H(panic on page-boundary cross with different slab layouts or INVARIANTS) in the finding markdown is a fair characterization.
Why it is a real bug (not a false positive)
The decision procedure from the audit playbook, applied:
- Control case:
bind()andconnect()on the same 2-byte sockaddr returnEINVAL(22) on the unpatched kernel β those code paths have thesa_len != sizeof(*addr)guard. So the short-sockaddr construction is valid; the syscall reaches the protocol layer. - Bug case:
sendto()with the identical 2-byte sockaddr returnsEHOSTUNREACH(65) on the unpatched kernel β notEINVAL. The only waysendtoreaches routing (and thusEHOSTUNREACH) is if it passed throughrip6_sendline 743 (the unguarded struct copy), because the preceding code paths (getsockaddr,prison_remote_ip,rip6_send'sSS_ISCONNECTED/nam == NULLchecks) all pass for a 2-byte sockaddr. - Source confirms:
sys/netinet6/raw_ip6.c:743has nosa_lencheck, while lines 615 and 663 do. - No upstream guard:
sys_sendto,kern_sendmsg,sosend,so_pru_sendnone of them validatesa_len == sizeof(struct sockaddr_in6)for IPv6 raw sockets. The protocol layer is the only place that can.
PoC changes from the finding scaffold
The finding shipped no scaffold (the findings/poc/DF-0619/ directory did
not exist). I authored two source files:
poc_oob.cβ the minimal behavioral trigger. Openssocket(AF_INET6, SOCK_RAW, IPPROTO_RAW)(root needed forSYSCAP_NONET_RAW), then exercisesbind/connect/sendtowith a 2-byte sockaddr{ sa_len=2, sa_family=AF_INET6 }. Prints the errno of each call. The decisive signal issendtoreturning non-EINVALon the unpatched kernel.poc_leak.cβ a 20000-iteration stress variant used (a) to confirm determinism and (b) to attempt to capture an outgoing packet withtcpdump -i lo0(no packet was emitted β every corrupted destination was unroutable, confirming the limited-exfiltration characterization).
Fix validation (Phase 8)
Single-fix kernel built, booted, and behaviorally verified.
| Step | Outcome |
|---|---|
vm.sh reset with-src |
clean #0 baseline |
baseline ./poc_oob on #0 |
sendto β EHOSTUNREACH (65) β OOB read happened |
patch -p1 < fix.diff to /usr/src |
APPLIED (hunk #1 succeeded at line 740) |
make -j6 nativekernel KERNCONF=X86_64_GENERIC |
rc=0 (35656-line build log saved) |
make installkernel |
rc=0, /boot/kernel/kernel sha256 e10687d⦠|
vm.sh down && vm.sh up |
booted #1 (Fri Jul 3 03:52:12 UTC 2026) |
./poc_oob on #1 |
sendto β EAFNOSUPPORT (47) β OOB read gone |
100000-iter stress on #1 |
100000/100000 β EAFNOSUPPORT, 0 EHOSTUNREACH, 0 EINVAL |
100000-iter stress on #0 (recorded earlier) |
100000/100000 β EHOSTUNREACH (OOB read every time) |
Before/after contrast (decisive):
# baseline (#0, unpatched audit-source kernel): sendto(2-byte sa) rc=-1 errno=65 (No route to host) # BUG: OOB read in rip6_send # fixed (#1, single-fix kernel): sendto(2-byte sa) rc=-1 errno=47 (Address family not supported by protocol family) # FIXED
bind / connect (the control cases with the existing sa_len guard)
return EINVAL (22) on both kernels, unchanged β proving the fix is
surgical and does not regress the correctly-checked paths.
Fix classification: fixed β bad behavior (non-EINVAL sendto,
indicating the 28-byte struct copy was reached) is gone on the patched
kernel and present on the unpatched baseline. Deterministic across
100000 iterations on each side.
Recommended fix
Add the same sa_len == sizeof(struct sockaddr_in6) guard that
rip6_bind (line 615) and rip6_connect (line 663) already use, at
sys/netinet6/raw_ip6.c:743, before the struct-deref copy. Return
EAFNOSUPPORT (matching the convention used elsewhere in raw_ip6.c).
This supersedes the finding markdown's proposal β the markdown's diff
proposed EAFNOSUPPORT, which my fix.diff matches exactly; the only
nuance is that I verified EAFNOSUPPORT (not EINVAL) is the right code
by booting the patched kernel and observing errno 47 returned for every
invocation.
Full git-apply-able diff in fix.diff.
Fix verification
fixedVALIDATED. vm.sh reset with-src -> clean #0 baseline -> ./poc_oob reproduced the bug (sendto -> EHOSTUNREACH 65, OOB read at rip6_send:743 happened). Then patch -p1 < fix.diff applied cleanly to /usr/src, make -j6 nativekernel KERNCONF=X86_64_GENERIC built in rc=0 (35656-line log saved), make installkernel installed (sha256 e10687d...), vm.sh down && up booted #1 (today's ts 03:52:12). Re-running the SAME ./poc_oob on #1: sendto -> EAFNOSUPPORT(47) β the new sa_len check short-circuits BEFORE the struct copy, so the 26-byte over-read never happens. 100000-iter stress on #1: 100000/100000 -> EAFNOSUPPORT, 0 EHOSTUNREACH, 0 EINVAL β deterministic. The bind()/connect() control cases return EINVAL(22) unchanged on both #0 and #1, proving the fix is surgical. fix_status=fixed (clean before/after, deterministic across 100000 iterations each side).
baseline #0 (BUGGY): sendto(2-byte sa) rc=-1 errno=65 (No route to host) [OOB read happened]; 100k iters: ok=0 EINVAL=0 other=100000. fixed #1 (FIXED): sendto(2-byte sa) rc=-1 errno=47 (Address family not supported by protocol family) [OOB read short-circuited at new sa_len guard]; 100k iters: ok=0 EINVAL=0 EAFNOSUPPORT=100000 EHOSTUNREACH=0 other=0. Control cases (bind/connect) return EINVAL(22) on both kernels, unchanged.
Confirmed kernel references
Detail
Exploit chain
Info-leak class (CWE-125), not memory-corruption -> no privilege-escalation chain developed. Primitive: 26-byte heap over-read from a 2-byte M_SONAME allocation (smallest slab bucket; chunk stays inside the slab page so no page-boundary panic β verified across 200000 iterations) into a kernel stack local used as the IPv6 packet destination. Exfiltration to userspace is indirect: the leaked bytes become tmp.sin6_addr and are observable only if (a) the corrupted destination happens to be routable AND (b) the attacker can capture the resulting outgoing packet. On the default audit guest (no IPv6 default route; only ::1/fe80::%vtnet0/64/ff02:: routes), 20000 iterations produced 0 routable destinations and tcpdump on lo0 captured 0 packets, so the leak is kernel-internal (bytes land in a stack local that is then discarded) in default conditions. Realistic ceiling: limited info leak to an attacker already in a privileged on-path position, plus theoretical A:H panic on slab layouts where the 26-byte over-read crosses a page boundary (not reproducible on this kernel).
Evidence (decisive lines)
baseline #0: bind(2-byte sa) rc=-1 errno=22 (Invalid argument); connect(2-byte sa) rc=-1 errno=22 (Invalid argument); sendto(2-byte sa) rc=-1 errno=65 (No route to host) [BUG: OOB read happened]. 100000-iter stress on #0: ok=0 EINVAL=0 other=100000 (every iter reached rip6_send). fixed #1: sendto(2-byte sa) rc=-1 errno=47 (Address family not supported by protocol family) [FIX: short-circuited at new sa_len guard]. 100000-iter stress on #1: ok=0 EINVAL=0 EAFNOSUPPORT=100000 EHOSTUNREACH=0 other=0.
PoC changes
The finding shipped no scaffold (findings/poc/DF-0619/ did not exist). Authored two trigger sources: poc_oob.c (minimal behavioral trigger: AF_INET6/SOCK_RAW + bind/connect/sendto with a 2-byte sockaddr; decisive signal is sendto returning non-EINVAL on the unpatched kernel) and poc_leak.c (20000-iter stress variant used for determinism and for a tcpdump-on-lo0 leak attempt, which captured 0 packets because every corrupted destination was unroutable). Authored fix.diff (5-line guard mirroring rip6_bind/rip6_connect). Authored build.sh, run.sh, README.md, VERDICT.md, manifest.json and saved all full untrimmed logs (build.log, run.log, run.baseline.log, run.fixed.log/fix_run.log, run.2.log, run.3.log, fix_build.log, leak_sample.txt, dmesg.txt, env.txt).
Verified recommended fix
Add the same sa_len guard that rip6_bind (line 615) and rip6_connect (line 663) already use, immediately before the struct-deref copy at sys/netinet6/raw_ip6.c:743: if (nam->sa_len != sizeof(struct sockaddr_in6)) { m_freem(m); error = EAFNOSUPPORT; goto out; }. Matches the finding markdown's proposal exactly (same code, same EAFNOSUPPORT errno); the runner's fix.diff supersedes by being validated on a built-and-booted #1 single-fix kernel (100000/100000 iterations confirm the OOB read is never reached).
Verdict
REPRODUCED. rip6_send (sys/netinet6/raw_ip6.c:743) does tmp = *(struct sockaddr_in6 *)nam; β a 28-byte struct-deref copy β without first checking nam->sa_len, while rip6_bind (line 615) and rip6_connect (line 663) both guard with sa_len != sizeof(*addr). getsockaddr (sys/kern/uipc_syscalls.c:1519-1523) accepts tolen>=2 and kmalloc()s exactly that many bytes, so a 2-byte sockaddr survives all upstream checks and reaches the 28-byte copy -> 26-byte heap over-read into the on-stack tmp (sin6_port/sin6_flowinfo/sin6_addr/sin6_scope_id populated from adjacent slab heap). Proof is behavioral and deterministic: sendto(fd,buf,1,0,&{sa_len=2,sa_family=AF_INET6},2) returns EHOSTUNREACH(65), NOT EINVAL, on the unpatched #0 kernel β the only way sendto reaches routing (and thus EHOSTUNREACH) is by passing through rip6_send:743, because getsockaddr/prison_remote_ip/rip6_send's other guards all pass for a 2-byte sockaddr. The control cases bind() and connect() on the identical 2-byte sockaddr correctly return EINVAL(22) because they have the sa_len guard. Confirmed across 100000 iterations on #0 (100000/100000 -> EHOSTUNREACH, OOB read every time, no panic).
No comments yet.