Series0-dayNo CVEHighPublic

pfSense CE leftover interpolations · Phase 6

pfSense CE leftovers, part 7: what they do to each other

Thirty-nine pfSense CE leftovers are not thirty-nine islands. A limited page grant is already root on that page's sink. GUI XSS drives csrf-magic in the same origin. Config restore plants the RCE fields. Root PHP-FPM turns a file write into host execute. Lab-proven edges and inferred composition, labeled.

Name
pfSense CE leftovers, part 7: what they do to each other
Series
pfSense CE leftover interpolations
Phase
6 · Chains
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Netgate
Affected
pfSense CE 2.9.0-DEVELOPMENT public GitHub master 9363ac5. Composition of the 39 leftover labs plus the five already published shells. 2.9.0-RELEASE PHP was not on the public tree.
Published
15 Oct 2026
Updated
15 Oct 2026
Tags
0-day, pfsense, chains, rce, xss

They are not 39 islands

I am @abraxas_null. Parts 2 through 6 of this series walked sinks. This part walks edges. The pin is still pfSense CE 2.9.0-DEVELOPMENT (9363ac5b8651a1c7a333180425ce7719070f95f9). WebGUI PHP-FPM is still root. exec / mwexec is still /bin/sh -c. csrf-magic still sits on POST. A limited GUI user can still be granted a single page without Diagnostics: Command.

The leftover list is 39 new labs plus five already published shells. I mailed Netgate. No CVE numbers yet. AGPLv3 packs live under https://github.com/abraxas. This post is not another walk of every interpolation. It is what they do to each other.

A page grant is not "the admin already has root." It is root on that page's sink. A config restore is not "just backup." It is a blob that the RCE pages will later interpolate. A Medium XSS in the GUI origin is not cookie theft. csrf-magic is already in the DOM. SameSite=Strict does not stop same-origin JavaScript. The script POSTs whatever the victim can already open.

Some of those edges are lab-proven. Some are inferred from how the product is wired. I label them every time.

How to read this

The hub is part 1. Sink details live in the chapters:

  1. Part 1: the pin, skip list, privilege model.
  2. Part 2: ifconfig, pfctl, dpinger, crontab, Captive Portal HA.
  3. Part 3: php.ini, sshguard, mpd, Unbound, OpenVPN eval.
  4. Part 4: wizard, uploadconfig, ACB, LDAP name, readonly restore, WOL GET.
  5. Part 5: Captive Portal file manager, nginx alias, file://, DynDNS cache, voucher SQLi.
  6. Part 6: seven XSS leftovers plus $http_host.
  7. This part: composition.

Prev is part 6. The five already published shells sit on the same graph: QinQ, OpenVPN wizard CRLF, CARP VHID, limiter number, IPsec login_banner.

Privilege is a page, not "admin"

The product's mental model is the bug class. Operators treat a GUI login as "this person is already root," because Diagnostics: Command exists and PHP-FPM is root. That sentence is true for page-diagnostics-command. It is not true for a limited user who was granted one page.

isAllowedPage matches a URI against $_SESSION['page-match']. isAdminUID short-circuits the match for uid 0. Everyone else gets the pages they were given. csrf-magic still wants a token. The sink on that page still interpolates POST into /bin/sh -c as root.

Lab-proven: a session that holds only page-interfaces-wireless-edit already has root via ifconfig. Wireless clone stored $_POST['if'] with no allowlist. interface_wireless_clone interpolated the base into exec("/sbin/ifconfig wlan create wlandev {$baseif} ..."). POST if=ath0;w ran extra w and wrote PFSENSE-WIRELESS-CLONE-WITNESS. Diagnostics: Command was not granted and was not required.

The same shape repeats. Lab-proven: page-system-generalsetup writes timezone into php.ini auto_prepend_file (part 3). Lab-proven: page-system-advanced-admin writes sshguard THRESHOLD plus an extra BACKEND= that sshguard execs. Lab-proven: page-services-dnsresolver-acls writes ACL name/action CRLF include: that Unbound follows. Lab-proven: page-services-captiveportal-hasync splits unquoted pfctl -a on the HA host. Lab-proven: page-interfaces-ppps-edit adds an extra root crontab line. None of those pages is standard-warning-root. None of them needs Diagnostics: Command.

