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
- Part 1 - the pin, skip list, map.
- Part 2 - ifconfig / pfctl / dpinger.
- Part 3 - php.ini, sshguard, mpd, Unbound, OpenVPN.
- Part 4 (you are here) - wizard, backup, the name admin.
- Part 5 - captive portal files, file://, nginx.
- Part 6 - XSS and
$http_host. - 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:
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-WITNESSSetup 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:
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-WITNESSHidden 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:
SUCCESS PFSENSE-UPLOADCONFIG control-no-install=yes trigger-install-reboot=yes ident=page-hidden-uploadconfiguration warn-root=absent PFSENSE-UPLOADCONFIG-WITNESSAuto 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:
SUCCESS PFSENSE-ACB-RESTORE-PRIV ident-no-warn-root=yes no-newver-no-restore=yes newver-restore=yes PFSENSE-ACB-RESTORE-PRIV-WITNESSDeny 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:
SUCCESS PFSENSE-CONFIG-READONLY-RESTORE write-denied=yes install-replace=yes restore-replace=yes PFSENSE-CONFIG-READONLY-RESTORE-WITNESSWake-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:
SUCCESS PFSENSE-WOL-GET-ALL control-no-packets=yes trigger-wake-all-get=yes PFSENSE-WOL-GET-ALL-WITNESSWhat 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