Research/pfsense-openvpn-wizard-crlf
0-dayNo CVEHighPublic

pfSense OpenVPN wizard CRLF leftover of SA-26_07

pfSense CE 2.9.0-DEVELOPMENT OpenVPN wizard copies Default Domain into dns_domain with no is_domain() check. openvpn_add_dhcpopts writes it into a server conf that already has script-security 3. OpenVPN ran injected up. Sibling of SA-26_07, which named only vpn_openvpn_server.php.

Name
pfSense OpenVPN wizard CRLF leftover of SA-26_07
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Netgate
Affected
pfSense CE 2.9.0-DEVELOPMENT (9363ac5) openvpn_wizard.inc. Privilege page-pfsensewizardsubsystem. CCD up is ignored.
Published
13 Oct 2026
Updated
13 Oct 2026
Tags
0-day, pfsense, openvpn, rce, cwe-74

SA-26_07 named the server page. The wizard still writes the same keys.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-openvpn-wizard-crlf (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-74 / 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 Wizard Subsystem. Not OpenVPN advanced. Not Diagnostics: Command.

SA-26_07 (announced 2026-08-13, credits @lujiefsi): dns_domain and netbios_scope on vpn_openvpn_server.php accept CR/LF and quotes, which add OpenVPN directives including up / route-up. Affects CE <= 2.8.1. Corrected 2026-06-17 on CE master 2.9.0 commit fd301381 (not in this public tree). Redmine #16899.

The wizard copies the same keys from defaultdomain / nbtscope with no is_domain() check, then openvpn_resync runs the same openvpn_add_dhcpopts() helper into a server config.ovpn that already has script-security 3. IPsec mobile already uses is_domain() for the analogous field. If 2.9.0-RELEASE escaped the helper, this is the same SA. The SA named only the server page.

Why pfSense, and this pin

PHP-FPM is root. A limited GUI user can be granted a single page without Diagnostics: Command. Public GitHub has no RELENG_2_9_0. I hunted master 9363ac5 (2.9.0-DEVELOPMENT) and dropped leftovers that match a public SA even when the fix landed later. Skip Plus. Skip packages not in this tree.

The 2026 skip list is SA-26_01 through SA-26_22, including SA-26_06 proxy crontab, SA-26_18 widget LFI, SA-26_20 Kea JSON, and SA-26_22 / CVE-2026-92182 xmlrpc uid skip (still in this tree, public). Advisories: https://docs.netgate.com/advisories/index.html

Twenty hunts. CSC dns_domain uses the same helper into CCD files. OpenVPN 2.6 CCD accepts push / iroute / ifconfig-push / config / disable. up in a CCD file is not executed. I dropped CSC as High. The wizard writes a server conf.

How the wizard lands in config.ovpn

Wizard step 10:

PHP
if (isset($pconfig['step10']['defaultdomain']))
    $server['dns_domain'] = $pconfig['step10']['defaultdomain'];
...
if (isset($pconfig['step10']['nbtscope'])) {
    $server['netbios_scope'] = $pconfig['step10']['nbtscope'];
}

Then config_set_path('openvpn/openvpn-server/', $server); openvpn_resync('server', $server);. Helper:

PHP
if (!empty($settings['dns_domain'])) {
    $conf .= "push \"dhcp-option DOMAIN {$settings['dns_domain']}\"\n";
}
...
$conf .= "script-security 3\n";

A quote plus LF closes the push "dhcp-option ..." line and adds server directives. Wizard step-10 checks DNS/NTP/WINS as IPs, not the domain or scope strings. page-openvpn-server-advanced already warns as root for custom_options. The wizard does not.

The lab

Pinned wizard assign plus openvpn_add_dhcpopts. OpenVPN 2.6.14 on tun0. up points at /oracle/write-witness.sh. Loopback 127.0.0.1:18820.

CONTROL: example.com is one DOMAIN push plus script-security 3, no up. TRIGGER: quote + LF adds up /oracle/write-witness.sh. OpenVPN ran that up (up-exec=yes). Same poison in a CCD blob was not executed (ccd-up-ignored=yes).

Last run:

Plain text
SUCCESS PFSENSE-OPENVPN-WIZARD-CRLF wizard-assign=yes dhcpopts-up=yes script-security-3=yes control-clean=yes up-exec=yes PFSENSE-OPENVPN-WIZARD-CRLF-WITNESS

OpenVPN log: /oracle/write-witness.sh tun0 1500 0 10.8.0.1 255.255.255.0 init then Initialization Sequence Completed. Stack tears down on EXIT.

What an attacker can do

Method Sink Privilege
Complete OpenVPN Remote Access wizard; set Default Domain (or NetBIOS scope) to a value that closes the push line and adds up openvpn_add_dhcpopts into server config.ovpn (openvpn.inc:697-738) with script-security 3 (:905) Integrity (lab-proven): OpenVPN 2.6.14 ran that up on tun0 and wrote the witness (up-exec=yes). Control example.com stayed a single DOMAIN push.
Same host commands OpenVPN up as the daemon user (root on pfSense) Recon (inferred): host filesystem, config.xml, keys.
Broken OpenVPN config or host commands OpenVPN server Availability (inferred): OpenVPN / firewall disruption.

Lab-proven vs inferred: wizard assign, dhcpopts up, script-security 3, control-clean, and tun0 up-exec are lab-proven. Filesystem recon is inferred from the up process. CCD up stayed ignored.

Requires: WebGUI session with page-pfsensewizardsubsystem. CSRF token. Does not require page-openvpn-server or page-openvpn-server-advanced.

Adjacent surface that stays closed

Surface Lab
Control domain example.com one DOMAIN push, control-clean=yes
CCD up (OpenVPN 2.6 subset) ccd-up-ignored=yes
Unauth / CSRF without token closed
OpenVPN custom_options intended advanced privilege
Diagnostics: Command stays closed unless also granted

The fix

is_domain() (or strip CR/LF/quotes) on wizard defaultdomain / nbtscope before they become dns_domain / netbios_scope, same as IPsec mobile already does. Sanitize inside openvpn_add_dhcpopts() so every caller is covered, including the wizard.

Replay:

Plain text
cd lab
./run.sh

Loopback 127.0.0.1:18820. Witness PFSENSE-OPENVPN-WIZARD-CRLF-WITNESS.

References

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