Series0-dayNo CVEMediumPublic

pfSense CE leftover interpolations · Phase 5

pfSense CE leftovers, part 6: stored and reflected XSS, plus $http_host

Seven leftover interpolations on pfSense CE 2.9.0-DEVELOPMENT that still become script in a browser: one stored XSS on the unauth captive-portal login, six GUI-origin XSS sinks, and an unauthenticated nginx 301 that copies $http_host into Location. Same-origin script can POST as the victim because csrf-magic is already in the page. SameSite=Strict does not stop that. PHP DNS-rebind on the HTTPS GUI stays closed.

Name
pfSense CE leftovers, part 6: stored and reflected XSS, plus $http_host
Series
pfSense CE leftover interpolations
Phase
5 · XSS and redirect
Type
0-day analysis
CVE
n/a
CVE Risk
medium
Disclosure Status
public
Vendor
Netgate
Affected
pfSense CE 2.9.0-DEVELOPMENT (9363ac5). Captive Portal default login HTML, status_logs_filter.php, firewall_rules.php, rss.widget.php, filter display_separator, head.inc, crash_reporter.php, system.inc HTTP-to-HTTPS nginx server{}.
Published
15 Oct 2026
Updated
15 Oct 2026
Tags
0-day, pfsense, xss, open-redirect, cwe-79

Medium is the bridge

I am @abraxas_null. This is part 6 of pfSense CE leftover interpolations. Previous: files and portal. Next: chains.

I said in the QinQ note that Medium stays on the floor unless it chains. These eight leftovers are Medium. CWE-79 and one CWE-601. They are not root shells. They are not Diagnostics: Command. They are leftover interpolations into HTML, JSON-as-HTML, a class= attribute, a logout title, a crash textarea, and an nginx return 301. The reason they are in this series is the same reason csrf-magic exists: a same-origin script in the WebGUI can mint the token the form already printed and POST as the logged-in victim. SameSite=Strict does not stop same-origin script. That is the bridge into the part 2 through part 5 leftovers the victim can already reach.

This is pfSense CE 2.9.0-DEVELOPMENT (9363ac5b8651a1c7a333180425ce7719070f95f9). No CVE yet. WebGUI PHP-FPM is still root. Unauth WebGUI login is still closed. The HTTP listener is not.

Seven XSS packs and one open-redirect pack. Loopback labs. Authorized lab only.

Leftover Class Privilege CVSS 3.1 Pack
Captive Portal filename into default login HTML stored XSS plant page-services-captiveportal-filemanager; victim is an unauth portal client 6.1 pfsense-cp-filename-xss
status_logs_filter resolve JSON as text/html reflected XSS page-diagnostics-logs-firewall 5.4 pfsense-logs-filter-xss
firewall_rules.php GET if= reflected XSS page-firewall-rules 5.4 pfsense-rules-if-xss
RSS item body / permalink leftover of SA-26_04 stored XSS page-all / dashboard widgets 5.4 pfsense-rss-body-xss
firewall separator color into class= stored XSS page-firewall-rules or NAT order-store 5.4 pfsense-separator-color-xss
LDAP/RADIUS session username in head.inc stored XSS LDAP/RADIUS WebGUI session 5.4 pfsense-auth-username-xss
crash_reporter textarea of /var/crash stored XSS page-diagnostics-crash-reporter 5.4 pfsense-crash-reporter-xss
HTTP-to-HTTPS 301 uses $http_host open redirect unauth client on the WebGUI HTTP listener 4.7 pfsense-https-host-redirect

Captive Portal filename on the unauth login

Pack: abraxas/pfsense-cp-filename-xss. Privilege to plant: page-services-captiveportal-filemanager. Victim: an unauthenticated captive-portal client. Loopback 127.0.0.1:19110.

The file manager prefixes captiveportal- onto an upload name unless the name already contains that substring. The GUI list of installed names runs htmlspecialchars. The default portal login HTML does not. get_captive_portal_logo walks zone elements for a name containing captiveportal-logo. and returns that name as $logo_src. get_captive_portal_bg does the same for captiveportal-background. and concatenates it into url(...). get_default_captive_portal_html interpolates both raw:

PHP
<img src="{$logo_src}"/><br>

and, in the inline stylesheet:

PHP
background:{$bg_src};

A stored name captiveportal-logo.png" onerror="PFSENSE-CP-FILENAME-XSS-WITNESS is a quote-break on the unauth portal page. Custom logo and custom background flags on the zone are how the name is selected. CONTROL with logo.png stayed inert. TRIGGER with the quote-break was raw in the <img> and in the CSS url(...). The login form still has name="auth_user" and name="auth_pass" on that same HTML. Script on the portal origin can read those fields as the user types. That is inferred. The lab proved the raw onerror= plus the witness in unauth portal HTML, and it proved the GUI list encoded the same name.

