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

ipfw3 sockopt path bypasses securelevel>=3 firewall-mutability gate β€” missing check present in all sibling glue layers

Summary

ip_fw3_glue.c:50-62 ip_fw3_sockopt dispatches every IP_FW_X setsockopt to ip_fw_ctl_x_ptr WITHOUT securelevel>=3 check. ip_fw2_glue.c:65-69 HAS the check: if(sopt_name==IP_FW_ADD||(SOPT_SET&&!=IP_FW_RESETLOG)) if(securelevel>=3) return EPERM. ip_dummynet_glue.c:161 ip_dummynet3_glue.c:161 ip6_fw.c:1119 pf_ioctl.c:992 ALL have the check. ipfw3 omits it. Result: at securelevel 3 root can still add/del/flush/set/nat/dummynet/table/sync reconfigure ipfw3 rules β€” defeats securelevel 3 firewall immutability guarantee. Post-compromise root attacker (exact threat securelevel 3 contains) can flush rulebase insert allow-all rules disable sets alter NAT to re-open attack surface. Defense-in-depth boundary bypass not new priv-esc (root required) hence Medium not High. Fix: if(sopt_dir==SOPT_SET&&sopt_name==IP_FW_X) if(securelevel>=3) return EPERM.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0761 Β· 15 files
FileTypeDescriptionSize
df0761.c trigger-source minimal C trigger: raw-socket setsockopt(IP_FW_X, FLUSH) probe with BYPASS/GATED/INCONCLUSIVE marker 5.2 KB view raw
build.sh build-script cc -O0 -g -o df0761 df0761.c 392 B view raw
run.sh run-script orchestrates: flip filters_default_to_accept=1 -> kldload ipfw3 -> securelevel=3 -> run probe 2.1 KB view raw
fix.diff suggested-fix one-hunk git-apply-able fix: add securelevel>=3 gate to ip_fw3_sockopt (matches dummynet3 sibling form) 636 B view raw
VERDICT.md verdict full narrative: mechanism, evidence, fix, validation 8.0 KB ↓ raw
README.md readme human-facing reproduce instructions + expected output 3.3 KB ↓ raw
build.log build-log baseline build (cc, exit 0) 97 B view raw
run.log run-log baseline run on #0 kernel: BYPASS CONFIRMED (setsockopt rc=0 at securelevel=3) 1.2 KB view raw
fix_build.log build-log single-fix kernel nativekernel build (NK_DONE rc=0), full output 5.6 MB ↓ download
fix_run.log run-log patched #1 kernel run: GATED/FIXED (setsockopt rc=-1 EPERM at securelevel=3), run 1 1.2 KB view raw
fix_run.2.log run-log patched #1 kernel run 2: GATED/FIXED (determinism) 1.2 KB view raw
env.txt environment guest uname, cc version, default sysctls 948 B view raw
boot_tail.txt boot-log serial console tail (no panic β€” clean policy test) 116 B 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 reproduce instructions + expected output
↓ download raw

DF-0761 β€” ipfw3 sockopt path bypasses securelevel>=3 firewall-mutability gate

Severity: Medium Β· Class: privileged policy/logic bypass (NOT memory corruption) Verified: REPRODUCED on unpatched #0; FIX VALIDATED on single-fix #1.

Claim

sys/net/ipfw3/ip_fw3_glue.c:50-62 β€” ip_fw3_sockopt() dispatches every IP_FW_X setsockopt to ip_fw_ctl_x_ptr() WITHOUT the securelevel >= 3 firewall-mutability gate that all sibling glue layers enforce (ip_fw2_glue.c:65-69, ip_dummynet3_glue.c:159-163, pf_ioctl.c:992). Result: at securelevel 3 a root process can still flush/add/del/set/nat/dummynet/table/sync-reconfigure the ipfw3 ruleset, defeating the securelevel-3 firewall-immutability guarantee.

How to reproduce (full procedure)

The test runs as root (the threat model is "root at securelevel>=3"; the bypass is privileged-only). Run on a freshly-reset with-src guest (securelevel=-1, ipfw3 not loaded β€” securelevel is irreversible in a running kernel, so each run needs a fresh guest).

./build.sh && ./run.sh

