git: a94a6326507c - main - pf tests: exercise pfctl -l across a privilege drop

From: Kristof Provost <kp_at_FreeBSD.org>
Date: Fri, 30 Oct 2026 14:08:12 UTC
The branch main has been updated by kp:

URL: https://cgit.FreeBSD.org/src/commit/?id=a94a6326507c5a901adee15bf3844eaf60479ab1

commit a94a6326507c5a901adee15bf3844eaf60479ab1
Author:     Avinash Duduskar <avinash.duduskar@gmail.com>
AuthorDate: 2026-10-26 14:38:12 +0000
Commit:     Kristof Provost <kp@FreeBSD.org>
CommitDate: 2026-10-30 14:07:25 +0000

    pf tests: exercise pfctl -l across a privilege drop
    
    Open /dev/pf and a generic Netlink socket as root, drop to nobody and
    exec pfctl with both inherited, the way authpf(8) runs it. Without -l
    the load is refused with EPERM; with it the rules land in the anchor.
    An unusable descriptor is an error, not a fallback to a new socket.
    Skips when fdescfs is not mounted, which -p /dev/fd/N needs.
    
    PR:             291981
    Signed-off-by:  Avinash Duduskar <avinash.duduskar@gmail.com>
    Reviewed by:    kp
    Pull Request:   https://github.com/freebsd/freebsd-src/pull/2508
---
 tests/sys/netpfil/pf/Makefile       |  3 ++
 tests/sys/netpfil/pf/authpf.sh      | 75 +++++++++++++++++++++++++++++++++++++
 tests/sys/netpfil/pf/pfctl_adopt.py | 43 +++++++++++++++++++++
 3 files changed, 121 insertions(+)

diff --git a/tests/sys/netpfil/pf/Makefile b/tests/sys/netpfil/pf/Makefile
index c95bac83c92c..be3ac9f91486 100644
--- a/tests/sys/netpfil/pf/Makefile
+++ b/tests/sys/netpfil/pf/Makefile
@@ -5,6 +5,7 @@ TESTS_SUBDIRS+=	ioctl
 
 ATF_TESTS_SH+=	altq \
 		anchor \
+		authpf \
 		counters \
 		debug \
 		divert-to \
@@ -89,6 +90,7 @@ ${PACKAGE}FILES+=	\
 			frag-overreplace.py \
 			frag-overhole.py \
 			frag-adjhole.py \
+			pfctl_adopt.py \
 			pfsync_defer.py \
 			pft_ether.py \
 			pft_read_ipfix.py \
@@ -107,6 +109,7 @@ ${PACKAGE}FILESMODE_frag-overlimit.py=	0555
 ${PACKAGE}FILESMODE_frag-overreplace.py=	0555
 ${PACKAGE}FILESMODE_frag-overhole.py=	0555
 ${PACKAGE}FILESMODE_frag-adjhole.py=	0555
+${PACKAGE}FILESMODE_pfctl_adopt.py=	0555
 ${PACKAGE}FILESMODE_pfsync_defer.py=	0555
 ${PACKAGE}FILESMODE_pft_ether.py=	0555
 ${PACKAGE}FILESMODE_pft_read_ipfix.py=	0555
diff --git a/tests/sys/netpfil/pf/authpf.sh b/tests/sys/netpfil/pf/authpf.sh
new file mode 100644
index 000000000000..38cb80e94dc1
--- /dev/null
+++ b/tests/sys/netpfil/pf/authpf.sh
@@ -0,0 +1,75 @@
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 Avinash Duduskar <avinash.duduskar@gmail.com>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+#    notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+#    notice, this list of conditions and the following disclaimer in the
+#    documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+
+. $(atf_get_srcdir)/utils.subr
+
+atf_test_case "pfctl_adopt" "cleanup"
+pfctl_adopt_head()
+{
+	atf_set descr 'pfctl -l across a privilege drop, as authpf(8) runs it'
+	atf_set require.user root
+	atf_set require.progs python3
+}
+
+pfctl_adopt_body()
+{
+	pft_init
+
+	# pfctl is handed /dev/pf as -p /dev/fd/N, which needs fdescfs.
+	if ! ( exec 3</dev/null; [ -e /dev/fd/3 ] ); then
+		atf_skip "This test requires fdescfs mounted on /dev/fd"
+	fi
+
+	helper=$(atf_get_srcdir)/pfctl_adopt.py
+
+	vnet_mkjail alcatraz
+	jexec alcatraz pfctl -e
+
+	# Without -l pfctl opens its own, unprivileged socket (PR 291981).
+	echo "pass" | atf_check -s not-exit:0 -e ignore \
+	    jexec alcatraz ${helper} -a authpf_test -f -
+	atf_check -s exit:0 -o not-match:"authpf_test" \
+	    jexec alcatraz pfctl -q -sA
+
+	# An unusable descriptor is an error, not a fallback to a new socket.
+	atf_check -s exit:1 -e ignore \
+	    jexec alcatraz sh -c 'pfctl -l 3 -sr 3</dev/null'
+
+	echo "pass" | atf_check -s exit:0 \
+	    jexec alcatraz ${helper} -l -a authpf_test -f -
+	atf_check -s exit:0 -o match:"^pass all" \
+	    jexec alcatraz pfctl -q -a authpf_test -sr
+}
+
+pfctl_adopt_cleanup()
+{
+	pft_cleanup
+}
+
+atf_init_test_cases()
+{
+	atf_add_test_case "pfctl_adopt"
+}
diff --git a/tests/sys/netpfil/pf/pfctl_adopt.py b/tests/sys/netpfil/pf/pfctl_adopt.py
new file mode 100755
index 000000000000..480f6e58a1ed
--- /dev/null
+++ b/tests/sys/netpfil/pf/pfctl_adopt.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 Avinash Duduskar <avinash.duduskar@gmail.com>
+#
+# Run pfctl as authpf(8) does: open /dev/pf and a Netlink socket as root,
+# drop to nobody, exec pfctl with -p /dev/fd/N and, given -l, -l M.
+
+import os
+import pwd
+import socket
+import sys
+
+AF_NETLINK = 38         # sys/socket.h; not exported by the socket module
+NETLINK_GENERIC = 16    # netlink/netlink.h
+
+
+def main():
+    args = sys.argv[1:]
+    adopt = False
+    if args and args[0] == "-l":
+        adopt = True
+        args = args[1:]
+
+    dev = os.open("/dev/pf", os.O_RDWR)
+    os.set_inheritable(dev, True)
+    nl = socket.socket(AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC)
+    os.set_inheritable(nl.fileno(), True)
+
+    pw = pwd.getpwnam("nobody")
+    os.setgroups([pw.pw_gid])
+    os.setgid(pw.pw_gid)
+    os.setuid(pw.pw_uid)
+
+    argv = ["pfctl", "-p", "/dev/fd/%d" % dev]
+    if adopt:
+        argv += ["-l", str(nl.fileno())]
+    os.execv("/sbin/pfctl", argv + args)
+
+
+if __name__ == "__main__":
+    main()