Series0-dayNo CVEHighPublic

pfSense CE leftover interpolations · Phase 4

pfSense CE leftovers, part 5: captive portal files, file://, and nginx

Captive Portal file manager skips the captiveportal- prefix when the upload name already contains it, so remaining ../ walks out of /var/db/cpelements. maxprocperip is stored raw into the portal nginx server{} and an extra location/alias is unauth file read on that listener. Custom DynDNS, Check IP, and the RSS widget fetch file://; the DynDNS cache path treats escapeshellarg(FQDN) as a filename; voucher_expire concatenates into SQLite WHERE.

Name
pfSense CE leftovers, part 5: captive portal files, file://, and nginx
Series
pfSense CE leftover interpolations
Phase
4 · Files and portal
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Netgate
Affected
pfSense CE 2.9.0-DEVELOPMENT (9363ac5) captive portal file manager and maxprocperip nginx, Custom DynDNS / Check IP / RSS file://, DynDNS cache path, voucher_expire SQLite WHERE.
Published
15 Oct 2026
Updated
15 Oct 2026
Tags
0-day, pfsense, file-write, file-read, captive-portal, nginx

The prefix is a substring. The path is not a jail.

I am @abraxas_null. This is part 5 of pfSense CE leftover interpolations, phase 4: files and portal. Previous: config takeover. Next: XSS and redirect.

Six leftovers. Two writes, two file:// reads, one extra nginx location on the portal vhost, one SQLite WHERE. Privilege pages that are not Diagnostics: Command. WebGUI PHP-FPM is still root, so a write under www is an inferred execute, and I will keep that sentence separate from the lab-proven write.

This is pfSense CE 2.9.0-DEVELOPMENT (9363ac5b8651a1c7a333180425ce7719070f95f9). No CVE yet. Authorized lab only. Each pack talks to loopback.

Leftover Class Privilege CVSS Lab
pfsense-cp-filemanager-write File write CWE-22 page-services-captiveportal-filemanager 8.8 wrote outside /var/db/cpelements
pfsense-cp-maxprocperip Config injection CWE-94 plant page-services-captiveportal, then unauth on the portal listener 7.1 extra location /p { alias /oracle/; }; nginx -t passed; unauth GET returned the file. Host RCE not shown.
pfsense-dyndns-file File read CWE-73 page-services-dynamicdnsclient and/or page-services-checkipedit 6.5 file:/// body on Custom DynDNS and Check IP
pfsense-rss-file-redirect File read CWE-73 page-dashboard-all / widgets MATCH 6.5 HTTP 302 Location: file:/// returned planted XML. Direct file:// rewritten away.
pfsense-dyndns-cache-path File write CWE-22 page-services-dynamicdnsclient 5.4 hostname with / wrote IP|epoch outside /conf. Not shell.
pfsense-voucher-expire-sqli SQLi CWE-89 page-status-captiveportal-expire / page-services-captiveportal-vouchers 6.5 UNION selected another sessionid; stacked-UPDATE rewrote that row. CONTROL expire only deleted A.

The first leftover in the dedicated Captive Portal hunt was maxprocperip. I dropped it as High on that pass because the shown breakout was portal availability. The second pass kept the interpolation and asked nginx whether an extra location would start. It did. That is why it is here and not in the shell leftovers.

Captive Portal file manager: skip the prefix, walk out of cpelements

Pack: abraxas/pfsense-cp-filemanager-write (loopback client). Lab: lab/. Loopback 127.0.0.1:19000. Witness PFSENSE-CP-FILEMANAGER-WRITE-WITNESS. Privilege page-services-captiveportal-filemanager. CWE-22. 8.8 High.

services_captiveportal_filemanager.php names the upload with a substring test, not basename():

PHP
function filemanager_assign_upload_name() {
    if ((!stristr($_FILES['new']['name'], "captiveportal-"))
        && ($_FILES['new']['name'] != 'favicon.ico')) {
        $name = "captiveportal-" . $_FILES['new']['name'];
    } else {
        $name = $_FILES['new']['name'];
    }
    return $name;
}

A name that already contains captiveportal- skips the prefix. favicon.ico is the other skip. Everything else is glued. Then captiveportal_write_elements() writes that string as a path component:

PHP
file_put_contents(
    "{$g['captiveportal_element_path']}/{$data['name']}",
    base64_decode($data['content'])
);

$g['captiveportal_element_path'] is /var/db/cpelements. There is no realpath jail. There is no .. reject. PHP-FPM is root, so the write is a root write. Empty content takes the unlink_if_exists plus touch of the same joined path. After a successful put, the function also @symlinks cpelements/{$data['name']} into /usr/local/captiveportal/{$data['name']} when that path is missing. The leftover is the write. The symlink uses the same unsanitized name and is not how you leave the directory.

