Series0-dayNo CVEHighPublic

pfSense CE leftover interpolations · Phase 3

pfSense CE leftovers, part 4: wizard, backup, and the name admin

pfSense CE WebGUI pages that are not User Manager still rewrite the uid 0 hash, install a config blob, or restore Auto Config Backup. A remote LDAP/RADIUS name admin is isAdminUID. Deny Config Write does not bind config_install. Wake-on-LAN all devices is still a GET.

Name
pfSense CE leftovers, part 4: wizard, backup, and the name admin
Series
pfSense CE leftover interpolations
Phase
3 · Config takeover
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Netgate
Affected
pfSense CE 2.9.0-DEVELOPMENT (9363ac5) LDAP/RADIUS name collision, setup wizard step 7, hidden uploadconfig, Auto Config Backup restore, user-config-readonly vs config_install, services_wol.php GET wakeall.
Published
15 Oct 2026
Updated
15 Oct 2026
Tags
0-day, pfsense, privilege, wizard, ldap, series

The box is a file named config.xml

I am @abraxas_null. This is part 4 of pfSense CE leftover interpolations. Previous: conf breakouts. Next: files and portal.

Parts 2 and 3 were interpolations into ifconfig and into daemon language. This part is the file that those interpolations read. config.xml is the device. The uid 0 bcrypt row is the device. A page that can replace either one does not need Diagnostics: Command. It does not need standard-warning-root either, which is the leftover.

This is pfSense CE 2.9.0-DEVELOPMENT (9363ac5b8651a1c7a333180425ce7719070f95f9). No CVE yet. WebGUI PHP-FPM is root. csrf-magic sits on POST. isAllowedPage sits on the URI. A limited GUI user can be granted a single page. User Manager is page-system-usermanager and it is warn-root. The setup wizard is not. Hidden uploadconfig is not. Auto Config Backup is not. passwordmg is not.

I mailed Netgate. Packs are AGPLv3. 2.9.0-RELEASE PHP was not on the public tree. Authorized lab only. Loopback gadgets.

The six leftovers in this phase are not the same class. Three rewrite uid 0 or the whole config from a named page. One is a remote directory name that collides with the local admin row. One is Deny Config Write that forgot config_install. One is a GET mutator that still wakes every stored MAC. They sit together because each one is "I was not supposed to be able to change the box from this privilege," and because they feed the interpolations in the other parts.

How to read this

  1. Part 1 - the pin, skip list, map.
  2. Part 2 - ifconfig / pfctl / dpinger.
  3. Part 3 - php.ini, sshguard, mpd, Unbound, OpenVPN.
  4. Part 4 (you are here) - wizard, backup, the name admin.
  5. Part 5 - captive portal files, file://, nginx.
  6. Part 6 - XSS and $http_host.
  7. Part 7 - what they do to each other.

The name admin

Class / Reach: Privilege leftover / Remote CWE / CVSS: CWE-269 / 8.8 High CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Privilege: remote WebGUI login when authmode != Local. passwordmg is not warn-root. Pack: abraxas/pfsense-ldap-name-collision (loopback client). Port 19010. Witness PFSENSE-LDAP-NAME-COLLISION-WITNESS.

pfSense can authenticate the WebGUI against LDAP or RADIUS. That is the feature. The leftover is what happens when the remote directory contains a user named admin.

getUserEntry($name) returns the local row on a name match. isAdminUID() then sees uid 0. isAllowedPage allows every page. passwordmg treats the session as local and writes that user's hash. uid == 0 is a loose compare.

The lab is a stub directory, not a real LDAP box. Stub-only remote names that do not collide fail closed: not admin, no diag_command, hash unchanged. The colliding name admin is isAdminUID, every page including Diagnostics: Command, and passwordmg set the uid 0 hash. uid-loose-eq=yes.

Method Sink Privilege
Remote WebGUI login as LDAP/RADIUS name admin getUserEntry name match then isAdminUID Integrity (lab-proven): session is uid 0. All pages including diag_command.
Same session, passwordmg local user row hash Integrity (lab-proven): uid 0 bcrypt replaced.
Same session isAllowedPage Recon (lab-proven): allow flag for every page.

Closed: stub-only names that do not collide. User Manager itself is warn-root. SA-26_22 / CVE-2026-92182 xmlrpc LDAP/RADIUS uid skip is a different bug and already public.

Fix: do not map a remote name onto a local uid 0 row. Use strict uid === '0' plus auth source. Make passwordmg warn-root.

This is the ATO hub for the series. After the hash is yours, every leftover in parts 2 through 6 is in reach, including the intended root shell. The same remote username also shows up in part 6 as a logout-attribute XSS, and in part 3 as an OpenVPN RADIUS username that evals. One directory object. Three leftovers. The collision is the one that ends the conversation about "limited GUI user."

Last line:

Plain text
SUCCESS PFSENSE-LDAP-NAME-COLLISION control-isadmin=no control-diag=no control-hash-unchanged=yes trigger-isadmin=yes trigger-all-pages=yes trigger-hash-set=yes uid-loose-eq=yes PFSENSE-LDAP-NAME-COLLISION-WITNESS

