DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0362

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] = '/':

  1. The while (*++path == '/') loop reads anchor[1] through anchor[1023] (all /), then reads anchor[1024] which is pfrt_name[0] = /one byte past the array boundary.
  2. off becomes 1025, exceeding siz (1024).
  3. siz - off: siz is size_t (unsigned 64-bit), off is int (1025). The int is promoted to size_t, then 1024 - 1025 wraps to 0xFFFFFFFFFFFFFFFF (~2^64).
  4. bcopy(path, anchor, 0xFFFFFFFFFFFFFFFF) attempts to copy ~2^64 bytes — an immediate out-of-bounds read from path and write to anchor that 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/pf descriptor. /dev/pf is mode 0600 root:wheel by default, but is frequently exposed to jails for firewall management.
  • Reachability: DIOCRGETTABLES ioctl calls pfr_get_tables() (pf_table.c:1280) which calls pfr_fix_anchor(filter->pfrt_anchor) directly with no prior anchor validation. DIOCRGETTABLES is:
  • 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) via DIOCRADDADDRS, DIOCRDELADDRS, DIOCRSETADDRS, etc. (the attacker sets pfrt_name = {'/', 0, ...} which passes all name validation checks at lines 1719-1727 before pfr_fix_anchor runs).

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 at pf_ioctl.c:1013).
  • In jail configurations where /dev/pf is exposed, a jailed root can panic the host kernel, breaking jail isolation.
  • The size_t wraparound means bcopy corrupts 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.

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_anchor issue (anchor not NUL-terminated) in 2014.
  • DIOCRGETTABLES is defined in sys/net/pf/pfvar.h.

Timeline

  • 2026-07-01 Discovered during automated audit.
  • 2026-07-01 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0362 · 14 files
FileTypeDescriptionSize
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
README.md readme finding summary, reachability analysis, build/run, expected output
↓ download 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 in dfbsd-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.shcc -o poc poc.c.
  • run.shkldload pf && ./poc (root).
  • VERDICT.md — full analysis.
  • fix.diffgit apply-able fix (bound loop + NUL check).
  • manifest.json — artifact catalog.
VERDICT.md verdict full root-cause + reachability + exploit-chain-blocker + fix validation
↓ download raw

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)

  1. Reach. DIOCRGETTABLES ioctl on /dev/pf (pf_ioctl.c:2430). pfrio_esize == sizeof(struct pfr_table) gate at pf_ioctl.c:2433, then pfr_get_tables(&io->pfrio_table, ...) at pf_ioctl.c:2437.
  2. Sink. pfr_get_tables() calls pfr_fix_anchor(filter->pfrt_anchor) at pf_table.c:1280 with no prior validation of the anchor.
  3. 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>siz With pfrt_anchor[0..1023]='/' and pfrt_name[0]='/', the loop walks pfrt_anchor[1..1023] (all /) then reads pfrt_name[0] (the byte immediately past the array — pfvar.h:1037-1038 lays pfrt_anchor[1024] directly in front of pfrt_name[32]). off reaches 1025.
  4. Wrap. siz(1024) - off(1025): off (int) is promoted to size_t (unsigned 64-bit) before the subtraction, so the result wraps to 0xFFFFFFFFFFFFFFFF. bcopy is memmove on x86_64 (sys/platform/pc64/x86_64/support.S), implemented as rep movsb which 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_GENERIC has no device pf). It is built only as a KLD module (sys/net/pf/Makefile, KMOD=pf).
  • /dev/pf does not exist on a default boot. It is created only after kldload pf (root-only) via pf_load() → make_dev(&pf_ops, 0, UID_ROOT, GID_WHEEL, 0600, PF_NAME) (pf_ioctl.c:3360) — i.e. mode 0600 root:wheel.
  • pfsync does not call any pfr_* table routine (if_pfsync.c has zero references to pfr_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

  1. Use the guest's installed <net/pf/pfvar.h> (via <net/if.h> for IFNAMSIZ) so struct pfioc_table/pfr_table/DIOCRGETTABLES are byte-accurate. The scaffolded PoC had pfrio_buffer as int (should be void *) and used ioctl number 66 (= DIOCRCLRADDRS) instead of 63 (= DIOCRGETTABLES).
  2. Set pfrio_esize = sizeof(struct pfr_table) — required to pass the gate at pf_ioctl.c:2433 before pfr_get_tables is reached.
  3. Added reachability/caveat documentation to the PoC source.

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_GENERIC succeeds (rc=0); produces patched pf.ko (350368 B). Module-only build is sufficient because PF is a KLD module and the kernel proper is unchanged by this fix (avoiding the kernel.stripped loader-format issue seen with full nativekernel).
  • Install: copy patched pf.ko over /boot/kernel/pf.ko, kldload pf. The running kernel stays at the with-src #0 baseline.
  • AFTER (patched pf.ko): 3/3 PoC runs return rc=-1 cleanly. Zero boot.log delta. Guest stays up.
  • BEFORE (control, same boot): source reverted with patch -R, unpatched pf.ko rebuilt, kldunload+kldload, same PoC → Fatal trap 12, kernel reboots. (Full signature with memmove+0x24f / pfr_get_tables+0x31 captured earlier in session — see panic.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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline Fatal trap 12 panic; patched pf.ko ioctl rc=-1 clean 3/3. Module rebuild.

BEFORE: panic. AFTER: rc=-1 clean.
↓ fix.diff6.5-DEVELOPMENT #0 (module-only fix, patched pf.ko)

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.