pfSense Plus MIM GET /services/acme dumps keys
pfSense Plus MIM OpenAPI GET /services/acme returns ACME account keys, issued private keys, custom leaf keys, and DNS credentials. Labbed on the published spec plus a loopback mock. Plus not executed. No CVE yet.
- Name
- pfSense Plus MIM GET /services/acme dumps keys
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Netgate
- Affected
- pfSense Plus MIM API toolkit (Netgate/pfsense-api) main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25). OpenAPI GetACMEConfig. No Plus image.
- Published
- 14 Oct 2026
- Updated
- 14 Oct 2026
- Tags
- 0-day, pfsense, pfsense-plus, mim, acme, privilege-leftover, cwe-200
Config GET is the dump
I am @abraxas_null. This is part 5 of the pfSense Plus MIM leftovers. The proof of concept is on GitHub: abraxas/pfsense-api-acme-key-dump (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Netgate/pfsense-api public main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions). OpenAPI 3.0.3 plus a generated Python client for pfSense Plus Nexus Multi-instance Management. Controller on :8443. No CVE yet. CWE-200. 6.5 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N) if a services-read role receives the body. Remote privilege leftover. Plus not executed.
Cert manager at least has a dedicated GET /system/certificates/{refid}/export-key. ACME has none. GET /services/acme (GetACMEConfig) returns the whole ACMEConfig object. The 200 schema includes account_keys[].account_key, certs[].issued_cert.privateKey, certs[].custom_key, and domain_list[].config.credentials. Those are the Let's Encrypt (or other ACME) account private key, every issued leaf private key, any custom leaf key, and the DNS-provider tokens that prove control of the names. format: byte on the key fields. No writeOnly. No redaction flag.
A controller session that can GET this path copies every hostname key the firewall enrolls. Issue and renew stay a modify operation. Listing should not be the export.
It chains. G9 is the official samples setting verify_ssl=False, so a MITM of :8443 sees admin (base64) plus the access JWT plus the 24-hour refresh cookie. G6 is one login Bearer reused for every device_id. Stolen JWT, then fleet walk, then this GET on each box. This leftover is the body.
Why this tree, after CE
I had just mailed five pfSense CE 2.9.0-DEVELOPMENT leftovers: QinQ parent-if into ifconfig, OpenVPN wizard CRLF leftover of SA-26_07, CARP VHID, limiter number, IPsec login_banner. Those are WebGUI PHP. Plus is paid. I did not download it.
What I could read without a license is the public toolkit Netgate published for the MIM controller. GitHub Netgate/pfsense-api. Apache-2.0. No tags. 486 paths, 755 operations. OpenAPI plus generated Python plus sample scripts that enroll boxes over SSH and then replay a JWT against /api and against /api/device/pfsense/{device_id}/api.
That is not the third-party CE REST package jaredhendrickson/pfsense-api / pfrest.org. Different server, different GitHub org, different privilege model. I wrote that on the skip list before I opened a path.
I only wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. Intended admin power is listed so it is not inflated into a CVE. Netgate already treats authenticated Diagnostics: Command PHP/shell as intended (CVE-2025-69691 disputed). POST /diag/cmd/php and POST /diag/cmd/shell are the MIM wrap of that GUI. I am not ranking them as unpublished RCE.
Pinning b4acd7ca
Public GitHub has no version tags on this repo. Latest commit on the day I cloned was b4acd7ca21dbca739a530403a1f62a7440f7726f, 2026-05-25, "Add Snort-3 API definitions." That is origin/main HEAD. OpenAPI 3.0.3, title pfSense API, version 1.0. Generated client from openapi-python-client. Docs at netgate.github.io/pfsense-api.
The server binary is Plus-only. Not in this tree. Forum notes the controller is a Plus box and the local instance is not registered as a managed device. Examples talk HTTPS to :8443 with verify_ssl=False. Library default is verify_ssl=True. Path params percent-encode ../. All of that is in the clone. None of it is a live controller.
The skip list is half the hunt
Already public or already mailed, so not this post:
- CE G1-G5: pfsense-qinq-parent-if, pfsense-openvpn-wizard-crlf, pfsense-carp-vhid, pfsense-limiter-number, pfsense-ipsec-login-banner.
- SA-26_01 through SA-26_22 on the CE WebGUI, including SA-26_07 OpenVPN server page and SA-26_22 / CVE-2026-92182 xmlrpc uid skip.
- CVE-2024-57273 ACB key from SSH banner. CE pin already random 64-hex.
- Community
jaredhendrickson13/pfsense-api. Different product. - Diagnostics PHP/shell as unpublished Critical. Intended admin console.
Missing OAS security / securitySchemes on all 755 ops is a spec leftover. Generators type every call as AuthenticatedClient | Client. Examples still POST /login and send Bearer. I did not file unauth php/shell from an omitted YAML key.
Twelve hunts, most of them empty
I split the toolkit: login/JWT/SSO, diagnostics PHP/shell/files, diagnostics ping/DNS/pcap, MIM enrollment JSON/SSH/unix socket, client TLS and sample verify_ssl=False, users/certs/logs, packages (shellcmd, HAProxy, FRR, Snort, ACB, ACME, FreeRADIUS, UPnP), VPN/firewall/interfaces leftovers of the CE five, OpenAPI security, per-device IDOR / fleet JWT, git/firmware/feeds/ThreatGate, plus a loopback mock of every generated operation on 127.0.0.1:55980. 755/755 _get_kwargs sent. Witness PFSENSE-API-MOCK-WITNESS. Torn down.
Surfaces that returned nothing at this bar:
Firmware URL SSRF. POST /system/firmware body is upgrade: boolean. No operator URL. Latest-version check is the configured branch. Closed.
Unauth ping/DNS/traceroute. /diag/ping and friends are per-device REST. Packets leave the named firewall. Host fields are unconstrained because that is the diagnostic. Login tag text and example.py still want a JWT. Not unauth SSRF.
GET /services/acme/validmethods. Method catalog. No keys.
Git sync write. GET settings returns git_sync.repo_url (token-in-URL is a sibling secret dump). Documented POST body omits git_sync. I did not file "git clone over /" from extra JSON.
UPnP permuser. miniupnpd ACL line. Low. Floor.
OpenAPI no securitySchemes. Info. The YAML still describes JWT login plus mim_* group roles. Official Plus docs (2026-09-17) document the same RBAC. A gateway that trusts the schema will treat POST /ports/shellcmd as public. That is a contract leftover, not a 200-without-JWT proof.
VPN/firewall/interfaces on this pin did not replay the CE five as unpublished High. QinQ parent, OpenVPN wizard, CARP VHID, limiter number, IPsec banner are WebGUI. The API fields are a different question and not this leftover.
Enrollment SSH interpolating controller JSON, AutoAddPolicy, the unix socket with no JWT, cert-manager export-key, fleet JWT, ACB userkey, Snort {file}, sample TLS verify: those are the other High rows. Other posts. This one is ACME GET.
The leftover is a listing that includes live keys
GET /services/acme:
/services/acme:
get:
tags:
- services
summary: Get ACME Config
operationId: GetACMEConfig
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/ACMEConfig'ACMEConfig is account keys, certs, servers, settings. No "omit secrets" sibling schema. Walking properties:
ACMEAccountKey.account_key:
account_key:
type: string
description: |
if key_type is not "custom", system will overwrite this field with generated private key
format: byteThe description is honest. If you did not supply a custom key, the system wrote one. GET hands it back.
ACMEIssuedCert.privateKey is format: byte, next to certificate, issuerCertificate, csr. ACMECertificate.custom_key is format: byte when key_type=custom. ACMEValidationMethodConfig.credentials is additionalProperties: string. Cloudflare tokens, AWS keys, RFC2136 secrets, whatever the DNS plugin wants. GET dumps those too.
action_list[].command with method of shellcommand / php_command / servicerestart is the ACME package "restart after renew" hook. Intended for services-modify. Seeing the command string on GET is recon. Running it is a different op.
POST /services/acme/genprivatekey returns ACMEPrivateKey.key. That mint is the feature for an operator who is about to register. I almost filed it. It is not this leftover. The leftover is the config GET handing every stored account key and every issued cert key to whoever can call the listing.
Cert manager:
| Method | Path | What it is |
|---|---|---|
| GET | /system/certificates/{refid}/export-key |
Dedicated key download. Still a leftover if a read role can call it. At least it is named. |
| GET | /system/certauth/{refid}/export-key |
CA signing key. Same class. |
| GET | /system/certificates/{refid}/export-p12 |
PKCS12, spec text "without password protection". |
ACME has no export-key. Config GET is the dump. That is the shape that is wrong even if only superuser can call it. It is High if mim_services_read receives the body. Docs map GET of the services tag to mim_services_read / mim_services / mim_superuser. Spec does not attach those roles to the path. Plus RBAC was not executed. Confidence on the schema: high. Confidence a non-superuser actually gets the bytes: inferred.
LDAP text on this pin names roles view, logs, usermgmt, services, firewall, interfaces, devices, system, superuser with read/modify/delete. mim_view is documented as no confidential information. If GET /services/acme is allowed to view or to any mim_read_*, that sentence is false. I cannot prove the gate without Plus.
How the generated client sends it
py/pfapi/api/services/get_acme_config.py:
def _get_kwargs() -> dict[str, Any]:
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/services/acme",
}
return _kwargsNo Authorization. No cookie. sync_detailed types client: AuthenticatedClient | Client because the generator saw no OAS security. AuthenticatedClient injects Bearer. Client does not. Examples construct the authenticated one after login. Official example.py then prints the JWT to stdout, which is a different kind of leftover and not this post.
Two bases share one schema. {controller}/api is the controller Plus box. {controller}/api/device/pfsense/{device_id}/api is the proxy to a managed instance. Same JWT. Same GET. G6 is that reuse. This GET on the device prefix is every enrolled firewall's ACME store, not only the controller's.
Wrong turns, in the order I made them
Treating genprivatekey as the bug. POST /services/acme/genprivatekey returns a newly minted account key. That is how you register. Filing "the generate endpoint returns a key" is a product review.
Treating missing securitySchemes as proven unauth. 755/755 ops security: MISSING. Zero 401 responses in the YAML. The login tag still says you need a session token. example.py still logs in. Mock 200 without Bearer proves the generated Client sends no header. It does not prove Plus accepts the call.
Claiming mim_services_read actually receives the keys. The schema includes them. Docs map GET of the services tag to that role. Plus might strip account_key / privateKey at runtime. The generated client still parses them. I scored the dump as High if the read role gets the body, and I wrote plus-not-executed=1 on the SUCCESS line so nobody can pretend I had a license.
A live Plus box. Theatre. The oracle is the published contract plus a loopback mock that round-trips a witness through the same four fields.
Filing issue/renew as RCE. POST /services/acme/cert/{name}/issue talks ACME. Renewal hooks can run php_command. That is the ACME package admin GUI, leftover only if a cert-only role can set the hook. Not this GET.
Custom ACME server URL as this leftover. PUT /services/acme/server takes a bare url. Register and issue then post the account to that host. Sibling SSRF / key theft on modify. Different post. GET still dumps the stored account key even if you never change the server.
CE ACME package PHP. Plus historically wraps the same package. I did not re-file a CE WebGUI dump from an OpenAPI field name.
Inflating to RCE. Stolen keys let you impersonate TLS and talk to the ACME account off-box. The GET does not run a shell. Adjacent POST /diag/cmd/php is intended admin. I left it.
The lab
Host is macOS. No Plus image. Compose project pfsense-api-acme-key-dump. Bind 127.0.0.1:19151.
The mock is a stdlib ThreadingHTTPServer. GET /health is 200. GET /services/acme and GET /api/services/acme return a JSON ACMEConfig with account_key, issued_cert.privateKey, custom_key, and domain_list[0].config.credentials.CF_Token set to PFSENSE-API-ACME-KEY-DUMP-WITNESS. Nothing else.
The PoC, not in mock mode:
- Sparse-clone or copy pin
b4acd7ca(pfapi_openapi.ymlpluspy/pfapi/api/services/get_acme_config.py). - Confirm
components.securitySchemesis absent. - Walk
GET /services/acme200 schema plusACMEAccountKey,ACMECertificate,ACMEIssuedCert. Requireaccount_key,privateKey,credentialson both the GET walk and the component. - Exec generated
_get_kwargs. Fail if the source mentions authorization, bearer, cookie, api_key, or token. Fail if extra keys besides method and url. Fail if method is not GET or url is not/services/acme. urllibGET to the mock with those kwargs. Fail if the client added Authorization or Cookie.- Round-trip the four witness fields. HTTP 200.
Last run:
IOC plus-not-executed=1 mock=http://127.0.0.1:19151 pin=b4acd7ca21dbca739a530403a1f62a7440f7726f
IOC schema-get-acme account_key=1 privateKey=1 custom_key=1 credentials=1
IOC schema-account-cert ACMEAccountKey.account_key=1 ACMEIssuedCert.privateKey=1 ACMECertificate.custom_key=1 ACMECertificate.credentials=1
IOC acme-get-paths=['/services/acme', '/services/acme/validmethods']
IOC mock-get http=200 account_key=1 privateKey=1 custom_key=1 credentials=1
SUCCESS pfsense-api-acme-key-dump schema=account_key,privateKey,custom_key,credentials mock-http=200 plus-not-executed=1 PFSENSE-API-ACME-KEY-DUMP-WITNESSReplay:
git clone https://github.com/abraxas/pfsense-api-acme-key-dump
cd pfsense-api-acme-key-dump
python3 pfsense-api-acme-key-dump-Abraxas-Labs.pyor cd lab && ./run.sh. Bind it to loopback. Docker required. The pin is cloned at runtime from GitHub. No Plus.
validmethods showing up on the GET-path list is the catalog. The PoC does not treat it as a key dump.
What an attacker can do
Requires a controller session that can GET /services/acme. Not proven unauth. A stolen JWT from G9 or a helpdesk fleet token from G6 is enough to point this GET at every enrolled box. That chain is inferred. This leftover is the GET body.
Recon
| Method | Sink | Privilege |
|---|---|---|
GET /services/acme |
ACMEConfig.account_keys[].account_key (format: byte) |
Lab-proven on the published spec and mock: 200 schema includes account_key; mock body round-trips PFSENSE-API-ACME-KEY-DUMP-WITNESS. Inferred on Plus: copy the ACME account private key. Talk to the ACME account off-box. |
| Same GET | certs[].issued_cert.privateKey (format: byte) |
Lab-proven spec+mock. Inferred on Plus: copy every issued leaf private key the firewall enrolled. Off-box TLS impersonation for those names. |
| Same GET | certs[].custom_key (format: byte) |
Lab-proven spec+mock. Inferred on Plus: copy a custom leaf key when key_type=custom. |
| Same GET | certs[].domain_list[].config.credentials |
Lab-proven spec+mock. Inferred on Plus: copy DNS validation secrets. Those prove control of the names. |
| Same GET | account emails, custom server URLs, action_list.command |
Lab-proven the fields sit on the schema. Hook execution is a modify path, not this GET. |
Integrity
None by the GET itself. Later use of stolen keys and DNS tokens is off-box: issue a rogue cert, talk to the ACME account, change DNS. Not exercised.
Availability
None. Config listing does not revoke or rewrite certificates.
Adjacent surface that stays closed
| Surface | Why |
|---|---|
| Live pfSense Plus / Nexus controller | Not executed. plus-not-executed=1. |
| Unauthenticated Internet-to-8443 | Examples require POST /login. Missing OAS security is a spec gap. |
POST /services/acme/cert/{name}/issue and .../renew |
Intended services-modify. |
POST /services/acme/genprivatekey |
Intended generate-and-return of a new account key. |
POST /diag/cmd/php / POST /diag/cmd/shell |
Intended admin console. |
GET /system/certificates/{refid}/export-key |
Separate leftover. Dedicated export on the cert manager. |
GET /services/acme/validmethods |
Method catalog. No keys. |
CE jaredhendrickson/pfsense-api |
Different product. |
Class is privilege leftover, not RCE, not unauth, not a Plus binary bug I executed. Reach is remote. Auth is a MIM JWT that can GET the ACME tag.
The fix
Omit private keys and DNS credentials from GET /services/acme. Return them only on an explicit export that is superuser. Same shape as cert-manager export-key, not a config listing. Mark those fields writeOnly on the GET schema so generated clients stop parsing them from a listing.
Until then, treat any JWT that can GET /services/acme as holding every hostname key on that box. Do not hand mim_services_read to a helpdesk user and assume listing is metadata.
I am not going to print a curl you can paste at someone else's :8443. The schema is the useful part. If you own the controller, run the proof of concept against loopback.
References
- Proof of concept: abraxas/pfsense-api-acme-key-dump · pfsense-api-acme-key-dump-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - Hub: pfsense-api-nexus-mim
- Same pin: pfsense-api-export-key-get · pfsense-api-fleet-jwt · pfsense-api-example-ssl-verify
- @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-200
- Tree: Netgate/pfsense-api
b4acd7ca21dbca739a530403a1f62a7440f7726f·GET /services/acme·get_acme_config.py