Setup wizard step 7 still resets uid 0

Class / Reach: Privilege leftover / Remote CWE / CVSS: CWE-269 / 8.8 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Privilege: page-pfsensewizardsubsystem Pack: abraxas/pfsense-wizard-uid0 (loopback client). Port 19020. Witness PFSENSE-WIZARD-UID0-WITNESS.

The setup wizard is how a fresh box becomes a router. After first boot it is still a WebGUI page. wizard.php?xml=setup_wizard.xml step 7 calls getUserEntryByUID(0) plus local_user_set_password(). The privilege MATCH is wizard.php*. There is no standard-warning-root. User Manager does the same class of write and is warn-root. The wizard is the leftover.

I looked at xml= first because wizard paths are a classic LFI. xml= is realpath-prefixed under /usr/local/www/wizards. ../etc/passwd rejected. Absolute path rejected. lfi-closed=yes. The leftover is not include. The leftover is step 7 still being allowed to rewrite uid 0 after the box is already somebody's firewall.

Method Sink Privilege
Wizard step 7 POST new password local_user_set_password on uid 0 Integrity (lab-proven): uid 0 bcrypt changed.
Same wizard WAN/LAN/DHCP/hostname fields Recon / availability (inferred): rewrite the network the operator still thinks they own.

Closed: xml= realpath prefix. page-system-usermanager warn-root for the same hash write.

Fix: mark wizard.php standard-warning-root, or drop uid 0 password from the setup wizard after first boot.

The already-published OpenVPN wizard CRLF is a sibling on the same privilege family: wizard pages that keep doing first-boot work. One plants up into OpenVPN. This one plants a password into uid 0. Grant page-pfsensewizardsubsystem and you have both conversations.

Last line:

Plain text
SUCCESS PFSENSE-WIZARD-UID0 lfi-closed=yes control-no-hash-change=yes trigger-uid0-hash-change=yes wizard-no-warn-root=yes usermanager-warn-root=yes PFSENSE-WIZARD-UID0-WITNESS

Hidden uploadconfig

Class / Reach: Privilege leftover / Remote CWE / CVSS: CWE-269 / 8.8 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Privilege: page-hidden-uploadconfiguration Pack: abraxas/pfsense-uploadconfig (loopback client). Port 19030. Witness PFSENSE-UPLOADCONFIG-WITNESS.

diag_backup.php is warn-root. That is the page operators know. uploadconfig.php is the same install-and-reboot with a privilege name that says hidden. POST config= calls config_install() then system_reboot(). IDENT has no warn-root.

GET without POST did not install. That is the control. POST installed the blob and set reboot. ident=page-hidden-uploadconfiguration. warn-root=absent.

Method Sink Privilege
POST uploadconfig.php config= config_install then system_reboot Integrity (lab-proven): planted config.xml. Availability (lab-proven): reboot requested.
GET the same URI no install Closed (lab-proven): GET is not enough.

Fix: give uploadconfig.php standard-warning-root, or remove the hidden page.

A hidden page is how leftovers survive a review that greps for Backup. The privilege string is the tell. Once you can install a blob, you can plant every interpolation in parts 2 and 3: a hostile timezone, a hostile Unbound ACL name, a hostile wireless if, a hostile IPsec banner. Restore is not "just backup." Restore is a compiler for the rest of the series. That composition is part 7. The lab here only claims install plus reboot.

Last line:

Plain text
SUCCESS PFSENSE-UPLOADCONFIG control-no-install=yes trigger-install-reboot=yes ident=page-hidden-uploadconfiguration warn-root=absent PFSENSE-UPLOADCONFIG-WITNESS

Auto Config Backup restore

Class / Reach: Privilege leftover / Remote CWE / CVSS: CWE-269 / 8.8 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Privilege: page-services-acb Pack: abraxas/pfsense-acb-restore-priv (loopback client). Port 19040. Witness PFSENSE-ACB-RESTORE-PRIV-WITNESS.

Auto Config Backup is the cloud-ish sibling of local backup. newver downloads, decrypts with the stored ACB password, config_restore. Optional userkey is another device's backup set. Local backup and history pages are warn-root. page-services-acb is not.

Restore without newver did not replace the file. newver restore replaced config.xml with the planted revision. IDENT has no warn-root.

Method Sink Privilege
ACB restore with newver config_restore Integrity (lab-proven): config.xml replaced.
ACB restore without newver same function not taken Closed (lab-proven): no restore.
Same page, decrypted XML / userkey download Recon (inferred): view another device set.

Closed: diag_backup.php / diag_confbak.php warn-root.

Fix: mark page-services-acb standard-warning-root.

Same story as hidden uploadconfig, different door. The stored ACB password is already on the box. The leftover is that a non-warn-root page can pull a revision and install it. If you can choose userkey, you can choose someone else's backup set. I did not claim that as lab-proven. The proven edge is newver vs not.

Last line:

