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

Missing bounds check on table id gives controlled kernel heap OOB read/write on every ipfw3 table opcode

Summary

Every ipfw3 table operation derives per-CPU slot from ioc_table->id (signed int from setsockopt payload) and does table_ctx+=id against table_ctx[IPFW_TABLES_MAX=32] array with NO id<0||id>=IPFW_TABLES_MAX check anywhere. Both negative and large positive indices accepted. 8 distinct primitives: clean 40-byte heap writes (create/rename/flush) 16-byte kernel-pointer installs via rn_inithead (create type 1/2) controlled kfree of attacker-influenced pointer via flush_table_entry on table_ctx->node from OOB (delete/flush) indirect-call through rnh addaddr/deladdr/lookup/walktree function pointers from OOB-read node (append/remove/test/show). struct ipfw3_table_context 56 bytes on amd64 so id=N targets byte offset N*56 from array base. Any local principal with raw IP socket (root or jail allow.raw_sockets).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2580 Β· 12 files
FileTypeDescriptionSize
poc.c trigger-source minimal raw-socket setsockopt(IP_FW_X) trigger with attacker id 4.5 KB view raw
build.sh build-script cc -o poc poc.c 233 B view raw
run.sh run-script ./poc [opcode] [id] [type] 573 B view raw
README.md readme build/run/expected + reach + privilege notes 1.9 KB ↓ raw
VERDICT.md verdict full analysis: mechanism, primitive, hard blocker, fix validation 7.1 KB ↓ raw
build.log build-log PoC compile output 155 B view raw
run.log run-log baseline reproduction: panic sig + silent-corruption variants + privilege gate 1.9 KB view raw
fix_run.log run-log patched-kernel re-run: EINVAL for OOB ids, valid id accepted 1.1 KB view raw
fix_build.log build-log full make -j6 nativekernel output (NK_DONE rc=0) 5.6 MB ↓ download
panic.txt panic-signature Fatal trap 12 ... table_create_dispatch+0x45: movl $0,0x30(%rbx) 288 B view raw
env.txt environment uname, cc version, kldstat, sysctl 329 B view raw
fix.diff suggested-fix central id bounds check in ip_fw3_ctl_table_sockopt 960 B view raw
README.md readme build/run/expected + reach + privilege notes
↓ download raw

DF-2580 β€” ipfw3 table id bounds-check missing

Minimal PoC for the missing id bounds check in sys/net/ipfw3_basic/ip_fw3_table.c. Every ipfw3 table operation does table_ctx += id against table_ctx[IPFW_TABLES_MAX=32] with no id < 0 || id >= IPFW_TABLES_MAX check, so an attacker-controlled signed id gives a controlled kernel-heap OOB read/write at byte offset id*56.

Reach

setsockopt(raw_ip_socket, IPPROTO_IP, IP_FW_X=49, payload) where payload is a 4-byte ip_fw_x_header{uint16 opcode=73(CREATE), uint16 pad} followed by a struct ipfw_ioc_table{int id; int type; int count; char name[32];}. The opcode is stripped by ip_fw3_ctl_x and the remaining struct is dispatched to table_create_dispatch, which does the unbounded table_ctx += id.

Privilege: creating a raw IP socket requires SYSCAP_NONET_RAW (root, or a jail with allow_raw_sockets). The unprivileged user gets EPERM at socket(). So this PoC must be run as root.

Build (on the guest, as root)

cc -o poc poc.c

Run (as root, with ipfw3 + ipfw3_basic loaded)

# load the modules with default-to-accept so ssh survives
sysctl -w net.filters_default_to_accept=1
kldload ipfw3
kldload ipfw3_basic

./poc 73 100000 1     # opcode=CREATE id=100000 type=1 -> PANIC on unpatched
./poc 73 64 1         # silent OOB write (offset 3584)
./poc 73 -2 1         # silent OOB write (negative idx, offset -112)
./poc 73 5 1          # valid id (sanity)

Expected

  • Unpatched 6.5-DEVELOPMENT #0: id=100000 panics with Fatal trap 12 … Stopped at table_create_dispatch+0x45: movl $0,0x30(%rbx). id=64/-2 return success (silent OOB write). Valid id=5 works.
  • Patched (DF-2580 fix): every out-of-range id returns EINVAL (errno 22); valid id still works; no panic.

