Research/pfsense-api-acb-userkey
0-dayNo CVEHighPublic

pfSense Plus MIM ACB userkey capability URL

GET /services/acb?userkey= lists and fetches Auto Config Backup blobs. Public OpenAPI models userkey as a capability URL. Sibling of CE CVE-2024-57273. Loopback lab. Plus not executed. No CVE yet.

Name
pfSense Plus MIM ACB userkey capability URL
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Netgate
Affected
pfSense Plus MIM API toolkit (Netgate/pfsense-api) pin b4acd7ca OpenAPI. GET /services/acb and GET /services/acb/bkp take userkey as a query parameter. No securitySchemes. Plus controller not executed.
Published
14 Oct 2026
Updated
14 Oct 2026
Tags
0-day, pfsense, pfsense-api, mim, idor, acb, cwe-639, remote

The backup key is the password

I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-api-acb-userkey (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 pin b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, "Add Snort-3 API definitions"). Public OpenAPI plus generated Python for pfSense Plus Nexus Multi-instance Management. Controller on :8443. Paid. Not in this GitHub tree. No CVE yet. CWE-639. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) if GET /services/acb?userkey= honors the query key without binding a JWT to the device. Remote IDOR. Capability URL.

This is part 7 of the MIM API series. Hub: pfsense-api-nexus-mim.

Auto Config Backup is supposed to be a hosted copy of config.xml keyed to a device. The public schema puts that device key on the query string. Generated get_acb_list.py and get_acb.py copy it into params. The YAML has no securitySchemes. Anyone who has the 64-hex userkey can list that device's hosted backups and fetch a revision body. Full config sits in those blobs.

CE already burned this class. CVE-2024-57273 / SA-25_03 was an unsanitized ACB reason plus a device key derived from the public SSH key. Netgate's own advisory says: do not let untrusted parties know the AutoConfigBackup Device Key. This leftover is not that derivation. I am not re-filing the banner. This leftover is the Plus MIM API sibling: the key is an unconstrained query parameter, and the spec treats possession as authorization.

I did not execute pfSense Plus. The lab is a loopback mock of the public contract. Stolen key returned the witness. Wrong key did not.

Why this tree

I had just finished the pfSense CE 2.9.0-DEVELOPMENT WebGUI leftovers and mailed those. Plus is the paid product. The controller that shepherds a fleet of Plus boxes speaks REST on :8443. The server is closed. The contract is not. github.com/Netgate/pfsense-api publishes OpenAPI 3.0.3 (pfapi_openapi.yml, 486 paths, 755 operations) plus a generated Python client and a handful of sample scripts.

That is a useful tree. You can hunt a paid control plane without downloading the paid image.

It is also an easy tree to misread. The third-party CE REST package (jaredhendrickson/pfsense-api / pfrest.org) shares a name and none of the server. Diagnostics PHP/shell on the MIM wrap is the same admin power Netgate already treats as intended (CVE-2025-69691 disputed). Missing securitySchemes in OpenAPI is a documentation leftover until a controller answers 200 without a JWT. I wrote those on the skip list before I opened ACB.

I only wanted Critical or High. Remote first. Medium stays on the floor unless it chains. This leftover is High if the GETs honor userkey without a device-bound JWT. The pack is honest about Plus not being in the room.

Pinning b4acd7ca

No tags on this repo. main HEAD on the day I cloned was b4acd7ca, 2026-05-25, "Add Snort-3 API definitions." Apache-2.0. Schema generated docs at netgate.github.io/pfsense-api. Python from openapi-python-client.

The generated library default is verify_ssl=True and follow_redirects=False. Path params percent-encode ../. AuthenticatedClient always sends Bearer. Client sends none, including php/shell. That last fact is a client footgun, not a server 200. I mock-tested all 755 generated ops on loopback. They all left the building. They did not prove the controller is open.

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" a CVE with a press release.

Already mailed from the CE review, do not re-file as MIM API bugs:

  • QinQ parent interface
  • OpenVPN wizard CRLF
  • CARP VHID into ifconfig
  • limiter number
  • IPsec login banner

The MIM schema still names some of those fields. Same PHP apply sinks. Different door. Same CVE if you squint, which is why they stay on the skip list.

Already public on ACB:

  • CVE-2024-57273 / SA-25_03. XSS in services_acb.php reason, plus a derivable device key from the SSH banner. CE now uses random 64-hex. Do not re-file the derivation. The API leftover is the query-param sibling.

Intended admin, not unpublished RCE:

  • POST /diag/cmd/php and /diag/cmd/shell
  • file download/upload/edit
  • reboot/halt
  • ACB restore of config.xml as the logged-in admin
  • shellcmd package boot hooks

Docs leftover, not proven unauth:

  • entire OpenAPI with security: MISSING and no securitySchemes
  • generated ops accepting Client (no Bearer)

I am not filing "OpenAPI forgot the padlock icon" as remote RCE.

Surfaces that returned nothing