The CONTROL name logo.png does not contain the substring, so it becomes captiveportal-logo.png under cpelements. The prefix-block name ../../oracle/WITNESS also lacks the substring, so it becomes captiveportal-../../oracle/WITNESS. Path split of that glued name is the component captiveportal-.., then .., then oracle. From /var/db/cpelements that lands back under cpelements. Remaining ../ after glued captiveportal-.. does not reach /. That is prefix-no-escape=yes.

The TRIGGER name already contains the substring:

Plain text
captiveportal-../../../../../oracle/PFSENSE-CP-FILEMANAGER-WRITE-WITNESS

Prefix skipped. Components: captiveportal-.., then four .., then oracle. From /var/db/cpelements that is /. Lab wrote the witness outside cpelements.

Plain text
SUCCESS PFSENSE-CP-FILEMANAGER-WRITE control-prefixed=yes prefix-no-escape=yes trigger-outside=yes PFSENSE-CP-FILEMANAGER-WRITE-WITNESS
Method Sink Privilege
POST file-manager name that already contains captiveportal- plus ../ file_put_contents cpelements/{$data['name']} Integrity (lab-proven): write outside /var/db/cpelements. Root PHP write.
same leftover, write a PHP file under www or chain the timezone leftover from part 3 PHP-FPM Integrity (inferred): host RCE. Lab did not execute the written bytes.
same leftover config.xml, keys, other tenants Recon / availability (inferred): overwrite.

Lab-proven vs inferred: the outside write is lab-proven. Host RCE is inferred from root PHP-FPM plus a later execute gadget: a PHP file PHP-FPM will run, or the timezone php.ini auto_prepend_file leftover in part 3 of this series. I did not run that chain in this pack. Documented PHP inside the portal jail is a different feature and is not this leftover.

The same privilege plants the filename XSS in part 6. One POST name can be a path write and a portal <img src> at the same time. Different sinks. Same page.

Closed: a name without captiveportal- is prefixed and cannot escape that way. Unauth N/A. Diagnostics: Command not required and not granted.

Fix: basename() the upload name. Always prefix. Reject ...

maxprocperip: the integer check is on the unused field

Pack: abraxas/pfsense-cp-maxprocperip (loopback client). Lab: lab/. Loopback 127.0.0.1:19120. Witness PFSENSE-CP-MAXPROCPERIP-WITNESS. Plant with page-services-captiveportal, then unauth on the portal listener. CWE-94. 7.1.

services_captiveportal.php validates $_POST['maxproc']. It stores $_POST['maxprocperip']:

PHP
if ($_POST['maxproc'] && (!is_numeric($_POST['maxproc'])
    || ($_POST['maxproc'] < 4) || ($_POST['maxproc'] > 100))) {
    $input_errors[] = gettext(
        "The maximum number of concurrent connections per client IP address may not be larger than the global maximum."
    );
}

if (!$input_errors) {
    $newcp['maxproc'] = $_POST['maxproc'];
    $newcp['maxprocperip'] = $_POST['maxprocperip']
        ? $_POST['maxprocperip'] : false;
}

The error text talks about per-client IP. The check does not. system_generate_nginx_config() emits the stored field inside the Captive Portal server{} when $captive_portal !== false:

PHP
$captive_portal_maxprocperip = "\t\tlimit_conn addr $maxprocperip;\n";

CONTROL 10 is one limit_conn addr 10; line. nginx -t passed. TRIGGER:

Plain text
10; location /p { alias /oracle/; } #

That is an extra location inside the portal server{}. The generator interpolates $captive_portal_maxprocperip into a heredoc only when $captive_portal !== false. GUI nginx is the same function with that flag false, so it never emits this field. nginx -t passed again (fail-to-start=no). Unauth GET of /p/ plus the witness filename on the portal listener returned the planted file. Portal vhost, not the GUI. The extra location is static alias. I did not add a PHP location. Host RCE is not shown and I am not claiming it.

Plain text
SUCCESS PFSENSE-CP-MAXPROCPERIP control-limit-conn=yes nginx-t-control=yes trigger-inject=yes nginx-t-trigger=yes host-read=yes fail-to-start=no PFSENSE-CP-MAXPROCPERIP-WITNESS
Method Sink Privilege
POST maxprocperip with location / alias, apply, then unauth GET portal limit_conn addr $maxprocperip; inside CP server{} Integrity (lab-proven): extra location in the generated conf. nginx -t accepted it.
unauth GET of that location portal nginx alias Recon (lab-proven): file body from the aliased directory on the portal listener. Not the GUI.
invalid nginx language zone start Availability (inferred): a parse-fail conf can fail to start that zone. This TRIGGER started.