The grant is the privilege. If you can give a helpdesk user "just wireless" or "just DNS resolver ACLs," you have given them the sink. Form_Select is HTML. POST is a string. isAllowedPage never asked whether the string was a live object.

Inferred: the same operator who granted one page will grant two, because the mental model says the damage is already done. Two High leftovers compose without a new auth step. I did not lab a two-page user clicking both. I labbed each page in isolation. The composition is the privilege system doing what it says on the tin.

The name admin is the ATO hub

LDAP and RADIUS WebGUI logins skip the local username charset. getUserEntry($name) still returns the local row on a name match. isAdminUID then sees uid 0 with a loose compare.

PHP
function isAdminUID($username) {
	$user = getUserEntry($username);
	$user = $user['item'];
	if (isset($user)) {
		if (isset($user['uid'])) {
			if ($user['uid'] == 0) {
				return true;
			}
		}
	}
	return false;
}

isAllowedPage short-circuits on that true. Every page is allowed, including diag_command.php. passwordmg treats the session as local and writes that user's hash. uid 0's bcrypt changes.

Lab-proven on pfsense-ldap-name-collision: remote name admin is isAdminUID; all pages including Diagnostics: Command; passwordmg set the uid 0 hash. Stub-only remote names fail closed. usermanager itself is warn-root. SA-26_22 / CVE-2026-92182 is a different xmlrpc uid skip and stays skipped.

That is the account-takeover hub. One directory identity named admin becomes the local uid 0 row, then every leftover in this series, then the intended root shells. Sink details for the collision are in part 4.

The same remote username is also XSS. head.inc interpolates $_SESSION['Username'] raw into the logout title. passwordmg Form_StaticText is also raw. Local charset [^a-zA-Z0-9.-_] rejects the trigger. LDAP names skip it. Lab-proven on pfsense-auth-username-xss: quote-break raw on every authed page that includes head.inc. That is a stored-from-directory XSS in the GUI origin. csrf-magic is already on those pages. See part 6.

If the same directory identity is used as an OpenVPN RADIUS user, the username also lands in openvpn.connect_async.sh eval. Lab-proven on pfsense-ovpn-connect-eval: alice'; w; : ' ran extra w. Local GUI charset rejects that username. RADIUS does not. The injector is a VPN user, not the admin typing in the GUI. Composing "this LDAP admin is both a WebGUI login and an OpenVPN user" is inferred. The two sinks are lab-proven in isolation. Directories that reuse the same name for both are the product.

Restore is not "just backup"

Four leftovers replace config.xml or the uid 0 hash without Diagnostics: Command. Sink details: part 4.

Lab-proven: uploadconfig.php POST config= calls config_install() then system_reboot(). IDENT is page-hidden-uploadconfiguration. No warn-root. diag_backup.php is warn-root for the same class. GET without POST did not install. Reboot was requested.

Lab-proven: ACB newver downloads, decrypts with the stored ACB password, config_restore. Restore without newver did not. Local backup/history pages are warn-root. Optional userkey is another device's backup set. Inferred: the same page can view/download decrypted ACB XML.

Lab-proven: setup wizard step 7 getUserEntryByUID(0) plus local_user_set_password(). Privilege MATCH wizard.php* has no warn-root. xml= LFI is closed (../etc/passwd and absolute path rejected). Inferred: the same wizard rewrites WAN/LAN/DHCP/hostname and can lock the operator out of the LAN.

Lab-proven: Deny Config Write stops write_config. config_install and config_restore still replace config.xml. Readonly is a flag on the write helper, not on the blob install.

A hostile blob is not a curiosity. The RCE leftovers in part 2 and part 3 interpolate fields out of config. Timezone, Unbound ACL name/action, wireless if, limiter hidden number (already published), IPsec login_banner (already published), sshguard threshold, L2TP RADIUS secret, PPPoE ptpid, Captive Portal HA host, QinQ parent, CARP VHID. Plant those fields in the blob. Restore. The next apply, the next service restart, the next member-add interpolates them.

