pfSense CE leftover interpolations · Phase 1
pfSense CE leftovers, part 2: ifconfig, pfctl, and the pages that are not Diagnostics: Command
Authenticated pfSense CE WebGUI pages that are not Diagnostics: Command still interpolate POST into ifconfig, pfctl, and dpinger as root PHP-FPM. Wireless clone, assign Add plus an unanchored wireless_regex, an IPv6 %zone, and Captive Portal HA host are CWE-78. VLAN parent-not-in-$portlist is the ioctl cousin.
- Name
- pfSense CE leftovers, part 2: ifconfig, pfctl, and the pages that are not Diagnostics: Command
- Series
- pfSense CE leftover interpolations
- Phase
- 1 · Shell interpolations
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Netgate
- Affected
- pfSense CE 2.9.0-DEVELOPMENT (9363ac5) interfaces_wireless_edit.php, interfaces_assign.php plus GET interfaces.php, gwlb.inc start_dpinger, captiveportal.inc captiveportal_load_pfctl, interfaces_vlan_edit.php. Privilege pages are not Diagnostics: Command.
- Published
- 15 Oct 2026
- Updated
- 15 Oct 2026
- Tags
- 0-day, pfsense, rce, cwe-78, leftover, series
Diagnostics: Command is the intended root shell. These pages were not.
I am @abraxas_null. This is part 2 of pfSense CE leftover interpolations, phase 1: the leftover is a string that still reaches /bin/sh -c. Next is conf breakouts.
This is pfSense CE 2.9.0-DEVELOPMENT (9363ac5b8651a1c7a333180425ce7719070f95f9). No CVE yet. WebGUI PHP-FPM is root. PHP exec / mwexec is /bin/sh -c. CSRF-magic sits on POST. isAllowedPage sits on the URI. A limited GUI user can be granted a single page. Diagnostics: Command is not that page.
I wanted a large FreeBSD firewall with a PHP WebGUI that people treat as "the admin already has root." That is true for Diagnostics: Command. It is not true for page-interfaces-wireless-edit. It is not true for Assign Network Ports plus a later GET of interfaces.php. It is not true for gateway edit, Captive Portal HA sync, or VLAN edit. The HTML widget is a Form_Select. Form_Select does not re-validate POST.
Public GitHub has no RELENG_2_8_x and no RELENG_2_9_0. Tags stop at v2.5.2. Latest public RELENG is RELENG_2_7_2. Actual latest stable CE is 2.9.0-RELEASE (2026-08-20). PHP for that release is not on public GitHub. I pinned public master 9363ac5 (2026-03-31, Encode RSS widget post titles). src/etc/version is 2.9.0-DEVELOPMENT. I dropped any leftover that matches a public SA even when the fix landed after this commit. Skip Plus. Advisories: https://docs.netgate.com/advisories/index.html
The dedicated cmd-injection hunt returned NONE and walked past QinQ. The interfaces hunt named the parent-if. After those five went out, I walked the same pin again: every WebGUI POST field that still reaches exec / mwexec / a daemon conf / a file, on a privilege page that is not standard-warning-root and not Diagnostics: Command. Wireless clone was sitting next to QinQ the whole time. Assign Add was the other door into the same wlandev string. Gateway %zone and Captive Portal HA $host are the same /bin/sh -c class on dpinger and pfctl. VLAN is the control: parent not in the widget list, existence check present, ioctl only.
The first five leftovers on this pin are already public: QinQ parent-if, OpenVPN wizard CRLF, CARP VHID, limiter hidden number, IPsec login_banner. This phase is the next shell cluster. Four of the five new leftovers are CWE-78 at 8.8 High. The fifth is the closed-shell cousin: VLAN parent not in $portlist, ioctl only, CWE-863 at 4.3. I am putting it here because the instinct is the same as QinQ and the lab already closed the shell.
Authorized lab only. Every pack talks to loopback.
Same family as QinQ and CARP
I am not re-litigating those two. They belong in this picture so the cluster is visible.
G1 QinQ. Privilege page-interfaces-qinq-edit. VLAN edit already rejects a parent that is not a live interface. QinQ edit stores $_POST['if'] anyway, always write_config, and member add/remove interpolates the stored vlanif into unescaped ifconfig. Pack: abraxas/pfsense-qinq-parent-if (loopback client). Writeup: /research/pfsense-qinq-parent-if. Historical class is GIF/GRE as CVE-2023-42326. QinQ did not get the existence check.
G3 CARP VHID. Privilege VIP edit plus apply. CARP apply escapeshellargs the VHID. Stacked IP Alias apply interpolates vhid {$carpvip['vhid']} outside escapeshellarg. Status/widget interpolates the same stored VHID into a double-quoted grep pipeline; command substitution still runs. Pack: abraxas/pfsense-carp-vhid (loopback client). Writeup: /research/pfsense-carp-vhid. One field. Three sinks. One of them already did the right thing.
Wireless clone and QinQ are sibling ifconfig interpolations on different privileges. Assign Add plants a hostile iface name and later GET interfaces.php clones it: a two-page chain inside this part, landing on the same interface_wireless_clone exec QinQ never touched. VLAN ioctl is the closed-shell cousin of QinQ: same parent-not-in-the-list instinct, does_interface_exist already present, no /bin/sh. CARP is the same /bin/sh -c class on a VHID instead of a parent name. A limited user granted only wireless-edit does not need Diagnostics: Command. Root PHP-FPM means any of these shells can read config.xml and keys. That recon is inferred. The pages do not print exec output.
| Leftover | Class | Privilege | Sink |
|---|---|---|---|
| QinQ parent-if (already public) | RCE | page-interfaces-qinq-edit |
unescaped ifconfig of stored vlanif |
| CARP VHID (already public) | RCE | VIP edit plus apply / status | unescaped vhid into ifconfig and grep |
| pfsense-wireless-clone | RCE | page-interfaces-wireless-edit |
ifconfig wlan create wlandev {$baseif} |
| pfsense-assign-if-add | RCE | page-interfaces-assignnetworkports then GET interfaces.php?if=optN |
Add stores if_add; clone ifconfig |
| pfsense-gw-ipv6-zone | RCE | page-system-gateways-editgateway then apply |
start_dpinger mwexec of {$gateway['monitor']} |
| pfsense-cp-ha-pfctl | RCE | page-services-captiveportal-hasync |
unquoted $host in pfctl -a |
| pfsense-vlan-parent-ioctl | ioctl leftover | page-interfaces-vlan-edit |
pfSense_vlan_create on a live parent the UI hid |
pfsense-wireless-clone
Class / reach: RCE / Remote. Privilege: page-interfaces-wireless-edit. CWE / CVSS: CWE-78 / 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). Pack: abraxas/pfsense-wireless-clone (loopback client). Lab stack lab/. Loopback 127.0.0.1:18910.
The wireless clone page builds a Form_Select from interface_list_wireless(). Required POST fields are if and mode. Save copies $_POST['if'] with no allowlist and no does_interface_exist. Hidden cloneif is generated as {$_POST['if']}_wlan{$clone_id} when the row is new. It is not taken from POST. Mode is a switch that becomes the constant wlanmode hostap, wlanmode adhoc, or empty. Then interface_wireless_clone($clone['cloneif'], $clone) runs.
$clone['if'] = $_POST['if'];
$clone['mode'] = $_POST['mode'];
/* ... */
$clone['cloneif'] = "{$_POST['if']}_wlan{$clone_id}";
if (!interface_wireless_clone($clone['cloneif'], $clone)) {interface_get_wireless_base strips a _wlan suffix and otherwise returns the string unchanged. ath0;w has no _wlan. $baseif is ath0;w. The create line is unescaped:
$baseif = interface_get_wireless_base($wlcfg['if']);
exec("/sbin/ifconfig wlan create wlandev {$baseif} {$mode} bssid 2>&1", $out, $ret);That is interfaces.inc:3370. PHP exec of a single string is /bin/sh -c. The semicolon is a new command. Mode never reaches the injector. The existence-check ifconfig of $realif already uses escapeshellarg. The create line does not.
PHP gadget of the pinned wireless_edit_save / interface_wireless_clone / interface_get_wireless_base lines. PATH stubs for ifconfig and w so the oracle is a witness file, not host ifconfig. CONTROL: a clean parent, wlandev in the log, witness absent, hidden cloneif not copied from POST. TRIGGER: POST if=ath0;w ran extra w via /bin/sh -c and wrote the witness.
Last run:
SUCCESS PFSENSE-WIRELESS-CLONE control-wlandev=yes trigger-exec=yes hidden-cloneif-copied=no PFSENSE-WIRELESS-CLONE-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST interfaces_wireless_edit.php if=ath0;w |
interface_wireless_clone exec ifconfig wlan create wlandev {$baseif} (interfaces.inc:3370) |
Integrity (lab-proven): root shell via /bin/sh -c. Lab: extra w wrote the witness (trigger-exec=yes). Root host commands from wireless-edit only. |
| Same root shell | host filesystem | Recon (inferred): config.xml, keys, other tenants. This page does not print exec output. |
Root shell or broken ifconfig |
wireless clones | Availability (inferred): hostile ifconfig on wireless clones. |
Lab-proven vs inferred: control-wlandev, trigger-exec, and hidden-cloneif-copied=no are lab-proven. Filesystem recon and clone disruption are inferred from root PHP-FPM.
Requires: WebGUI session with page-interfaces-wireless-edit. CSRF token. Default admin has the page. A limited user can be granted only this page.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| Mode | constant wlanmode ..., not the injector |
Hidden cloneif copied from POST |
hidden-cloneif-copied=no |
| Unauth / CSRF without token | guiconfig + CSRF-magic |
| Diagnostics: Command | stays closed unless also granted |
Fix: Allowlist POST if against live wireless parents. escapeshellarg $baseif before ifconfig.
Replay: cd lab && ./run.sh. Loopback 127.0.0.1:18910. Witness PFSENSE-WIRELESS-CLONE-WITNESS. Stack tears down on EXIT.
pfsense-assign-if-add
Class / reach: RCE / Remote. Privilege: page-interfaces-assignnetworkports then GET interfaces.php?if=optN. CWE / CVSS: CWE-78 / 8.8 High. Pack: abraxas/pfsense-assign-if-add (loopback client). Lab stack lab/. Loopback 127.0.0.1:18920.
This is the two-page chain. Assign Network Ports has two writers. Submit remaps existing friendly names and only accepts a port that is in $portlist:
if (array_key_exists($ifport, $portlist)) {
$portifmap[$ifport][] = strtoupper($ifname);
} else {
$input_errors[] = sprintf(gettext('Cannot set port %1$s because the submitted interface does not exist.'), $ifname);
}Add does not. Add stores $_POST['if_add'] after a "not already assigned" check and nothing else. Next free name is lan or optN. If wireless_regex matches, it plants an empty wireless array and syncs clones.
config_set_path("interfaces/{$newifname}", [
'descr' => $descr,
'if' => $_POST['if_add']
]);
if (preg_match(g_get('wireless_regex'), $_POST['if_add'])) {
config_set_path("interfaces/{$newifname}/wireless", []);wireless_regex in globals.inc:99 is /^(ath|athp|bwi|...|zyd)[0-9]+/ with no $. ath0;w matches. The prefix is ath0. The semicolon is extra.
Later GET interfaces.php?if=optN sees the planted wireless array, clones if the real iface is missing, and asks get_wireless_modes to ifconfig {$cloned_interface} list chan. Both land in /bin/sh -c. The clone line is the same wlandev {$baseif} leftover as wireless-edit. Assign is how you get there without page-interfaces-wireless-edit.
CONTROL: Submit remap of a non-$portlist if_add does not exec. TRIGGER: Add stored ath0;w as opt1; GET interfaces.php?if=opt1 ran clone ifconfig; extra w wrote the witness.
Last run:
SUCCESS PFSENSE-ASSIGN-IF-ADD control-no-exec=yes trigger-exec=yes PFSENSE-ASSIGN-IF-ADD-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST interfaces_assign.php Add if_add=ath0;w, then GET interfaces.php?if=optN |
unanchored wireless_regex then interface_wireless_clone ifconfig (interfaces.inc:3370); get_wireless_modes ifconfig list chan (interfaces.inc:7101) |
Integrity (lab-proven): root shell. Lab: Add stored ath0;w as opt1; GET ran clone; extra w wrote the witness (trigger-exec=yes). |
| Same root shell | host filesystem | Recon (inferred): root shell can read host files. |
Root shell or broken ifconfig |
assigned ports | Availability (inferred): hostile ifconfig on assigned ports. |
Lab-proven vs inferred: control-no-exec on Submit remap and trigger-exec on Add-then-GET are lab-proven. Filesystem recon and port disruption are inferred from root PHP-FPM.
Requires: page-interfaces-assignnetworkports to plant. GET of interfaces.php?if=optN to fire. CSRF on the Add POST.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
Submit remap of a non-$portlist if_add |
control-no-exec=yes |
| Diagnostics: Command | not required |
Fix: Require if_add in $portlist. Anchor wireless_regex with $. escapeshellarg clone names.
Replay: cd lab && ./run.sh. Loopback 127.0.0.1:18920. Witness PFSENSE-ASSIGN-IF-ADD-WITNESS.
pfsense-gw-ipv6-zone
Class / reach: RCE / Remote. Privilege: page-system-gateways-editgateway then apply. CWE / CVSS: CWE-78 / 8.8 High. Pack: abraxas/pfsense-gw-ipv6-zone (loopback client). Lab stack lab/. Loopback 127.0.0.1:18930.
A link-local IPv6 address is not complete without a zone. The GUI accepts fe80::1%em0. is_ipaddrv6() in util.inc:1406 strips %zone for Net_IPv6::checkIPv6 and never charset-checks the zone:
if (strstr($ipaddr, "%") && is_linklocal($ipaddr)) {
$tmpip = explode("%", $ipaddr);
$ipaddr = $tmpip[0];
}fe80::1%eth0;w still passes. The left side is a link-local. The right side is whatever you typed. Empty monitor copies the gateway IP, so the planted address is the monitor.
start_dpinger concatenates a parameter string and interpolates the monitor last, with no escapeshellarg:
return mwexec("/usr/local/bin/dpinger {$params} {$gateway['monitor']} >/dev/null");That is gwlb.inc:535. mwexec is PHP exec of a single string. The semicolon is a new command as root. Gateway name is is_validaliasname. That field is not this leftover.
CONTROL: a clean zone, no extra exec. TRIGGER: fe80::1%eth0;w still passed is_ipaddrv6; mwexec /bin/sh -c ran extra w.
Last run:
SUCCESS PFSENSE-GW-IPV6-ZONE control-no-extra=yes trigger-extra-exec=yes PFSENSE-GW-IPV6-ZONE-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST system_gateways_edit.php gateway/monitor fe80::1%eth0;w, then apply |
start_dpinger mwexec {$gateway['monitor']} (gwlb.inc:535) |
Integrity (lab-proven): root shell. Lab: still passed is_ipaddrv6; extra w ran (trigger-extra-exec=yes). |
| Same root shell | host filesystem | Recon (inferred): root shell can read host files. |
| Same leftover | dpinger | Availability (inferred): kill or replace dpinger monitoring. |
Lab-proven vs inferred: control-no-extra and trigger-extra-exec are lab-proven. Filesystem recon and monitor disruption are inferred from root PHP-FPM.
Requires: page-system-gateways-editgateway plus apply. CSRF on POST.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| Gateway name | is_validaliasname |
| Unauth | N/A |
Fix: Charset-check the zone. escapeshellarg monitor before mwexec. Prefer argv arrays.
Replay: cd lab && ./run.sh. Loopback 127.0.0.1:18930. Witness PFSENSE-GW-IPV6-ZONE-WITNESS.
pfsense-cp-ha-pfctl
Class / reach: RCE / Remote. Privilege: page-services-captiveportal-hasync. CWE / CVSS: CWE-78 / 8.8 High. Pack: abraxas/pfsense-cp-ha-pfctl (loopback client). Lab stack lab/. Loopback 127.0.0.1:18990.
Captive Portal HA sync stores backwardsyncip without is_ipaddr. The check that exists is is_ipaddr_configured: "is this address already on this node." That is a uniqueness complaint, not a charset. captiveportal_load_pfctl then interpolates $host unquoted into a pipeline:
function captiveportal_load_pfctl($anchor, $host, $rules) {
if (!empty($rules)) {
mwexec("/usr/bin/printf \"{$rules}\" | /sbin/pfctl -a {$anchor}/{$host} -f-");
}
}That is captiveportal.inc:2962. $rules sits inside the printf double quotes. $host does not. 192.0.2.1; w splits -a and runs extra w. The gadget proves that interpolation. A peer blob or MITM plant of that host is inferred, not this gadget.
CONTROL: a clean host, no extra exec. TRIGGER: host 192.0.2.1; w split the unquoted pfctl -a and ran extra w.
Last run:
SUCCESS PFSENSE-CP-HA-PFCTL control-clean=yes trigger-exec=yes PFSENSE-CP-HA-PFCTL-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
HA sync host / MAC into captiveportal_load_pfctl |
mwexec of printf piped to pfctl -a {$anchor}/{$host} -f- (captiveportal.inc:2962) |
Integrity (lab-proven): root shell. Lab: 192.0.2.1; w split -a and ran extra w (trigger-exec=yes). |
| Same leftover | same sink | Recon (inferred): root shell. Peer blob / MITM plant of that host is inferred, not this gadget. |
| Same leftover | CP pf anchors | Availability (inferred): flush Captive Portal pf anchors. |
Lab-proven vs inferred: control-clean and trigger-exec are lab-proven. Filesystem recon, peer/MITM plant, and anchor flush are inferred.
Requires: page-services-captiveportal-hasync. CSRF on POST.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| SA-26_05 widget XSS | different leftover, skip-list |
| #16339 status-output password sanitize | different leftover |
Fix: escapeshellarg $host and $anchor. is_ipaddr / is_macaddr the HA fields.
Replay: cd lab && ./run.sh. Loopback 127.0.0.1:18990. Witness PFSENSE-CP-HA-PFCTL-WITNESS.
pfsense-vlan-parent-ioctl
Class / reach: ioctl leftover / Remote. Privilege: page-interfaces-vlan-edit. CWE / CVSS: CWE-863 / 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N). Pack: abraxas/pfsense-vlan-parent-ioctl (loopback client). Lab stack lab/. Loopback 127.0.0.1:19290.
This one is here because it looks like QinQ and is not a shell. VLAN edit builds $portlist for Form_Select only. LAGG members are unset so the UI will not offer them. OpenVPN TUN is unset. Save does not ask $portlist membership. Save asks does_interface_exist($_POST['if']) and then interface_vlan_configure:
if (!does_interface_exist($_POST['if'])) {
$input_errors[] = gettext("Interface supplied as parent is invalid");
}QinQ never had that existence check. VLAN does. A live LAGG member is still a kernel iface. It is hidden from the widget. It still exists. interface_vlan_configure calls pfSense_vlan_create($vlanif, $if, $tag, $pcp) (interfaces.inc:448). That is an ioctl. The ifconfig vlanproto path already uses escapeshellarg. The lab models create as ifconfig vlan create vlandev with escapeshellarg on the parent so a semicolon cannot become /bin/sh. Missing parent is rejected. Shell metachar is rejected. Hidden live parent is not.
CONTROL: a $portlist parent, create on that parent, no extra shell. REJECT: missing parent, no create. SHELL: em1;w, no create, no witness. TRIGGER: live LAGG member hidden from the UI still got vlan create vlandev.
Last run:
SUCCESS PFSENSE-VLAN-PARENT-IOCTL control-portlist=yes reject-missing=yes no-shell=yes trigger-hidden-ioctl=yes PFSENSE-VLAN-PARENT-IOCTL-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST interfaces_vlan_edit.php if= hidden live parent |
interface_vlan_configure / pfSense_vlan_create (interfaces.inc:448) |
Integrity (lab-proven): live LAGG member hidden from the UI still got vlan create vlandev. Missing parent and shell metachar rejected. |
| Same leftover | same sink | Recon: none shown. |
| Same leftover | kernel iface the UI hid | Availability (inferred): vlan on a kernel iface the UI hid. |
Lab-proven vs inferred: control-portlist, reject-missing, no-shell, and trigger-hidden-ioctl are lab-proven. Data-plane disruption of a hidden parent is inferred. There is no root shell on this leftover.
Requires: page-interfaces-vlan-edit. CSRF on POST. A live parent that $portlist hid (lab: LAGG member).
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| Shell metachar in parent | no-shell=yes |
| Missing parent | reject-missing=yes |
| QinQ missing existence check | different leftover, already public |
| Diagnostics: Command | stays closed unless also granted |
Fix: Require POST if in $portlist, not only does_interface_exist.
Replay: cd lab && ./run.sh. Loopback 127.0.0.1:19290. Witness PFSENSE-VLAN-PARENT-IOCTL-WITNESS.
How they interact in this cluster
Assign Add is the planter. Wireless clone is the one-page version of the same wlandev string. QinQ is the sibling interpolation on page-interfaces-qinq-edit instead of wireless-edit: parent stored, ifconfig unescaped, Diagnostics: Command not in the grant list. VLAN is what QinQ would have been if someone had copied the existence check and then forgotten the allowlist: ioctl of a live parent the widget hid, semicolon already dead. CARP is the same /bin/sh -c class with a VHID instead of an iface name, and one of its three sinks already escapeshellargs.
IPv6 %zone is the same instinct on a different object. is_ipaddrv6 looks like a validator. It is a validator of the address, not of the zone. dpinger still takes a string. Captive Portal HA is the same instinct on pfctl -a. $rules got quotes. $host did not.
A limited user granted only wireless-edit gets root. A limited user granted Assign plus the interfaces page gets root without wireless-edit. A limited user granted gateway edit gets root without touching interfaces. A limited user granted Captive Portal HA sync gets root without Diagnostics: Command. A limited user granted only VLAN edit does not get a shell on this leftover. They still create a vlan on a parent the UI refused to list.
Root PHP-FPM is the recon. Any of the shells can read config.xml. I did not print that file in the labs. I am not going to claim a cat I did not run. I am going to say the process is uid 0 and the file is on disk.
Next
Conf files are the next phase. Timezone into php.ini auto_prepend_file. sshguard THRESHOLD CRLF BACKEND. L2TP/PPPoE RADIUS secret into mpd.conf. PPPoE crontab. Unbound include:. Those are not this page. They are part 3 / conf breakouts.
Previous: pfSense CE leftovers, part 1.
References
- Packs: pfsense-wireless-clone, pfsense-assign-if-add, pfsense-gw-ipv6-zone, pfsense-cp-ha-pfctl, pfsense-vlan-parent-ioctl
- Same family, already public: pfsense-qinq-parent-if, pfsense-carp-vhid
- Product: github.com/pfsense/pfsense
9363ac5b8651a1c7a333180425ce7719070f95f9 interfaces_wireless_edit.phpinterfaces_assign.phpinterfaces.phpinterfaces.incgwlb.incutil.inccaptiveportal.incinterfaces_vlan_edit.phpglobals.inc- CWE-78
- CWE-863
- Historical class (GIF/GRE, not these pages): CVE-2023-42326
Abraxas Labs
https://abraxaslabs.tech
https://github.com/abraxas
https://x.com/abraxas_null (@abraxas_null)
abraxas.null@proton.me