Research/pfsense-api-snort-file-join
0-dayNo CVEHighPublic

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:

YAML
  /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: GetSnortRulesetFile

Responses: 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:

Python
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 _kwargs

quote(..., 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:

Python
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:

  1. Client encoding of ../../../etc/passwd is exactly ..%2F..%2F..%2Fetc%2Fpasswd.
  2. Wire path for {file}=../../oracle/WITNESS contains %2F, still has .. before the first encoded slash, and does not contain a raw /oracle.
  3. Ping says the gadget is decode-then-join and Plus join is not executed.
  4. Benign local.rules is 200 with sid:1 and no witness, naive and control.
  5. Naive GET of the encoded traversal is 200, filename /oracle/WITNESS, body contains the witness.
  6. 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:

Plain text
SUCCESS PFSENSE-API-SNORT-FILE-JOIN encoding=%2F decode-then-join=yes control-confined=yes plus-join-not-executed PFSENSE-API-SNORT-FILE-JOIN-WITNESS

Honesty 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

Plain text
python3 pfsense-api-snort-file-join-Abraxas-Labs.py

or cd lab && ./run.sh. Loopback only: http://127.0.0.1:19181. Pin b4acd7ca21dbca739a530403a1f62a7440f7726f.

Plain text
SUCCESS PFSENSE-API-SNORT-FILE-JOIN encoding=%2F decode-then-join=yes control-confined=yes plus-join-not-executed PFSENSE-API-SNORT-FILE-JOIN-WITNESS