run.sh does: 1. sysctl net.filters_default_to_accept=1 β€” so loading ipfw3 (which defaults to DENY, ip_fw3.c:1468) does not black-hole our ssh session. This does NOT affect the test. 2. kldload ipfw3. 3. sysctl kern.securelevel=3 β€” raise the firewall-immutability gate. 4. ./df0761 β€” issues setsockopt(IPPROTO_IP, IP_FW_X, {opcode=IP_FW_FLUSH}) and prints the result marker.

Expected output

Bug present (unpatched #0 kernel):

[bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=0 errno=0 (OK)
=== RESULT: BYPASS CONFIRMED ===

Bug fixed (patched #1 kernel, with fix.diff applied):

[bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=-1 errno=1 (Operation not permitted)
=== RESULT: GATED (FIXED) ===

Files

File Purpose
df0761.c minimal C trigger: raw-socket setsockopt(IP_FW_X, FLUSH) probe
build.sh cc -O0 -g -o df0761 df0761.c
run.sh orchestrates: load ipfw3 β†’ raise securelevel β†’ run probe
VERDICT.md full narrative: mechanism, evidence, fix, validation
fix.diff git-apply-able one-hunk fix adding the securelevel>=3 gate
build.log baseline build output
run.log baseline run (BYPASS CONFIRMED on #0)
fix_build.log single-fix kernel build (NK_DONE rc=0)
fix_run.log patched-kernel run (GATED/FIXED on #1, run 1)
fix_run.2.log patched-kernel run (GATED/FIXED on #1, run 2 β€” determinism)
env.txt guest environment (uname, cc, sysctls)
manifest.json machine-readable artifact catalog

Notes / caveats

  • ipfw3 is not loaded by default on the with-src guest; run.sh loads it.
  • ipfw3 defaults to DENY on load; run.sh flips filters_default_to_accept=1 first so loading does not cut ssh. This is purely a test-harness concern and does not touch the securelevel-bypass code path.
  • securelevel is monotonic in a running kernel (sys/kern/kern_mib.c:257-258); each test run requires a fresh vm.sh reset with-src.
  • The sanity-GET line in the PoC output (opcode=54) also returns EPERM on the patched kernel because the PoC issues it via setsockopt() (SOPT_SET); the fix correctly gates all SOPT_SET. The real getsockopt() (SOPT_GET) path is NOT blocked by the fix.
VERDICT.md verdict full narrative: mechanism, evidence, fix, validation
↓ download raw

DF-0761 β€” VERDICT

Status: REPRODUCED (policy/logic bypass) + FIX VALIDATED Impact: policy-bypass β€” securelevel>=3 firewall-immutability gate defeated (privileged-only) Confidence: certain Severity: Medium (matches finding)


Verdict (one line)

The ipfw3 IP_FW_X setsockopt dispatch path (sys/net/ipfw3/ip_fw3_glue.c:50-62) omits the securelevel >= 3 firewall-mutability gate that ALL sibling glue layers enforce, so a root process can keep flushing/altering the ipfw3 ruleset after the system has gone immutable β€” confirmed on the unpatched #0 kernel (setsockopt(IP_FW_X, FLUSH) β†’ rc=0 at kern.securelevel=3) and closed on the single-fix #1 kernel (β†’ EPERM).


Mechanism (trigger β†’ effect, every hop cited)

  1. Trigger (userspace, root). A root process opens a raw socket (socket(AF_INET, SOCK_RAW, IPPROTO_RAW)) and issues setsockopt(IPPROTO_IP, IP_FW_X, &xhdr, sizeof(xhdr)) where xhdr.opcode selects the operation (IP_FW_FLUSH=52, IP_FW_ADD=50, IP_FW_DEL=51, etc., per sys/net/ipfw3/ip_fw3.h:372-377). IP_FW_X itself is 49 (sys/netinet/in.h:389).

  2. Dispatch. rip_ctloutput (sys/netinet/raw_ip.c:385-387) routes IP_FW_X SOPT_SET calls to ip_fw3_sockopt() (sys/netipfw3/ip_fw3_glue.c:50-62).

  3. The bug β€” missing gate. ip_fw3_sockopt() checks only IPFW3_LOADED and unconditionally calls ip_fw_ctl_x_ptr(sopt) (line 58). There is no securelevel >= 3 check anywhere in the function. Contrast with every sibling: - sys/net/ipfw/ip_fw2_glue.c:65-69 β€” ip_fw_sockopt HAS the gate: if (sopt->sopt_name == IP_FW_ADD || (sopt->sopt_dir == SOPT_SET && sopt->sopt_name != IP_FW_RESETLOG)) { if (securelevel >= 3) return EPERM; } - sys/net/dummynet3/ip_dummynet3_glue.c:159-163 β€” ip_dn_sockopt HAS the gate: if (sopt->sopt_dir == SOPT_SET) { if (securelevel >= 3) return EPERM; } - sys/net/pf/pf_ioctl.c:992 β€” pf ioctl path HAS if (securelevel > 1) gate.

  4. Effect. At kern.securelevel >= 3 (the "really-really secure" / network-immutable level, settable via sysctl kern.securelevel=3 and irreversible in a running kernel per sys/kern/kern_mib.c:257-258), a root process can still FLUSH / ADD / DEL / SET-TOGGLE / NAT / dummynet / table / sync-reconfigure the ipfw3 ruleset β€” defeating the exact guarantee securelevel 3 exists to provide (containment of a compromised root).

Threat model. This is a privileged-only policy/logic bypass β€” NOT memory corruption. The setsockopt needs SYSCAP_NONET_RAW (root). There is no unprivileged escalation chain; the impact is a defeated securelevel boundary for a post-compromise root attacker, which is precisely the scenario securelevel 3 is designed to contain (a root attacker who must NOT be able to alter the firewall ruleset, flush it to re-open attack surface, or re-NAT to exfiltrate). Hence Medium, not High.


Exploit chain

none (non-corruption class). This is a privileged policy-bypass. The "primitive" is the ungated setsockopt itself; the impact ceiling is "root at securelevel>=3 can mutate the ipfw3 firewall arbitrarily", characterized above. No heap grooming, victim object, or uid=0 conversion applies.


Reproduction evidence (decisive lines)

Baseline β€” unpatched #0 kernel (6.5-DEVELOPMENT #0, Thu Jul 2 06:02:54 UTC 2026):

=== [3] raise kern.securelevel to 3 (network-immutable gate) ===
kern.securelevel: -1 -> 3
kern.securelevel: 3
=== [4] attempt IP_FW_X FLUSH at securelevel=3 (the bypass probe) ===
[env] kern.securelevel = 3
[bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=0 errno=0 (OK)

=== RESULT: BYPASS CONFIRMED ===
setsockopt(IP_FW_X, FLUSH) succeeded at securelevel=3 -> ipfw3 securelevel>=3 gate is MISSING (DF-0761 reproduced)

Patched β€” single-fix #1 kernel (6.5-DEVELOPMENT #1, Thu Jul 9 12:10:06 UTC 2026):

=== [3] raise kern.securelevel to 3 (network-immutable gate) ===
kern.securelevel: -1 -> 3
kern.securelevel: 3
=== [4] attempt IP_FW_X FLUSH at securelevel=3 (the bypass probe) ===
[env] kern.securelevel = 3
[bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=-1 errno=1 (Operation not permitted)

=== RESULT: GATED (FIXED) ===
setsockopt(IP_FW_X, FLUSH) returned EPERM at securelevel=3 -> securelevel>=3 gate is present (bug closed)

(Second patched-kernel run, fix_run.2.log, is byte-identical at the decisive line β€” the fix is deterministic.)

Full untrimmed logs: run.log (baseline), fix_run.log + fix_run.2.log (patched), fix_build.log (single-fix kernel build, NK_DONE rc=0).


PoC changes

Authored from scratch β€” no prior PoC existed in findings/poc/DF-0761/ (the finding was filed but the initial scaffolding had not been seeded). Files written: - df0761.c β€” minimal C trigger: opens a raw socket, issues setsockopt(IP_FW_X, {opcode=FLUSH}), reports rc/errno, and prints a BYPASS CONFIRMED / GATED (FIXED) / INCONCLUSIVE marker. Constants (IP_FW_X=49, opcodes from ip_fw3.h:372-377) are defined in-file so it builds on a stock master-DEV install with no kernel headers in /usr/include. - build.sh β€” cc -O0 -g -o df0761 df0761.c. - run.sh β€” orchestrates the test: flips net.filters_default_to_accept=1 (so loading ipfw3 does not black-hole ssh β€” ipfw3 defaults to DENY on load, ip_fw3.c:1468), kldload ipfw3, raises kern.securelevel to 3, then runs ./df0761.

Two PoC bugs fixed during iteration: 1. First run hung ssh β€” ipfw3's default-deny on kldload cut the control channel before the test could run. Fixed by setting net.filters_default_to_accept=1 before kldload (this does not affect the securelevel-bypass test, which exercises the dispatch path, not the default policy). 2. PoC's internal sysctl read of kern.securelevel used the wrong KERN_SECURELVL constant (6 β†’ 9, per sys/sys/sysctl.h:505); fixed so the PoC's printed securelevel matches the kernel's own sysctl kern.securelevel.


fix.diff adds the missing gate to ip_fw3_sockopt(), matching the dummynet3 sibling form (sys/net/dummynet3/ip_dummynet3_glue.c:159-163):

int
ip_fw3_sockopt(struct sockopt *sopt)
{
    int error;

    ASSERT_NETISR0;

    /* Disallow firewall mutations in really-really secure mode, matching
     * the gate present in every sibling glue layer ... SOPT_GET remains
     * allowed, consistent with ip_fw2_glue.c and ip_dummynet3_glue.c. */
    if (sopt->sopt_dir == SOPT_SET && securelevel >= 3)
        return EPERM;

    if (IPFW3_LOADED)
        error = ip_fw_ctl_x_ptr(sopt);
    else
        error = ENOPROTOOPT;
    return (error);
}

This matches the finding's proposed fix (if(sopt_dir==SOPT_SET&&sopt_name==IP_FW_X) if(securelevel>=3) return EPERM) β€” for ipfw3, sopt_name is always IP_FW_X at this point (the sub-opcode is inside the payload), so checking sopt_dir==SOPT_SET alone is equivalent and simpler. It is slightly stricter than the ipfw2 sibling (which carves out IP_FW_RESETLOG), but ipfw3 has no equivalent carve-out requirement and the dummynet3 sibling uses exactly this all-SET-blocked form. A maintainer wanting the RESETLOG carve-out can parse the ip_fw_x_header.opcode later; the minimal correct fix is this one.


Fix validation (Phase 8)

  • Baseline #0: PoC β†’ BYPASS CONFIRMED (setsockopt rc=0 at securelevel=3). βœ“ bug present.
  • Applied fix.diff to in-guest /usr/src via patch -p1 (hunk succeeded at line 54).
  • Built single-fix kernel make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ NK_DONE rc=0 (warm obj, ~13 min full rebuild because mkdep regenerated; .c-only change).
  • Installed kernel.stripped β†’ /boot/kernel/kernel, kernel.debug β†’ /boot/kernel/kernel.debug, clean reboot β†’ kern.version now #1: Thu Jul 9 12:10:06 UTC 2026 (was #0: Thu Jul 2 ...).
  • Patched #1: PoC β†’ GATED (FIXED) (setsockopt rc=-1 errno=EPERM at securelevel=3). Deterministic across 2 runs.
  • fix_status: fixed β€” bad behavior gone on patched, present on baseline. Clean before/after.
  • Reset to with-src baseline at end.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: ./run.sh on unpatched 6.5-DEVELOPMENT #0 baseline returned '=== RESULT: BYPASS CONFIRMED ===' ([bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=0 at kern.securelevel=3) -- bug present. Applied fix.diff (patch -p1, hunk succeeded at line 54), built single-fix kernel (make -j6 nativekernel, NK_DONE rc=0), installed kernel.stripped->/boot/kernel/kernel, rebooted to #1 (Thu Jul 9 12:10:06 UTC 2026). Re-running ./run.sh on #1 returned '=== RESULT: GATED (FIXED) ===' ([bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=-1 errno=1/EPERM at kern.securelevel=3) -- bug closed. Deterministic across 2 runs (fix_run.log, fix_run.2.log). Guest reset to with-src baseline at end.

BEFORE (#0 baseline, run.log): kern.securelevel: -1 -> 3 | [bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=0 errno=0 (OK) | === RESULT: BYPASS CONFIRMED ===. AFTER (#1 single-fix, fix_run.log): kern.securelevel: -1 -> 3 | [bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=-1 errno=1 (Operation not permitted) | === RESULT: GATED (FIXED) ===. Build: === NK_DONE rc=0 === (Thu Jul 9 12:13:57 UTC 2026).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 9 12:10:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

none (non-corruption class). This is a privileged-only policy/logic bypass -- the setsockopt needs SYSCAP_NONET_RAW/root, so there is no unprivileged escalation chain and no heap/victim/uid=0 conversion applies. The impact ceiling is 'root at securelevel>=3 can FLUSH/ADD/DEL/SET-TOGGLE/NAT/dummynet/table/sync-reconfigure the ipfw3 ruleset' -- defeating the securelevel-3 firewall-immutability guarantee that exists precisely to contain a post-compromise root attacker (re-open attack surface, exfil via NAT, etc.).

Evidence (decisive lines)

BASELINE #0 (run.log): kern.securelevel: -1 -> 3 ; [bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=0 errno=0 (OK) ; === RESULT: BYPASS CONFIRMED === setsockopt(IP_FW_X, FLUSH) succeeded at securelevel=3 -> ipfw3 securelevel>=3 gate is MISSING. PATCHED #1 (fix_run.log): kern.securelevel: -1 -> 3 ; [bypass-FLUSH] setsockopt(IP_FW_X, opcode=52) -> rc=-1 errno=1 (Operation not permitted) ; === RESULT: GATED (FIXED) ===. Deterministic across fix_run.2.log. Single-fix kernel build: === NK_DONE rc=0 === (Thu Jul 9 12:13:57 UTC 2026).

PoC changes

Authored full evidence pack from scratch (no prior PoC existed in findings/poc/DF-0761/). df0761.c: minimal C trigger opening a raw socket and issuing setsockopt(IPPROTO_IP, IP_FW_X=49, {opcode=IP_FW_FLUSH=52}); constants defined in-file (IP_FW_X=49, opcodes from ip_fw3.h:372-377) so it builds on stock master-DEV without kernel headers in /usr/include. build.sh: cc -O0 -g -o df0761 df0761.c. run.sh: orchestrates the test (flip net.filters_default_to_accept=1 so kldload ipfw3 does not black-hole ssh via its default-deny at ip_fw3.c:1468; kldload ipfw3; sysctl kern.securelevel=3; run probe). Two iteration fixes: (1) first run hung ssh because ipfw3 default-deny cut the control channel before the test ran -- fixed by setting filters_default_to_accept=1 pre-load; (2) PoC's internal sysctl securelevel read used wrong KERN_SECURELVL constant (6 -> 9 per sys/sys/sysctl.h:505) -- fixed.

Verified recommended fix

fix.diff adds the missing gate to ip_fw3_sockopt() in sys/net/ipfw3/ip_fw3_glue.c (one hunk, git-apply-able): after ASSERT_NETISR0, add 'if (sopt->sopt_dir == SOPT_SET && securelevel >= 3) return EPERM;'. Matches the dummynet3 sibling form (ip_dummynet3_glue.c:159-163) and matches the finding's proposed fix (for ipfw3, sopt_name is always IP_FW_X at this point, so checking sopt_dir==SOPT_SET alone is equivalent and simpler). SOPT_GET remains allowed, consistent with ipfw2/dummynet3. 'matches finding proposal'.

Verdict

REPRODUCED + FIX VALIDATED. sys/net/ipfw3/ip_fw3_glue.c:50-62 ip_fw3_sockopt() dispatches every IP_FW_X setsockopt straight to ip_fw_ctl_x_ptr() WITHOUT the securelevel>=3 firewall-mutability gate that ALL sibling glue layers enforce (ip_fw2_glue.c:65-69, ip_dummynet3_glue.c:159-163, pf_ioctl.c:992). Confirmed on unpatched #0 kernel: at kern.securelevel=3, setsockopt(IPPROTO_IP, IP_FW_X=49, {opcode=IP_FW_FLUSH=52}) returned rc=0 (BYPASS CONFIRMED) -- root can keep mutating the ipfw3 ruleset after the system has gone immutable. Closed on single-fix #1 kernel: same call returns rc=-1 errno=EPERM (GATED/FIXED). Privileged-only policy/logic bypass (NOT memory corruption) -- defeats the securelevel-3 boundary whose explicit purpose is to contain a compromised root; hence Medium per finding.