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

udp6_output corrupts sticky socket options and leaks per-call options memory when ip6_setpktoptions fails

Summary

udp6_output.c:123 saves stickyopt=in6p->in6p_outputopts. :134-138 ip6_setpktoptions(&opt,...) on success sets in6p->in6p_outputopts=&opt :138. On failure :137 goto release β€” in6p_outputopts still stickyopt. :264 releaseopt: ip6_clearpktopts(in6p->in6p_outputopts,-1) = clears STICKY options not local opt. Destroys user persistent setsockopt() state. Meanwhile partial allocations in local opt (copypktopts + successful ip6_setpktoption calls e.g. IPV6_PKTINFO) never freed = M_IP6OPT leak per sendmsg. Trigger: unprivileged local PF_INET6 SOCK_DGRAM sendmsg with 2 cmsg: valid IPV6_PKTINFO (allocates) then malformed cmsg_len=0 (EINVAL). Each iteration leaks ~20 bytes + destroys sticky. Driven in tight loop = kernel memory exhaustion. Correct pattern: raw_ip6.c:301-308,436-443 uses separate optp never mutates in6p_outputopts. Fix: ip6_clearpktopts(&opt) on failure path + only clear local opt at releaseopt if in6p_outputopts==&opt.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0744 Β· 19 files
FileTypeDescriptionSize
corrupt.c trigger-source sticky-option corruption demonstrator (set IPV6_PKTINFO, sendmsg with cmsg_len=0, re-read) 5.4 KB view raw
leak.c trigger-source tight-loop M_IP6OPT leak (valid IPV6_PKTINFO cmsg + malformed cmsg_len=0) 4.3 KB view raw
build.sh build-script cc -O2 -Wall -Wextra -o corrupt/leak 206 B view raw
run.sh run-script runs corrupt + leak with vmstat -m ip6opt snapshots 920 B view raw
build.log build-log full build output on the guest 254 B view raw
run.log run-log decisive run on unpatched #0 kernel β€” both bugs reproduced 1.0 KB view raw
run.2.log run-log extra corruption run on #0 kernel (determinism) 287 B view raw
run.3.log run-log extra corruption run on #0 kernel (determinism) 287 B view raw
fix.diff suggested-fix one-line git-apply-able fix: clear &opt not in6p->in6p_outputopts 828 B view raw
fix_build.log build-log full single-fix nativekernel build log (rc=0) 5.6 MB ↓ download
fix_run.log run-log decisive run on patched #1 kernel β€” both bugs gone 1015 B view raw
fix_run.2.log run-log extra corruption run on #1 kernel (determinism) 134 B view raw
fix_run.3.log run-log extra corruption run on #1 kernel (determinism) 134 B view raw
env.txt environment uname, cc version, baseline ip6opt slab 422 B view raw
VERDICT.md verdict full narrative + mechanism + before/after 6.8 KB ↓ raw
README.md readme human-facing build/run/expected 1.2 KB ↓ raw
manifest.json manifest this catalog 3.5 KB view 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
README.md readme human-facing build/run/expected
↓ download raw

DF-0744 β€” PoC evidence pack

udp6_output corrupts sticky socket options and leaks per-call options memory when ip6_setpktoptions fails. Severity: Medium.

Build

./build.sh

(compiles corrupt.c and leak.c with cc -O2 -Wall -Wextra)

Run

./run.sh

(runs as the unprivileged user; needs IPv6 loopback ::1)