Twelve parallel hunts. Most of the 755 ops are the product: firewall rules, interfaces, VPN, packages, users, firmware as upgrade: boolean. Path parameters go through quote(..., safe=""), so a naive ../ dies on the wire. The library verifies TLS unless you tell it not to. Login still returns a JWT in every official sample. GET /mim/devices lists ids you already enrolled. That is one customer tenancy, not SaaS cross-tenant.

FRR preview of generated files is intended routing-admin. HAProxy Lua is the HAProxy Files GUI. ACME php_command on renew is the ACME package hook. UPnP enable is the UPnP page. None of those are this leftover.

The temperature changed on secrets that travel as GET query or GET body, and on keys that authorize a hosted backup without naming the caller.

The leftover

GetACBList is GET /services/acb?userkey=. GetACB is GET /services/acb/bkp?userkey=&revision=. Both take userkey as a free string. Neither names a device id. Neither names a JWT.

Generated client, list:

Python
params["userkey"] = userkey
_kwargs = {
    "method": "get",
    "url": "/services/acb",
    "params": params,
}

Retrieve is the same shape against /services/acb/bkp with revision sitting next to it.

ACBConfigInfo.userkey is described as "hexidecimal encoded 32-byte random bytes". Random is good. Random does not help if the check is "do you know the random value" and the value is also sitting on GET /services/acb/config.

That GET is the G11 class on this surface: secrets on GET. The schema returns the live capability secret and ACBConfig.encryption_password in the same object. I did not exercise config GET in this lab. I am not inflating this leftover into a live key dump. I am saying the spec hands you the key on one op and then treats that key as the lock on two others.

G9 is the other leak. Every Python sample and helper_funcs.py pass verify_ssl=False. example.js sets NODE_TLS_REJECT_UNAUTHORIZED=0. The generated package default is True. The samples are how people actually talk to a factory-cert controller on :8443. A MITM on that path sees admin credentials, the access JWT, and any ACB userkey that crosses the wire. G9 is Medium copy-paste hygiene on its own. It is also a way the capability secret leaves the building.

Once you have the key, this leftover is IDOR-shaped capability URL: list and fetch another device's hosted backups. Adjacent restore still needs a JWT in the samples, so I did not treat restore as this finding.

How a key becomes a backup

Intended model, as far as the YAML will admit:

  1. Device has an ACB userkey.
  2. Device (or controller) uploads encrypted config.xml blobs to the hosted ACB service.
  3. Admin lists revisions and fetches one.
  4. Admin may restore.

WebGUI ACB already knew the key was the secret. SA-25_03 says so in as many words. The MIM API still puts it on the query string, where it lands in access logs, reverse-proxy logs, browser history, and Referer. Query strings are where secrets go to be copied.

RetrievedBackup.backup is a string. The mock returned plaintext XML with the witness in a sysctl descr. A live blob may be encrypted. The decrypt password is the other field on GET /services/acb/config. I did not talk to acb.netgate.com. I did not decrypt a customer backup. The privilege is: whoever holds the key can ask the list and retrieve ops the spec published.

POST /services/acb/config can replace userkey. Description: "all future backups will use the new key." That is a rotation API. It is not this leftover. POST /services/acb/restore takes { timestamp } and is intended admin. Samples still send Bearer. Closed for this writeup.

The lab

Host is macOS. One container. Python 3.12 Alpine. Stdlib HTTP mock. Bind 127.0.0.1:19171. Compose project pfsense-api-acb-userkey. No Plus image. plus-not-executed=1 is an IOC on purpose.

The mock implements three paths:

  • /health
  • /services/acb - stolen userkey returns a backup list whose reason is the witness. Wrong key returns an empty list. Missing key returns 403.
  • /services/acb/bkp - stolen key plus the known revision returns {"backup": "<xml>...WITNESS...</xml>"}. Wrong key 403. Missing key 403.

Host-side poc.py also greps a pin excerpt of the public YAML and the generated _get_kwargs so SUCCESS cannot fire if the spec stopped modeling userkey as a query parameter or grew securitySchemes.

Last run:

Plain text
IOC pin=b4acd7ca21dbca739a530403a1f62a7440f7726f openapi=client-reviews/pfsense-api/src/pfapi_openapi.yml mock=http://127.0.0.1:19171
IOC plus-not-executed=1
IOC openapi GetACBList query=userkey GetACB query=userkey,revision securitySchemes=missing
IOC generated-client userkey-query=1 bkp-userkey-revision=1
IOC health http=200
IOC stolen-list http=200 body={"backups": [{"timestamp": "20241014120000", "reason": "PFSENSE-API-ACB-USERKEY-WITNESS"}], "staged_backups": 0}
IOC missing-userkey-list http=403 body={"code": 403, "message": "userkey required"}
IOC wrong-key-list http=200 body={"backups": [], "staged_backups": 0}
IOC stolen-bkp http=200 body={"backup": "<?xml version=\"1.0\"?><pfsense><sysctl><item><descr>PFSENSE-API-ACB-USERKEY-WITNESS</descr></item></sysctl></pfsense>"}
IOC wrong-key-bkp http=403 body={"code": 403, "message": "unknown userkey"}
IOC missing-userkey-bkp http=403 body={"code": 403, "message": "userkey required"}
SUCCESS pfsense-api-acb-userkey capability-url stolen-key-returns-witness wrong-key-does-not plus-not-executed public-spec-models-acb-userkey-capability-url PFSENSE-API-ACB-USERKEY-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/pfsense-api-acb-userkey
cd pfsense-api-acb-userkey
python3 pfsense-api-acb-userkey-Abraxas-Labs.py