SA-26_05 is the GUI widget. Different origin. The portal cookie jar is not the WebGUI cookie jar. Lab flag gui-cookie-theft=no. I did not steal a GUI session from this page. I can still phish portal credentials on a page the operator put in front of every unauth client that hits the zone.

Closed: GUI cookies. File-manager list encode. SA-26_05 widget.

Fix: htmlspecialchars the filename in portal HTML. Restrict upload charset. basename() was the sibling leftover in files and portal. Encode is this leftover.

Last run:

Plain text
SUCCESS PFSENSE-CP-FILENAME-XSS control-inert=yes trigger-raw=yes witness-unescaped=yes gui-cookie-theft=no PFSENSE-CP-FILENAME-XSS-WITNESS

JSON that the browser treats as HTML

Pack: abraxas/pfsense-logs-filter-xss. Privilege page-diagnostics-logs-firewall. Loopback 127.0.0.1:19130.

status_logs_filter.php has an AJAX resolve branch. POST resolve= copies the value through strtolower, optionally gethostbyaddr if is_ipaddr is true, then:

PHP
echo json_encode(str_replace("\\", "\\\\", $response)); // single escape chars can break JSON decode
exit;

No Content-Type: application/json. PHP's default is text/html. No JSON_HEX_TAG. No X-Content-Type-Options: nosniff. The comment is about backslashes breaking a JSON decode. The response is not served as JSON. json_encode without JSON_HEX_TAG leaves < and > as those bytes. A browser that sniffs text/html will parse a <script> in that body as script, not as a JSON string.

resolve_ip is the POST body even when it is not an IP. is_ipaddr fails, resolve_text becomes Cannot resolve, and json_encode still emits the attacker string as a JSON object the browser parses as HTML. A <script> in resolve= is reflected in the GUI origin. The resolve POST is the same origin as the logs page. csrf-magic is already in that page. The script does not need a second click to keep going.

Lab-proven: unescaped script tag in JSON-as-HTML. Inferred: that script can drive further GUI POSTs as the victim, because csrf-magic is already on the page that issued the resolve. SameSite=Strict does not apply to a same-origin fetch from that script. Unauth is N/A. The gadget requires the firewall-logs page.

Closed: application/json plus nosniff. JSON_HEX_TAG. csrf-magic as a CSRF defense against a foreign site. SameSite=Strict as a CSRF defense against a foreign site. Unauth N/A.

Fix: send Content-Type: application/json. json_encode(..., JSON_HEX_TAG|JSON_HEX_AMP). Prefer nosniff on every GUI response.

Last run:

Plain text
SUCCESS PFSENSE-LOGS-FILTER-XSS control-json-html=yes trigger-unescaped-script=yes PFSENSE-LOGS-FILTER-XSS-WITNESS

GET if= into an attribute and a JS string

Pack: abraxas/pfsense-rules-if-xss. Privilege page-firewall-rules. Loopback 127.0.0.1:19140.

firewall_rules.php copies $_REQUEST['if'] into $if. When requirefirewallinterface is off, an unknown if is replaced with the first configured interface (usually lan). When the knob is on, the unknown value is kept. Then the page prints it twice without encode:

PHP
<input name="if" id="if" type="hidden" value="<?=$if?>" />
JavaScript
iface = "<?=strtolower($if)?>";

Other $if interpolations on the same page, hrefs and Location, already use htmlspecialchars. These two do not. A quote-break in GET if= is raw in the hidden attribute and in the JS string the helpers read.

Lab-proven: quote-break raw in both sinks with require-on. CONTROL with require-off replaced the unknown if and stayed inert. Cookie is httponly and SameSite=Strict. That stops a foreign-site CSRF of the GET. It does not stop a victim who opens a GUI link, and it does not stop a same-origin script that already ran.

Closed: other $if hrefs and Location. require-off replacement. Unauth N/A.

Fix: htmlspecialchars($if, ENT_QUOTES) in both sinks. Allowlist if against configured interfaces, including any / EthernetRules / FloatingRules.

Last run:

Plain text
SUCCESS PFSENSE-RULES-IF-XSS control-inert=yes trigger-attr=yes trigger-js=yes PFSENSE-RULES-IF-XSS-WITNESS

SA-26_04 encoded the title. Body and permalink stayed.