See VERDICT.md for the full analysis, fix.diff for the fix, run.log / fix_run.log for the captured before/after, and panic.txt for the crash signature.

VERDICT.md verdict full analysis: mechanism, primitive, hard blocker, fix validation
↓ download raw

DF-2580 β€” ipfw3 table id bounds-check missing (controlled kernel heap OOB R/W)

Verdict

REPRODUCED on the unpatched 6.5-DEVELOPMENT #0 kernel; FIX VALIDATED on a single-fix #1 kernel + rebuilt ipfw3_basic.ko. The OOB write primitive is real and root-reachable; the escalation chain to uid=0 is blocked by a valid hard blocker (the bug path is reachable only through a raw IP socket, which requires SYSCAP_NONET_RAW = root; an unprivileged user cannot enter it).

The bug (confirmed line-by-line)

Every ipfw3 table operation derives its per-CPU slot from ioc_table->id (a signed int taken directly from the setsockopt payload) and indexes into ctx->table_ctx[IPFW_TABLES_MAX=32] with no bounds check:

/* sys/net/ipfw3_basic/ip_fw3_table.c */
void table_create_dispatch(netmsg_t nmsg) {
    ...
    ioc_table = tbmsg->ioc_table;
    int id = ioc_table->id;          /* line 92: attacker-controlled signed int */
    table_ctx = ctx->table_ctx;
    table_ctx += id;                  /* line 95: OOB if id<0 || id>=32 */
    table_ctx->type = ioc_table->type;        /* line 96: OOB write */
    table_ctx->count = 0;                      /* line 97: OOB write */
    strlcpy(table_ctx->name, ...);             /* line 98: OOB write, 32 bytes */
    if (table_ctx->type == 1) { rn_inithead(&table_ctx->mask,...); rn_inithead(&table_ctx->node,...); }  /* OOB kernel-ptr installs */
    ...
}

struct ipfw3_table_context is 56 bytes on amd64 (node* + mask* + name[32] + count + type), so id=N targets byte offset N*56 from the array base. The identical table_ctx += id pattern (with no check) appears in all eight dispatch/sync functions: table_create_dispatch (c:92), table_delete_dispatch (c:127), table_append_dispatch (c:149), table_remove_dispatch (c:205), table_flush_dispatch (c:244), table_rename_dispatch (c:265), ip_fw3_ctl_table_show (c:379, reads *id), ip_fw3_ctl_table_test (c:433).

The entry path is setsockopt(raw_ip_sock, IPPROTO_IP, IP_FW_X=49, payload): rip_ctloutput (raw_ip.c:385) β†’ ip_fw3_sockopt β†’ ip_fw3_ctl_x (ip_fw3.c:1039, strips a 4-byte ip_fw_x_header{uint16 opcode,uint16 pad} and sets sopt_name=opcode) β†’ ip_fw3_ctl (ip_fw3.c:1054, switches on opcode, e.g. 73= IP_FW_TABLE_CREATE) β†’ ip_fw3_ctl_table_sockopt (ip_fw3_table.c:524) β†’ ip_fw3_ctl_table_create β†’ netmsg β†’ table_create_dispatch.