Plain text
SUCCESS PFSENSE-ACB-RESTORE-PRIV ident-no-warn-root=yes no-newver-no-restore=yes newver-restore=yes PFSENSE-ACB-RESTORE-PRIV-WITNESS

Deny Config Write forgot config_install

Class / Reach: Privilege leftover / Remote CWE / CVSS: CWE-863 / 7.1 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L Privilege: a page that can call config_install / config_restore while Deny Config Write is set Pack: abraxas/pfsense-config-readonly-restore (loopback client). Port 19200. Witness PFSENSE-CONFIG-READONLY-RESTORE-WITNESS.

user-config-readonly is the flag operators set so a GUI user can look and not save. write_config honors it. config_install and config_restore replace config.xml without that check.

The lab set Deny Config Write, then called both install and restore. write-denied=yes. install-replace=yes. restore-replace=yes.

Method Sink Privilege
write_config with readonly denied Closed (lab-proven): ordinary saves stay denied.
config_install with readonly replace config.xml Integrity (lab-proven): file replaced.
config_restore with readonly replace config.xml Integrity (lab-proven): file replaced.

Fix: honor user-config-readonly in config_install and config_restore.

This is the confused-deputy of a control that looks complete. The operator checked the box. write_config agreed. The two functions that actually swap the file did not get the memo. Pair it with hidden uploadconfig or ACB and the readonly user is not readonly.

Last line:

Plain text
SUCCESS PFSENSE-CONFIG-READONLY-RESTORE write-denied=yes install-replace=yes restore-replace=yes PFSENSE-CONFIG-READONLY-RESTORE-WITNESS

Wake-on-LAN all devices is still a GET

Class / Reach: CSRF / Remote CWE / CVSS: CWE-352 / 4.3 CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N Privilege: page-services-wakeonlan (session; GET mutator) Pack: abraxas/pfsense-wol-get-all (loopback client). Port 19300. Witness PFSENSE-WOL-GET-ALL-WITNESS.

I put a Low leftover in a High-looking part because it is the same mistake as "this page is not supposed to change the world." services_wol.php still has $_REQUEST['wakeall'] and href="services_wol.php?wakeall=true" without usepost. #16961 is notes-only on this public tree.

GET without wakeall sent no packets. GET ?wakeall=true sent wol for both stored MACs. Unauth without a session cookie does not fire it. SameSite=Strict still applies to a foreign-site CSRF. A same-origin script does not care about SameSite. That script is part 6.

Method Sink Privilege
GET services_wol.php?wakeall=true with session wol of stored MACs Availability (lab-proven): both stored targets woke.
GET without wakeall no packets Closed (lab-proven).
Unauth, no cookie no session Closed. Foreign-site CSRF also SameSite=Strict.

Fix: POST-only wakeall. usepost on the Wake All link.

This is not RCE. It is a GET mutator leftover of a CSRF pass that converted POSTs and left a query string. I am not inflating it. I am leaving it on the graph because GUI XSS in the same origin is a GET.

Last line:

Plain text
SUCCESS PFSENSE-WOL-GET-ALL control-no-packets=yes trigger-wake-all-get=yes PFSENSE-WOL-GET-ALL-WITNESS

What an attacker can do in this phase

The privilege model is the story. User Manager is warn-root. These pages are not.

Leftover Lab-proven Inferred Closed
LDAP/RADIUS name admin isAdminUID, all pages, uid 0 hash write after ATO, every other leftover non-colliding stub names
Wizard step 7 uid 0 bcrypt rewrite WAN/LAN rewrite, lockout xml= LFI
Hidden uploadconfig install + reboot plant interpolations from parts 2-3 GET without POST; diag_backup warn-root
ACB newver config.xml replaced decrypted XML, other userkey restore without newver
readonly vs install install/restore replace while write_config denied planted config reboots ordinary writes stay denied
WOL GET wake all stored MACs GUI XSS drives the GET unauth; foreign CSRF SameSite

I did not get a root shell from any of these six gadgets. I got the file the shells read, or the hash the shells authenticate, or a magic packet. On this platform that is enough to matter. PHP-FPM is still root. The next part is the other write: Captive Portal file manager walking out of /var/db/cpelements.

How this phase feeds the others

A remote name admin ends limited-user talk. Wizard step 7 is the same hash from a local page grant. uploadconfig and ACB are compilers for part 2 and part 3: plant the timezone, the Unbound ACL, the wireless parent, the IPsec banner, then visit the page that interpolates. readonly is how a "can't save" user still swaps the file. WOL GET is how part 6 becomes a magic packet without a POST.

The already-published OpenVPN wizard CRLF lives on the wizard privilege family. It is not this leftover. It is why I keep saying wizard pages do first-boot work forever.

Pin, lab, contact

Public master 9363ac5. 2.9.0-DEVELOPMENT. Labs are PHP gadgets of the pinned functions. Loopback only. AGPLv3.

Previous: conf breakouts. Next: files and portal. Series hub: pfSense CE leftover interpolations. The edges: part 7.

abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me