Research/pfsense-qinq-parent-if
0-dayNo CVEHighPublic

pfSense QinQ parent-if into ifconfig

pfSense CE 2.9.0-DEVELOPMENT QinQ edit stores a parent interface with no existence check. Member add/remove interpolates that name into unescaped ifconfig. Limited GUI user gets root. Labbed. Not Diagnostics: Command.

Name
pfSense QinQ parent-if 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) interfaces_qinq_edit.php. VLAN edit already rejects a missing parent. Privilege page-interfaces-qinq-edit.
Published
13 Oct 2026
Updated
13 Oct 2026
Tags
0-day, pfsense, qinq, rce, cwe-78

VLAN already asked. QinQ just stored it.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-qinq-parent-if (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, gadget.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 page-interfaces-qinq-edit. Not Diagnostics: Command.

VLAN edit already rejects a parent that is not a live interface. QinQ edit is the same kind of parent-plus-tag object. The HTML widget is a Form_Select of QinQ-capable ports. Form_Select does not re-validate POST. Save copies $_POST['if'] into config. Create calls interface_qinq_configure, which returns early when the parent does not exist, and then always write_config. Member add and member delete interpolate the stored vlanif into unescaped ifconfig. PHP exec is /bin/sh -c. WebGUI PHP-FPM is root.

That is the leftover. GIF/GRE already had this class as CVE-2023-42326. QinQ did not get the existence check.

Why pfSense

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 a limited GUI user who was granted a single page. CSRF-magic sits on POST. isAllowedPage sits on the URI. PHP-FPM still runs as root.

I only wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. Unauth WebGUI is closed on this pin. Packages that are not in the tree are out. Plus is paid. Skip.

Pinning a public tree that is not 2.9.0-RELEASE

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 9363ac5b8651a1c7a333180425ce7719070f95f9 (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.

The skip list is half the hunt

docs.netgate.com/advisories already named a 2026 crop:

  • SA-26_01 isvalidpid on this pin is already posix_kill plus an integer PID.
  • SA-26_06 proxyuser/proxypass crontab CRLF.
  • SA-26_07 OpenVPN vpn_openvpn_server.php dns_domain / netbios_scope. Named page only. The wizard is a sibling leftover.
  • SA-26_18 dashboard widget sequence LFI.
  • SA-26_20 Kea custom JSON.
  • SA-26_22 / CVE-2026-92182 xmlrpc LDAP/RADIUS uid skip. Still in this tree. Affects CE <= 2.9.0. Public. Skip.

The rest of SA-26_02 through SA-26_21 is XSS / info-disclosure. SA-26_04 RSS widget XSS is the last commit on this pin. Historical GIF/GRE is CVE-2023-42326.

Twenty surfaces, five leftovers

I split the tree: cmd injection, xmlrpc/HA, auth/CSRF/priv, captive portal, widgets/LFI, SSRF, config restore, OpenVPN/IPsec, DHCP/Kea, diag pages, packages/wizard, DynDNS/ACB, include path, remote-auth siblings, users/certs, filter/NAT/alias/shaper/VIP, interfaces/status, DNS/NTP/SNMP, webserver/rc, SA siblings.

The dedicated cmd-injection hunt returned NONE and walked past QinQ. The interfaces hunt named the parent-if. OpenVPN wizard, CARP VHID, limiter number, and IPsec login_banner are the other four. CSC CRLF dropped because CCD has no up. Captive Portal maxprocperip dropped as High because the shown breakout is portal availability.

Unauth WebGUI, session regen, isAdminUID fail-closed, CSRF token plus SameSite=Strict, GIF/GRE regex, LAGG members does_interface_exist plus escapeshellarg: closed.

How QinQ actually stores a parent

VLAN edit:

PHP
if (!does_interface_exist($_POST['if'])) {
    $input_errors[] = gettext("Interface supplied as parent is invalid");
}

QinQ edit has no such check. Save:

PHP
$qinqentry = array();
$qinqentry['if'] = $_POST['if'];
$qinqentry['tag_type'] = $_POST['tag_type'];

vlan_interface() builds {if}.{tag} when the tag is 1-4094. Create always write_config. Edit of that row forbids changing if / tag and allows member add/remove. New members and deleted members interpolate the stored vlanif:

PHP
$parent = $qinqentry['vlanif'];
foreach ($delmembers as $tag) {
    exec("/sbin/ifconfig {$parent}.{$tag} destroy");
}

qinq2 create:

PHP
$vlanif = "{$if}.{$tag}";
if (strlen($vlanif) > IF_NAMESIZE) {
    /* log and return */
}
exec("/sbin/ifconfig vlan create vlandev {$if} vlan {$tag} name {$vlanif}");

IF_NAMESIZE is 15. Member-delete destroy has no length check. List-page QinQ delete requires does_interface_exist($vlanif), so a never-created fake parent is blocked there. Two POSTs: create, then edit members.

The lab

PHP 8.3 gadget of the pinned vlan_interface / interface_qinq_configure / member exec lines. PATH stubs for ifconfig and w so the oracle is a witness file, not host ifconfig. Loopback 127.0.0.1:18810. Compose project pfsense-qinq-parent-if.

CONTROL: VLAN edit rejects the parent. QinQ create-only stores config and returns (called with invalid if), no exec. IF_NAMESIZE: qinq2 name len 16 logs interface name too big and returns before exec. TRIGGER: member-delete destroy (no length cap) and member-add qinq2 (len 8) both wrote the witness via /bin/sh -c.

Last run:

Plain text
SUCCESS PFSENSE-QINQ-PARENT-IF vlan-rejected=yes create-no-exec=yes member-sink=yes if-namesize-block=yes PFSENSE-QINQ-PARENT-IF-WITNESS

Stack tears down on EXIT.

What an attacker can do

Method Sink Privilege
POST QinQ create with a parent that is not a live iface, then POST edit adding a member interface_qinq2_configure exec ifconfig vlan create ... (interfaces.inc:620) Integrity (lab-proven): root shell via /bin/sh -c. Lab: member-add qinq2 wrote the witness (member-sink=yes).
Same stored vlanif, POST edit removing a member exec("/sbin/ifconfig {$parent}.{$tag} destroy") (interfaces_qinq_edit.php:212) Integrity (lab-proven): same root exec. Destroy has no IF_NAMESIZE cap.
Same root shell host filesystem Recon (inferred): config.xml, keys, other tenants. This page does not print exec output.
Root shell or broken ifconfig vlan devices Availability (inferred): create/destroy vlan devices; data plane is in play.

Lab-proven vs inferred: VLAN reject, create-only no exec, IF_NAMESIZE block at len 16, and member-add qinq2 witness are lab-proven. Filesystem recon and data-plane disruption are inferred from root PHP-FPM.

Requires: WebGUI session with page-interfaces-qinq-edit. CSRF token. Two POSTs. Default admin has the page. A limited user can be granted only this page.

Adjacent surface that stays closed

Surface Lab
VLAN parent existence check vlan-rejected=yes
QinQ create-only (parent missing) stores config, create-no-exec=yes
qinq2 name length 16 if-namesize-block=yes before exec
Unauth / CSRF without token guiconfig + CSRF-magic
GIF/GRE name regex, LAGG members exist + escapeshellarg other pages
QinQ list delete of a never-created parent does_interface_exist($vlanif)
Diagnostics: Command stays closed unless also granted

No unauth. No Diagnostics: Command. No GIF/GRE leftover on this pin.

The fix

QinQ edit: reject a parent that is not a live interface, same as VLAN edit. escapeshellarg the stored vlanif before every ifconfig. Cap member-delete destroy the same way interface_qinq2_configure caps IF_NAMESIZE.

Replay:

Plain text
cd lab
./run.sh

Loopback 127.0.0.1:18810. Witness PFSENSE-QINQ-PARENT-IF-WITNESS.

References

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