Pack: abraxas/pfsense-rss-body-xss. Privilege page-all / dashboard widgets. Loopback 127.0.0.1:19150.

The pin commit is "Encode RSS widget post titles." That is SA-26_04. rss.widget.php now does:

PHP
<?=htmlspecialchars(strip_tags($item->get_title()))?>

Body is still strip_tags plus textLimit. Permalink is still a raw href:

PHP
<a href="<?=$item->get_permalink()?>" target="_blank" class="list-group-item">

strip_tags without an allowlist does not encode attribute context. A body with a leftover onerror= after tag stripping still fires. A permalink of javascript: is a click-XSS on the dashboard. Title encoded. Body and permalink not.

Lab-proven: title-encoded=yes, body-onerror=yes, permalink-javascript=yes. Inferred: dashboard XSS as the logged-in user. The operator chose the feed URL. A hostile feed, or the SimplePie file:// Location leftover from files and portal, is how the body arrives.

Closed: 26_04 title encode. That was the last commit on this pin. It was not the last sink on the widget.

Fix: htmlspecialchars the body text. Allowlist permalink to http / https.

Last run:

Plain text
SUCCESS PFSENSE-RSS-BODY-XSS title-encoded=yes body-onerror=yes permalink-javascript=yes PFSENSE-RSS-BODY-XSS-WITNESS

Separator color is not a color

Pack: abraxas/pfsense-separator-color-xss. Privilege page-firewall-rules or page-firewall-nat-portforward order-store. Loopback 127.0.0.1:19160.

The UI offers bg-info / bg-danger / bg-success / bg-warning. display_separator concatenates $separator['color'] into class= three times with no encode. Separator text is htmlspecialchars. Color is not.

PHP
$cellcolor = $separator['color'];
print('<tr class="ui-sortable-handle separator">' .
    '<td class="' . $cellcolor . '" colspan="' . ($columns_in_table -1) . '">' .
    '<span class="' . $cellcolor . '">' . htmlspecialchars($separator['text']) . '</span></td>' .
    '<td  class="' . $cellcolor . '">...');

firewall_rules.php order-store copies $_POST['separator'] into config with no color whitelist. NAT reorder is the same copy into nat/separator. Three interpolations, two pages, one sink.

The string-row helper JS does not match row/0. That path stayed closed. Text encode stayed closed. The quote-break in class= did not.

Lab-proven: quote-break raw in class= on three interpolations. nat-same-sink=yes. Stored XSS in the GUI origin once a rules or NAT user opens the page.

Closed: text encode. UI color names. string-row helper. Unauth N/A.

Fix: allowlist separator color. htmlspecialchars in class=. Do not copy POST separator blobs blindly.

Last run:

Plain text
SUCCESS PFSENSE-SEPARATOR-COLOR-XSS control-class=yes trigger-unescaped=yes text-encoded=yes string-row-closed=yes nat-same-sink=yes PFSENSE-SEPARATOR-COLOR-XSS-WITNESS

The logout title is the session name

Pack: abraxas/pfsense-auth-username-xss. Privilege: an LDAP or RADIUS WebGUI session. Loopback 127.0.0.1:19170.

head.inc prints the logout icon on every authenticated page:

PHP
title="<?=gettext("Logout") . " (" . $_SESSION['Username'] . "@" . htmlspecialchars($system_url) . ")"?>"

$system_url is encoded. The username is not. The menu label is the same concatenation without @host. passwordmg Form_StaticText also prints $_SESSION['Username'] raw.

Local usernames are [^a-zA-Z0-9.-_]. The GUI rejects the trigger. LDAP and RADIUS names skip that charset. A quote-breaking remote name is stored XSS on every authed page that includes head.inc, which is every authed page. The lab trigger was "><span id="PFSENSE-AUTH-USERNAME-XSS-WITNESS">. CONTROL admin stayed inside the title attribute. The same session name is also the OpenVPN connect_async eval username when that server is server_user / server_tls_user, and it is the name getUserEntry will collide with a local uid 0 row. Parts 3 and 4 already labbed those two. This leftover is the HTML one.

Lab-proven: quote-break raw in logout HTML and StaticText. control-admin-encoded=yes. local-charset-blocks-trigger=yes. witness-unescaped=yes. Session username reflected wherever head.inc ran. The XSS does not need the victim to open a specific page. The victim logged in.

Closed: local charset. htmlspecialchars($system_url). Unauth N/A.

Fix: htmlspecialchars the session username in head.inc and passwordmg. Filter RADIUS and LDAP names the same as local, or at least strip quotes and angle brackets before they become a session.