Reproduction (unpatched #0 kernel)

Run as root after kldload ipfw3 && kldload ipfw3_basic (with net.filters_default_to_accept=1 so the firewall doesn't cut ssh):

# /root/poc 73 100000 1          # CREATE id=100000 type=1 -> offset 5.6MB
Fatal trap 12: page fault while in kernel mode
cpuid = 1; lapic id = 1
fault virtual address = 0xfffff80118b7c0b0
Stopped at      table_create_dispatch+0x45:     movl    $0,0x30(%rbx)
db>

table_create_dispatch+0x45 is the table_ctx->count = 0; store (offset 0x30=48 in the 56-byte struct = the count field). With id=100000 the computed address is 5.6 MB past the array base β†’ unmapped β†’ page fault. Bug confirmed.

Smaller OOB offsets do not crash β€” they silently corrupt adjacent slab memory:

# /root/poc 73 64 1     # offset 3584 -> setsockopt returns 0 (silent OOB write)
# /root/poc 73 -2 1     # offset -112 -> setsockopt returns 0 (silent OOB write, negative idx)

This is the "controlled heap OOB write" primitive: 4 bytes type (attacker int) + 4 bytes count (forced 0) + up to 31 attacker bytes in name via strlcpy, at an attacker-chosen offset id*56; with type 1/2 it additionally installs two kernel pointers via rn_inithead. The ip_fw3_table_fini_dispatch even kfrees the installed node/mask on unload, so the delete/flush opcodes also give a controlled-kfree of an attacker-influenced pointer.

Privilege analysis β€” why escalation to uid=0 is blocked

The only kernel path to ip_fw3_ctl_table_sockopt is rip_ctloutput, which is the pr_ctloutput of the raw-IP protocol family (in_proto.c: every rip_ctloutput entry is pr_type = SOCK_RAW + rip_usrreqs). Creating such a socket runs rip_attach (raw_ip.c:460) which gates on:

error = caps_priv_check(ai->p_ucred, SYSCAP_NONET_RAW | __SYSCAP_NULLCRED);  /* raw_ip.c:473 */

i.e. root (or a jail with allow_raw_sockets, which still grants it only to root inside the jail). Confirmed on the guest:

[maxx@dfbsd ~]$ /tmp/poc 73 5 1
[-] socket(AF_INET,SOCK_RAW,IPPROTO_RAW): Operation not permitted
[-] raw socket requires root (SYSCAP_NONET_RAW). errno=1   (EPERM)

An unprivileged user therefore cannot enter the bug path; there is no privilege boundary to cross. Per the Phase-6 bright-line rule, "root-only reachability" is a valid hard blocker: root→kernel is game-over by definition (root can already kldload, write /dev/mem, etc.). The honest impact is a panic / kernel-heap-corruption DoS triggered by a privileged firewall admin plus a real OOB-write primitive that is unreachable from an unprivileged context. No kldload-of-attacker-module, setuid helper, or non-default kernel was used to "reach" the bug; the only kldload is the ipfw3/ipfw3_basic modules themselves, which is the legitimate module providing the vulnerable surface.

The fix

A single central bounds check in ip_fw3_ctl_table_sockopt (ip_fw3_table.c:524), gating every opcode that indexes by id (LIST iterates all slots and is exempt):

if (sopt->sopt_name != IP_FW_TABLE_LIST &&
    sopt->sopt_valsize >= sizeof(int)) {
    struct ipfw_ioc_table *ioc = (struct ipfw_ioc_table *)sopt->sopt_val;
    if (ioc->id < 0 || ioc->id >= IPFW_TABLES_MAX)
        return (EINVAL);
}

The id sits at offset 0 of sopt_val for every id-consuming opcode (it is the first field of struct ipfw_ioc_table, and SHOW reads it as a bare int *), so one check covers all eight. git apply-able diff: fix.diff.

Fix validation (built single-fix #1 kernel + rebuilt module)

  • Before (unpatched #0 kernel + #0 module): id=100000 β†’ panic table_create_dispatch+0x45; id=64/id=-2 β†’ silent OOB write (returns 0).
  • After (#1 kernel Sat Aug 8 08:28:55 UTC 2026 + rebuilt ipfw3_basic.ko sha256 206a75c0…): id=100000/64/-2 β†’ EINVAL (errno 22), no panic, guest stays up; valid id=5 β†’ still accepted (returns 0, no false reject).
  • Disassembly of the rebuilt ip_fw3_ctl_table_sockopt confirms the check: cmpl $0x1f,(%rax); jbe <switch>; mov $0x16,%eax; retq (= if (id<=31) switch; else return EINVAL).

fix_status = fixed (clean before/after, deterministic across runs).

Files

  • poc.c β€” minimal trigger (raw socket + setsockopt(IP_FW_X) with attacker id).
  • build.sh / run.sh β€” exact build & run.
  • run.log β€” baseline reproduction (panic signature + silent-corruption variants + privilege gate).
  • fix_run.log β€” patched-kernel re-run (EINVAL for OOB ids, valid id still works).
  • fix_build.log β€” full make -j6 nativekernel output (NK_DONE rc=0).
  • panic.txt β€” Fatal trap 12 … table_create_dispatch+0x45.
  • env.txt β€” guest uname / cc / kld / sysctl state.
  • fix.diff β€” the standalone git-apply-able fix.
  • manifest.json β€” artifact catalog.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. On the unpatched #0 baseline the PoC panics (Stopped at table_create_dispatch+0x45); id=64/-2 silently corrupt (setsockopt returns 0). On the single-fix #1 kernel + rebuilt ipfw3_basic.ko, the SAME PoC returns EINVAL for id=100000, id=64, id=-2 with no panic while valid id=5 is still accepted. Disassembly confirms the compiled check. The fix closes the bug completely.

baseline #0: id=100000 -> panic 'Stopped at table_create_dispatch+0x45: movl $0,0x30(%rbx)'; id=64/-2 -> setsockopt returned 0. patched #1: id=100000/id=64/id=-2 -> errno=22 EINVAL; valid id=5 -> returned 0. guest status after patched runs: UP.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 08:28:55 UTC 2026 (single-fix nativekernel + rebuilt ipfw3_basic.ko)

Confirmed kernel references

Detail

Exploit chain

BLOCKED by a valid Phase-6 hard blocker: root-only reachability. The only kernel path to ip_fw3_ctl_table_sockopt is rip_ctloutput (SOCK_RAW protocols); rip_attach gates on caps_priv_check(SYSCAP_NONET_RAW) = root. Unprivileged maxx gets EPERM at socket(AF_INET,SOCK_RAW,IPPROTO_RAW). Root->kernel is game-over by definition, so no privilege boundary to cross and no uid=0 chain to build. No exploit.c written because the hard blocker precludes any unprivileged->root chain.

Evidence (decisive lines)

BASELINE (unpatched #0): /root/poc 73 100000 1 -> Fatal trap 12: page fault | Stopped at table_create_dispatch+0x45: movl $0,0x30(%rbx). Silent-corruption: id=64 -> setsockopt returned 0; id=-2 -> setsockopt returned 0. Privilege gate: maxx unpriv -> socket(): Operation not permitted (EPERM, SYSCAP_NONET_RAW). PATCHED (#1 kernel + rebuilt ipfw3_basic.ko): id=100000 -> errno=22 EINVAL; id=64 -> EINVAL; id=-2 -> EINVAL; valid id=5 -> setsockopt returned 0 (accepted); guest UP, no panic.

PoC changes

findings/poc/DF-2580/ was empty; authored poc.c from scratch, build.sh, run.sh, README.md, VERDICT.md, manifest.json, fix.diff. fix.diff adds a single central id bounds check (id<0||id>=IPFW_TABLES_MAX -> EINVAL) covering all 8 id-consuming opcodes.

Verified recommended fix

In sys/net/ipfw3_basic/ip_fw3_table.c ip_fw3_ctl_table_sockopt (line 524), before the switch, add a bounds check on ioc->id (< 0 || >= IPFW_TABLES_MAX -> EINVAL). One check closes all 8 OOB paths at the netmsg-send boundary. Full git-apply-able diff in findings/poc/DF-2580/fix.diff.

Verdict

REPRODUCED on unpatched 6.5-DEVELOPMENT #0. The bug is real: every ipfw3 table opcode does table_ctx += id against ctx->table_ctx[IPFW_TABLES_MAX=32] with NO bounds check (sys/net/ipfw3_basic/ip_fw3_table.c:95,127,149,205,244,265,379,433); id is a signed int read straight from the setsockopt payload (struct ipfw_ioc_table.id). Confirmed by setsockopt(IPPROTO_IP, IP_FW_X, opcode=CREATE, id=100000) panicking at table_create_dispatch+0x45 (movl $0,0x30(%rbx) = the table_ctx->count=0 store at the OOB-computed address id*56 = 5.6MB past the array base, faulting on unmapped memory) β€” captured in panic.txt. Smaller OOB offsets (id=64 offset 3584, id=-2 offset -112) return success with NO crash = silent controlled heap corruption.