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)
PoC verification
Evidence pack
findings/poc/DF-2580 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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=100000panics withFatal trap 12 β¦ Stopped at table_create_dispatch+0x45: movl $0,0x30(%rbx).id=64/-2return success (silent OOB write). Validid=5works. - 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.
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β panictable_create_dispatch+0x45;id=64/id=-2β silent OOB write (returns 0). - After (#1 kernel
Sat Aug 8 08:28:55 UTC 2026+ rebuiltipfw3_basic.kosha256206a75c0β¦):id=100000/64/-2βEINVAL(errno 22), no panic, guest stays up; validid=5β still accepted (returns 0, no false reject). - Disassembly of the rebuilt
ip_fw3_ctl_table_sockoptconfirms 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β fullmake -j6 nativekerneloutput (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
fixedVALIDATED. 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.
Confirmed kernel references
- sys/net/ipfw3_basic/ip_fw3_table.c:92
- sys/net/ipfw3_basic/ip_fw3_table.c:95
- sys/net/ipfw3_basic/ip_fw3_table.c:127
- sys/net/ipfw3_basic/ip_fw3_table.c:149
- sys/net/ipfw3_basic/ip_fw3_table.c:205
- sys/net/ipfw3_basic/ip_fw3_table.c:244
- sys/net/ipfw3_basic/ip_fw3_table.c:265
- sys/net/ipfw3_basic/ip_fw3_table.c:379
- sys/net/ipfw3_basic/ip_fw3_table.c:433
- sys/net/ipfw3_basic/ip_fw3_table.c:524
- sys/net/ipfw3_basic/ip_fw3_table.h:39
- sys/netinet/raw_ip.c:473
- sys/netinet/raw_ip.c:385
- sys/net/ipfw3/ip_fw3.c:1039
- sys/net/ipfw3/ip_fw3.c:1054
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.
No comments yet.