pfSense Plus MIM, Snort {file} decode-then-join
GET /services/snort/rules/{name}/{file} takes unconstrained strings. The generated client encodes ../ as %2F. A server that decodes then joins reads outside the ruleset. Fleet JWT (G6) replays that GET on every enrolled box. Plus join not executed. No CVE yet.
- Name
- pfSense Plus MIM, Snort {file} decode-then-join
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Netgate
- Affected
- github.com/Netgate/pfsense-api main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions). pfSense Plus Nexus MIM controller REST on :8443. Plus binary not executed.
- Published
- 14 Oct 2026
- Updated
- 14 Oct 2026
- Tags
- 0-day, pfsense, pfsense-plus, mim, nexus, snort, cwe-22, path-traversal, file-read
Percent-encoding is not a basedir
I am @abraxas_null. This is leftover 8 of 9 on the pfSense Plus Nexus Multi-instance Management API toolkit. The map is pfsense-api-nexus-mim. The proof of concept is on GitHub: abraxas/pfsense-api-snort-file-join (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, mock.py. Authorized lab only. It talks to loopback.
This is Netgate/pfsense-api main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, "Add Snort-3 API definitions"). No CVE yet. CWE-22. 6.5 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N) for a JWT that may GET the Snort ruleset file. Remote file read. Plus join was not executed.
GET /services/snort/rules/{name}/{file} is documented as "inspect a file in the ruleset." Both path parameters are type: string. The YAML has zero pattern: keys anywhere. The generated Python client percent-encodes ../ as ..%2F because quote(..., safe="") treats slash as unsafe. That is the correct thing for a client to do. It is also the wrong thing to treat as a server basedir. HTTP servers decode path segments before they join. A decode-then-join gadget of that public path returned /oracle/WITNESS. A confined control of the same wire returned 400.
Same pin leftover fleet JWT: one POST /login Bearer is reused for every device_id. Official examples copy it onto /api/device/pfsense/{id}/api. This GET is one of those 755 ops. A helpdesk-shaped controller token plus a naive join is file read on every enrolled box, not only the one the login was meant for.
This is not the third-party CE REST package at jaredhendrickson/pfsense-api. The Plus controller binary is not in the GitHub tree. I did not download Plus. I did not talk to a live controller.
Why this tree, and this leftover
I wanted the API the vendor ships for a paid fleet controller, not another WebGUI PHP hunt. Operators copy example.py. They point it at :8443. They iterate GET /mim/devices. They replay the same JWT onto every instance. 486 paths, 755 operations, OpenAPI 3.0.3, generated Python, a handful of samples. The server those operations talk to is closed-source and ships with pfSense Plus 25.11+ as the Nexus Multi-instance Management controller.
I only wanted Critical or High. Remote and local. Medium stays on the floor unless it chains. Diagnostics PHP/shell is intended admin power. I am not ranking POST /diag/cmd/php as unpublished RCE. The leftovers that survived that bar live in enrollment, GET private keys, ACME dump, fleet JWT, ACB userkey, this Snort join, and the official TLS-verify-off samples. They stack. This post is the join.
HEAD on 2026-10-14 had no tags. Latest commit adds Snort-3 API definitions. That is how this path exists as a contract. pfapi_openapi.yml is 42685 lines. Tags by count: ports 183, services 164, system 126, firewall 70, vpn 66, interfaces 56, diag 51, mim 20. Snort lives under services. {file} is the only path parameter in the whole schema whose description is "path of file."
The skip list
Before you look for leftovers you write down what is already public so you do not "discover" a CVE with a press release.
Diagnostics: Command PHP/shell is intended. Netgate already treated that as such (CVE-2025-69691 disputed). POST /diag/cmd/php, /diag/cmd/shell, /diag/cmd/download, /diag/editfile are the documented wrap of GUI Diagnostics. A superuser JWT that can call them is a fleet console. I list them so this leftover is not inflated into "the API is RCE."
QinQ if_device, IPsec login_banner, OpenVPN dns_domain / netbios_scope are the same PHP apply sinks I already mailed as CE WebGUI leftovers. Limiter number is an integer in this OAS. CARP VHID is not a string leftover here. I am not re-filing those.
CE ACB key derived from SSH is CVE-2024-57273 / SA-25_03. Leftover 7 is the API query-param sibling, not that derivation, and not this post.
POST /services/snort/rules unpacks an archive looking for rules/*.rules and so_rules/precompiled/<os>/*.so. Zip-slip / planted .so loaded by Snort is intended IDS admin unless extract follows ... I did not KEEP it as RCE of a Snort admin. A limited role that can upload is a different leftover. This pack is the GET.
The generated library default is verify_ssl=True. Path params use quote(..., safe=""). ../ ; $() never left the 755-op mock unescaped. That is client-correct. A leftover there is a server join after decode.
This is not jaredhendrickson/pfsense-api / pfrest.org. Mixing those trees is how you file the wrong product.
Surfaces that returned nothing
I split the public client. Login/JWT, diagnostics, MIM enrollment, client TLS, users/certs, packages, VPN/firewall, OpenAPI security, device IDOR, git/firmware/feeds, plus a loopback mock of every generated operation. 755/755 _get_kwargs sent on 127.0.0.1:55980. Witness PFSENSE-API-MOCK-WITNESS. Torn down.
Most of that is the product doing what the product said it would do, or a leftover that belongs to a sibling pack.
POST /ports/shellcmd plants a persistent root command at boot. That is the Shellcmd package. KEEP only if a MIM role other than superuser can call it. The spec does not say. Not this leftover.
HAProxy writetodisk name unconstrained is a write leftover if the package joins it. Different sink. Different pack if it ever ships.
FRR preview of generated files is intended routing-admin. Description says generated paths. Confidence not a traversal GET: high enough to drop.
GET /system/logs/{filename} is the same unconstrained-string class as Snort {file}. Confidence 55%. Medium. I did not pack it. If the Snort join is real, that sibling is the next place to look.
ThreatGate remotes SSRF, LDAP/RADIUS/SAML/HA/git secrets on GET, OpenAPI missing securitySchemes, generated Client sending php/shell with no Bearer: Medium/Low/Info. They still matter as documentation. They are not this GET.
Unauth 200 on the Snort path is not shown. Examples send Bearer. OpenAPI has no securitySchemes. Rank as spec leftover, not proven unauth file read.
The leftover
Path, pin pfapi_openapi.yml:
/services/snort/rules/{name}/{file}:
parameters:
- in: path
name: name
required: true
schema:
type: string
- in: path
name: file
required: true
schema:
type: string
get:
tags:
- services
summary: Get the original contents of the specified file in the ruleset
description: Obtain the contents of the specified file in the ruleset. This is intended for inspecting scripts provided by it.
operationId: GetSnortRulesetFileResponses: 200 SnortRuleFileContents, 400 Error. SnortRuleFileContents.filename is described as "path of file". Not "basename of a .rules member." Path. Same unconstrained {name} on GET|DELETE /services/snort/rules/{name}, GET|DELETE /services/snort/addresses/{name}, suppress lists. The YAML has zero pattern: keys anywhere. Generators have nothing to reject.
Generated client, py/pfapi/api/services/get_snort_ruleset_file.py:
def _get_kwargs(
name: str,
file: str,
) -> dict[str, Any]:
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/services/snort/rules/{name}/{file}".format(
name=quote(str(name), safe=""),
file=quote(str(file), safe=""),
),
}
return _kwargsquote(..., safe="") encodes / as %2F. ../../../etc/passwd is ..%2F..%2F..%2Fetc%2Fpasswd on the wire. A naive reader looks at that and says the client closed traversal. The client closed a different class: sending a raw slash that some routers treat as another path segment. It did not close a server that does unquote(file) and then open(ruleset_dir / file).
That is CWE-22. Intended: a .rules / Lua / include inside the named ruleset. Leftover: {file} as a filesystem path with no allowlist. If the device joins it, this is authenticated arbitrary file read as the API UID. Plus WebGUI/PHP is typically root. The MIM agent on an enrolled box is the same class of uid.
sync_detailed still types client: AuthenticatedClient | Client. Client will GET this path with no Authorization. That is openapi-python-client typing, not a server 200. Examples still login. I did not promote unauth.
Fleet JWT plus this GET
Leftover 6 is the reason this GET is not "read a rules file on the box you already admin." Official example.py:
devApi = AuthenticatedClient(
base_url=CONTROLLER_URL+"/api/device/pfsense/{}/api".format(device.device_id),
verify_ssl=False,
headers={"Content-Type": "application/json"},
cookies=cookies,
token=token,
)One POST /login Bearer. GET /mim/devices lists ids (tags filter only). LoginResponse is token / user / version / alerts. No per-device allow-list. LDAP roles in the schema are global: view, logs, usermgmt, services, firewall, interfaces, devices, system, superuser, each with read / modify / delete. Forum 2026 asked for instance-vs-controller split. The public client still copies the same token onto every device prefix.
GET /services/snort/rules/{name}/{file} is in the 755. The device proxy is the same OpenAPI. A caller who can GET Snort on the controller can GET Snort on firewall B by changing device_id. If the join is naive, that is file read as the MIM agent on B. Helpdesk with a services-read-shaped login is the interesting case. Superuser already has Diagnostics: Command. This leftover is the read that is not Diagnostics: Command.
Plus RBAC was not executed. The public client and the public schema do this. I said that in leftover 6 and I am saying it here.
Sibling leftovers on the same stolen JWT: GET export-key (CA and leaf private keys), GET /services/acme (account and issued keys), ACB userkey if it also leaks. Those are other posts. This one is the join.
Building the lab
The controller binary is Plus-only. I did not compose it. The leftover that can be proven from the public client is the encoding plus the join contract. I stood a gadget of that contract.
Image python:3.12-slim-bookworm. Ruleset foo at /rulesets/foo/local.rules with sid:1. Oracle /oracle/WITNESS holds PFSENSE-API-SNORT-FILE-JOIN-WITNESS. Mock listens on 19181.
Naive prefix /services/snort/rules/{name}/{file}: unquote both params, Path join onto /rulesets, read. Control prefix /control/services/snort/rules/{name}/{file}: same unquote, then relative_to(ruleset_dir.resolve()), 400 if the resolved path leaves the named ruleset. /ping reports plus_join=not-executed. /source returns the two pin lines the poc greps: urllib.parse.unquote(file) and relative_to(ruleset_dir.resolve()).
poc.py copies get_snort_ruleset_file._get_kwargs: quote(str(...), safe=""). It refuses SUCCESS unless:
- Client encoding of
../../../etc/passwdis exactly..%2F..%2F..%2Fetc%2Fpasswd. - Wire path for
{file}=../../oracle/WITNESScontains%2F, still has..before the first encoded slash, and does not contain a raw/oracle. - Ping says the gadget is decode-then-join and Plus join is not executed.
- Benign
local.rulesis 200 withsid:1and no witness, naive and control. - Naive GET of the encoded traversal is 200,
filename/oracle/WITNESS, body contains the witness. - Control GET of the same wire is not 200 and does not leak the witness.
Loopback 127.0.0.1:19181. Compose project pfsense-api-snort-file-join. Stack torn down after the run. Witness-only. No reverse shell. No live /etc/passwd. Last line:
SUCCESS PFSENSE-API-SNORT-FILE-JOIN encoding=%2F decode-then-join=yes control-confined=yes plus-join-not-executed PFSENSE-API-SNORT-FILE-JOIN-WITNESSHonesty is in the token. plus-join-not-executed is not a footnote. It is the difference between a public-client leftover and a live Plus 200.
Wrong turns, in order
I treated quote(..., safe="") as closing traversal. It closes a raw-slash split. It does not close decode-then-join. The first draft of the hunt note said the client percent-encodes ../ so the leftover is weak. Then I wrote unquote on the mock and the oracle lit up. Encoding is the delivery. The join is the leftover.
I almost composed pfSense Plus. Paid. Not in this tree. The operator bar is loopback gadgets of the public contract, not a downloaded Plus image. A live join lab would be GET /services/snort/rules/foo/..%2f..%2fetc%2fpasswd vs 400. I did not run that.
I almost ranked Snort archive .so as this leftover. POST /services/snort/rules looking for so_rules/precompiled/<os>/*.so is intended IDS admin. A ruleset tarball that contains a native plugin is the Snort package. KEEP zip-slip only if a limited role can upload. Different class. Different sentence. Not this GET.
I almost claimed unauth GET. OpenAPI has no securitySchemes. Generated Client sends no Bearer. Examples still POST /login. The 755-op mock proved the client will omit Authorization. It did not prove the server returns 200. PR:L. CVSS 6.5, not 7.5.
I almost used {name} as the traversal. {name} is unconstrained too. Lab drove {file} because that is the parameter described as "path of file" and because a ruleset directory join is the obvious sink. {name} walking out of /rulesets is inferred. I did not SUCCESS it.
I almost called this RCE because Plus PHP is root. File read as root is still file read. Diagnostics: Command is the shell. Inflating File read to RCE is how you get a vendor to stop reading. The class is File read.
Theatre already recorded: a reverse shell; hitting a production controller; treating Linux Docker as a Plus image; SUCCESS on naive 200 without a confined control (that would be a gadget that cannot fail closed).
What an attacker can do
Needs a caller who can GET /services/snort/rules/{name}/{file}. Official examples use Authorization: Bearer after POST /login. CSRF is not in play on this GET. Fleet JWT (leftover 6) replays that Bearer on every enrolled device_id.
| Method | Sink | Privilege | Lab vs inferred |
|---|---|---|---|
Generated-client GET with {file}=../../oracle/WITNESS (wire ..%2F..%2Foracle%2FWITNESS) |
decode unquote(file) then join onto the named ruleset directory and read |
Recon: HTTP 200, filename /oracle/WITNESS, body PFSENSE-API-SNORT-FILE-JOIN-WITNESS. Intended is a .rules file inside that ruleset. |
Lab-proven (gadget). Plus join was not executed. |
Same GET with {file}=../../../etc/passwd |
same join if the controller maps it onto the device filesystem | Recon: read host files as the MIM agent (Plus WebGUI/PHP is typically root). Lab used /oracle/WITNESS, not a live /etc/passwd. |
Inferred (same join, host path). |
Same GET after leftover 6 (POST /login once, replay on /api/device/pfsense/{id}/api) |
device proxy of the same unconstrained {file} |
Recon: one helpdesk-shaped controller token reads files on every enrolled box, not only the one the login was meant for. | Inferred (G6 contract + this path). Plus RBAC not executed. |
Same leftover on {name} |
GET|DELETE /services/snort/rules/{name} and sibling address/suppress {name} |
Recon / integrity: rename or delete a ruleset whose name is a path. | Inferred from the spec. Lab drove {file} only. |
| Confined control of the same wire path | relative_to(ruleset_dir.resolve()) after decode |
Closed. HTTP 400, no witness. | Lab-proven |
Benign {file}=local.rules |
intended ruleset read | Closed. 200, sid:1, no witness, both naive and control. |
Lab-proven |
Integrity: none on this GET. The leftover does not write. Archive POST is intended IDS admin, not this pack.
Availability: none. A missing file is 404 on the gadget. I did not prove a delete via {name}.
Adjacent closed: client percent-encoding of ../ (encoding=%2F). Control join after decode (control-confined=yes). Plus controller join (plus-join-not-executed). Intended read of local.rules inside ruleset foo. Unauthenticated GET (not proven; examples send Bearer). POST /services/snort/rules archive / planted .so. Diagnostics PHP/shell. File write. Host shell. Library default verify_ssl=True. This leftover is not RCE.
The fix
Resolve under a ruleset root after decode. Reject .. and absolute paths. Path.resolve() then relative_to(ruleset_dir.resolve()), or equivalent, before the read. Refuse anything that is not a child of the named ruleset directory.
Constrain {file} (and {name}) to a ruleset-safe pattern in the OpenAPI so generators stop sending path-shaped strings. A pattern of ^[A-Za-z0-9._-]+$ is not clever. It is the thing this 42685-line schema does not have a single instance of.
Do not treat client quote(safe="") as the basedir. The client is doing its job. The server still has to join.
Lab replay
python3 pfsense-api-snort-file-join-Abraxas-Labs.pyor cd lab && ./run.sh. Loopback only: http://127.0.0.1:19181. Pin b4acd7ca21dbca739a530403a1f62a7440f7726f.
SUCCESS PFSENSE-API-SNORT-FILE-JOIN encoding=%2F decode-then-join=yes control-confined=yes plus-join-not-executed PFSENSE-API-SNORT-FILE-JOIN-WITNESSReferences
- Series map: pfsense-api-nexus-mim
- Same pin, fleet token: pfsense-api-fleet-jwt · abraxas/pfsense-api-fleet-jwt
- Pack: abraxas/pfsense-api-snort-file-join
- github.com/Netgate/pfsense-api
b4acd7ca21dbca739a530403a1f62a7440f7726f pfapi_openapi.yml/services/snort/rules/{name}/{file}get_snort_ruleset_file.py- CWE-22
Contact
abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me