Do not claim host RCE. Alias is a read of whatever path you point at. Combined with the file-manager write you choose the bytes, then read them from the portal vhost. Combined with a directory that already holds config.xml or keys, the extra location is unauth recon of those files from a client that can hit the portal, not the GUI. That is still a read. PHP-FPM does not run the aliased bytes unless you also inject a PHP location, which this lab did not.

Closed: GUI nginx (captive_portal=false) does not interpolate this field. Unauth plant. Host RCE. Diagnostics: Command.

Fix: is_numericint maxprocperip. Reject braces and semicolons.

Custom DynDNS and Check IP: CURLOPT_URL without CURLOPT_PROTOCOLS

Pack: abraxas/pfsense-dyndns-file (loopback client). Lab: lab/. Loopback 127.0.0.1:19090. Witness PFSENSE-DYNDNS-FILE-WITNESS. Privilege page-services-dynamicdnsclient and/or page-services-checkipedit. CWE-73. 6.5. Read only.

HTTP(S) to an operator URL is the documented feature. Custom update URL is stored with no is_URL(). Check IP calls is_URL($_POST['url']) with the default $httponly = false. is_URL() is FILTER_VALIDATE_URL plus an optional scheme allowlist that nobody passes here:

PHP
function is_URL($url, $httponly = false) {
    if (filter_var($url, FILTER_VALIDATE_URL)) {
        if ($httponly) {
            return in_array(
                strtolower(parse_url($url, PHP_URL_SCHEME)),
                ['http', 'https']
            );
        }
        return true;
    }
    return false;
}

is_URL('file:///oracle/secret.xml') is true. is_URL(..., true) is false. Check IP does not pass true. Custom DynDNS does not call it at all.

updatedns::_update() and the Check IP curl in services.inc set CURLOPT_URL and CURLOPT_INTERFACE. Neither sets CURLOPT_PROTOCOLS or CURLOPT_REDIR_PROTOCOLS. libcurl 7.88.1 still ships FILE. The interface bind did not block FILE. A Result Match miss logs the body, so the file is not a silent 404.

CONTROL https://example.invalid/ip did not return the oracle. TRIGGER file:///oracle/secret.xml returned the planted XML on both Custom DynDNS and Check IP (custom-witness=1 checkip-witness=1). Verbose log of a Result Match miss prints Response Data, so the file body is not a silent miss.

Plain text
SUCCESS PFSENSE-DYNDNS-FILE control-no-file=yes trigger-file-witness=yes custom-witness=1 checkip-witness=1 PFSENSE-DYNDNS-FILE-WITNESS
Method Sink Privilege
POST Custom update URL file:/// curl CURLOPT_URL Recon (lab-proven): root-readable file body.
POST Check IP URL file:/// same class, is_URL without httponly Recon (lab-proven): same body.
Integrity / availability None. Read only.

PHP-FPM is root, so this is recon of root-readable files: config.xml, keys, other tenants. Privilege is DynDNS or Check IP, not Diagnostics: Command.