Lab-proven: each of those pages interpolates its field. Lab-proven: uploadconfig / ACB newver / wizard / readonly install replace config or the uid 0 hash. Inferred: a blob that already contains a hostile timezone, then php.ini rebuild, then auto_prepend_file. I did not lab "uploadconfig a timezone payload, wait for rc.php_ini_setup." I labbed the replace, and I labbed the timezone sink. The edge between them is the product reading config.xml.

That is why restore is High even when the page looks like backup. The blob is a delayed write to every leftover field.

Root PHP write is host execute on this platform

Captive Portal file manager does not basename() the upload name. A name that already contains captiveportal- skips the prefix. file_put_contents("{$g['captiveportal_element_path']}/{$data['name']}") with cpelements = /var/db/cpelements. Remaining ../ after captiveportal-.. still walks to /.

Lab-proven: name captiveportal-../../../../../oracle/... skipped the prefix and wrote outside cpelements. Privilege page-services-captiveportal-filemanager. Documented PHP inside the portal jail is a different feature. Names without the substring are prefixed and cannot escape that way. Sink details: part 5.

WebGUI PHP-FPM is root. PHP-FPM runs PHP from /usr/local/www and from whatever auto_prepend_file names. A root file write that can reach those trees is host RCE on this platform. Lab-proven is the write. Execute is inferred, because I did not restart php-fpm against a planted www file in that lab. I did lab timezone auto_prepend_file in part 3: generated php.ini accepted the prepend; php -c plus a php-fpm analog ran it and wrote PFSENSE-TIMEZONE-PHPINI-WITNESS.

The composition is obvious once both leftovers exist. File manager plants the prepended PHP. Timezone, or a planted php.ini, names it. Or the write lands directly on a www PHP file the GUI already includes. I am not going to draw the path bytes. The model is: root PHP writes files, root PHP runs files from those trees, the two facts are the same process.

Inferred: overwrite config.xml from the same write, then you do not even need uploadconfig. That recon/integrity step was not shown. The gadget wrote outside cpelements. Where you point ../ is the rest of the filesystem the UID can touch, which is root.

GUI XSS is a driver, not a page grant

Part 6 is seven Medium XSS leftovers plus an unauth $http_host 301. CVSS 5.4 looks like "reflected script, cookies httponly, SameSite=Strict, next." That is the wrong sentence on this product.

csrf-magic emits a hidden token into the GUI DOM on every POST page. Same-origin JavaScript can read it. SameSite=Strict stops a foreign site from attaching the cookie on a cross-site POST. It does not stop a script that is already running as the GUI origin. The cookie goes with the request because the request is first-party.

Lab-proven XSS in the GUI origin:

Inferred: any of those scripts POSTs a leftover page the victim can already open. Wireless edit. Timezone. Unbound ACL. uploadconfig. Captive Portal file manager. The High leftover does not need a new grant. It needs the victim's existing page-match plus a token the script can see.

This upgrades Medium XSS into a driver for High leftovers the victim's role already includes. It does not by itself grant a page the victim lacks. isAllowedPage still runs on the POST. A helpdesk user who cannot open interfaces_wireless_edit.php cannot be driven into that sink by XSS. A helpdesk user who can open it, and who loads crash reporter or firewall logs or a dashboard RSS widget, can.

Unauth N/A on the GUI XSS leftovers. Cookie httponly. csrf-magic still required for POST. The closed list is real. The leftover is the driver sitting inside the origin those controls were built for.

Captive portal is a plant surface and an unauth victim surface

Five leftovers share the portal. Sink details: part 5 and the HA host in part 2.

Lab-proven plant, GUI privilege: file manager path write (page-services-captiveportal-filemanager). Filename stored XSS on the unauth portal (same page plants, portal clients fetch). maxprocperip extra location / alias inside the portal server{} (page-services-captiveportal, then unauth GET on the portal listener). HA host into pfctl (page-services-captiveportal-hasync). Voucher expire SQLite WHERE (page-status-captiveportal-expire / vouchers).

