Research/pfsense-carp-vhid
0-dayNo CVEHighPublic

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:

PHP
if ($post['mode'] === "carp") {
    $vipent['vhid'] = $post['vhid'];
    $vipent['advskew'] = $post['advskew'];
    $vipent['advbase'] = $post['advbase'];
}

Stacked IP Alias on a CARP parent:

PHP
$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:

Plain text
status-quoted-semicolon=no
status-subst=yes
SUCCESS PFSENSE-CARP-VHID carp-escaped=yes ipalias-unescaped=yes status-sink=yes PFSENSE-CARP-VHID-WITNESS

Stack 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:

Plain text
cd lab
./run.sh

Loopback 127.0.0.1:18830. Witness PFSENSE-CARP-VHID-WITNESS.

References

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

pfSense CARP VHID leftover into ifconfig · Abraxas Labs