or cd lab && ./run.sh. Bind it to loopback.

Wrong turns, in the order I made them

Hunting the CE REST package. Same words, different GitHub org, different server. jaredhendrickson/pfsense-api is not Nexus MIM. I wrote that on the folder before the first grep.

Treating missing OAS security as unauth php/shell. All 755 ops say security: MISSING. Login still returns a JWT. Examples send Bearer. Confidence the controller is open on the Internet is 15%. Rank that as documentation, not Critical. The mock sending php/shell with a bare Client is a generator typing leftover.

Re-filing CVE-2024-57273. The banner derivation is public. CE moved to random 64-hex. The API leftover is "random key on the query string is still a capability URL." Same class, different bug. SA-25_03 already told operators to keep the key secret. The spec still accepts that secret from whoever presents it.

Claiming a live Plus dump. I do not have a Plus image in this pack. I did not talk to a controller. I did not pull a customer blob from acb.netgate.com. Theatre. The oracle is: public spec models the contract, loopback stolen key returns the witness, wrong key does not.

Inflating restore. POST /services/acb/restore would be integrity of the whole box. Samples still attach a JWT. I left it closed. Do not turn a GET leftover into "unauth restore."

Inflating G9 into this CVE. verify_ssl=False in samples is Medium MITM hygiene. The library default is True. G9 can leak the userkey. It is not the IDOR. It is a leak path into the IDOR.

Inflating G11 into this CVE. LDAP bind passwords, RADIUS secrets, SAML keys, HA XMLRPC password, MaxMind license, git token-in-URL are a Medium secrets-on-GET class of their own. On this surface, G11 is GET /services/acb/config returning userkey and encryption_password. That GET is how you learn the capability secret if you can already call it. This leftover is what the secret then buys on list and bkp.

A reverse shell. Theatre. The oracle is a witness string in a JSON backup list and a JSON backup body.

What an attacker can do

Requires a stolen or leaked ACB userkey. Spec does not bind the key to a JWT or to the caller's device.

Recon

Method Sink Privilege
GET /services/acb?userkey=<stolen> GetACBList. Generated params["userkey"]. Lab-proven (mock): list that device's hosted backups. Witness in backups[].reason.
GET /services/acb/bkp?userkey=<stolen>&revision= GetACB -> RetrievedBackup.backup. Lab-proven (mock): fetch the revision body. Witness in backup.
Same backup string on a live controller hosted ACB blob Inferred: full config.xml (users, keys, packages) once readable. Mock returned plaintext witness XML. I did not talk to acb.netgate.com or decrypt a live blob.
GET /services/acb/config ACBConfigInfo.userkey, ACBConfig.encryption_password Spec, not this lab: live capability secret and decrypt password. G11 class. Not exercised.

Integrity

Method Sink Privilege
POST /services/acb/restore RestoreACB body { timestamp } Adjacent closed: samples still send a JWT. Not this leftover. Not executed.
POST /services/acb/config replace userkey SaveACBConfig Adjacent closed: not a GET capability URL. Not executed.

Availability

GET list / GET bkp do not delete. DELETE /services/acb/bkp/{timestamp} is a different op. Not exercised.

Wrong userkey and missing userkey returned no witness (lab-proven).

Adjacent surface that stays closed

Plus controller (not executed). CE REST package. SSH-banner ACB key derivation (CVE-2024-57273; do not re-file). POST restore as this finding. Unauth php/shell from omitted OAS security (docs leftover). Missing userkey. Wrong key. Generated library verify_ssl=True.

Lab-proven vs inferred: stolen-key list and bkp returned the witness. Wrong key did not. Plus was not executed. Live blob contents are inferred from the schema.

The fix

Require JWT on the ACB GETs. Do not treat userkey as a capability URL. Bind list and retrieve to the authenticated device's key. Do not accept another device's key from a foreign caller. Stop putting the secret on the query string; if the controller must select a key, put it in a header or a POST body that is not logged by default. Stop returning the live userkey and encryption_password on GET config to any role that is not already the device owner. Keep G9 samples from disabling TLS verify, or at least stop using those samples against a controller that will emit the ACB key.

Until then, treat the ACB device key the way SA-25_03 already told you to: do not let untrusted parties know it. Query-string capability URLs are how untrusted parties learn it anyway.

I am not going to print a curl you can paste at someone else's :8443. The public YAML is the useful part. If you own the controller, run the proof of concept against loopback.

References

pfSense Plus MIM ACB userkey capability URL · Abraxas Labs