The cluster is the point. A GUI user who owns the portal pages can:

  1. Write a file outside cpelements as root PHP. Lab-proven write. Inferred execute.
  2. Plant a filename that becomes onerror= on the default login HTML. Lab-proven raw in unauth portal HTML. GUI list encoded the same name. Portal origin is not the GUI origin. Closed: GUI cookies. SA-26_05 is the GUI widget, different leftover.
  3. Inject location /p { alias /oracle/; } into the portal vhost. Lab-proven: nginx -t passed; unauth GET returned the planted file on the portal listener, not the GUI. Host RCE not shown. GUI nginx (captive_portal=false) stayed closed.
  4. Split pfctl -a on the HA host. Lab-proven extra w. Inferred: peer blob / MITM plant of that host, not this gadget.
  5. UNION another sessionid and stacked-UPDATE rewrite that row in captiveportal{zone}.db. Lab-proven. Unauth login INSERT stayed escaped. No shell. No other-zone DB. No GUI cookie.

Inferred: filename XSS on the portal login reads auth_user / auth_pass on that page. That is portal credentials, not GUI cookies. The victim is an unauth client who opened the captive portal. The plant required a GUI page. The victim surface is unauth.

That is why I keep calling it a cluster. One role owns file manager, portal settings, HA sync, and voucher expire. The unauth listener then serves what that role planted. File write, XSS, nginx alias, SQLi, pfctl. Different CWEs. Same object.

file:// is recon without a shell

Custom DynDNS / Check IP and RSS widget SimplePie read files as root. Lab-proven: file:///oracle/secret.xml body returned on Custom DynDNS and Check IP. libcurl 7.88.1 FILE works. CURLOPT_INTERFACE bind did not block FILE. Custom updateurl has no is_URL(). Check IP uses is_URL() with $httponly default false. Neither curl sets CURLOPT_PROTOCOLS.

Lab-proven: RSS direct file:// is rewritten to http by SimplePie fix_protocol(). Recursive 3xx Location: file:/// does not call fix_protocol() and file_get_contents the path as root. HTTP 302 returned planted XML. Privilege page-dashboard-all / widgets MATCH.

Integrity: none. Read only. Availability: none shown. Documented HTTP(S) operator URL is the feature.

The RCE leftovers in parts 2 and 3 would also read config.xml once they have a shell. Inferred: file:// of /cf/conf/config.xml (or the live config path) is the same bytes without the shell. I labbed an oracle file, not the live config path in that gadget. The gadget has no path allowlist. The file the RCE leftovers would dump is in scope.

DynDNS cache path is a sibling write, not a read: escapeshellarg used as a filename lets / split /conf/dyndns_*. Lab-proven: hostname with / wrote IP|epoch outside /conf. Content is not a shell. Inferred: overwrite a file whose name matches the quoted suffix. Widgets MATCH and DynDNS are the recon pair. They do not need Diagnostics: Command. They do not need ifconfig. They need a page that was supposed to fetch a URL.

Two remote-user RCEs that do not need a WebGUI account

OpenVPN connect_async eval interpolates the RADIUS username into eval "/sbin/pfctl -a '${anchorname}' ..." with anchorname=openvpn/${dev}_${username}_${trusted_port}. Single quotes do not contain the username after expand. Lab-proven: extra w. Local GUI charset rejects that username. RADIUS/LDAP usernames skip it. Privilege is OpenVPN server_user / server_tls_user client. Not a WebGUI page.

OpenVPN learn-address CN interpolates the cert CN into su -m unbound -c "... local_data ${CN}.${DOMAIN} ..." unquoted. Gated on Unbound Register OpenVPN clients. Lab-proven: CN $(w) ran extra w as the su -c string. CONTROL also planted unsanitized local-data. Inferred: commands as unbound, not root PHP-FPM. Off unless regovpnclients is on.

These two do not sit behind isAllowedPage. The injector is a VPN user. The admin typed nothing in the GUI except, at some earlier time, "use RADIUS" or "register OpenVPN clients." That is a different edge from "limited GUI user gets root on wireless-edit." It is also why the LDAP name collision in part 4 matters twice: the same directory name is a WebGUI ATO hub and an OpenVPN eval injector, if the deployment uses it for both.

