pfSense Plus MIM API toolkit, nine leftovers
Netgate/pfsense-api is the public OpenAPI and generated client for pfSense Plus Nexus Multi-instance Management. Nine leftovers in the enrollment sample, the unix socket, GET private-key export, ACME dump, fleet JWT, ACB userkey, Snort path join, and official TLS-verify-off samples. They stack.
- Name
- pfSense Plus MIM API toolkit, nine leftovers
- 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, openapi, cwe-78, cwe-322, cwe-306, cwe-200, cwe-639, cwe-22, cwe-295
A public client for a paid controller
I am @abraxas_null. This is a series. Nine leftovers, nine loopback labs, nine GitHub packs. This post is the map: what the product actually is, what I tested, what each leftover class is, what an attacker can do with it, and how the nine of them lean on each other.
The tree is github.com/Netgate/pfsense-api at main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, "Add Snort-3 API definitions"). Apache-2.0. No tags. OpenAPI 3.0.3, 486 paths, 755 operations, plus generated Python and a handful of official samples. The server those operations talk to is closed-source and ships with pfSense Plus 25.11+ as the Nexus Multi-instance Management controller on :8443. I did not download Plus. I did not talk to a live controller. The leftovers that can be proven from the public client, I proved. The leftovers that need the paid binary, I said so.
This is not the third-party CE REST package at jaredhendrickson/pfsense-api / pfrest.org. It is also not the pfSense CE WebGUI I already mailed Netgate about on 2026-10-13 (QinQ parent-if, OpenVPN wizard CRLF, CARP VHID, limiter number, IPsec login banner). Those PHP apply sinks still show up as field names in this OpenAPI. I am not re-filing them.
The parts
- Enrollment SSH interpolates controller JSON - Remote privilege leftover, CWE-78. Pack: abraxas/pfsense-api-enroll-json-ssh
- Enrollment SSH AutoAddPolicy - Remote privilege leftover, CWE-322. Pack: abraxas/pfsense-api-enroll-autoadd
- Device unix socket has no JWT - Local privilege leftover, CWE-306. Pack: abraxas/pfsense-api-unix-sock-noauth
- GET export-key returns private keys - Remote privilege leftover, CWE-200. Pack: abraxas/pfsense-api-export-key-get
- GET /services/acme dumps ACME keys - Remote privilege leftover, CWE-200. Pack: abraxas/pfsense-api-acme-key-dump
- One controller JWT is a fleet token - Remote IDOR, CWE-639. Pack: abraxas/pfsense-api-fleet-jwt
- ACB userkey is a capability URL - Remote IDOR, CWE-639. Pack: abraxas/pfsense-api-acb-userkey
- Snort
{file}decode-then-join - Remote file read, CWE-22. Pack: abraxas/pfsense-api-snort-file-join - Official samples set
verify_ssl=False- Remote privilege leftover, CWE-295. Pack: abraxas/pfsense-api-example-ssl-verify
Authorized labs only. They talk to loopback.
Why this tree
The interesting client for a firewall controller is the one the vendor ships. Operators copy example-add-devices.py. They point it at :8443. They paste SSHPASS. They disable TLS verify because the sample does. That is the threat model for leftovers 1, 2, 3, and 9. Leftovers 4 through 8 live in the OpenAPI the generator used: GET private keys, GET ACME secrets, one Bearer for every device_id, ACB userkey on the query string, Snort {file} as an unconstrained path.
I wanted the API implementation, not another WebGUI PHP hunt. 755 operations. Zero securitySchemes. Login still returns a JWT. The generator therefore types every sync_detailed as client: AuthenticatedClient | Client. Client will POST /diag/cmd/php with no Authorization header. That is a spec leftover. It is not proof the Plus server accepts it.
Pinning b4acd7ca
HEAD on 2026-10-14. No tags. Latest commit adds Snort-3 API definitions, which is how leftover 8 even exists as a path. 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, then login, controller, aliases, auth, events.
Auth in the samples: POST /login with base64 username and password, JWT access token, 24h refresh cookie. Device calls go to /api/device/pfsense/{device_id}/api plus the same 755 ops. Local enroll uses /var/run/pfnet-controller.sock with no Bearer. LDAP roles in the schema: view, logs, usermgmt, services, firewall, interfaces, devices, system, superuser, each with read / modify / delete. LoginResponse is token, user, version, alerts. No per-device allow-list.
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 admin power. Netgate already treated that as such (CVE-2025-69691 disputed). I am not ranking POST /diag/cmd/php or /diag/cmd/shell as unpublished RCE unless a non-superuser role can call them. I could not prove that without Plus.
QinQ if_device, IPsec login_banner, OpenVPN dns_domain / netbios_scope are the same PHP apply sinks I mailed as CE WebGUI leftovers. Limiter number is an integer in this OAS. CARP VHID is not a string leftover here.
CE ACB key derived from SSH is CVE-2024-57273 / SA-25_03. Leftover 7 is the API query-param sibling, not that derivation.
The generated library default is verify_ssl=True. Path params use quote(..., safe=""), so ../ leaves the client as ..%2F. That is client-correct. A leftover there is a server join after decode.
755 operations, one mock
I stood a stdlib mock on 127.0.0.1:55980, invoked every generated _get_kwargs (zero hand-written fallbacks), sent each op twice (Client with no token, AuthenticatedClient with Bearer mock-token), then tore it down. Witness PFSENSE-API-MOCK-WITNESS. Methods: GET 289, POST 281, DELETE 123, PUT 57, PATCH 5. 755/755 sent. AuthenticatedClient always carried Bearer. Client never did, including php, shell, upload, editfile, shellcmd, and export-key. Path probes of ../ ; $() never left the wire unescaped. OpenAPI security on any op: none.
The mock is a client contract. It cannot prove the Plus controller returns 200 without a JWT.
Intended admin surface
A superuser / mim_system JWT is a fleet console. These are the documented Diagnostics: Command / Edit File / Backup wrap. I list them so the class of leftovers 4 through 8 is not inflated into "the API is RCE."
| Method | Path | What it grants |
|---|---|---|
| POST | /diag/cmd/php |
Arbitrary PHP as the MIM agent |
| POST | /diag/cmd/shell |
Shell, timeout 90-300s |
| GET/POST | /diag/cmd/download /upload /diag/editfile |
Read or write a file path |
| POST | /diag/control |
Reboot / halt |
| POST | /diag/backup/restore |
Restore config |
| CRUD | /ports/shellcmd |
Persistent boot shell |
| GET | /system/certificates/{refid}/export-key |
Leaf private key |
| GET | /system/certauth/{refid}/export-key |
CA signing key |
| GET | /system/certificates/{refid}/export-p12 |
PKCS12 without password protection |
Closed on the client I tested: library verify_ssl default True; path params percent-encode ../; unauth 200 on php/shell is not shown.
How they stack
Read leftover 9 first even though it is the lowest CVSS of the nine. Every official Python sample and helper_funcs.py pass verify_ssl=False. example.js sets NODE_TLS_REJECT_UNAUTHORIZED=0. The library default is True. Operators copy the sample. An on-path peer of :8443 sees the base64 admin login and the access JWT, plus the 24h refresh cookie.
That JWT is leftover 6: one POST /login Bearer is reused for every device_id. Official examples copy it onto /api/device/pfsense/{id}/api. LoginResponse has no per-device allow-list. LDAP roles are global. A helpdesk login that can list devices can talk to firewall B. If that role includes console, POST /diag/cmd/php is the intended admin wrap on every enrolled box. Plus RBAC was not executed. The public client and the public schema do this.
The same stolen JWT is leftovers 4 and 5. GET export-key and GET export-p12 return CA and leaf private keys, PKCS12 documented without password protection. GET /services/acme schema includes account_key, issued privateKey, custom_key, and DNS credentials. A read-shaped MIM JWT pulling signing keys is a different class from "admin clicked Diagnostics: Command." I asked Netgate to confirm whether mim_system_read can call those GETs.
Leftover 7 is the backup plane. GET /services/acb?userkey= is a capability URL. A stolen or leaked ACB device key lists and fetches Auto Config Backup blobs. Full config sits in those blobs. Same class as CE CVE-2024-57273 if the key leaks. G9 MITM of a sample that GETs ACB config can leak the live userkey. Then leftover 7 no longer needs the JWT.
Enrollment is leftovers 1, 2, and 3, and leftover 9 feeds 1. example-add-devices.py dumps ControllerSummary JSON, escapes only ", .formats it into a POSIX curl -d "..." script, then Paramiko exec_command as SSHUSER (default admin). $() survives. Combined with verify_ssl=False, a MITM of :8443 shapes the JSON that becomes shell on every address in pfsense_addresses. The same sample sets AutoAddPolicy(), so first-contact SSH accepts whatever host key answers, then runs that script, then POSTs the returned identity to the real controller. Once the script is on the box it curls --unix-socket /var/run/pfnet-controller.sock with no Authorization. A local uid that can connect(2) the sock can enroll a hostile controller and dump identity. Real sock DAC is not in the public tree.
Leftover 8 is a GET with a fleet token. GET /services/snort/rules/{name}/{file} takes unconstrained strings. The client encodes ../ to ..%2F. A server that decodes then joins reads outside the ruleset as the MIM agent. Plus join was not executed. I proved the encoding and a decode-then-join gadget of the public path shape.
The short version: steal or mint a controller JWT (9, or a helpdesk password), and you have a fleet token (6) that can export keys (4), dump ACME (5), fetch ACB if you also have userkey (7), and read Snort-adjacent files if the join is naive (8). Enroll a box with the official sample and you also get SSH injection (1), SSH MITM (2), and a JWT-less unix sock (3).
What an attacker can do (series)
Every privilege, split by leftover. Lab-proven vs inferred. Adjacent surface that stays closed.
| # | Reach | Class | Lab-proven | Inferred | Closed |
|---|---|---|---|---|---|
| 1 | Remote | Privilege leftover | Quote-only escape lets $() write a witness during enroll-script expansion |
MITM of sample HTTPS, or a stored controller field, supplies the JSON | Library default verify True; generated client does not interpolate |
| 2 | Remote | Privilege leftover | AutoAddPolicy connects to unknown host and receives identity JSON | Stolen SSHUSER password; planted DeviceIdentity on the real controller | RejectPolicy fails the same host |
| 3 | Local | Privilege leftover | Official curl shape has Authorization absent; mock returns identity | Enroll hostile controller; POST pubkey including privkey | HTTPS login still issues a JWT; sock DAC unknown |
| 4 | Remote | Privilege leftover | OpenAPI has three GET key exports; Client sends them with no Bearer; mock returns witness PEM | Read-shaped JWT pulls CA/leaf keys; impersonate VPN/WebGUI/IPsec | Plus RBAC not executed; php/shell may still be superuser |
| 5 | Remote | Privilege leftover | Schema includes account_key/privateKey/credentials; mock round-trips witness | Steal Let's Encrypt account and issued keys for every enrolled hostname | Issue/renew POST is services-modify |
| 6 | Remote | IDOR | One token, two device ids, both 200; LoginResponse has no device ACL | Helpdesk JWT php/shell on firewall B | One customer tenancy, not SaaS cross-tenant; unauth stays 401 |
| 7 | Remote | IDOR | Stolen userkey returns backup list and blob; wrong key does not | Full config.xml from ACB | POST restore still wants a JWT in samples |
| 8 | Remote | File read | Client encodes %2F; decode-then-join gadget reads /oracle/WITNESS; confined control 400 |
Plus join after %2F decode |
Client encoding is correct; leftover is the server join |
| 9 | Remote | Privilege leftover | verify True fails self-signed; verify False gets JWT; mock captures base64 admin | Replay G4-G7 and intended php/shell | Library default True |
What I could not prove
The controller binary is Plus-only. Unauth php/shell, mim_view vs export-key, sock mode, and the real Snort path join need a licensed box. G4 through G8 labs are public-client and OpenAPI contract labs. I said that in every pack and in the mail to Netgate.
ThreatGate remotes SSRF, secrets on GET (LDAP/RADIUS/SAML/HA/git), and /system/logs/{filename} are Medium leftovers I did not pack. OpenAPI missing securitySchemes and generated Client sending php/shell with no Bearer are Low/Info spec and client findings. They still matter as documentation: implementers will copy them.
I sent one email to security@netgate.com covering all nine, with a table of class, reach, impact, pack folder, and witness. Each pack restores with bash restore-names.sh.txt then ./run.sh. Please request the CVE for this report and credit Abraxas Labs is in that mail.
Contact
abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me