Closed: documented HTTP(S) operator URL. is_URL(file://, true) is already false. The leftover is that Check IP never passes httponly, and Custom never calls is_URL.

Fix: is_URL($url, true). CURLOPT_PROTOCOLS CURLPROTO_HTTP|CURLPROTO_HTTPS.

RSS widget: fix_protocol rewrites the URL you typed. Not the Location you followed.

Pack: abraxas/pfsense-rss-file-redirect (loopback client). Lab: lab/. Loopback 127.0.0.1:19100. Witness PFSENSE-RSS-FILE-REDIRECT-WITNESS. Privilege page-dashboard-all / widgets MATCH. CWE-73. 6.5. Read only.

rss.widget.php saves a feed if is_URL($feed) is true. Same helper, same default, so file:// is a valid saved URL. Then SimplePie set_feed_url plus init(). SimplePie 1.9.0 (76cccb1) Misc::fix_protocol() rewrites a non-http(s) scheme to http:

PHP
if ($parsed['scheme'] !== '' && $parsed['scheme'] !== 'http'
    && $parsed['scheme'] !== 'https') {
    return Misc::fix_protocol(
        Misc::compress_parse_url(
            'http', $parsed['authority'], $parsed['path'],
            $parsed['query'], $parsed['fragment']
        ),
        $http
    );
}

CONTROL is a direct file:///oracle/secret.xml. fix_protocol rewrites it to http. The oracle is not read. That is control-rewritten-away=yes. SA-26_04 title encode is already on this pin. The operator HTTP(S) feed URL is the feature. Direct file:// is not the leftover.

TRIGGER is an HTTP URL that 302s Location: file:///oracle/secret.xml. The recursive hop does not call fix_protocol(). SimplePie File file_get_contents the path as root. Lab returned the planted XML.

Plain text
SUCCESS PFSENSE-RSS-FILE-REDIRECT control-rewritten-away=yes trigger-file-location=yes PFSENSE-RSS-FILE-REDIRECT-WITNESS

Same class as Custom DynDNS: recon of root-readable files from a dashboard-widgets privilege. The RSS leftover is the redirect, not the scheme you typed.

Closed: 26_04 title encode. Direct file:// rewrite. Operator HTTP(S) feed. Unauth N/A.

Fix: force HTTP(S) feeds (is_URL($feed, true)). Do not follow file: Location. CURLOPT_PROTOCOLS HTTP(S) if the fetch stays on curl.

DynDNS cache path: escapeshellarg is not a filename function

Pack: abraxas/pfsense-dyndns-cache-path (loopback client). Lab: lab/. Loopback 127.0.0.1:19270. Witness PFSENSE-DYNDNS-CACHE-PATH-WITNESS. Privilege page-services-dynamicdnsclient. CWE-22. 5.4. Not shell.

Custom (and custom-v6) skip is_domain() on the host. updatedns::__construct() then builds the cache path as if the FQDN were going to a shell:

PHP
$this->_cacheFile = "{$g['conf_path']}/dyndns_{$dnsIf}{$dnsService}"
    . escapeshellarg($this->_FQDN) . "{$dnsID}.cache";

escapeshellarg('example.com') is 'example.com'. Those quotes are filename bytes. A slash still splits the path. On a successful update the class writes {$ip}|{$epoch} with file_put_contents. The body is not a command. Nothing mwexecs the FQDN. Nothing cron/php-execs *.cache.

CONTROL host example.com wrote under /conf. TRIGGER host ../../../oracle/PFSENSE-DYNDNS-CACHE-PATH-WITNESS wrote IP|epoch outside /conf. Non-Custom dyndns rejected that host (is_domain).

Plain text
SUCCESS PFSENSE-DYNDNS-CACHE-PATH control-under-conf=yes trigger-outside-conf=yes PFSENSE-DYNDNS-CACHE-PATH-WITNESS
Method Sink Privilege
POST Custom hostname with / file_put_contents of escapeshellarg($FQDN) path Integrity (lab-proven): write IP|epoch outside /conf.
same leftover Recon: none. Content is IP|epoch.
overwrite a file whose name matches the quoted suffix Availability (inferred).

This is the small write. File-manager is the large write. Same class, different privilege, different payload. Do not upgrade this one to RCE because escapeshellarg is in the source. It is a path constructor wearing a shell function.

Closed: unauth. mwexec of FQDN. cron/php exec of *.cache. Non-Custom dyndns host charset.

Fix: do not use escapeshellarg as a filename. basename / is_domain the host. Apply the same host check to Custom.

voucher_expire: the WHERE is a string. The login INSERT is not.

Pack: abraxas/pfsense-voucher-expire-sqli (loopback client). Lab: lab/. Loopback 127.0.0.1:19280. Witness PFSENSE-VOUCHER-EXPIRE-SQLI-WITNESS. Privilege page-status-captiveportal-expire / page-services-captiveportal-vouchers. CWE-89. 6.5. Session integrity, not host RCE.

voucher_expire() splits on whitespace, escapeshellargs each token into /usr/local/bin/voucher, and on OK looks up the session:

PHP
$cpentry = captiveportal_read_db("WHERE username = '{$voucher}'");
if (!empty($cpentry) && !empty($cpentry[0])) {
    $cpentry = $cpentry[0];
    captiveportal_disconnect($cpentry, 13);
    $unsetindexes[] = $cpentry[5];
}

captiveportal_read_db() concatenates that WHERE onto SELECT * FROM captiveportal. First row wins. Column 5 is sessionid. captiveportal_remove_entries() interpolates those ids into DELETE FROM captiveportal WHERE sessionid in (...) and captiveportal_write_db() runs it with SQLite3::exec. exec will run a stacked statement. query on the SELECT will return a UNION row.

The lab stubbed /usr/local/bin/voucher to print OK so the SQL sink is reached. Two seeded rows: voucher A session sessA, voucher B session sessB.

CONTROL expire of VOUCHERA deleted only A. B stayed. Unauth login INSERT of VOUCHERA'OR username='VOUCHERB'-- used SQLite3::escapeString and did not rewrite B (control-login-escaped=yes).

TRIGGER is a voucher whose WHERE is a UNION SELECT of a crafted first row. query() returns that row. Column 5 is taken as sessionid and handed to captiveportal_remove_entries(). That function concatenates ids into DELETE FROM captiveportal WHERE sessionid in ('{$unindex}') and captiveportal_write_db() runs the string with SQLite3::exec. exec will run a stacked statement. The UNION-selected sessionid is itself x');UPDATE captiveportal SET username='WITNESS' WHERE username='VOUCHERB';--. First-row sessionid is the injector. Stacked-UPDATE rewrote B. Expire also captiveportal_disconnected the injected first row. That is captive-portal session integrity on captiveportal{zone}.db. Not a GUI cookie. Not another zone. Not a shell. voucher_auth still escapeshellargs. The /usr/local/bin/voucher argv is not the leftover.

Plain text
SUCCESS PFSENSE-VOUCHER-EXPIRE-SQLI control-expire-only-a=yes control-login-escaped=yes trigger-rewrite-b=yes PFSENSE-VOUCHER-EXPIRE-SQLI-WITNESS
Method Sink Privilege
POST expire token into WHERE username = '{$voucher}' captiveportal_read_db then remove_entries exec Integrity (lab-proven): UNION first-row sessionid; stacked-UPDATE rewrite of another session.
UNION of other columns on that zone DB same SELECT Recon (inferred).
disconnect / rewrite of the first row captiveportal_disconnect + DELETE Availability (lab-proven) on that zone.

Closed: unauth login escape. voucher_auth escapeshellarg. No shell. No other-zone DB. No GUI cookie. Host RCE not in play.

Fix: bind parameters. Stop concatenating the voucher into SQL.

How they chain. How they do not.

File-manager write is the host-RCE story if you give it an execute gadget. I already showed timezone into php.ini auto_prepend_file in part 3 of this series. A limited user who has page-services-captiveportal-filemanager plus page-system-generalsetup can write the PHP and then prepend it. A limited user who can already drop a gadget under www does not need timezone. This pack proved the write. It did not prove the execute. I am not collapsing those two sentences.

File-manager is also the plant for the filename XSS in part 6. Same privilege. The portal login HTML interpolates the name into <img src> / CSS url(...) with no encode. GUI list htmlspecialchars encoded the same name. Portal origin is not the GUI origin. That leftover is XSS, not this write.

maxprocperip nginx alias is unauth read of whatever the alias points at, from a client that can hit the portal listener. Combined with file-manager you choose the bytes, write them somewhere the alias can see, then GET them without a GUI session. Combined with a directory that already exists (/conf, /etc, /root) the extra location is unauth recon. It is still not host RCE. I am repeating that because the first CP hunt wanted it to be a shell and it is not.

file:// DynDNS, Check IP, and RSS-via-302 are recon of root-readable files from a DynDNS or dashboard-widgets privilege. config.xml and keys are the obvious targets. RSS is the interesting one because SimplePie already "fixed" direct file:// and then followed Location anyway. Custom DynDNS never even asked is_URL().

DynDNS cache path is a small write of IP|epoch outside /conf. Same privilege as Custom file://, different constructor. Do not treat escapeshellarg in a path as command injection. Do not treat this write as the file-manager write.

Voucher stacked-UPDATE is captive-portal session integrity. Disconnect A, rewrite B, UNION-read columns from that zone DB. The unauth login path stayed escaped, so this is not "the portal login is SQLi." It is the expire page, with a GUI session.

CSRF-magic still sits on POST. isAllowedPage still sits on the URI. None of these are unauth WebGUI. The portal listener is unauth by design once you have planted maxprocperip or a filename. That is the split.

Fixes, in one place

Leftover Fix
file manager basename() the upload name. Always prefix. Reject ...
maxprocperip is_numericint. Reject braces and semicolons. Check the field you interpolate.
DynDNS / Check IP file:// is_URL($url, true). CURLOPT_PROTOCOLS HTTP(S).
RSS file:// redirect Force HTTP(S) feeds. Do not follow file: Location.
DynDNS cache path Do not use escapeshellarg as a filename. basename / is_domain the host, including Custom.
voucher expire Bind parameters. Stop concatenating the voucher into SQL.

Replay each pack:

Plain text
cd lab
./run.sh