Inferred: a hostile CN that is only local-data (no shell char) still poisons resolver records. CONTROL planted that. Availability of DNS for VPN clients follows. I did not lab a full resolver outage.

Wizard OpenVPN CRLF is the already-published sibling (G2). That one does need page-pfsensewizardsubsystem. SA-26_07 named vpn_openvpn_server.php only. The wizard still writes dns_domain with no is_domain(). Three OpenVPN leftovers, three privileges, one daemon.

WOL GET is a small mutator. XSS makes it first-party.

Wake-on-LAN all devices still has $_REQUEST['wakeall'] and href="services_wol.php?wakeall=true" without usepost. Redmine #16961 is notes-only on this public tree. Lab-proven: GET ?wakeall=true sent wol for both stored MACs. CONTROL GET without wakeall sent none. Availability against stored WOL targets. Privilege page-services-wakeonlan. Session required.

Closed: unauth without a session cookie. SameSite=Strict still applies to CSRF from a foreign site. csrf-magic was the POST fix. GET was the leftover.

GUI XSS from part 6 is same-origin JavaScript. A same-origin GET does not need csrf-magic. It does not need SameSite help. The script requests services_wol.php?wakeall=true. The cookie goes. The stored MACs wake.

Lab-proven: the GET mutator. Lab-proven: GUI XSS in the same origin. Inferred: the composition. I did not lab "crash reporter script fetches wakeall." I labbed both ends. The edge is a GET the browser will make for any script in that origin, if the victim's page-match includes WOL.

Small. Availability, not RCE. It is here because it is a leftover of a CSRF fix that still mutates on GET, and because the XSS leftovers exist to drive it.

The graph

Plain text
flowchart TD
  pageGrant["Limited page grant"] --> wireless["wireless ifconfig RCE"]
  pageGrant --> timezone["timezone auto_prepend"]
  pageGrant --> sshguard["sshguard BACKEND"]
  pageGrant --> unboundAcl["Unbound ACL include"]
  pageGrant --> cpPages["CP filemgr / HA / maxproc"]
  ldapAdmin["LDAP/RADIUS name admin"] --> isAdmin["isAdminUID every page"]
  isAdmin --> diag["Diagnostics: Command"]
  isAdmin --> passwordmg["passwordmg uid 0 hash"]
  ldapAdmin --> headXss["head.inc username XSS"]
  ldapAdmin --> ovpnEval["OpenVPN RADIUS eval"]
  restore["uploadconfig / ACB / wizard / readonly"] --> blob["hostile config.xml fields"]
  blob --> timezone
  blob --> unboundAcl
  blob --> wireless
  blob --> g1g5["QinQ / CARP / limiter / IPsec / wizard CRLF"]
  cpWrite["CP file manager write"] --> prepend["plant auto_prepend or www PHP"]
  prepend --> hostRce["inferred host RCE"]
  timezone --> hostRce
  guiXss["GUI XSS + csrf-magic in DOM"] --> pageGrant
  guiXss --> wol["WOL GET wakeall"]
  cpPages --> portalXss["portal filename XSS"]
  cpPages --> nginxAlias["portal nginx alias"]
  cpPages --> voucher["voucher SQLi"]
  cpPages --> pfctl["CP HA pfctl"]
  fileRead["DynDNS / RSS file://"] --> recon["read config.xml without a shell"]
  ovpnCn["OpenVPN cert CN + regovpnclients"] --> unboundSu["unbound su -c"]
