pfSense Plus MIM API toolkit b4acd7ca - High - Remote - File read - Snort rules {file} decode-then-join
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · pfsense-api-snort-file-join
Class: File read Reach: Remote
pfSense Plus MIM API (Netgate/pfsense-api b4acd7ca) - Netgate
GET /services/snort/rules/{name}/{file} takes two path parameters, both type: string, no pattern. Description: inspect a file in the ruleset. SnortRuleFileContents.filename is "path of file". Generated get_snort_ruleset_file._get_kwargs does quote(str(...), safe=""), so ../ is ..%2F on the wire. A server that URL-decodes then joins into the ruleset directory still walks out.
A caller who can GET that path, plus a fleet JWT, reads files outside the named ruleset as the MIM agent on every enrolled box. Plus join was not executed. The lab is a decode-then-join gadget of the public path.
This is leftover 8 of 9. Map: pfsense-api-nexus-mim. Same pin leftover: fleet JWT. This is not jaredhendrickson/pfsense-api.
| ID | no CVE yet |
| Class | File read (Snort {file} decode-then-join) |
| Reach | Remote (authenticated GET; fleet JWT reuses the same path on every device_id) |
| CWE | CWE-22 |
| CVSS | High: 6.5 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 |
| Product | Netgate/pfsense-api (pfSense Plus Nexus MIM controller OpenAPI + generated client) |
| Affected | main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions) |
| Auth | Examples send a controller JWT after POST /login. OpenAPI has no securitySchemes. Unauth is not proven. |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1:19181 only. Decode-then-join gadget. Plus join not executed. |
Needs a caller who can GET /services/snort/rules/{name}/{file}. Official examples use Authorization: Bearer after POST /login, then replay that token as base_url=.../api/device/pfsense/{device_id}/api for every id from GET /mim/devices. Generated ops still type AuthenticatedClient | Client. CSRF is not in play on this GET.
| 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 a fleet JWT (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} are also unconstrained strings | 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 |
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 (intended IDS admin, not this leftover). Diagnostics PHP/shell. File write. Host shell. This leftover is not RCE.
I pinned Netgate/pfsense-api b4acd7ca21dbca739a530403a1f62a7440f7726f because HEAD on 2026-10-14 had no tags and the latest commit is "Add Snort-3 API definitions". That is how this path exists. The YAML has zero pattern: keys anywhere. {name} and {file} are unconstrained strings. quote(..., safe="") is a client encoder, not a server basedir check.
/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:
description: Obtain the contents of the specified file in the ruleset. This is intended for inspecting scripts provided by it.
operationId: GetSnortRulesetFile
"url": "/services/snort/rules/{name}/{file}".format(
name=quote(str(name), safe=""),
file=quote(str(file), safe=""),
),
Wrong turns already recorded: treating client encoding as closing traversal; composing pfSense Plus; ranking Snort archive .so as this leftover; claiming unauth GET. Theatre: a reverse shell. The oracle is gadget 200 of /oracle/WITNESS plus control 400.
python3 pfsense-api-snort-file-join-Abraxas-Labs.py
or
cd lab
./run.sh
Target only http://127.0.0.1:19181. Compose project pfsense-api-snort-file-join. Decode-then-join gadget of the public path. Plus join is not executed. Stack tears down after the run.
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
Client encoding of ../../../etc/passwd is ..%2F..%2F..%2Fetc%2Fpasswd. Naive GET of /services/snort/rules/foo/..%2F..%2Foracle%2FWITNESS returned 200, filename /oracle/WITNESS, body PFSENSE-API-SNORT-FILE-JOIN-WITNESS. The same request under a confined control that resolves under the ruleset root returned 400 and no witness. Benign local.rules stayed inside foo.
Resolve under a ruleset root after decode. Reject .. and absolute paths. Path.resolve() then relative_to(ruleset_dir.resolve()), or equivalent, before the read. Constrain {file} (and {name}) to a ruleset-safe pattern in the OpenAPI so generators stop sending path-shaped strings.