pfSense CARP VHID leftover into ifconfig
pfSense CE 2.9.0-DEVELOPMENT stores CARP VHID from POST with no 1-255 check. CARP apply escapes it. Stacked IP Alias apply and CARP status do not. Limited GUI user gets root. Labbed.
- Name
- pfSense CARP VHID leftover into ifconfig
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Netgate
- Affected
- pfSense CE 2.9.0-DEVELOPMENT (9363ac5) firewall_virtual_ip.inc saveVIP, interfaces.inc stacked ipalias mwexec, pfsense-utils.inc status grep.
- Published
- 13 Oct 2026
- Updated
- 13 Oct 2026
- Tags
- 0-day, pfsense, carp, rce, cwe-78
Form_Select is HTML. POST is a string.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-carp-vhid (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, lab.php. Authorized lab only. It talks to loopback.
This is pfSense CE 2.9.0-DEVELOPMENT (9363ac5b8651a1c7a333180425ce7719070f95f9). No CVE yet. CWE-78. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). Remote authenticated RCE. Privilege VIP edit plus apply. Status/widget is a later trigger. Not Diagnostics: Command.
CARP apply escapeshellargs the VHID. Password / advbase / advskew are escaped. That path does not execute metacharacters. Stacked IP Alias apply interpolates vhid {$carpvip['vhid']} outside escapeshellarg. CARP status interpolates the same stored VHID into a double-quoted grep pipeline. Semicolon stays inside the quotes. Command substitution inside those quotes still runs under /bin/sh -c.
One field. Three sinks. One of them already did the right thing.
Why pfSense, and this pin
PHP-FPM is root. Public GitHub has no RELENG_2_9_0. I hunted master 9363ac5 (2.9.0-DEVELOPMENT) and dropped leftovers that match a public SA. Skip Plus. Skip SA-26_01 through SA-26_22 including SA-26_22 / CVE-2026-92182 xmlrpc uid skip (still in this tree, public). Advisories: https://docs.netgate.com/advisories/index.html
Twenty hunts. filter/NAT/alias/shaper/VIP is where VHID lived. xmlrpc apply of the same objects without HA creds is the named SA-26_22, not this leftover.
How a VHID leaves the form
Form_Select options are 1-255 in the HTML only. saveVIP uniqueness-compares $post['vhid'] as a string and stores it raw:
if ($post['mode'] === "carp") {
$vipent['vhid'] = $post['vhid'];
$vipent['advskew'] = $post['advskew'];
$vipent['advbase'] = $post['advbase'];
}Stacked IP Alias on a CARP parent:
$vhid = '';
if (substr($vip['interface'], 0, 4) == "_vip") {
$carpvip = get_configured_vip($vip['interface']);
$iface = $carpvip['interface'];
$vhid = "vhid {$carpvip['vhid']}";
}
mwexec("/sbin/ifconfig " . escapeshellarg($realif) ." {$af} ".
escapeshellarg($vip['subnet']) ."/" . escapeshellarg($vip['subnet_bits']) .
" alias {$gateway} {$vhid}");$gateway is empty on a normal Ethernet parent. mwexec is PHP exec of a single string.
CARP status (pfsense-utils.inc:794) interpolates the stored VHID into a double-quoted grep pattern on a pipeline. Callers: status_carp.php, carp_status.widget.php, DHCP / relay / OpenVPN carp-status checks. The injected command is the planter's VHID. The widget trigger is any session whose dashboard includes the CARP widget.
applyVIP carp-branch XOR of old and new VHID is not a reliable trigger (integer cast). Direct ipalias apply and status/widget exec do not use that XOR.
The lab
PHP gadget of pinned interface_carp_configure, stacked ipalias mwexec, and status exec. PATH stub w is the oracle. Absolute /sbin/ifconfig is a no-op so the leftover is the shell parse of the interpolated string. Loopback 127.0.0.1:18830.
CONTROL: CARP apply escapeshellarg of the VHID, witness absent. TRIGGER A: stacked IP Alias mwexec interpolates vhid {$carpvip['vhid']} unescaped, witness present. TRIGGER B: status exec with VHID inside double quotes; semicolon stayed quoted; command substitution ran and wrote the witness (status-subst=yes).
Last run:
status-quoted-semicolon=no
status-subst=yes
SUCCESS PFSENSE-CARP-VHID carp-escaped=yes ipalias-unescaped=yes status-sink=yes PFSENSE-CARP-VHID-WITNESSStack tears down on EXIT.
What an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST carp VIP with a non-integer VHID, then apply a stacked IP Alias on that _vip{uniqid} parent |
mwexec ifconfig ... alias {$gateway} {$vhid} (interfaces.inc:3238) |
Integrity (lab-proven): root shell. Lab: ipalias-unescaped wrote the witness. CARP apply of the same VHID wrote none (carp-escaped=yes). |
Same stored VHID, then GET status_carp.php or render carp_status.widget.php |
exec grep pipeline (pfsense-utils.inc:794) |
Integrity (lab-proven): command substitution inside the double quotes ran and wrote the witness (status-sink=yes). Semicolon stayed quoted. Widget trigger can be another GUI session. |
| Same root shell | host filesystem | Recon (inferred): config.xml, keys. |
Root shell or broken ifconfig |
CARP / alias devices | Availability (inferred): interface / CARP / filter disruption. |
Lab-proven vs inferred: carp-escaped, ipalias-unescaped witness, and status command-substitution witness are lab-proven. Filesystem recon and filter disruption are inferred from root PHP-FPM.
Requires: page-firewall-virtualipaddress-edit (save) and page-firewall-virtualipaddresses (apply). Status sink: page-status-carp or any dashboard with the CARP widget. CSRF on POST.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| CARP apply itself | escapeshellarg on VHID, carp-escaped=yes |
| CARP password / advbase / advskew / subnet | is_ipaddr / escaped |
| Quoted semicolon on status | stayed inside the double quotes |
| Unauth | closed |
| xmlrpc apply of the same objects without HA creds | SA-26_22 skip-list |
| Diagnostics: Command | stays closed unless also granted |
The fix
Integer-range the VHID to 1-255 on save. escapeshellarg the VHID on stacked IP Alias apply and on the status grep, the same way CARP apply already does.
Replay:
cd lab
./run.shLoopback 127.0.0.1:18830. Witness PFSENSE-CARP-VHID-WITNESS.
References
- Pack: github.com/abraxas/pfsense-carp-vhid
- Product: github.com/pfsense/pfsense
9363ac5b8651a1c7a333180425ce7719070f95f9 firewall_virtual_ip.incinterfaces.incpfsense-utils.inc- CWE-78
Abraxas Labs
https://abraxaslabs.tech
https://github.com/abraxas
https://x.com/abraxas_null (@abraxas_null)
abraxas.null@proton.me