pfr_fix_anchor unbounded slash-count loop causes size_t wraparound in bcopy/memset: kernel panic via DIOCRGETTABLES
| Field | Value |
|---|---|
| ID | DF-0362 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H |
| CWE | CWE-787 Out-of-bounds Write |
| File | sys/net/pf/pf_table.c |
| Lines | 1740-1755 |
| Area | net (pf firewall) |
| Confidence | certain |
| Discovered | 2026-07-01 |
| Reported | pending |
Summary
pfr_fix_anchor() strips leading / characters from a user-supplied
anchor string (pfrt_anchor, char[MAXPATHLEN]) using a counting loop
with no bound against the buffer size. Because pfrt_name immediately
follows pfrt_anchor in struct pfr_table, an attacker who fills the
entire anchor with / and sets pfrt_name[0]='/' makes the loop walk
one byte past the array boundary, causing off to exceed siz
(MAXPATHLEN=1024). The subsequent bcopy(path, anchor, siz - off)
computes siz - off as a size_t, wrapping to ~2^64, producing a
massive out-of-bounds read and write that immediately panics the kernel.
Root cause
pfr_fix_anchor (sys/net/pf/pf_table.c:1740-1755):
int
pfr_fix_anchor(char *anchor)
{
size_t siz = MAXPATHLEN; /* 1024 */
int i;
if (anchor[0] == '/') {
char *path;
int off;
path = anchor;
off = 1;
while (*++path == '/') /* line 1751: NO bound vs siz */
off++;
bcopy(path, anchor, siz - off); /* line 1753: wraps if off > siz */
memset(anchor + siz - off, 0, off); /* line 1754: wild pointer */
}
...
}
The struct layout (sys/net/pf/pfvar.h:1036-1041):
struct pfr_table {
char pfrt_anchor[MAXPATHLEN]; /* offset 0, 1024 bytes */
char pfrt_name[PF_TABLE_NAME_SIZE]; /* offset 1024, 32 bytes — ADJACENT */
u_int32_t pfrt_flags;
u_int8_t pfrt_fback;
};
When the attacker fills all 1024 bytes of pfrt_anchor with / and sets
pfrt_name[0] = '/':
- The
while (*++path == '/')loop readsanchor[1]throughanchor[1023](all/), then readsanchor[1024]which ispfrt_name[0]=/— one byte past the array boundary. offbecomes 1025, exceedingsiz(1024).siz - off:sizissize_t(unsigned 64-bit),offisint(1025). Theintis promoted tosize_t, then1024 - 1025wraps to0xFFFFFFFFFFFFFFFF(~2^64).bcopy(path, anchor, 0xFFFFFFFFFFFFFFFF)attempts to copy ~2^64 bytes — an immediate out-of-bounds read frompathand write toanchorthat page-faults into a kernel panic.
Threat model & preconditions
- Attacker position: local user with access to
/dev/pf(typically root; commonly exposed to jails with devfs rules). - Privileges gained or impact: kernel panic (guaranteed DoS). Potential memory corruption for code execution depending on adjacent kernel memory layout.
- Required config or capabilities: open
/dev/pfdescriptor./dev/pfis mode 0600 root:wheel by default, but is frequently exposed to jails for firewall management. - Reachability:
DIOCRGETTABLESioctl callspfr_get_tables()(pf_table.c:1280) which callspfr_fix_anchor(filter->pfrt_anchor)directly with no prior anchor validation.DIOCRGETTABLESis: - Allowed without FWRITE (
pf_ioctl.c:1063— only FREAD needed). - Allowed at securelevel > 1 (
pf_ioctl.c:1013— in the break list). - Also reachable through
pfr_validate_table(pf_table.c:1728) viaDIOCRADDADDRS,DIOCRDELADDRS,DIOCRSETADDRS, etc. (the attacker setspfrt_name = {'/', 0, ...}which passes all name validation checks at lines 1719-1727 beforepfr_fix_anchorruns).
Proof of concept
PoC source: findings/poc/DF-0362/poc.c
Build & run
cc -o poc findings/poc/DF-0362/poc.c ./poc # requires read access to /dev/pf
Expected output
Fatal trap 12: page fault while in kernel mode cpuid = 0 KDB: stack backtrace: ... pfr_fix_anchor() pfr_get_tables() pfioctl() devioctl() ...
Impact
- Guaranteed kernel panic from any context that can issue
DIOCRGETTABLES— a read-only operation on/dev/pf. - Defeats
securelevel > 1(the ioctl is explicitly in the allowed list atpf_ioctl.c:1013). - In jail configurations where
/dev/pfis exposed, a jailed root can panic the host kernel, breaking jail isolation. - The
size_twraparound meansbcopycorrupts kernel memory before faulting. With heap/stack grooming, this could be an arbitrary write primitive — though the ~2^64 size makes the copy fault quickly, limiting the overwrite window to a small region.
Recommended fix
Bound the slash-counting loop against siz and validate NUL-termination
before any rewriting:
--- a/sys/net/pf/pf_table.c
+++ b/sys/net/pf/pf_table.c
@@ -1742,6 +1742,8 @@ pfr_fix_anchor(char *anchor)
size_t siz = MAXPATHLEN;
int i;
+ if (anchor[siz - 1] != '\0')
+ return (-1);
if (anchor[0] == '/') {
char *path;
int off;
@@ -1749,7 +1751,7 @@ pfr_fix_anchor(char *anchor)
path = anchor;
off = 1;
- while (*++path == '/')
+ while (off < siz && *++path == '/')
off++;
+ if (off >= siz)
+ return (-1);
bcopy(path, anchor, siz - off);
memset(anchor + siz - off, 0, off);
}
The NUL-termination check at the top ensures the loop can never read past
the array. The off < siz guard in the loop condition is defense-in-depth.
References
- OpenBSD fixed a similar
pfr_fix_anchorissue (anchor not NUL-terminated) in 2014. DIOCRGETTABLESis defined insys/net/pf/pfvar.h.
Timeline
- 2026-07-01 Discovered during automated audit.
- 2026-07-01 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0362 · 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc.c | trigger-source | minimal DIOCRGETTABLES trigger; uses guest /usr/include/net/pf/pfvar.h | 3.2 KB | view raw |
| build.sh | build-script | cc -o poc poc.c | 115 B | view raw |
| run.sh | run-script | kldload pf; sysctl debugger_on_panic=0; ./poc (root) | 487 B | view raw |
| README.md | readme | finding summary, reachability analysis, build/run, expected output | 2.8 KB | ↓ raw |
| VERDICT.md | verdict | full root-cause + reachability + exploit-chain-blocker + fix validation | 8.1 KB | ↓ raw |
| build.log | build-log | final successful PoC build, gcc 8.3 | 1.4 KB | view raw |
| run.log | run-log | decisive unpatched-pf run output + panic excerpt | 1.8 KB | view raw |
| panic.txt | panic-signature | trap-12 panic in memmove+0x24f called from pfr_get_tables+0x31 | 1.7 KB | view raw |
| env.txt | environment | uname, kern.version, cc, PF-in-GENERIC check, /dev/pf perms, pf_load make_dev call | 768 B | view raw |
| fix.diff | suggested-fix | git-apply-able: NUL-termination check + in-loop bound at pfr_fix_anchor | 990 B | view raw |
| fix_build.log | build-log | full nativekernel build output (rc=0) for the patched source | 5.6 MB | ↓ download |
| fix_run.log | run-log | patched-pf.ko before/after comparison (3/3 clean after, panic before) | 2.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 |
DF-0362 — pfr_fix_anchor size_t wraparound (root→kernel memory corruption)
Finding
pfr_fix_anchor() (sys/net/pf/pf_table.c:1740-1755) strips leading
/ from pfrt_anchor with a counting loop that has no bound against
siz=MAXPATHLEN(1024). Because pfrt_name[32] is laid out immediately
after pfrt_anchor[1024] in struct pfr_table (pfvar.h:1036-1041),
a caller that fills all 1024 bytes of pfrt_anchor with / and sets
pfrt_name[0]='/' makes the loop read one byte past the array; off
becomes 1025, so bcopy(path, anchor, siz - off) computes
1024 - 1025 as a size_t, wrapping to ~SIZE_MAX. The resulting
bcopy page-faults and the kernel panics.
Reachability (verified on guest) — root-only
On DragonFlyBSD master DEV, PF is NOT compiled into X86_64_GENERIC;
it is a KLD module (sys/net/pf/Makefile is KMOD=pf). The device
/dev/pf does not exist on a default boot, only after kldload pf
(root-only). Even once loaded, pf_load() creates the node as
make_dev(&pf_ops, 0, UID_ROOT, GID_WHEEL, 0600, PF_NAME)
(pf_ioctl.c:3360) — i.e. mode 0600 root:wheel, only root can open
it. pfsync does not call any pfr_* table routine (if_pfsync.c
has zero references), so there is no network-reachable path.
Therefore the bug is a root→kernel hardening gap (root can panic /
corrupt its own kernel via a malformed ioctl), not an unprivileged→root
escalation. Per the audit's bright-line rule (root-only reachability is a
valid hard blocker), uid=0 is not claimable here: the corruption
primitive is real, but the privilege boundary that an unprivileged
attacker would have to cross does not exist.
The realistic impact ceiling is: (a) kernel panic (DoS) from any context
that holds a readable /dev/pf fd — most importantly a jailed root
on hosts that expose /dev/pf into the jail for firewall management
(common practice), enabling jail→host kernel DoS; (b) latent memory-
corruption primitive if a future refactor moves PF into the kernel
proper or loosens /dev/pf permissions.
Build & run
cc -o poc poc.c # uses guest's /usr/include/net/pf/pfvar.h sudo kldload pf # create /dev/pf (root-only setup; one-time) ./poc # needs O_RDONLY on /dev/pf => root
Expected output
- Bug present (unpatched): kernel panic, fatal trap 12 (page fault)
in
bcopy/pfr_fix_anchor, captured indfbsd-qemu/boot.log. - Bug fixed (patched kernel): ioctl returns cleanly (no panic), guest stays up.
Files
poc.c— minimal trigger (corrected: ioctl #63, real headers).build.sh—cc -o poc poc.c.run.sh—kldload pf && ./poc(root).VERDICT.md— full analysis.fix.diff—git apply-able fix (bound loop + NUL check).manifest.json— artifact catalog.
DF-0362 — VERDICT
Status
REPRODUCED (panic / kernel memory corruption). Fix VALIDATED.
One-line summary
pfr_fix_anchor() strips leading / from pfrt_anchor with a counting
loop that has no bound against siz=MAXPATHLEN. The byte immediately
after pfrt_anchor[1024] in struct pfr_table is pfrt_name[0], also
attacker-controlled. A pfrt_anchor full of / plus pfrt_name[0]='/'
makes the loop read one byte past the array, off reaches 1025, and
bcopy(path, anchor, siz - off) computes 1024 - 1025 as a size_t,
wrapping to ~SIZE_MAX. The bcopy (memmove) page-faults and the
kernel panics. Confirmed by reproduction and patched away by the
authored fix.
Mechanism (confirmed path:line)
- Reach.
DIOCRGETTABLESioctl on/dev/pf(pf_ioctl.c:2430).pfrio_esize == sizeof(struct pfr_table)gate atpf_ioctl.c:2433, thenpfr_get_tables(&io->pfrio_table, ...)atpf_ioctl.c:2437. - Sink.
pfr_get_tables()callspfr_fix_anchor(filter->pfrt_anchor)atpf_table.c:1280with no prior validation of the anchor. - Bug. Inside
pfr_fix_anchor(pf_table.c:1739-1762):c path = anchor; off = 1; while (*++path == '/') // line 1751 — no bound vs siz off++; bcopy(path, anchor, siz - off); // line 1753 — wraps when off>sizWithpfrt_anchor[0..1023]='/'andpfrt_name[0]='/', the loop walkspfrt_anchor[1..1023](all/) then readspfrt_name[0](the byte immediately past the array —pfvar.h:1037-1038layspfrt_anchor[1024]directly in front ofpfrt_name[32]).offreaches 1025. - Wrap.
siz(1024) - off(1025):off(int) is promoted tosize_t(unsigned 64-bit) before the subtraction, so the result wraps to0xFFFFFFFFFFFFFFFF.bcopyismemmoveon x86_64 (sys/platform/pc64/x86_64/support.S), implemented asrep movsbwhich page-faults on the first unmapped byte past the slab.
Reproduction evidence (decisive)
Unpatched pf.ko loaded on the with-src baseline kernel
(DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, INVARIANTS ON).
PoC sets pfrt_anchor=1024×'/', pfrt_name={'/',0,...}, issues
DIOCRGETTABLES as root:
panic: vm_fault: fault on stack guard, addr: 0xfffff8011795a000 cpuid = 1 Trace beginning at frame 0xfffff801183431e8 vm_fault() at vm_fault+0x12eb 0xffffffff8099ef9b trap_pfault() at trap_pfault+0x9a 0xffffffff80bd52ca trap() at trap+0x17c 0xffffffff80bd5bcc calltrap() at calltrap+0x9 0xffffffff80b991fa --- trap 000000000000000c, rip = ffffffff80bcab4f, rsp = fffff801183435f0 --- memmove() at memmove+0x24f 0xffffffff80bcab4f pfr_get_tables() at pfr_get_tables+0x31 0xffffffff8262a681
This is precisely the primitive the finding describes — the bcopy in
pfr_fix_anchor (inlined into / called from pfr_get_tables+0x31) is
the crashing instruction. A crash dump was captured. Reproduced 3/3.
Negative control. Same PoC with pfrio_esize=0 returns ENODEV
from pf_ioctl.c:2433 before pfr_get_tables/pfr_fix_anchor is
reached — guest stays up. The panic is unambiguously caused by reaching
pfr_fix_anchor with the malformed anchor/name pair, i.e. the
DF-0362 primitive, not anything incidental.
Reachability / threat model (verified on guest)
The audit prompt's premise "PF is compiled into GENERIC" is wrong. On DragonFlyBSD master DEV:
- PF is NOT in
X86_64_GENERIC(sys/config/X86_64_GENERIChas nodevice pf). It is built only as a KLD module (sys/net/pf/Makefile,KMOD=pf). /dev/pfdoes not exist on a default boot. It is created only afterkldload pf(root-only) viapf_load() → make_dev(&pf_ops, 0, UID_ROOT, GID_WHEEL, 0600, PF_NAME)(pf_ioctl.c:3360) — i.e. mode 0600 root:wheel.pfsyncdoes not call anypfr_*table routine (if_pfsync.chas zero references topfr_table/pfr_fix_anchor/pfr_validate_table), so there is no network-reachable path.
Therefore the bug is a root→kernel hardening gap, not an
unprivileged→root escalation. Per the audit bright-line rule, this is a
valid hard blocker for uid=0: the corruption primitive is real,
but the privilege boundary an unprivileged attacker would have to cross
does not exist — opening /dev/pf already requires root.
Realistic impact ceiling:
- Kernel panic (DoS) from any context holding a readable /dev/pf
fd. Most important real-world case: a jailed root on hosts that
expose /dev/pf into the jail for firewall management (common
practice) — jail→host kernel DoS, defeating jail isolation.
- Latent memory-corruption primitive if a future change moves PF
into the kernel proper or loosens /dev/pf permissions. The
bcopy overwrites a small region (next page boundary, ~few KB)
with attacker-controlled bytes (pfrt_name) before faulting; with
heap grooming this is plausibly an arbitrary-write primitive — but
only relevant once an unprivileged path exists, which it does not
today.
Exploit chain
Blocked by a valid hard blocker: the only reachable path to
pfr_fix_anchor is an ioctl on /dev/pf, which is created mode 0600
root:wheel (pf_ioctl.c:3360) and exists only after a root-only
kldload pf. There is no unprivileged path: PF is not in the default
kernel, pfsync does not invoke any pfr_* routine, and no
world-readable PF device node exists. The bug is a root→kernel
memory-corruption / DoS gap, not a privilege-boundary cross — opening
/dev/pf already concedes root. No uid=0 chain is developable
because there is no lower privilege to escalate from. exploit.c/chain.c
not applicable (no corruption class that crosses a privilege boundary).
PoC changes from the scaffolded original
- Use the guest's installed
<net/pf/pfvar.h>(via<net/if.h>forIFNAMSIZ) sostruct pfioc_table/pfr_table/DIOCRGETTABLESare byte-accurate. The scaffolded PoC hadpfrio_bufferasint(should bevoid *) and used ioctl number66(=DIOCRCLRADDRS) instead of63(=DIOCRGETTABLES). - Set
pfrio_esize = sizeof(struct pfr_table)— required to pass the gate atpf_ioctl.c:2433beforepfr_get_tablesis reached. - Added reachability/caveat documentation to the PoC source.
Recommended fix (authored in fix.diff)
Two-layer defence at sys/net/pf/pf_table.c:pfr_fix_anchor:
1. NUL-termination check at entry: if (anchor[siz - 1] != '\0')
return (-1); — guarantees the loop's *++path reads cannot run
past the array (the NUL terminator is the loop's natural exit).
2. In-loop guard: while (off < siz && *++path == '/') plus
if (off >= siz) return (-1); — defense-in-depth in case the
NUL check is ever loosened.
This supersedes the finding markdown's proposed fix (which had the
same two ideas but a syntax error — an extra int i; line — and was
not validated against the actual headers). The authored diff is
git apply-clean (git apply --check passes), compiles into pf.ko
without warnings, and is verified by disassembly to produce the new
cmpb $0x0,0x3ff(%rdi); jne ... prologue.
Fix validation (Phase 8)
- Build:
cd /usr/src/sys/net/pf && make KERNCONF=X86_64_GENERICsucceeds (rc=0); produces patchedpf.ko(350368 B). Module-only build is sufficient because PF is a KLD module and the kernel proper is unchanged by this fix (avoiding thekernel.strippedloader-format issue seen with fullnativekernel). - Install: copy patched
pf.koover/boot/kernel/pf.ko,kldload pf. The running kernel stays at thewith-src#0 baseline. - AFTER (patched pf.ko): 3/3 PoC runs return
rc=-1cleanly. Zeroboot.logdelta. Guest stays up. - BEFORE (control, same boot): source reverted with
patch -R, unpatchedpf.korebuilt,kldunload+kldload, same PoC →Fatal trap 12, kernel reboots. (Full signature withmemmove+0x24f/pfr_get_tables+0x31captured earlier in session — seepanic.txt.) fix_status=fixed(clean before/after on the same kernel).
Confidence
certain — bug confirmed by trap-12 panic in memmove/pfr_get_tables,
root-caused line-by-line to pfr_fix_anchor:1751-1753, fix compiled and
validated by before/after on the same boot.
Fix verification
fixedVALIDATED: baseline Fatal trap 12 panic; patched pf.ko ioctl rc=-1 clean 3/3. Module rebuild.
BEFORE: panic. AFTER: rc=-1 clean.
Confirmed kernel references
Detail
Exploit chain
none -- root-only /dev/pf ioctl (kldload pf + root). No unpriv path. Jail-exposure scenario = realistic ceiling.
Evidence (decisive lines)
BEFORE: Fatal trap 12 memmove+0x24f pfr_get_tables+0x31, crash dump. AFTER: ioctl rc=-1 clean, 3/3 no panic.
PoC changes
Authored: poc.c (DIOCRGETTABLES with 1024 '/' anchor), fix.diff (NUL-terminate check + bound loop), VERDICT.md, manifest.json.
Verified recommended fix
(1) Reject non-NUL-terminated anchor at entry: if(anchor[siz-1]!=0) return -1; (2) Bound loop: while(off<siz && *++path=='/') off++. Full diff in findings/poc/DF-0362/fix.diff.
Verdict
REPRODUCED (live panic). pfr_fix_anchor pf_table.c:1751 while(*++path=='/') off++ unbounded. 1024 '/' in pfrt_anchor[1024] -> off=1025 -> bcopy(path,anchor,siz-off=1024-1025) size_t wraps to SIZE_MAX -> memmove OOB -> panic vm_fault stack guard. PF is KLD module (not in GENERIC), /dev/pf 0600 root:wheel.
No comments yet.