Last run:

Plain text
SUCCESS PFSENSE-AUTH-USERNAME-XSS control-admin-encoded=yes trigger-quote-break=yes witness-unescaped=yes local-charset-blocks-trigger=yes PFSENSE-AUTH-USERNAME-XSS-WITNESS

Crash blobs in a textarea, PHP log encoded

Pack: abraxas/pfsense-crash-reporter-xss. Privilege page-diagnostics-crash-reporter. Loopback 127.0.0.1:19180.

crash_reporter.php is the page that says inspect the dump before you send it to Netgate. /tmp/PHP_errors.log goes through htmlspecialchars. /var/crash/* does not:

PHP
$crash_reports .= file_get_contents($cf);
PHP
<textarea readonly style="width: 100%; height: 350px;">
<?=$crash_reports?>
</textarea>

A crash blob of </textarea><script>... is raw in the page. The PHP log of the same payload is encoded. Files larger than FILE_SIZE (450000) are skipped. Everything under that cap is concatenated into one textarea. Stored-from-dump XSS as the GUI user who opens crash reporter. How the blob arrived in /var/crash is a different leftover or a hostile core. This leftover is the render. The page's own copy tells the operator to inspect the dump before sending it. Inspection is the trigger.

Lab-proven: crash blob raw, PHP log encoded. Inferred: the operator who follows the "inspect before disclose" instruction is the victim.

Closed: PHP error log htmlspecialchars. Unauth N/A.

Fix: htmlspecialchars crash blobs in the textarea. Treat /var/crash as untrusted the same way the PHP log already is.

Last run:

Plain text
SUCCESS PFSENSE-CRASH-REPORTER-XSS control-encoded=yes trigger-raw=yes PFSENSE-CRASH-REPORTER-XSS-WITNESS

nginx 301 copies $http_host before PHP can say no

Pack: abraxas/pfsense-https-host-redirect. Privilege: none. Unauth client that can hit the WebGUI HTTP listener. CWE-601. 4.7. Loopback 127.0.0.1:19210.

system_generate_nginx_http_https_redirect emits a port-80 server{} with no server_name:

Plain text
server {
    listen 80;
    listen [::]:80;
    return 301 https://$http_host$redirectport$request_uri;
}

The block is emitted only when GUI protocol is https and disablehttpredirect is off. nginx $http_host is the raw Host header, including a port if the client sent one. nginx $host is the server_name match, or the Host header after lowercase, without the port. This server{} has no server_name, so there is no allowlist. $http_host is the client.

nginx returns that 301 before php-fpm. auth.inc DNS-rebind (redmine 708, 501 "Potential DNS Rebind attack detected") never runs on this path. It still runs on the HTTPS GUI itself. The 501 checks hostname, domain, DynDNS hosts, RFC2136 hosts, and althostnames. None of that PHP runs if nginx already 301'd off port 80.

Lab-proven: Host: evil.example became Location: https://evil.example/index.php. CONTROL Host: localhost stayed localhost. php-rebind-skips-301=yes. The PHP 501 on a hostile Host against the HTTPS GUI stayed closed.

Integrity: none. This is not XSS and not a session. Recon: an unauth open redirect off the firewall's HTTP listener. Useful for phishing a "click the GUI" mail. The operator's TLS certificate is not on evil.example. The Location is. $request_uri is copied too, so the path the victim asked for is the path the evil host receives. A mail that says "your firewall needs you" can point at http://firewall/index.php with a Host the attacker controls, and the box will send the browser there.

Closed: PHP DNS-rebind on the HTTPS GUI. Captive-portal nginx is a different server{} ($captive_portal === true skips this block).

Fix: use a configured hostname, or nginx $host after an allowlisted server_name, not $http_host. Do not 301 to a Host you have not accepted.

Last run:

Plain text
SUCCESS PFSENSE-HTTPS-HOST-REDIRECT control-localhost=yes trigger-evil=yes php-rebind-skips-301=yes PFSENSE-HTTPS-HOST-REDIRECT-WITNESS

What same-origin script actually buys

I keep repeating csrf-magic because that is the point of this phase.

csrf-magic prints a token into the GUI page and requires it on POST. A foreign site cannot read that token. SameSite=Strict on the session cookie also stops the browser from attaching the cookie to a foreign POST. Both defenses assume the attacker is not running script in the GUI origin.

Logs-filter, rules if=, separator color, crash reporter, RSS dashboard, and auth username all run in that origin. A script there can:

  1. Read the csrf-magic token out of the DOM.
  2. POST any form the victim's session is allowed to submit.
  3. Ignore SameSite. Same-origin is same-site.

That is the bridge into the leftovers in parts 2 through 5. Wireless clone if=, assign if_add, gateway IPv6 zone, timezone php.ini, sshguard BACKEND, L2TP RADIUS secret, PPPoE cron, Unbound include:, file-manager path write, DynDNS file://, voucher expire, uploadconfig, ACB restore. Those pages already require a session and a token. The XSS supplies both, as the victim, for every page isAllowedPage already grants that session.

Default admin has every page. A limited user has the pages they were granted. The XSS cannot invent a privilege the session does not have. It can spend the ones it does. That is why I did not call these Critical, and why I also did not leave them on the floor.

Auth username XSS is planted by the same LDAP or RADIUS identity that can also be the OpenVPN eval username or the LDAP name-collision admin. Parts 3 and 4. One remote directory object. Three leftovers. The XSS does not need the victim to click a rules link or open crash reporter. Login is enough. head.inc is enough.

Captive Portal filename XSS is a different origin. Lab closed GUI cookie theft. The portal login still has auth_user / auth_pass. Phish the portal, not the GUI. The file-manager plant is authenticated. The victim of the stored name is not.

The $http_host 301 is unauth on the HTTP listener. It does not run script in the GUI origin. It does not steal a cookie. It is a phishing primitive: "your firewall sent you here." PHP rebind on HTTPS stays closed. Do not confuse the two.

RSS body is the leftover of 26_04 on this exact pin. Titles were fixed. Body and permalink were not. The feed URL is an operator choice. The widget still renders hostile markup from that choice.

Adjacent surface that stays closed

Surface Lab
Unauth WebGUI login still closed on this pin
GUI cookies from portal XSS gui-cookie-theft=no
File-manager list encode of the hostile name htmlspecialchars
SA-26_05 GUI widget XSS different advisory, skipped
SA-26_04 RSS title encode title-encoded=yes
requirefirewallinterface off, unknown if replaced with lan / first iface
Other $if hrefs and Location htmlspecialchars
Separator text htmlspecialchars
Separator string-row helper does not match row/0
Local username charset blocks the logout-title trigger
htmlspecialchars($system_url) encoded next to the raw username
PHP error log in crash reporter htmlspecialchars
PHP DNS-rebind on the HTTPS GUI 501 still fires; not on the nginx 301
csrf-magic against a foreign site still required
SameSite=Strict against a foreign site still attached

No unauth WebGUI session. No Diagnostics: Command. No claim that portal XSS is GUI XSS.

The labs

Each leftover is a PHP gadget of the pinned lines plus a loopback client. Compose project name matches the slug. Stack tears down on EXIT. Restore and run from the pack:

Plain text
bash restore-names.sh.txt
./run.sh

Ports: filename XSS 19110, logs-filter 19130, rules if= 19140, RSS body 19150, separator color 19160, auth username 19170, crash reporter 19180, $http_host 301 19210.

Witnesses: PFSENSE-CP-FILENAME-XSS-WITNESS, PFSENSE-LOGS-FILTER-XSS-WITNESS, PFSENSE-RULES-IF-XSS-WITNESS, PFSENSE-RSS-BODY-XSS-WITNESS, PFSENSE-SEPARATOR-COLOR-XSS-WITNESS, PFSENSE-AUTH-USERNAME-XSS-WITNESS, PFSENSE-CRASH-REPORTER-XSS-WITNESS, PFSENSE-HTTPS-HOST-REDIRECT-WITNESS.

Lab-proven versus inferred is in each section. Script-on-portal reads auth_user / auth_pass: inferred. XSS drives further GUI POSTs: inferred from csrf-magic living in the page, not from a chained POST in these eight labs. The chains are the next note.

Fixes, one line each

  • Portal HTML: encode the filename. Restrict upload charset.
  • status_logs_filter.php: Content-Type: application/json. JSON_HEX_TAG|JSON_HEX_AMP.
  • firewall_rules.php: encode $if in the hidden attr and the JS string. Allowlist interfaces.
  • RSS widget: encode body text. Allowlist permalink to http(s).
  • Separators: allowlist color. Encode in class=.
  • head.inc / passwordmg: encode the session username. Charset-check remote names.
  • crash_reporter.php: encode /var/crash the same as the PHP log.
  • HTTP-to-HTTPS: configured hostname or allowlisted $host, not $http_host.

Please request the CVE for this report and credit Abraxas Labs. If you split them, please request a CVE for each leftover and credit Abraxas Labs.

References

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