From leftover Enables leftover Provenance
page-interfaces-wireless-edit (and timezone, sshguard, Unbound ACL, CP HA, PPPoE cron, ...) root on that page's sink Lab-proven per page. Diagnostics: Command not required.
LDAP/RADIUS WebGUI name admin isAdminUID, every page including diag_command, passwordmg uid 0 hash Lab-proven
Same remote username head.inc XSS on every authed page Lab-proven
Same name as OpenVPN RADIUS user connect_async eval Lab-proven VPN sink. Dual-use of the name is inferred.
uploadconfig / ACB newver / wizard / readonly config_install hostile timezone, Unbound ACL, wireless if, limiter, IPsec banner, QinQ parent, CARP VHID in config.xml Replace lab-proven. Fire of planted RCE fields inferred.
CP file manager path write plant auto_prepend target or www PHP Write lab-proven. Execute inferred.
Timezone php.ini auto_prepend_file runs as root PHP Lab-proven prepend analog.
GUI XSS (logs, rules if, separator, crash reporter, RSS body, auth username) POST any leftover page the victim can already open; csrf-magic is in the DOM XSS lab-proven. Driver inferred. Does not grant a missing page.
GUI XSS GET services_wol.php?wakeall=true GET mutator lab-proven. Composition inferred.
CP file manager filename XSS on unauth portal HTML Plant privilege + raw portal HTML lab-proven. GUI cookies closed.
CP maxprocperip unauth file read on portal vhost via extra alias Lab-proven. Host RCE not shown.
voucher expire SQLi other sessions on that zone DB Lab-proven UNION/UPDATE.
DynDNS / Check IP / RSS file:// recon of host files including the config the RCE leftovers would dump File body lab-proven. config.xml specifically inferred.
OpenVPN cert CN with Register OpenVPN clients su -m unbound -c Lab-proven. Off unless gated.
G1-G5 (QinQ, wizard OpenVPN CRLF, CARP, limiter number, IPsec banner) same page-grant graph; restore can plant their fields Already published, lab-proven individually.

What stays closed

I am not inflating this series into unauth WebGUI RCE. There is none on this pin. Unauth Critical unpublished: none.

Diagnostics: Command is not required for the High leftovers. It is the intended root shell. The leftovers are the pages that are not standard-warning-root and still interpolate. LDAP name collision does grant Diagnostics: Command. That is the ATO hub, not the typical limited-user path.

xml= LFI on the wizard is closed. ../etc/passwd and absolute path rejected. Privilege leftover is uid 0 password reset, not file include.

Password ! on the PPPoE GUI is rejected and ltrimmed. The leftover is username CR/LF plus an ! helper line in mpd.secret. Browser type=text strips newlines. Direct POST keeps them. L2TP username charset is the one PPPoE should have copied.

VLAN parent shell metachar is rejected. ifconfig vlan create uses escapeshellarg. The leftover is ioctl of a live lagg member hidden from $portlist, not shell. Missing parent rejected.

M9 logfile AJAX FAIL: the serializer drops the message. Confused-deputy cat is real. The GUI does not return it. I packed 39 SUCCESS labs, not that FAIL.

PHP DNS-rebind on the HTTPS GUI itself returns 501. The leftover is the port-80 nginx return 301 https://$http_host... that runs before php-fpm, so auth.inc never sees the Host. Lab-proven: Host evil.example became Location https://evil.example/index.php.

Captive Portal filename XSS does not steal GUI cookies. Portal origin is not the GUI origin. That closed line is load-bearing. Portal XSS still reads portal fields. It is not a GUI session.

Unauth plant of the portal leftovers is closed. Someone with the GUI page plants. Unauth clients fetch.

SameSite=Strict still applies to foreign-site CSRF. WOL GET is a same-origin leftover, not a cross-site cookie bypass.

G1-G5 already sit on this graph

The five leftovers I published on 13 Oct 2026 are not a different product.

This series is the rest of the graph. Restore plants G1-G5 fields. XSS drives G1-G5 pages the victim already has. LDAP admin becomes every page, including those five. Limited grants of QinQ or limiter or IPsec mobile were already root last week. They still are.

What an attacker can do

Impact in the product's own nouns, not a pasteable recipe.

A limited GUI user with one leftover page runs root commands on that page's sink. Lab-proven for wireless, assign, dpinger, timezone prepend analog, sshguard backend, L2TP mpd up-script, PPPoE cron, PPPoE ! helper, CP HA pfctl, Unbound include, OpenVPN eval, OpenVPN learn-cn. Not Diagnostics: Command.

A remote directory user named admin is the local uid 0 row. Lab-proven all pages plus hash change. Inferred: from there, every other leftover, including the intended root shells.

