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

IPv6 firewall fully controllable by unprivileged local users: IPV6_FW_ADD/DEL/FLUSH/ZERO/GET setsockopt on any IPv6 datagram socket has no privilege check

Summary

ip6_fw_ctl()'s only authorization is 'securelevel > 2' (securelevel is -1 by default), and its socket entry has no privilege gate: udp6's protosw wires pr_ctloutput so any process holding a plain socket(AF_INET6, SOCK_DGRAM) reaches ip6_ctloutput's IPV6_FW_ADD/DEL/FLUSH/ZERO and IPV6_FW_GET, which forward straight to ip6_fw_ctl without consulting the 'privileged' capability flag the function already computes and uses for five other option cases. Contrast the v4 ipfw path, reachable only through raw IP sockets gated by SYSCAP_NONET_RAW. VERIFIED on stock INVARIANTS guest: after kldload ip6fw.ko (default policy 'deny ipv6 from any to any'), uid 65534 added '00001 allow ipv6 from any to any' via a UDP6 socket (ret 0), /sbin/ip6fw show confirmed the nobody-installed rule live, ping6 ::1 flipped 100%-loss -> 0%-loss -> 100%-loss with nobody's add/del, and nobody FLUSHed/ZEROed the chain at will - complete firewall-policy bypass + unprivileged IPv6 blackout capability. PLATFORM FACT: ip6fw is dead on default config (no IPV6FIREWALL in X86_64_GENERIC; ip6_fw_ctl_ptr NULL) but ip6fw.ko + /sbin/ip6fw ship in the default install, so the surface activates the moment an admin enables IPv6 filtering. Fix validated by full in-guest nativekernel rebuild + reboot: nobody now receives EPERM on all five ops; root management unchanged.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3392 Β· 12 files
FileTypeDescriptionSize
ip6fw_ctl.c β€” 2.8 KB view raw
ip6_fw.h β€” 8.6 KB view raw
build.sh β€” 109 B view raw
run.sh β€” 163 B view raw
build.log β€” 64 B view raw
run.log β€” 1.3 KB view raw
run.2.log β€” 566 B view raw
fix.diff β€” 527 B view raw
fix.log β€” 732 B view raw
VERDICT.md β€” 3.7 KB ↓ raw
verdict.json β€” 5.2 KB view raw
env.txt β€” 367 B view raw
VERDICT.md
↓ download raw

DF-3392 VERDICT β€” REPRODUCED (unprivileged local firewall policy bypass)

What was run

Stock INVARIANTS guest DragonFly 6.5-DEVELOPMENT #0 (uname in env.txt). PoC ip6fw_ctl.c built in-guest (cc -O, no special headers beyond the kernel's own ip6_fw.h).

Reproduction chain (run.log, run.2.log β€” full untrimmed)

  1. Platform fact / default-dead: with the stock kernel, net.inet6.ip6.fw.enable does not exist, kldstat shows no ip6fw, and IPV6_FW_GET as any user returns EINVAL β€” ip6_fw_ctl_ptr == NULL (ip6_input.c:130-131 only set by ip6_fw_init(), which runs exclusively from the module/option path; X86_64_GENERIC has no options IPV6FIREWALL).
  2. Admin opt-in: root runs kldload ip6fw.ko (module shipped in /boot/kernel; userland /sbin/ip6fw shipped). Module loads with default rule 65535 deny ipv6 from any to any (IPV6FIREWALL_DEFAULT_TO_ACCEPT not defined in sys/net/ip6fw/Makefile); net.inet6.ip6.fw.enable=1 by default. ping6 -c 1 ::1 β†’ 100% loss (policy enforcing).
  3. Unprivileged manipulation: uid 65534 (nobody), plain socket(AF_INET6, SOCK_DGRAM, 0): - IPV6_FW_ADD of allow ipv6 from any to any (#1) β†’ ret 0 - /sbin/ip6fw show (root) confirms rule 00001 allow ipv6 from any to any live in the kernel chain above the deny rule - ping6 -c 1 ::1 now β†’ 0% loss: the admin's deny policy is bypassed by a rule an unprivileged user installed - IPV6_FW_DEL (#1) β†’ ret 0; ping6 blocked again - IPV6_FW_ZERO, IPV6_FW_FLUSH β†’ ret 0 (counter wipe / full chain wipe by an unprivileged user = unpriv firewall DoS)

Root cause

ip6_ctloutput() computes privileged (ip6_output.c:1156-1158, caps-based, used by at least five other option cases at :1198/:1424/:1429/:1712/:1720) but the IPV6_FW_ADD/DEL/FLUSH/ZERO (:1534-1552) and IPV6_FW_GET (:1760-1774) cases never consult it. They forward straight to ip6_fw_ctl() whose only gate is securelevel > 2 (ip6_fw.c:1119 β€” normally 0/-1). The option is reachable from ANY IPv6 socket: the udp6 protosw uses .pr_ctloutput = ip6_ctloutput_dispatch (in6_proto.c:142) β€” contrast the v4 ipfw path which requires a raw IP socket, itself gated by SYSCAP_NONET_RAW (raw_ip.c:473). No raw socket, no privilege, no jail exception for the v6 path.

Impact

Once ip6fw is active (kldload / kernel option), every local user can: - bypass the admin's IPv6 security policy (insert accept-any rule #1), - destroy it (flush / delete arbitrary rules), - DoS all IPv6 connectivity (add deny rules; default is deny), - read accounting counters reset / rule-state changes. Not uid=0: this is a policy/privilege-boundary bypass, not memory corruption.

Fix validation (fix.diff)

if (!privileged) return EPERM; added to both IPV6_FW_ cases of ip6_ctloutput. Kernel rebuilt in-guest (make nativekernel KERNCONF=X86_64_GENERIC), installed, rebooted. Baseline (stock kernel): nobody's ADD/FLUSH return 0 (run.log). Patched: nobody gets EPERM on all IPV6_FW_ ops; root's ops still succeed (fix.log). No behavior change otherwise.

Notes

IPV6_FW_GET (sockopt form) returns EINVAL both before and after module load β€” a separate pre-existing plumbing defect in the GET path (ip6_fw_ctl builds an mbuf chain; ip6_output.c:1771 sopt_from_mbuf on a multi-mbuf chain), unrelated to the privilege issue and NOT required for the bypass (write ops are the impact).

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to in-guest /usr/src, full nativekernel rebuild, reboot into #1: nobody now receives EPERM for IPV6_FW_ADD/DEL/FLUSH/ZERO/GET (fix.log + probe output); root management (ADD/DEL, ip6fw show, GET) unchanged; default-deny enforcement and module load/unload unchanged. Baseline (stock #0) reproduced the bypass as uid 65534.

findings/poc/DF-3392/fix.log; findings/poc/DF-3392/fix.diff; kbuild.log KERNEL_DONE marker; uname #1
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 12 02:34:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

1) any local user: socket(AF_INET6, SOCK_DGRAM, 0) [in6_proto.c:142 -> ip6_ctloutput_dispatch]; 2) setsockopt(IPPROTO_IPV6, IPV6_FW_ADD, rule, 200) [ip6_output.c:1534, no priv gate] -> ip6_fw_ctl [ip6_fw.c:1119 securelevel-only gate] -> add_entry6 installs the rule; 3) '00001 allow ipv6 from any to any' shadows the admin's 65535 deny -> all IPv6 filtering bypassed; alternatively FLUSH/DEL/DENY rules -> unpriv IPv6 blackout.

