pfSense CE leftover interpolations · Phase 2
pfSense CE leftovers, part 3: php.ini, sshguard, mpd, Unbound, and OpenVPN eval
pfSense CE 2.9.0-DEVELOPMENT interpolates typed GUI fields into php.ini, sshguard.conf, mpd.conf, Unbound, crontab, and OpenVPN helper scripts with no CRLF or quote-close gate. Limited-page operators and RADIUS/LDAP OpenVPN users get root PHP or a daemon that will run what they planted. Labbed. Not Diagnostics: Command.
- Name
- pfSense CE leftovers, part 3: php.ini, sshguard, mpd, Unbound, and OpenVPN eval
- Series
- pfSense CE leftover interpolations
- Phase
- 2 · Conf breakouts
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Netgate
- Affected
- pfSense CE 2.9.0-DEVELOPMENT (9363ac5) System General Setup, sshguard, L2TP/PPPoE mpd, PPPoE cron and mpd.secret, Unbound Advanced/ACLs, OpenVPN connect_async eval and learn-address. Conf-only siblings on SNMP, NTP GPS, IGMP, ISC dhcpd, limiter AQM.
- Published
- 15 Oct 2026
- Updated
- 15 Oct 2026
- Tags
- 0-day, pfsense, rce, crlf, unbound, openvpn
The leftover is the file, not the argv
This is part 3 of leftover interpolations on pfSense CE, written by @abraxas_null. Previous chapter: shell interpolations. Next: config takeover. Pin is public master 9363ac5b8651a1c7a333180425ce7719070f95f9 (2.9.0-DEVELOPMENT). No CVE yet. WebGUI PHP-FPM is root. Privilege pages that are not standard-warning-root are the leftover versus Diagnostics: Command.
Part 2 was exec / mwexec of a user string through /bin/sh -c. This chapter is the next grammar: a typed field lands in a config file, and the daemon that reads that file has a language. php.ini has auto_prepend_file. sshguard.conf has BACKEND=. mpd.conf has set iface up-script. Unbound has include:. crontab has another root line. OpenVPN's connect helper evals a pfctl string after the username has already expanded into it. Quote-close and CRLF are the operators. The argv of the restart often stays a path. That does not make the leftover Medium. The file is the program.
I still skip Plus, packages not in this tree, and anything that matches a public SA even when the fix landed after this commit. docs.netgate.com/advisories already named SA-26_06 (System Proxy crontab), SA-26_07 (vpn_openvpn_server.php dns_domain / netbios_scope, named page only), SA-26_20 (Kea custom JSON). #16128 sanitized PPP client mpd strings. #16637 put unbound-checkconf on General Settings. #10275 ltrims a leading ! off a PPPoE password. Those are the closed cousins. The leftovers below are the other call sites.
Nine of these are RCE because the extra line is an executable (prepend, BACKEND, up-script, crontab, bang helper, Unbound include that the lab dumped, OpenVPN eval / unquoted su -c). Five more are config injection that I will not inflate: extra community, extra ntpd language, extra igmpproxy line, extra dhcpd statement, extra dnctl pipe. The apply argv stayed a path. That is still daemon language. It is not a root shell.
Labs talk to loopback. Stacks tear down on EXIT.
timezone into php.ini auto_prepend_file
Class / reach: RCE / remote. Privilege: page-system-generalsetup. CWE / CVSS: CWE-94 / 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-timezone-phpini. Loopback 127.0.0.1:18940. Witness PFSENSE-TIMEZONE-PHPINI-WITNESS.
system.php writes $_POST['timezone'] with no in_array against system_get_timezone_list(). is_timezone() only rejects a trailing slash. Form_Select is the widget. Widgets do not re-validate POST. system.inc system_timezone_configure dumps the stored string into /var/db/zoneinfo. rc.php_ini_setup interpolates it into php.ini:
date.timezone="${TIMEZONE}"Quote plus newline closes that assignment and adds auto_prepend_file. Hostname and domain on the same page already have charset checks. Timezone does not.
The lab oracle is not php -c … -r. PHP CLI -r skips auto_prepend_file. php -c plus a php-fpm analog script (echo.php) does load it. That is the WebGUI worker, not a one-liner. CONTROL timezone UTC writes a single date.timezone line. TRIGGER UTC" plus auto_prepend_file=/oracle/prepend.php is accepted by is_timezone(), is not allowlisted, rebuilds php.ini, and the analog worker ran the prepend.
SUCCESS PFSENSE-TIMEZONE-PHPINI control-clean=yes trigger-prepend=yes is-timezone-accepts=yes not-allowlisted=yes PFSENSE-TIMEZONE-PHPINI-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST system.php timezone with a quote-close and auto_prepend_file= |
rc.php_ini_setup date.timezone="${TIMEZONE}" then php-fpm as root |
Integrity (lab-proven): generated php.ini accepted prepend; php -c plus a script ran it and wrote the witness. |
| Same leftover after php.ini rebuild / php-fpm restart | root PHP on every GUI request | Recon (inferred): prepend runs as root PHP. config.xml, keys, other tenants. |
| Hostile php.ini | WebGUI worker | Availability (inferred): broken php.ini can take the GUI down until repaired. |
Lab-proven vs inferred: control-clean, trigger-prepend, is_timezone accept, and not-allowlisted are lab-proven. Filesystem recon and GUI-down are inferred from root PHP-FPM loading that ini.
Requires: WebGUI session with page-system-generalsetup. CSRF-magic on POST. Default admin has the page. A limited user can be granted only this page. Diagnostics: Command is not required.
Closed adjacent: Form_Select is UI only. hostname/domain charset. Unauth N/A. php -c -r does not run prepend (that is why the analog script exists).
Fix: in_array timezone against system_get_timezone_list(). Quote-escape the rc.php_ini_setup interpolation. File-write leftovers in part 5 of this series can plant the prepend file; this leftover is the ini line that names it.
sshguard.conf THRESHOLD CRLF BACKEND
Class / reach: RCE / remote. Privilege: page-system-advanced-admin. CWE / CVSS: CWE-78 / 8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). Pack: abraxas/pfsense-sshguard-crlf. Loopback 127.0.0.1:18950. Witness PFSENSE-SSHGUARD-CRLF-WITNESS.
sshguard_threshold, blocktime, and detection_time are not is_numericint. syslog.inc system_sshguard_start writes them raw:
$sshguard_config[] = 'THRESHOLD=' .
config_get_path('system/sshguard_threshold') . "\n";The same function already emits a first BACKEND="/usr/local/libexec/sshg-fw-pf". sshguard last-wins a later BACKEND=. That value is the executable the daemon runs as the firewall backend. Save restarts sshguard. Sibling class of SA-26_06 (proxyuser crontab CRLF), different file. SA-25_09 is a different sshguard bug. Whitelist is is_subnet.
CONTROL is a single backend. TRIGGER extra BACKEND= last-wins; the stub sourced sshguard.conf and exec'd that backend.
SUCCESS PFSENSE-SSHGUARD-CRLF control-single-backend=yes trigger-backend-exec=yes PFSENSE-SSHGUARD-CRLF-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST Advanced Admin sshguard threshold / blocktime / detection_time with CRLF plus BACKEND= |
system_sshguard_start then sshguard exec of last BACKEND |
Integrity (lab-proven): extra BACKEND= last-wins; stub sourced the conf and ran that backend. |
| Same leftover | backend as root | Recon (inferred): backend runs as root. |
| Same leftover | sshguard pf backend | Availability (inferred): replace the real sshg-fw-pf backend. |
Lab-proven vs inferred: control-single-backend and trigger-backend-exec are lab-proven. Root recon and replacing sshg-fw-pf are inferred from the backend running as root.
Requires: WebGUI session with page-system-advanced-admin. CSRF-magic on POST. Diagnostics: Command is not required.
Closed adjacent: whitelist is is_subnet. SA-25_09 is a different sshguard bug. Unauth N/A.
Fix: is_numericint the three sshguard fields. Reject CR/LF.
L2TP / PPPoE RADIUS secret into mpd.conf
Class / reach: RCE / remote. Privilege: page-vpn-vpnl2tp (sibling page-services-pppoeserver-edit). CWE / CVSS: CWE-78 / 8.8. Pack: abraxas/pfsense-l2tp-radius-mpd. Loopback 127.0.0.1:18960. Witness PFSENSE-L2TP-RADIUS-MPD-WITNESS.
vpn.inc interpolates the RADIUS secret quoted but unescaped. L2TP:
set radius server {$radiusip} "{$radiussecret}"PPPoE server is the same interpolation with ports on the line. PPP client already runs secrets through get_mpd_str(). Servers do not. #16128 is client-only. A quote-close secret adds set iface up-script. mpd last-wins that directive. The up-script runs as root on the next L2TP (or PPPoE) session.
CONTROL quoted secret: one radius line, no extra up-script, no witness. TRIGGER quote-close: extra up-script, last-wins, witness. get_mpd_str absent on this path.
SUCCESS PFSENSE-L2TP-RADIUS-MPD control-quoted-secret=1 control-extra-up-script=no control-witness=no trigger-extra-up-script=yes trigger-last-wins=yes trigger-witness=yes get_mpd_str=no PFSENSE-L2TP-RADIUS-MPD-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
| POST L2TP (or PPPoE server) RADIUS secret with a quote-close | vpn.inc set radius server … "{$radiussecret}" then mpd up-script |
Integrity (lab-proven): extra set iface up-script; stub mpd last-wins ran it. |
| Same leftover on the next session | up-script as root | Recon (inferred): up-script runs as root on the next L2TP session. |
| Hostile mpd.conf | mpd / VPN | Availability (inferred): break mpd parse / VPN service. |
Lab-proven vs inferred: control quoted secret with no extra up-script, trigger extra up-script last-wins, trigger witness, and get_mpd_str=no are lab-proven. Root recon on the next session and VPN disruption are inferred.
Requires: page-vpn-vpnl2tp or sibling page-services-pppoeserver-edit. CSRF-magic on POST. The next L2TP/PPPoE session is what fires the up-script.
Closed adjacent: PPP client get_mpd_str already on this pin. CONTROL secret does not write the witness.
Fix: run RADIUS secrets through get_mpd_str on L2TP and PPPoE server configure, not only the client path.
PPPoE periodic-reset crontab
Class / reach: RCE / remote. Privilege: page-interfaces-ppps-edit. CWE / CVSS: CWE-78 / 8.8. Pack: abraxas/pfsense-pppoe-cron. Loopback 127.0.0.1:18970. Witness PFSENSE-PPPOE-CRON-WITNESS.
interfaces.inc handle_pppoe_reset uses hidden ptpid without is_numericint. Custom hour/minute validation is AND where it needed OR:
if ($_POST['pppoe-reset-type'] == "custom" && $_POST['pppoe_resethour'] <> "" &&
!is_numericint($_POST['pppoe_resethour']) &&
$_POST['pppoe_resethour'] >= 0 && $_POST['pppoe_resethour'] <=23) {A value that fails is_numericint will not also sit in 0-23, so the error never fires. configure_cron then writes fields raw. Sibling of SA-26_06, not proxyuser. This is persistence: an extra root crontab line, not a one-shot exec.
CONTROL one crontab line. TRIGGER newline in ptpid added an extra root line; stub cron executed it.
SUCCESS PFSENSE-PPPOE-CRON control-one-line=yes trigger-extra-root-line=yes stub-cron-ran-extra=yes PFSENSE-PPPOE-CRON-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST PPPs edit ptpid / reset hour with a newline |
handle_pppoe_reset then configure_cron raw fields |
Integrity (lab-proven): extra root crontab line; stub cron ran it. |
| Same leftover | extra cron as root | Recon (inferred): extra cron as root. |
| Same leftover | periodic commands | Availability (inferred): extra periodic commands. |
Lab-proven vs inferred: control-one-line, trigger extra root crontab line, and stub cron executing it are lab-proven. Broader recon and extra periodic commands are inferred from root cron.
Requires: page-interfaces-ppps-edit. CSRF-magic on POST. Hidden ptpid is still POST.
Closed adjacent: SA-26_06 is System Proxy crontab. #16128 is PPP client sanitize.
Fix: is_numericint ptpid. Reject CR/LF in hour/minute. Quote crontab fields. Fix the AND/OR on the custom hour/minute check.
PPPoE mpd.secret username bang helper
Class / reach: RCE / remote. Privilege: page-services-pppoeserver-edit. CWE / CVSS: CWE-78 / 8.8. Pack: abraxas/pfsense-pppoe-secret-bang. Loopback 127.0.0.1:18980. Witness PFSENSE-PPPOE-SECRET-BANG-WITNESS.
PPPoE username charset allows CR/LF. Password ! strip (#10275) ltrims a leading ! off the password only:
$pass = str_replace('"', '\"', ltrim(base64_decode($data[1]), '!'));
$mpdsecret .= "{$data[0]} \"{$pass}\" {$data[2]}\n";$data[0] is the username, unescaped. mpd treats a line whose password field is ! plus a path as an external password helper and runs it as root on PAP/CHAP. Direct POST keeps newlines. Browser type=text strips them, so a click-ops operator will not plant this from the widget. A limited-page POST will.
L2TP usernames already reject [^a-zA-Z0-9\.\@\-_] and a leading ! on the password. PPPoE server users did not get that charset.
CONTROL one secret line, no helper, password ! GUI rejected. TRIGGER username newline plus ! helper; stub PAP/CHAP ran the helper.
SUCCESS PFSENSE-PPPOE-SECRET-BANG control-one-line=yes control-no-helper=yes password-bang-strip=yes trigger-helper=yes PFSENSE-PPPOE-SECRET-BANG-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST PPPoE server username with CR/LF, second line a ! helper |
vpn_pppoe_updatesecret mpd.secret then mpd PAP/CHAP helper |
Integrity (lab-proven): username newline plus ! helper; stub PAP/CHAP ran it. Password ! GUI rejected. |
| Same leftover | helper stdout | Recon (inferred): helper stdout unused. |
| Same leftover | Availability: none shown. |
Lab-proven vs inferred: control-one-line, control-no-helper, password-bang-strip, and trigger-helper are lab-proven. Helper stdout unused is inferred. Availability none shown.
Requires: page-services-pppoeserver-edit to plant. Direct POST (browser type=text strips newlines). Then a PAP/CHAP attempt against that user.
Closed adjacent: password ! GUI plus ltrim. L2TP username charset. Browser newline strip.
Fix: reject CR/LF in PPPoE usernames. Apply the same charset as L2TP.
This leftover fires when a client authenticates, not when the GUI saves. PPPoE cron is persistence on a timer. The bang helper is persistence on PAP/CHAP.
Unbound sock_queue_timeout CRLF include:
Class / reach: RCE / remote. Privilege: page-services-dnsresolver-advanced. CWE / CVSS: CWE-74 / 8.8. Pack: abraxas/pfsense-unbound-sock-timeout. Loopback 127.0.0.1:19050. Witness PFSENSE-UNBOUND-SOCK-TIMEOUT-WITNESS.
services_unbound_advanced.php allowlists message cache, TCP buffers, EDNS size, jostle timeout, cache TTLs. sock_queue_timeout is stored raw. unbound.inc emits it with no test_unbound_config on this page:
sock-queue-timeout: {$sock_queue_timeout}CRLF adds include: / logfile: / python-script:. #16637 is General Settings checkconf only. Documented custom_options is a separate leftover; this field is a typed integer in the UI.
CONTROL one timeout, checkconf would pass that blob. TRIGGER extra include:; stub unbound followed it and dumped the planted witness. Advanced save does not run checkconf.
SUCCESS PFSENSE-UNBOUND-SOCK-TIMEOUT control-one-timeout=yes control-checkconf-pass=yes trigger-include=yes trigger-dump-witness=yes advanced-no-checkconf=yes PFSENSE-UNBOUND-SOCK-TIMEOUT-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST DNS Resolver Advanced sock_queue_timeout with CRLF plus include: |
unbound.inc sock-queue-timeout: then unbound parse |
Integrity (lab-proven): extra include: in generated conf; stub unbound followed it and dumped the planted witness. |
include: of a host path |
Unbound parse | Recon (lab-proven): Unbound parse reads the included host file. |
| Poisoned conf | resolver | Availability (inferred): poisoned conf can fail resolver start. |
Unbound include: dumps whatever path you point at. That is recon, not a guess. It can also pull hostile conf (python-script:, extra local-data, a second include). The lab showed the dump.
Lab-proven vs inferred: control-one-timeout, control-checkconf-pass, trigger-include, trigger-dump-witness, and advanced-no-checkconf are lab-proven. Resolver fail-to-start is inferred.
Requires: page-services-dnsresolver-advanced. CSRF-magic on POST. No checkconf on this path.
Closed adjacent: documented custom_options textarea is a different leftover. General Settings checkconf does not cover this field.
Fix: is_numericint sock_queue_timeout. Run test_unbound_config on Advanced save.
Unbound ACL name / action CRLF include:
Class / reach: RCE / remote. Privilege: page-services-dnsresolver-acls. CWE / CVSS: CWE-74 / 8.8. Pack: abraxas/pfsense-unbound-acl-crlf. Loopback 127.0.0.1:19060. Witness PFSENSE-UNBOUND-ACL-CRLF-WITNESS.
Same include: class, different typed field. ACL aclname is written as a comment. aclaction is written as the third token of access-control:. Neither is charset-checked. There is no checkconf on ACL save. network/mask is is_ipaddr. descr is not interpolated. #16637 does not mention ACLs.
$aclcfg .= "#{$unbound_acl['aclname']}\n";
$aclcfg .= "access-control: {$network['acl_network']}/{$network['mask']} {$action}\n";CONTROL one stanza. TRIGGER CRLF include: in name or action; stub unbound dumped the planted witness. Checkconf on ACL save: no.
SUCCESS PFSENSE-UNBOUND-ACL-CRLF control-one-stanza=yes trigger-name-include=yes trigger-action-include=yes checkconf-on-acl-save=no PFSENSE-UNBOUND-ACL-CRLF-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
POST DNS Resolver ACLs name or action with CRLF plus include: |
unbound_acls_config access_lists.conf |
Integrity (lab-proven): CRLF include: in name or action; stub unbound dumped the planted witness. |
| Same leftover | included host file | Recon (lab-proven): included host file body returned. |
| Same leftover | resolver start | Availability (inferred): fail resolver start. |
Lab-proven vs inferred: control-one-stanza, trigger-name-include, trigger-action-include, and checkconf-on-acl-save=no are lab-proven. Included host file body is lab-proven recon. Resolver fail-to-start is inferred.
Requires: page-services-dnsresolver-acls. CSRF-magic on POST.
Closed adjacent: network/mask is_ipaddr. descr not interpolated. #16637 does not mention ACLs.
Fix: charset-check ACL name/action. test_unbound_config on ACL save.
Two pages, one Unbound grammar. Advanced is a numeric field that was not numeric. ACLs are a name and an action enum that were not enums. Both skip the checkconf that General Settings already grew.
OpenVPN connect_async eval of a RADIUS username
Class / reach: RCE / remote. Privilege: OpenVPN server_user / server_tls_user client (RADIUS/LDAP username). CWE / CVSS: CWE-78 / 8.8. Pack: abraxas/pfsense-ovpn-connect-eval. Loopback 127.0.0.1:19070. Witness PFSENSE-OVPN-CONNECT-EVAL-WITNESS.
This one is not a local admin typing in the GUI. openvpn.connect_async.sh builds anchorname=openvpn/${dev}_${username}_${trusted_port} and then:
eval "/sbin/pfctl -a '${anchorname}' -f '${rulesfile}'"The single quotes are in the eval string. After expand they are syntactic quotes around a value that already contains the username. They do not contain a username that itself holds a quote. Local GUI system_usermanager.php rejects [^a-zA-Z0-9\.\-_]. RADIUS and LDAP usernames skip that check. The injector is the remote-auth username on connect, not a limited GUI user pasting into a form.
Username alice'; w; : ' ran extra w. CONTROL eval of a clean username stayed one pfctl. Local GUI charset rejects the trigger username.
SUCCESS PFSENSE-OVPN-CONNECT-EVAL control-eval=yes local-gui-charset-rejects-trigger=yes trigger-eval=yes PFSENSE-OVPN-CONNECT-EVAL-WITNESSDisconnect uses the same eval to -F rules, and rm /tmp/${username}. The lab showed the connect eval and the pfctl argv for that session.
What an attacker can do
| Method | Sink | Privilege |
|---|---|---|
| OpenVPN client-connect with a RADIUS/LDAP username that closes the eval quotes | openvpn.connect_async.sh eval pfctl -a '${anchorname}' |
Integrity (lab-proven): alice'; w; : ' ran extra w. Local GUI charset rejects that username. |
| Same leftover | pfctl argv; disconnect rm /tmp/${username} |
Recon (lab-proven): pfctl argv for that session. Disconnect rm inferred. |
| Same leftover | pfctl -F on the user anchor |
Availability (inferred): flush rules on the user anchor. |
Lab-proven vs inferred: control-eval, local-gui-charset-rejects-trigger, trigger-eval, and pfctl argv for that session are lab-proven. Disconnect rm /tmp/${username} and pfctl -F impact are inferred.
Requires: an OpenVPN server_user / server_tls_user that authenticates against RADIUS or LDAP. The local GUI charset would reject that username. Not a local admin typing in the GUI.
Closed adjacent: Local Database username charset. Unauth N/A in the gadget (you still need a successful RADIUS/LDAP bind as that name, or a directory you control).
Fix: stop eval. Pass the anchor as a literal argv to pfctl. Filter RADIUS/LDAP usernames the same as local.
Remote-auth leftovers are easy to miss if you only POST the GUI. The GUI charset is doing its job. The connect script is a second parser, and it trusts a name that never passed that charset.
OpenVPN learn-address CN as unbound
Class / reach: RCE / remote. Privilege: OpenVPN cert client CN when Unbound "Register OpenVPN clients" is on. CWE / CVSS: CWE-78 / 8.1 High (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H). Pack: abraxas/pfsense-ovpn-learn-cn. Loopback 127.0.0.1:19080. Witness PFSENSE-OVPN-LEARN-CN-WITNESS.
Different privilege than eval. Eval is a RADIUS/LDAP username. This is a certificate CN. openvpn.learn-address.sh interpolates CN unquoted:
/usr/bin/su -m unbound -c "/usr/local/sbin/unbound-control -c /var/unbound/unbound.conf local_data ${CN}.${DOMAIN} ${ARECORD} ${IP}"Gated on unbound/regovpnclients. AC:H is that gate, not a mystery. A CN without a shell char still plants unsanitized local-data (the CONTROL path). A CN with $(w) ran extra w as the su -c string.
SUCCESS PFSENSE-OVPN-LEARN-CN control-local-data=yes control-no-witness=yes m24-local-data-plant=yes trigger-exec=yes PFSENSE-OVPN-LEARN-CN-WITNESSWhat an attacker can do
| Method | Sink | Privilege |
|---|---|---|
| OpenVPN learn-address with a hostile CN while Register OpenVPN clients is on | su -m unbound -c "… local_data ${CN} …" |
Integrity (lab-proven): CN $(w) ran extra w as the su -c string. CONTROL also planted unsanitized local-data. |
| Same leftover | commands as unbound | Recon (inferred): commands as unbound. |
| Same leftover, even without a shell char | resolver local-data |
Availability (inferred): poison resolver local-data. Lab-proven plant; impact inferred. |
Lab-proven vs inferred: control-local-data, control-no-witness, m24-local-data-plant, and trigger-exec are lab-proven. Commands as unbound and poisoning the resolver for clients are inferred from that plant plus the su -c extra command.
Requires: Unbound Register OpenVPN clients on, plus a cert client whose CN you control. Different privilege than eval.
Closed adjacent: off unless Register OpenVPN clients is enabled. Local CN charset may still allow some chars.
Fix: quote CN. Reject shell metacharacters. Keep local-data through unbound-control without a shell.
Eval and learn-cn sit next to each other in the OpenVPN helper directory and they are not the same leftover. One is a RADIUS name into eval. One is a cert CN into unquoted su -c, plus a silent local-data plant that does not need a metacharacter at all.
Config injection is still daemon language
These five change the file a daemon parses. The apply argv stayed a path. I am not calling them RCE. Conf injection without argv change still changes daemon language: extra community, extra ntpd peer, extra dhcp option, extra igmpproxy line, extra dummynet pipe.
bsnmpd syslocation quote, extra community
Class / reach: config injection / remote. Privilege: page-services-snmp. CWE / CVSS: CWE-74 / 5.4 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N). Pack: abraxas/pfsense-bsnmpd-conf. Loopback 127.0.0.1:19220.
services.inc services_snmpd_configure:
location := "{$snmpdcfg['syslocation']}"GUI still only rejects #. Quote-close adds read := community. No include/exec without a planted .so. mwexec stayed bsnmpd -c of that path.
SUCCESS PFSENSE-BSNMPD-CONF control-one-syslocation=yes trigger-extra-community=yes PFSENSE-BSNMPD-CONF-WITNESS| Method | Sink | Privilege |
|---|---|---|
| POST SNMP syslocation with a quote/semicolon | location := "…" then extra read := |
Integrity (lab-proven): extra community line in snmpd.conf. mwexec stayed bsnmpd -c <path>. |
| Same leftover | second community | Recon (inferred): extra community can widen SNMP read if bsnmpd honors the second read. |
| Parse-fail snmpd | Availability (inferred). |
Lab-proven vs inferred: extra community line and mwexec staying bsnmpd -c <path> are lab-proven. Widened SNMP read and parse-fail are inferred. Not shell.
Closed adjacent: no include/exec without a planted .so. Not shell.
Fix: reject quote/semicolon/newline in location/contact/community/trap.
NTP GPS / PPS fudge newline
Class / reach: config injection / remote. Privilege: page-services-ntpd-gps / page-services-ntpd-pps. CWE / CVSS: CWE-74 / 5.4 (C:N/I:L/A:L). Pack: abraxas/pfsense-ntp-gps-fudge. Loopback 127.0.0.1:19230.
system.inc concatenates GPS/PPS fudge/refid/stratum into ntpd.conf. mwexec is ntpd -c of that path. Newline added extra ntpd language. Not shell.
SUCCESS PFSENSE-NTP-GPS-FUDGE control-fudge0=yes trigger-extra-ntpd=yes not-shell=yes PFSENSE-NTP-GPS-FUDGE-WITNESS| Method | Sink | Privilege |
|---|---|---|
| POST NTP GPS/PPS fudge/refid/stratum with a newline | ntpd.conf fudge 127.127.20.0 … |
Integrity (lab-proven): extra ntpd language. Not shell. |
| same leftover | Recon: none shown. | |
| Hostile ntpd.conf | time sync | Availability (inferred): hostile ntpd.conf can fail time sync. |
Lab-proven vs inferred: extra ntpd language and not-shell are lab-proven. Time-sync failure is inferred. mwexec argv is the conf path.
Closed adjacent: not shell. mwexec argv is the conf path.
Fix: numeric/charset-check fudge/refid/stratum. Reject CR/LF.
IGMP Proxy type newline
Class / reach: config injection / remote. Privilege: page-services-igmpproxy-edit. CWE / CVSS: CWE-74 / 5.4. Pack: abraxas/pfsense-igmp-type. Loopback 127.0.0.1:19240.
services_igmpproxy_edit.php stores POST type with no enum check. Configure interpolates phyint {$realif} {$igmpcf['type']} … into igmpproxy.conf. Extra conf line. Daemon argv unchanged. Not shell. ifname still needs an IPv4 address. threshold is is_numeric. addresses is is_subnetv4.
SUCCESS PFSENSE-IGMP-TYPE control-type-upstream=yes trigger-extra-conf=yes PFSENSE-IGMP-TYPE-WITNESS| Method | Sink | Privilege |
|---|---|---|
POST IGMP Proxy edit type with a newline |
phyint {$realif} {$igmpcf['type']} … |
Integrity (lab-proven): extra conf line. Daemon argv unchanged. Not shell. |
| same leftover | Recon: none shown. | |
| Parse-fail igmpproxy | multicast | Availability (inferred). |
Lab-proven vs inferred: extra conf line and unchanged daemon argv are lab-proven. Parse-fail is inferred.
Closed adjacent: ifname needs an IPv4 ipaddr. threshold is_numeric. addresses is_subnetv4.
Fix: enum type to upstream/downstream. Reject CR/LF.
ISC dhcpd.conf filename quote
Class / reach: config injection / remote. Privilege: page-services-dhcpserver-editstaticmapping. CWE / CVSS: CWE-74 / 5.4. Pack: abraxas/pfsense-dhcpd-conf. Loopback 127.0.0.1:19250.
services_dhcpdv4_configure interpolates filename "{$sm['filename']}"; with no addslashes. GUI stores $_POST['filename'] with no quote/newline gate. Extra option tftp-server-name in the chroot conf. Filename never reached argv. Kea JSON is SA-26_20, skipped. Host shell, files outside the chroot, hostname charset, and numberoptions text quotes stayed closed.
SUCCESS PFSENSE-DHCPD-CONF control-one-filename=yes trigger-extra-statement=yes PFSENSE-DHCPD-CONF-WITNESS| Method | Sink | Privilege |
|---|---|---|
POST DHCP static mapping filename with a quote-close |
filename "{$sm['filename']}"; |
Integrity (lab-proven): extra option tftp-server-name in chroot conf. Filename never reached argv. |
| same leftover | Recon: none shown. | |
| Parse-fail dhcpd or extra PXE option | DHCP clients | Availability (inferred). |
Lab-proven vs inferred: extra statement in chroot conf is lab-proven. Host shell and files outside the chroot stayed closed.
Closed adjacent: host shell. Files outside the chroot. Kea JSON. hostname charset. numberoptions text quotes.
Fix: addslashes / reject quote and newline in filename/rootpath/ldap/DUID.
limiter AQM into rules.limiter
Class / reach: config injection / remote. Privilege: page-firewall-trafficshaper-limiter. CWE / CVSS: CWE-74 / 5.4. Pack: abraxas/pfsense-limiter-aqm. Loopback 127.0.0.1:19260.
FormatParameters / vsprintf of AQM params (target %sms interval %sms …) into /tmp/rules.limiter, then mwexec("/sbin/dnctl {$g['tmp_path']}/rules.limiter"). Apply argv is the file path. Extra pipe line in rules.limiter. Witness is not in argv. Not shell.
The shell cousin is already public: limiter hidden number into dnctl delete (abraxas/pfsense-limiter-number). Create overwrites number. Edit concatenates GetNumber() into dnctl pipe delete unquoted. That is part 2 grammar. This AQM leftover is part 3 grammar on the same page.
SUCCESS PFSENSE-LIMITER-AQM control-one-pipe-line=yes trigger-extra-dnctl=yes apply-file-argv=yes PFSENSE-LIMITER-AQM-WITNESS| Method | Sink | Privilege |
|---|---|---|
| POST limiter AQM target/interval with a newline | vsprintf into /tmp/rules.limiter then dnctl of that path |
Integrity (lab-proven): extra pipe line in rules.limiter. Apply argv stayed dnctl of that path. Not shell. |
| same leftover | apply argv | Recon (lab-proven): witness is not in argv. |
| Hostile dummynet pipes after apply | traffic shaper | Availability (inferred). |
Lab-proven vs inferred: extra pipe line, apply-file-argv, and witness-not-in-argv are lab-proven. Hostile pipes after apply are inferred. Not shell.
Closed adjacent: not shell. Limiter names stay charset-limited. Create overwrite and delete concat already mailed as limiter-number.
Fix: numeric-check AQM params. Reject CR/LF. Limiter names stay charset-limited; leave them that way.
Already-published siblings
Two conf-breakout leftovers from the first mail (13 Oct 2026) already have their own posts. They belong in this phase even though they shipped with the original five.
OpenVPN wizard CRLF (abraxas/pfsense-openvpn-wizard-crlf) is the leftover of SA-26_07. The SA named vpn_openvpn_server.php dns_domain / netbios_scope. The wizard copies the same keys from defaultdomain / nbtscope with no is_domain() check, then openvpn_add_dhcpopts writes them into a server config.ovpn that already has script-security 3. Quote plus LF adds up. OpenVPN 2.6.14 ran that up. CCD up stayed ignored. Privilege page-pfsensewizardsubsystem. Not OpenVPN advanced.
IPsec login_banner into charon start-scripts (abraxas/pfsense-ipsec-login-banner) dumps Mobile login_banner unquoted into strongswan.conf attr.28672. A Form_Input-safe banner can close attr/plugins and plant charon.start-scripts. Apply restarts charon, which popens that path. IPsec mobile dns_domain on the same page already uses is_domain(). Privilege page-vpn-ipsec-mobile.
G4 limiter-number, linked above, is the shell cousin of limiter-aqm. Same page, two grammars.
How they chain
Timezone php.ini prepend is host PHP RCE. The leftover is the directive. The prepend file still has to exist. File-write leftovers in part 5 of this series (Captive Portal file manager path, DynDNS cache path, and friends) can plant that file. A limited user who has General Setup plus a write page does not need Diagnostics: Command to get a root auto_prepend.
Unbound include: is recon and load. Point it at /cf/conf/config.xml and the resolver parse is a file read. Point it at a planted conf and you own local-data, or python-script: if the build has it. Sock-timeout and ACL name/action are two typed fields into the same grammar because checkconf was added to one page and not the others.
OpenVPN eval needs a RADIUS/LDAP username the local GUI charset would reject, so it is a remote-auth leftover, not a local admin typing in the GUI. learn-cn is a cert CN, different privilege than eval, and it still plants unsanitized local-data when the CN is boring.
PPPoE cron is persistence on a timer. The mpd bang helper fires on PAP/CHAP. The L2TP/PPPoE RADIUS secret up-script fires on the next session. Three mpd/PPP leftovers, three clocks.
Conf injection without argv change still changes daemon language. Extra SNMP community, extra ntpd peer, extra dhcp option, extra igmpproxy type line, extra dummynet pipe. I scored them 5.4 and I am leaving them there. The RCE leftovers in this chapter are the ones whose extra line is something the daemon runs.
The shape of a leftover
A public SA names a page, a helper, a Redmine. The helper has two callers. The charset sits on Local Database and not on RADIUS. get_mpd_str sits on the client and not on the server. unbound-checkconf sits on General and not on Advanced or ACLs. is_numericint sits on cache TTL and not on sock_queue_timeout. #10275 strips ! off a password and leaves the username line-breakable. eval quotes a string that has already expanded.
I wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. These conf-only five are on the floor as RCE and on the table as "the daemon will honor the extra statement." The nine RCE leftovers, plus the two already-published wizard/banner siblings, are the High.
Next chapter is config takeover: privilege leftovers that install a blob or collide a remote name onto uid 0, without interpolating a daemon file at all.
Abraxas Labs
https://abraxaslabs.tech
https://github.com/abraxas
https://x.com/abraxas_null (@abraxas_null)
abraxas.null@proton.me