A GUI user who can restore or install a config blob replaces config.xml and, on uploadconfig, requests reboot. Lab-proven install. Inferred: the blob's leftover fields fire later on apply.

A GUI user who can write Captive Portal files writes outside cpelements as root PHP. Lab-proven write. Inferred host execute via prepend or www PHP.

A GUI XSS victim with csrf-magic in the DOM is driven into leftover POSTs they could already issue. Lab-proven script in GUI origin. Inferred driver. No new page grant.

An unauth portal client receives planted XSS, planted nginx alias reads, and (if a GUI user SQLi'd vouchers) a rewritten session. Lab-proven each. GUI cookies closed.

A DynDNS or dashboard-RSS user reads host files through file://. Lab-proven body return. Inferred config.xml.

An OpenVPN RADIUS user or a cert CN (with register-clients on) runs host commands without a WebGUI account. Lab-proven.

A WOL page plus GUI XSS wakes stored MACs on GET. Lab-proven mutator. Inferred composition.

Unauth WebGUI RCE: none shown. Foreign-site CSRF of POST: csrf-magic plus SameSite=Strict still apply.

Fixes at the model layer

Page-level patches that allowlist one field and leave the sibling raw are how this graph grew. The fix is the model.

Allowlist POST against live objects. Wireless parent, assign if_add, VLAN parent, QinQ parent, CARP VHID 1-255, limiter number, separator color, NTP fudge, IGMP type, sshguard integers. Form_Select is not a validator. $portlist membership is. does_interface_exist is not membership.

escapeshellarg every value that still reaches /bin/sh -c. Prefer argv arrays. mwexec of a conf path is fine. mwexec of a concatenated VHID or monitor or ptpid is not. Stop eval in openvpn.connect_async.sh. Pass the pfctl anchor as a literal argv. Quote CN in learn-address. Keep local-data through unbound-control without a shell.

checkconf before reload. Unbound Advanced and ACL save skip test_unbound_config. General Settings checkconf does not cover those fields. nginx -t on Captive Portal server{} should reject extra location / alias, and maxprocperip should be is_numericint.

is_URL($url, true) plus CURLOPT_PROTOCOLS CURLPROTO_HTTP|CURLPROTO_HTTPS on DynDNS, Check IP, RSS. Do not follow file: Location. Do not use escapeshellarg as a filename.

JSON_HEX_TAG|JSON_HEX_AMP and Content-Type: application/json on logs resolve. htmlspecialchars session username in head.inc, crash blobs in the textarea, RSS body, permalink allowlist http(s), separator color allowlist, firewall if allowlist.

No $http_host in the port-80 301. Use a configured hostname. PHP DNS-rebind already knows this. nginx currently wins the race.

isAdminUID must not key off the string admin. Do not map a remote name onto a local uid 0 row. Strict uid === '0' plus auth source. passwordmg should be warn-root. Filter RADIUS usernames the same as local.

config_install and config_restore must honor user-config-readonly. Give uploadconfig.php and page-services-acb standard-warning-root, or remove the hidden page. Drop uid 0 password from the setup wizard after first boot, or mark wizard.php standard-warning-root.

GET mutators die. wakeall is POST plus usepost. #16961 should not be notes-only.

Captive Portal file manager: basename(), always prefix, reject ... Filename htmlspecialchars in portal HTML. Bind voucher SQL parameters.

None of that is a new idea. VLAN parent already does existence. PPP client already does get_mpd_str. IPsec mobile dns_domain already does is_domain(). diag_backup.php already does warn-root. The leftovers are the siblings that did not get the copy.

GitHub index

Loopback gadgets, AGPLv3. Not a full CE box. Witness-only.

Five already published:

Thirty-nine in this series:

Product tree: https://github.com/pfsense/pfsense/tree/9363ac5b8651a1c7a333180425ce7719070f95f9. Advisories I skipped rather than rediscover: https://docs.netgate.com/advisories/index.html.

References

Abraxas Labs https://abraxaslabs.tech https://github.com/abraxas https://x.com/abraxas_null (@abraxas_null) abraxas.null@proton.me