pfSense Plus MIM GET export-key private keys
pfSense Plus MIM OpenAPI GET export-key and GET export-p12 return unprotected private keys. Docs map GET under /system to a read role. Stolen JWT from G9 MITM or a G6 fleet token pulls CA keys. Labbed the public client contract. Plus RBAC was not executed. No CVE yet.
- Name
- pfSense Plus MIM GET export-key private keys
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- pfSense Plus MIM API (Netgate)
- Affected
- pfsense-api b4acd7ca OpenAPI (2026-05-25). Plus 25.11+ Nexus controller. Not the CE REST package. Plus image not in the lab.
- Published
- 14 Oct 2026
- Updated
- 14 Oct 2026
- Tags
- 0-day, pfsense, pfsense-plus, nexus, mim, privilege-leftover, remote, cwe-200, cwe-862
A read JWT should not be a CA
I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-api-export-key-get (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, generated_kwargs.py, openapi-export-key.yml. Authorized lab only. It talks to loopback.
This is Netgate/pfsense-api pin b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, "Add Snort-3 API definitions"). OpenAPI 3.0.3, title pfSense API version 1.0. The server is the pfSense Plus Nexus Multi-instance Management controller on :8443. Paid. Closed-source. Not in that GitHub tree. I did not compose Plus. No CVE yet. CWE-200 / CWE-862. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) if a read-shaped MIM JWT can call these GETs. Remote privilege leftover. Plus RBAC was not executed.
This is Part 4 of the Nexus MIM series. Hub: pfsense-api-nexus-mim.
The OpenAPI is not shy. GET /system/certauth/{refid}/export-key (DownloadCAKey) is "Download the CA certificate signing key". GET /system/certificates/{refid}/export-key (GetCertKey) is "Download the certificate's private key". GET /system/certificates/{refid}/export-p12 (GetCertP12) is "Download the certificate and key as a unified PKCS12 file, without password protection". Docs map GET under /system to mim_system_read / mim_read_system. mim_view is documented as no confidential information.
If you steal a JWT from the G9 sample TLS skip (every official Python example sets verify_ssl=False; example.js sets NODE_TLS_REJECT_UNAUTHORIZED=0) or you already hold a G6 fleet token from POST /login, you can call these GETs. The question the public contract cannot answer is whether Plus still treats them as a read. I labbed the contract. I did not pretend the mock was Plus.
Why this tree, after CE
I had just finished five pfSense CE 2.9.0-DEVELOPMENT leftovers: QinQ parent if, OpenVPN wizard CRLF leftover of SA-26_07, CARP VHID, limiter number, IPsec login banner. PHP-FPM is root. A limited GUI user can be granted a single page without Diagnostics: Command. That hunt lived in pfsense/pfsense. Plus is a different product.
github.com/Netgate/pfsense-api is the public OpenAPI plus generated Python/JS client for pfSense Plus Nexus Multi-instance Management. It is not the CE REST package jaredhendrickson/pfsense-api / pfrest.org. People confuse the two because both say "pfSense API". One is a community package on CE. This one is the controller that sits on :8443 and talks to a fleet.
I only wanted Critical or High. Remote and local. Medium stays on the floor unless it chains. Unauthenticated php/shell would have been Critical. The spec omits securitySchemes on all 755 ops, which looks like that for about ten minutes. Login still issues a JWT. Examples always switch to AuthenticatedClient before they touch devices. Treating omitted OAS security as unauth RCE is how you invent a CVE the server never offered.
A finding on this hunt needed something else: a read role that returns a signing key, a fleet token that is not scoped to one box, enrollment that interpolates JSON into SSH, a unix socket with no JWT, ACME GET that dumps account keys. Those are the Highs. This post is the cert one.
Pinning b4acd7ca
No tags on that repo. main on the day I cloned was b4acd7ca21dbca739a530403a1f62a7440f7726f, 2026-05-25, "Add Snort-3 API definitions". Apache-2.0. pfapi_openapi.yml is 1.2 MB, 486 paths, 755 operations. Generated Python lives under py/pfapi. Samples: example.py, example-add-devices.py, example-upgrade-devices.py, the async twins, example.js. Library default verify_ssl=True. Every official example sets it False.
I cloned that peel into client-reviews/pfsense-api/src and did not update it when later commits appeared. The lab sparse-checkouts pfapi_openapi.yml at that SHA when git can reach GitHub, and asserts the same three paths on a vendored snippet if clone is skipped.
Plus 25.11+ is the server. I do not have a Plus image. I am not going to "borrow" one. The public contract is what I can prove.
The skip list is half the hunt
Before you look for leftovers in a Netgate tree, you write down what is already public so you do not "discover" Diagnostics: Command.
- CVE-2025-69691 disputed. Vendor treats authenticated PHP/shell as intended admin power.
POST /diag/cmd/phpandPOST /diag/cmd/shellin this schema are the MIM wrap of that GUI. I am not ranking them as unpublished Critical. - CE WebGUI leftovers already mailed from the 2.9.0-DEVELOPMENT review: QinQ
if_device, OpenVPN wizarddns_domain/netbios_scope, CARP VHID, limiternumber, IPseclogin_banner. The MIM schema names those fields. Same PHP apply sinks. Do not re-file as new CVEs. - CE REST package jaredhendrickson/pfsense-api. Different server, different auth, different GitHub org.
- SA-26_01 through SA-26_22 on CE, including SA-26_22 / CVE-2026-92182 xmlrpc uid skip. Plus controller is not that PHP.
- GUI
system_certmanager.phpact=key/act=p12already exports keys forpage-system-certmanager. Superuser export is intended. Nexus making the read role equivalent to that GUI page is the leftover.
NVD has no CVE for Netgate/pfsense-api. GitHub advisories on that repo are empty. Issue #1 asked for schema quality, not security schemes.
Twelve hunts, then this GET
I split the tree. Login/JWT/SSO. Diagnostics php/shell/files. MIM enrollment JSON and SSH. Client TLS and example verify_ssl. Users, certs, logs, firmware. Packages shellcmd/haproxy/ACB. VPN/firewall/interfaces. OpenAPI missing security. Device IDOR / fleet JWT. Git/firmware/feeds/ThreatGate. A loopback mock of every generated operation.
Most of that is Netgate doing what Netgate said it would do, or it is a Medium that does not stand alone.
Login returns a JWT access token and a 24h refresh cookie. Examples decode exp from the payload with no signature check. Display only. Login credentials are base64, not a hash. Transport encoding. GET /login/notices and GET /login/ssoauth are login-page recon. POST /auth/test is a password oracle if unauth. Confidence the server is open is 45%. I left it.
Path params percent-encode ../ ; $(). 685 probes. Zero unescaped ../ on the wire. A server that decodes %2F after routing could still see ../. That is G8 (Snort {file}), not this post.
POST /system/firmware is upgrade: boolean. Intended. PUT /services/threatgate/remotes is SSRF-shaped for a services-modify JWT. Medium. LDAP bind_password, RADIUS secret, SAML sp_key_pem on GET. Medium secret echo. /system/logs/{filename} unconstrained string. Medium until a Plus join is proven.
The mock of all 755 ops on 127.0.0.1:55980 proved a client fact: _get_kwargs never adds Authorization. Client sent php, shell, upload, editfile, and export-key with no Bearer. AuthenticatedClient always sent Bearer mock-token. The mock always 200s. That is not a server 200-without-JWT proof. It is why a caller who constructs Client will ship a private-key GET with nothing on the wire.
Enrollment SSH interpolating controller JSON is G1. AutoAddPolicy is G2. Unix socket with no JWT is G3. ACME GET dumping account keys is G5. One controller JWT as a fleet token is G6. ACB userkey is G7. Snort {file} is G8. Sample TLS skip is G9. Those have their own packs. This one is the cert GET.
The leftover is GET, not POST
Nexus does not reuse pfSense WebGUI page-* ACLs for API authz. Docs: local admins is superuser; everyone else must be in a mim_* group. OpenAPI GET /system/users/privs (ListUserPrivileges) says MIM privileges are the group's name. LdapAuthServer.nexus_group_prefix (default mim_) documents roles view, logs, usermgmt, services, firewall, interfaces, devices, system, superuser with read/modify/delete. A role without those attributes is considered granted them all.
Official docs reverse the token order versus the OpenAPI examples. Docs: mim_<capability>_<role> (mim_read_system). OpenAPI: "<prefix><role>_<permission>" (mim_system_read). Either way, read is GET. Docs: read = "Read data, perform GET API calls". view = "Global view only, no confidential information". system = "System configuration and console access".
Every secret-export route on this pin is a GET, or a GET variant that skips the password the POST requires. Spec has no separate privilege, scope, or security annotation on export vs list.
| Method | Path | operationId | OpenAPI summary |
|---|---|---|---|
| GET | /system/certauth/{refid}/export-key |
DownloadCAKey |
"Download the CA certificate signing key" |
| GET | /system/certificates/{refid}/export-key |
GetCertKey |
"Download the certificate's private key" |
| GET | /system/certificates/{refid}/export-p12 |
GetCertP12 |
"Download the certificate and key as a unified PKCS12 file, without password protection" |
| POST | /system/certificates/{refid}/export-key |
DownloadCertKey |
same summary; body CertKeyExportOpts.password |
| POST | /system/certificates/{refid}/export-p12 |
DownloadCertP12 |
PKCS12; body CertPkcs12ExportOpts.password |
| GET | /system/certauth?withprivkey=true |
GetCertAuthorities |
list; query withprivkey has no description |
| GET | /system/certificates/{refid} |
GetCertificate |
CertificateDetailed includes prv |
DownloadCAKey has no password option at all. GET only. Cert key and PKCS12 split GET (clear) vs POST (optional wrap). The Python client get_cert_p12.py docstring copies the OpenAPI summary including "without password protection". That sentence survived generation. Someone typed it into the spec on purpose.
GUI sibling on CE: system_certmanager.php GET act=key also ships an unprotected key unless POST exportpass is set. That GUI page is page-system-certmanager. A helpdesk user is not supposed to have it. Nexus mapping GET-under-/system onto mim_system_read makes the helpdesk JWT the same call.
Related list/detail GETs (withprivkey=true, prv, private_key) are the bulk dump of the same secrets. I did not exercise them in this lab. They are the same class. If read can list, read can export. If read cannot export but can list with withprivkey, the leftover moved one query string to the left.
Generated _get_kwargs never heard of Bearer
download_ca_key.py on the pin:
def _get_kwargs(
refid: str,
) -> dict[str, Any]:
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/system/certauth/{refid}/export-key".format(
refid=quote(str(refid), safe=""),
),
}
return _kwargsGetCertKey and GetCertP12 are the same shape. sync_detailed types client: AuthenticatedClient | Client. That union is what you get when OpenAPI has no securitySchemes. openapi-python-client cannot tell login from export-key from php.
AuthenticatedClient.get_httpx_client is where Bearer actually appears:
self._headers[self.auth_header_name] = (
f"{self.prefix} {self.token}" if self.prefix else self.token
)Default prefix Bearer, header name Authorization. It is a wrapper property. It is not in _get_kwargs. A caller who uses Client sends the export with no Authorization header. A caller who uses AuthenticatedClient sends Bearer <token>. The leftover on Plus is which tokens are enough. The leftover in the client is that the generator will happily send the GET with none.
I vendored those three _get_kwargs bodies into lab/generated_kwargs.py. Identical to the pin. The lab does not import the rest of py/pfapi. It does not need httpx. It needs to prove the kwargs are method plus URL.
The lab is the contract, not Plus
Host is macOS. One container. Compose project pfsense-api-export-key-get. Loopback 127.0.0.1:19141. Image is python:3.12-alpine running the same poc.py --mock. There is no pfSense in it. There is no Plus. There is a ThreadingHTTPServer that returns a fake PEM containing PFSENSE-API-EXPORT-KEY-GET-WITNESS for the three export paths, records whether Authorization was present, and answers /health and /hits.
The client side, on the host:
- Assert the vendored OpenAPI snippet contains the three GET paths and the phrase "without password protection".
- Sparse-checkout
pfapi_openapi.ymlatb4acd7cafrom github.com/Netgate/pfsense-api. Assert the same three paths and the same phrase on the clone. If git cannot reach GitHub, logpin=snippet-onlyand keep going on the snippet. Last SUCCESS run waspin=clone. - Wait for
/health. - For each of
DownloadCAKey,GetCertKey,GetCertP12: build kwargs, refuse SUCCESS if kwargs grew a header or the word Bearer, send viaClient(no token), send viaAuthenticatedClient(token="mock-token"). - Read
/hits. Count three no-auth records and threeAuthorization: Bearer mock-tokenrecords. Count three witness PEM bodies.
Last run:
SUCCESS PFSENSE-API-EXPORT-KEY-GET openapi=3/3 client-no-auth=yes witness-body=yes authed-bearer=3 plus-rbac=not-executed pin=clone PFSENSE-API-EXPORT-KEY-GET-WITNESSplus-rbac=not-executed is in the SUCCESS line on purpose. I will not let a mock 200 become a claim that mim_view downloaded a CA. The pin clone is in the SUCCESS line on purpose too. The snippet matching the clone is the argument that I did not invent the summaries.
Replay:
python3 pfsense-api-export-key-get-Abraxas-Labs.pyor cd lab && ./run.sh. Stack tears down on EXIT.
Wrong turns, in the order they happened
Treating omitted OAS security as unauth php/shell. First pass on the 755-op mock, Client POSTed /diag/cmd/php with no Bearer and got a mock 200. That looks like Critical for about as long as it takes to remember the mock has no JWT check. Login still returns a JWT. Examples always switch wrappers. Confidence the Plus server honors bare Client is 15%. I ranked missing securitySchemes as a generator footgun, not a proven unauth RCE.
Treating superuser export as the leftover. GUI system_certmanager.php already exports keys. Filing "the API can download a private key" against mim_superuser is a product review. The leftover is GET on a read role, PKCS12 documented as unprotected, and CA key GET with no wrap at all.
Wanting a Plus box. I wanted two JWTs: mim_read_system versus mim_logs_read versus mim_view, against GET .../export-key and GET .../export-p12. Witness: PEM contains BEGIN private key. Negative: logs-read 403. Plus is paid. The controller binary is not in the GitHub tree. I stopped. The pack is honest about that. If Netgate's RBAC already gates these GETs to superuser, this is a spec lie and a client footgun. If it does not, it is a CA.
Bundling withprivkey=true into the same SUCCESS. The list GET is the same class. It is not this lab. One leftover per pack.
Calling it unauthenticated. Client sending no Authorization is a generated-library fact. The documented auth model is still POST /login then Bearer. CVSS is PR:L, not PR:N. The G9 MITM and G6 fleet posts are how you get that JWT. They are not this GET becoming anonymous.
A reverse shell. Theatre. The oracle is a witness PEM plus plus-rbac=not-executed. Integrity after key theft (mint descendant certs, impersonate Nexus TLS, decrypt VPN) is inferred. I marked it inferred.
Filing CE REST. Different GitHub org, different server, different auth. I wrote that on the skip list before I opened pfapi_openapi.yml.
Stolen JWT, fleet token, then this GET
G9 is the sample TLS skip. Every shipped Python example constructs AuthenticatedClient with verify_ssl=False against https://...:8443. example.js sets NODE_TLS_REJECT_UNAUTHORIZED=0. Library default is still True. The leftover is the example, not the library. A MITM of that HTTPS sees admin (base64) plus the access JWT plus the 24h refresh cookie. Replay is G4 through G6 and, if the JWT is superuser, intended php/shell.
G6 is the fleet token. One POST /login Bearer is reused for every device_id under /api/device/pfsense/{device_id}/api. GET /mim/devices lists ids. Tags filter only. LDAP text documents roles. No per-device allow-list in LoginResponse. A helpdesk JWT that can GET /system on the controller can name firewall B after authenticating as a user meant for firewall A. One customer tenancy, not SaaS cross-tenant.
This GET is what you do with that JWT if you want keys instead of php. You do not need Diagnostics: Command. You do not need mim_superuser if the leftover is real. You need a group allowed GET under /system/certauth and /system/certificates. Docs say that is mim_system_read / mim_read_system. Docs also say mim_view has no confidential information. I would like Plus to prove which sentence wins. I could not.
CA signing key plus leaf private keys plus unencrypted PKCS12 is impersonation of WebGUI TLS, VPN, and IPsec for the controller and every managed instance the token can name. It is not Unix root by itself. It is not firmware POST. It is enough.
What an attacker can do
Docs map GET under /system to mim_system_read / mim_read_system. mim_view is documented as no confidential information. The leftover is private-key export on GET, with PKCS12 documented as unprotected, and generated _get_kwargs that never adds Authorization.
| Method | Path | Privilege |
|---|---|---|
GET DownloadCAKey |
/system/certauth/{refid}/export-key |
Recon (lab-proven on mock): application/octet-stream body containing the witness PEM. Inferred on Plus: CA certificate signing key if RBAC allows the GET. |
GET GetCertKey |
/system/certificates/{refid}/export-key |
Recon (lab-proven on mock): witness PEM. Inferred on Plus: leaf private key. |
GET GetCertP12 |
/system/certificates/{refid}/export-p12 |
Recon (lab-proven on mock): same witness body. Inferred on Plus: cert plus key as PKCS12 without password protection (OpenAPI summary). |
Generated _get_kwargs |
all three ops | Recon (lab-proven): kwargs are method plus quoted url only. No headers, no Authorization, no Bearer. |
Client (no token) |
all three ops | Recon (lab-proven): three mock hits with has_authorization=false and witness body (client-no-auth=yes, witness-body=yes). |
AuthenticatedClient |
all three ops | Recon (lab-proven): three mock hits with Authorization: Bearer mock-token (authed-bearer=3). Bearer is a wrapper property, not _get_kwargs. |
| Stolen JWT from G9 MITM, or G6 fleet token | same three GETs, plus /api/device/pfsense/{device_id}/api if the fleet leftover holds |
Recon (inferred, chained): pull CA / leaf keys for the controller and every enrolled instance the token can name. |
| Use stolen CA / leaf keys | TLS, VPN, IPsec, WebGUI certs | Integrity (inferred, not labbed): mint descendant certs as the CA; impersonate the Nexus TLS name; decrypt tunnels that use those certs. Not Unix root by itself. |
| Related list/detail GETs | /system/certauth?withprivkey=true, cert prv / private_key fields |
Recon (spec, not this lab): bulk dump of the same secrets. Not exercised here. |
Availability: none required.
Lab-proven vs inferred: OpenAPI three GET paths, PKCS12 "without password protection" phrase, _get_kwargs with no Authorization, Client three no-auth hits with witness PEM, AuthenticatedClient three Bearer hits, and pin clone of pfapi_openapi.yml are lab-proven. Plus mim_system_read / mim_view 200s, minting descendant certs, and fleet-wide key pull are inferred. Plus RBAC was not executed.
Requires (inferred, Plus not executed): Nexus login plus a group allowed GET under /system/certauth and /system/certificates. Not admins / mim_superuser if the leftover is real. Not Diagnostics: Command. Network path to the controller :8443 (or the device proxy) with a JWT.
Adjacent surface that stays closed
| Surface | Why |
|---|---|
| Plus RBAC | Not executed. This pack does not claim a live Plus 200 for mim_system_read or mim_view. |
POST /diag/cmd/php, POST /diag/cmd/shell, GET/POST /diag/cmd/download /upload /diag/editfile |
Intended Diagnostics: Command. Superuser-shaped. Not this leftover. |
POST /system/firmware |
Intended system modify / superuser. |
GET .../export-ca and GET .../export-cert |
Public cert PEM. Intended. |
POST .../export-key and POST .../export-p12 with a password as superuser |
Intended wrap. GUI already does this. |
| Unauthenticated Internet-to-8443 | Login still issues a JWT. Missing OpenAPI securitySchemes is a generator footgun, not a proven unauth server. |
| CE package jaredhendrickson/pfsense-api / pfrest.org | Different product. |
Generated library verify_ssl default |
True. Official examples set False. That is G9, not this GET. |
The fix
Treat private-key export as superuser-only. Never return PEM or PKCS12 on a read role. Password-protect PKCS12. Mark the ops in OpenAPI with a security scheme that the generator cannot type as bare Client. If mim_view is documented as no confidential information, these three GETs should 403 for it. If mim_system_read is documented as GET, it still should not be a CA.
Until then, a stolen JWT from G9 or a G6 fleet token is a key export client. I am not going to print a curl you can paste at someone else's :8443. The missing privilege split is the useful part. If you own the controller, run the proof of concept against loopback. Confirm Plus RBAC yourself. I could not.
References
- Proof of concept: abraxas/pfsense-api-export-key-get · pfsense-api-export-key-get-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · generated_kwargs.py · openapi-export-key.yml - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- Series hub: pfsense-api-nexus-mim
- Related: pfsense-api-fleet-jwt (G6) · pfsense-api-example-ssl-verify (G9)
- Tree: Netgate/pfsense-api
b4acd7ca21dbca739a530403a1f62a7440f7726f· pfapi_openapi.yml · download_ca_key.py · get_cert_key.py · get_cert_p12.py - Nexus group authorization
- CWE-200 · CWE-862