Expected on the buggy kernel (6.5-DEVELOPMENT #0)

  • Corruption test: sticky IPV6_PKTINFO (set to ::42) is silently cleared to :: by a single failed sendmsg. Exits with code 2 and prints VERDICT: BUG.
  • Leak test: vmstat -m | grep ip6opt shows the ip6opt slab Count and MemUse grow by ~20 B per sendmsg iteration (4000 iters β†’ ~+94 KB of M_IP6OPT).

Expected on the fixed kernel (single-fix #1)

  • Corruption test: sticky IPV6_PKTINFO is preserved. Exits 0 and prints VERDICT: SAFE.
  • Leak test: ip6opt slab Count stays at 0; only the Requests column climbs (proving the calls happened but allocations were freed).

Files

See VERDICT.md for the full mechanism writeup and manifest.json for the artifact catalog. The fix is fix.diff (one-line; git apply-able).

VERDICT.md verdict full narrative + mechanism + before/after
↓ download raw

DF-0744 β€” PoC evidence pack

Finding: udp6_output corrupts sticky socket options and leaks per-call options memory when ip6_setpktoptions fails. File: sys/netinet6/udp6_output.c:262-267 Severity (per finding): Medium Β· CWE-401 (Missing Release of Memory on Error Path) Β· CWE-665 (Improper Initialization / state destruction)

Verdict (one line)

REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (unpatched audit baseline): both effects of the bug β€” silent sticky-option corruption and per-call M_IP6OPT leak β€” are demonstrated by an unprivileged user over a plain AF_INET6 SOCK_DGRAM socket. Fix VALIDATED on a single-fix #1 kernel: both effects disappear.

How to reproduce

./build.sh
./run.sh

Must run on the guest as the unprivileged user (maxx, uid 1001). Requires IPv6 loopback (::1) β€” present by default on the audit guest.

What the bug is

udp6_output() parses per-call IPv6 socket options through ip6_setpktoptions() into a stack-local struct ip6_pktopts opt (declared at udp6_output.c:123):

struct ip6_pktopts opt, *stickyopt = in6p->in6p_outputopts;
...
if (control) {
    if ((error = ip6_setpktoptions(control, &opt,
        in6p->in6p_outputopts, IPPROTO_UDP, priv)) != 0)
        goto release;                  /* <-- failure: in6p_outputopts
                                          STILL == stickyopt */
    in6p->in6p_outputopts = &opt;      /* <-- only runs on success */
}
...
releaseopt:
    if (control) {
        ip6_clearpktopts(in6p->in6p_outputopts, -1);   /* line 264: BUG */
        in6p->in6p_outputopts = stickyopt;             /* line 265 */
        m_freem(control);
    }

On the failure path of ip6_setpktoptions() the code jumps to release before line 138 reassigns in6p->in6p_outputopts = &opt. So when releaseopt runs, in6p->in6p_outputopts is still the user's sticky options, not the per-call local. Two consequences:

  1. Sticky-option corruption (CWE-665). ip6_clearpktopts(in6p->in6p_outputopts, -1) frees and zeroes the user's persistent setsockopt(IPV6_PKTINFO, …) state (and any other sticky IPv6 option). One failed sendmsg silently throws away every sticky option the user ever set on the socket.

  2. Per-call M_IP6OPT leak (CWE-401). ip6_setpktoptions() may have already heap-allocated inside the local opt β€” via copypktopts(opt, stickyopt, …) (line 2961) and/or via earlier successfully-parsed cmsgs (e.g. IPV6_PKTINFO allocates opt->ip6po_pktinfo). None of those allocations are freed before opt goes out of scope. Every failed sendmsg leaks ~20 B per struct in6_pkt_info (plus more for hop-by-hop / dest / route headers).

The reference correct pattern is in sys/netinet6/raw_ip6.c:301-308,436-443, which uses a separate optp pointer and only ever clears optp == &opt (never in6p->in6p_outputopts). udp6_output diverges from that pattern.

Demonstrators

  • corrupt.c β€” sets sticky IPV6_PKTINFO to ::42, sends one sendmsg with a single cmsg whose cmsg_len == 0 (which makes ip6_setpktoptions() return EINVAL at once), then re-reads IPV6_PKTINFO. On the buggy kernel the sticky value is zeroed; on a fixed kernel it is preserved.

  • leak.c β€” drives sendmsg in a tight loop with a control buffer containing a valid IPV6_PKTINFO cmsg followed by a malformed cmsg_len == 0 cmsg. Each iteration leaks one struct in6_pkt_info (20 B). The wrapper run.sh snapshots vmstat -m | grep ip6opt before and after to show the growth.

Observed impact

Kernel corruption test leak test (4000 iters)
#0 unpatched (with-src baseline) BUG β€” sticky cleared (addr …002a β†’ …0000) +3900 ip6opt, +~94 KB M_IP6OPT (cumulative)
#1 patched (single-fix) SAFE β€” sticky preserved 0 ip6opt growth (Requests: 3β†’3.91K, Count: 0)

Impact classification: - Not a memory-corruption primitive. The "corruption" is of the caller's own persistent socket state (data destructive to the user's own socket), not corruption of kernel data structures or any other process's state. There is no OOB write / UAF / type-confusion β€” the freed pointers belong to the user's own sticky struct and are nulled by ip6_clearpktopts, not turned into dangling references. - Realistic ceiling: (a) silent loss of sticky IPv6 options, which can have security-relevant consequences (e.g. silently clearing a IPV6_PKTINFO-pinned source address can change which source address subsequent packets use); and (b) kernel memory exhaustion DoS β€” an unprivileged user can drive M_IP6OPT growth monotonically with a tight sendmsg loop, ~20 B/iter with IPV6_PKTINFO, more with extension-header options. - No escalation chain. Per Phase 6 this is not a write-capable primitive, so no uid=0 chain is attempted. exploit_chain = none.

The fix (fix.diff)

Change line 264 from

ip6_clearpktopts(in6p->in6p_outputopts, -1);

to

ip6_clearpktopts(&opt, -1);

i.e. clear the per-call local opt (which ip6_setpktoptions() always init_ip6pktopts()-zeroes first, so it is always safe to clear), not the user's sticky options. The subsequent in6p->in6p_outputopts = stickyopt; then becomes the no-op restore it should be on the failure path, and the correct restore on the success path. This single one-line change closes both effects:

  • sticky options are no longer touched on the failure path β†’ no corruption;
  • the local opt's per-call allocations are now properly freed β†’ no leak.

The fix matches the spirit of the raw_ip6.c reference pattern (only clear the local opt, never the sticky) and is minimal/targeted at the confirmed root cause.

Files in this pack

File Purpose
corrupt.c trigger #1 β€” sticky-option corruption demonstrator
leak.c trigger #2 β€” per-call M_IP6OPT leak in a tight loop
build.sh exact build: cc -O2 -Wall -Wextra -o corrupt corrupt.c; cc … -o leak leak.c
run.sh exact run: runs corrupt, snapshots vmstat -m, runs leak, snapshots again
build.log full untrimmed build output on the guest
run.log full untrimmed run output on the unpatched #0 kernel (bug reproduced)
run.2.log, run.3.log extra corruption-test runs on the unpatched kernel (determinism)
fix.diff the validated one-line git apply-able fix
fix_build.log full single-fix kernel build log (make -j6 nativekernel)
fix_run.log full run output on the patched #1 kernel (bug gone)
fix_run.2.log, fix_run.3.log extra corruption-test runs on the patched kernel (determinism)
env.txt guest uname, cc version, baseline vmstat -m ip6opt
VERDICT.md this file
manifest.json machine-readable catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. On the unpatched 6.5-DEVELOPMENT #0 baseline both effects reproduce deterministically: corrupt.c exits 2 with VERDICT: BUG (sticky IPV6_PKTINFO ::42 silently zeroed after one failed sendmsg), and leak.c drives ip6opt slab growth of +3900 obj / +94KB per 4000-iter loop. After applying fix.diff (one line: ip6_clearpktopts(&opt,-1) instead of ip6_clearpktopts(in6p->in6p_outputopts,-1)), rebuilding with make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0), installing kernel.stripped->/boot/kernel/kernel and rebooting into #1, the SAME PoC shows VERDICT: SAFE (sticky preserved across 3 independent runs) and ip6opt Count stays at 0 across 4000-iter and 8000-iter loops (only Requests climbs, proving the calls happened but allocations are freed). Clean before/after, bug gone -> fix closes the bug.

BASELINE #0 (before fix):
  corrupt: VERDICT: BUG - sticky IPV6_PKTINFO was CLEARED (state corruption)
  leak:    ip6opt 3.91K/93.8K -> 7.81K/188K after 4000 iters (+3900 obj, +94KB)
PATCHED #1 (after fix):
  corrupt: VERDICT: SAFE - sticky IPV6_PKTINFO preserved  (x3 runs)
  leak:    ip6opt Count 0 -> 0 after 4000 iters; 0 -> 0 after 8000 iters  (Requests 3.91K -> 7.82K -> 15.6K, calls ran, 0 leaked)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 9 06:14:12 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (kernel.stripped sha256 5e49ee6a9409027ac17f5ea6227dc7e2c3e75fcca3498f4c6edf7c12b73a82de)

Confirmed kernel references

Detail

Exploit chain

none. This is NOT a memory-corruption primitive suitable for escalation. The 'corruption' is of the caller's OWN persistent socket state -- ip6_clearpktopts() frees the dynamic fields of the user's in6p_outputopts struct and NULLs them; the struct itself remains allocated, no pointers are left dangling, and no other process's or kernel's state is affected. There is no OOB write, no UAF, no type confusion, no overwritten function pointer / refcount / credential. Per Phase 6 (non-corruption class), no uid=0 chain is attempted. The realistic impact ceiling is: (1) silent destruction of a process's own sticky IPv6 options on a failed sendmsg (security-relevant if e.g. IPV6_PKTINFO was pinning a source address for policy reasons -- a single bad sendmsg silently unpins it); and (2) kernel memory-exhaustion DoS via a tight sendmsg loop that leaks ~20 B of M_IP6OPT per iteration (more if hop-by-hop/dest/route-header options are involved). Both are real and demonstrated, but neither crosses a privilege boundary.

Evidence (decisive lines)

UNPATCHED #0 kernel (run.log):
  [baseline] sticky IPV6_PKTINFO addr=0000:0000:0000:0000:0000:0000:0000:002a
  [trigger]  sendmsg returned -1 (errno=22: Invalid argument)
  [after]    sticky IPV6_PKTINFO addr=0000:0000:0000:0000:0000:0000:0000:0000
  VERDICT: BUG    - sticky IPV6_PKTINFO was CLEARED (state corruption, DF-0744 reproduced).
LEAK TEST (unpatched #0):
  before: ip6opt   3.91K   93.8K    Requests 3.91K
  (4000-iter leak loop)
  after:  ip6opt   7.81K    188K    Requests 7.82K    (+3900 obj, +94KB, monotone)
PATCHED #1 kernel (fix_run.log):
  [after] sticky IPV6_PKTINFO addr=0000:0000:0000:0000:0000:0000:0000:002a
  VERDICT: SAFE   - sticky IPV6_PKTINFO preserved.
LEAK TEST (patched #1): before ip6opt Count=0; after 4000-iter loop Count=0 (Requests 3.91K -> 7.82K, calls happened, 0 leaked).

PoC changes

No DF-0744 PoC existed in the repo prior to this run (the poc folder was missing despite a DB row); created the full evidence pack from scratch: corrupt.c (sticky-option corruption demonstrator using a single cmsg_len=0 trigger), leak.c (tight-loop M_IP6OPT leak using valid IPV6_PKTINFO cmsg + malformed cmsg_len=0 cmsg), build.sh, run.sh, README.md, VERDICT.md, fix.diff, manifest.json, env.txt, and full build/run/fix logs. Two compile fixes were needed during iteration: netinet6/in6.h must not be included directly on DragonFly (removed in favor of netinet/in.h which transitively pulls it in per RFC2553), and a typo'd in6addr6_any was replaced with an explicit zero-array compare.

Verified recommended fix

In sys/netinet6/udp6_output.c releaseopt: block change ip6_clearpktopts(in6p->in6p_outputopts, -1); (line 264) to ip6_clearpktopts(&opt, -1); -- always clear the per-call local opt (which ip6_setpktoptions always init_ip6pktopts()-zeroes first, so it is always safe), never the user's sticky options. The subsequent in6p->in6p_outputopts = stickyopt; then becomes the no-op restore it should be on the failure path and the correct restore on the success path. This single one-line change closes BOTH effects (no corruption AND no leak). Matches the spirit of the raw_ip6.c:436-443 reference pattern (only clear the local opt, never the sticky). supersedes finding proposal (the finding markdown's proposal is the same idea but more complex than needed -- unconditional ip6_clearpktopts(&opt,-1) is sufficient and equivalent because opt is always initialized and in6p->in6p_outputopts is restored unconditionally afterward).

Verdict

REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (unpatched with-src baseline). The bug is real and exercised end-to-end by an unprivileged user over a plain AF_INET6 SOCK_DGRAM socket. udp6_output.c:123 declares a stack-local struct ip6_pktopts opt and saves stickyopt = in6p->in6p_outputopts. At udp6_output.c:134-138 it calls ip6_setpktoptions(control,&opt,in6p->in6p_outputopts,...); on success it reassigns in6p->in6p_outputopts=&opt (line 138), but on failure it goto releases (line 137) BEFORE line 138 runs, so in6p->in6p_outputopts is still the sticky struct. The shared releaseopt: block (line 262-267) then unconditionally calls ip6_clearpktopts(in6p->in6p_outputopts,-1) (line 264) -- which now clears the USER'S STICKY setsockopt() state instead of the per-call local opt -- and the per-call local opt's heap allocations (from copypktopts at ip6_output.c:2961 and from earlier successfully-parsed cmsgs e.g. IPV6_PKTINFO at ip6_output.c:2664-2669) are leaked when opt goes out of scope. Confirmed via corrupt.c (set IPV6_PKTINFO=::42, single sendmsg with cmsg_len=0 -> EINVAL -> re-read IPV6_PKTINFO now zeroed) and leak.c (4000-iter loop with valid IPV6_PKTINFO cmsg + malformed cmsg_len=0 -> ip6opt slab grew +3900/+94KB, monotone across runs). The correct pattern exists in raw_ip6.c:301-308,436-443 (uses a separate optp, only clears optp==&opt).