Evidence (decisive lines)

["run.log: 'IPV6_FW_ADD: ACCEPT-any rule #1 added, uid=65534, ret=0' + ping6 ::1 toggling 100% loss -> 0% loss -> 100% loss with nobody's add/del", "run.2.log: /sbin/ip6fw show listing '00001 allow ipv6 from any to any' immediately after nobody's ADD (authoritative kernel-chain proof)", "fix.log: patched kernel #1 - nobody: 'Operation not permitted' on ADD/FLUSH/ZERO/DEL; root: 'IPV6_FW_ADD ... uid=0, ret=0' + ip6fw show works", "probe.c output (fix.log tail): nobody GET with valsize<=SOMAXOPT_SIZE -> EPERM (gate); the earlier GET EINVAL was sys_getsockopt's non-root valszmax cap (uipc_syscalls.c:1323-1330), not the protocol layer"]

PoC changes

seed had no runnable source; wrote ip6fw_ctl.c from struct ip6_fw in sys/net/ip6fw/ip6_fw.h. Fixed: added (LIST_ENTRY in header), moved binary to /tmp for nobody (mode 700 /root), DF ping6 has no -W flag, GET buffer must be <= SOMAXOPT_SIZE for non-root or sys_getsockopt returns EINVAL before the protocol layer (102400-byte buffer gave a misleading EINVAL; 200 bytes shows the true EPERM on patched / would reach ip6_fw_ctl on stock).

Verified recommended fix

Add 'if (!privileged) return (EPERM);' to the IPV6_FW_{ADD,DEL,FLUSH,ZERO} and IPV6_FW_GET cases of ip6_ctloutput() (the variable is already computed at ip6_output.c:1156 and used by five other option cases), matching the raw-socket privilege boundary the v4 ipfw path gets via SYSCAP_NONET_RAW.

Verdict

REPRODUCED on stock INVARIANTS guest: after root loads the shipped ip6fw.ko (the only way to use DF's IPv6 firewall; option absent from X86_64_GENERIC so ip6_fw_ctl_ptr==NULL by default - verified dead path), uid 65534 on a plain socket(AF_INET6, SOCK_DGRAM) adds/deletes/flushes ip6fw rules via setsockopt(IPPROTO_IPV6, IPV6_FW_*) with no privilege check anywhere on the path (ip6_ctloutput computes 'privileged' but the IPV6_FW cases never consult it; ip6_fw_ctl's only gate is securelevel>2, and securelevel is -1 by default). Policy bypass proven behaviorally: default 'deny ipv6 from any to any' blocks ping6 ::1; nobody's IPV6_FW_ADD of 'allow from any to any' (#1) restores it (ip6fw show confirms the nobody-installed rule live in the kernel chain); nobody's DEL/FLUSH re-breaks/re-wipes the policy at will. Contrast the v4 ipfw path which requires a raw IP socket gated by SYSCAP_NONET_RAW. Impact is firewall policy bypass/manipulation + IPv6 DoS by unprivileged local users once ip6fw is enabled - NOT memory corruption and not uid=0. Fix (priv gate mirroring the file's own 'if (!privileged) return EPERM' pattern) validated by full in-guest kernel rebuild: patched kernel returns EPERM to uid 65534 for ADD/DEL/FLUSH/ZERO/GET while root operation is unchanged.