Research/pfsense-api-fleet-jwt
0-dayNo CVEHighPublic

pfSense Plus MIM, one controller JWT is a fleet token

pfSense Plus MIM API toolkit b4acd7ca: one POST /login Bearer is reused for every enrolled device_id. Official examples copy it onto /api/device/pfsense/{id}/api. LoginResponse has no device ACL. Stolen JWT (leftover 9) is php/shell/export-key on every box. Labbed on loopback. Plus RBAC not executed. No CVE yet.

Name
pfSense Plus MIM, one controller JWT is a fleet token
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). pfSense Plus Nexus MIM controller REST. One customer tenancy. Plus binary not executed.
Published
14 Oct 2026
Updated
14 Oct 2026
Tags
0-day, pfsense, pfsense-plus, mim, nexus, idor, jwt, cwe-639, remote

The token does not know which firewall you meant

I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-api-fleet-jwt (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is leftover 6 in the pfSense Plus MIM API toolkit series. github.com/Netgate/pfsense-api pin b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions). No CVE yet. CWE-639. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Reach: Remote. Class: IDOR. Authenticated controller JWT.

You log into the Nexus Multi-instance Management controller once. POST /login returns a Bearer. Official examples take that same token and replay it as Authorization: Bearer on /api/device/pfsense/{device_id}/api for every id from GET /mim/devices. LoginResponse is token, user, version, alerts. There is no allow-list. LDAP MIM roles are global. They are not bound to a device_id. A helpdesk login that can list devices talks to firewall B with a token issued for a user who was meant for firewall A.

If leftover 9 already stole that JWT (verify_ssl=False samples, MITM of :8443), you do not even need the password. The stolen token is a fleet token. php, shell, export-key, ACME dump, aliases, VPN, the remaining 700-odd ops: same prefix, same Bearer, every enrolled box.

This is one customer tenancy, not a SaaS cross-tenant leftover. Unauthenticated calls stay 401. Plus RBAC was not executed. I did not download Plus. I did not talk to a live controller. The public client and the public schema do this.

Why this tree

I had just mailed five pfSense CE WebGUI leftovers (QinQ parent-if, OpenVPN wizard CRLF, CARP VHID, limiter number, IPsec login banner). Those are PHP apply sinks. The interesting next product is the paid controller that is supposed to sit above a fleet of those boxes.

github.com/Netgate/pfsense-api is not the third-party CE REST package at jaredhendrickson/pfsense-api / pfrest.org. It is the public OpenAPI 3.0.3 schema plus generated Python and JS for the pfSense Plus Nexus Multi-instance Management controller on :8443. 486 paths. 755 operations. Apache-2.0. No tags. The server is closed-source and ships with Plus 25.11+. Operators copy example.py. They copy example-add-devices.py. They copy helper_funcs.py. That is the threat model.

I only wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. Unauth php/shell from a missing securitySchemes block is not a finding. Diagnostics: Command as superuser is intended admin power (CVE-2025-69691 disputed). A finding on this hunt needed something else: a leftover of the published client contract, a missing object ACL, a sample that turns a JWT into a fleet, a GET that dumps keys.

Pinning b4acd7ca

HEAD on 2026-10-14. No tags. Latest commit adds Snort-3 API definitions. 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 nexus_group_prefix (default mim_) lists roles view, logs, usermgmt, services, firewall, interfaces, devices, system, superuser, each with optional _read / _modify / _delete. LoginResponse is token, user, version, alerts. No per-device allow-list.

I cloned that peel and did not wait for a tag that does not exist. The lab is a loopback mock of that pin's login and per-device proxy contract. Compose name pfsense-api-fleet-jwt. Port 127.0.0.1:19161. No Plus image.

The skip list is half the hunt

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. I am not ranking POST /diag/cmd/php or /diag/cmd/shell as unpublished Critical. The leftover here is that the same JWT reaches those sinks on every enrolled instance, not that the sinks exist.

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. I am not re-filing them.

CE ACB key derived from SSH is CVE-2024-57273 / SA-25_03. That is leftover 7's sibling, not this post.

The generated library default is verify_ssl=True. Path params use quote(..., safe=""), so ../ leaves the client as ..%2F. OpenAPI has zero securitySchemes and every op is security: MISSING. That is a documentation leftover. It is not proof the Plus server accepts php without a JWT. The mock still 401s.

mim_user is documented as VPN user with no Nexus. If the login gate holds, that account cannot start this chain. Intended admins / mim_superuser fleet console is the product model for the controller owner. I am not filing "the owner can manage the fleet."

Surfaces that returned nothing

I split the tree the way you split a controller API: login/JWT/SSO, diagnostics php/shell/files, MIM enrollment and the unix socket, client TLS in the samples, users/certs/logs, packages (shellcmd, HAProxy, ACB), VPN/firewall/interfaces, OpenAPI security, per-device proxy IDOR, git/firmware/feeds, plus a loopback mock of every generated operation.

Most of that is either intended, a different leftover, or closed on the public client.

Unauth GET /login/notices and GET /login/ssoauth are the login page. Not a finding. GET /controller is tagged controller, not login. Spec is silent. Examples always login first. Infer JWT required. I did not inflate spec silence into unauth Critical.

GET /events?last_id= is a long-poll with a sequential microsecond cursor. Same path exists on the controller prefix and on the per-device prefix. Fold it into the same object-ACL gap. Not a second Critical.

Aliases {id} is a string name on that firewall's table. Cross-firewall alias read/write is this leftover, not a distinct alias IDOR. example-set-alias.py already walks GET /mim/devices then child-client FirewallGetAliases.

Firmware POST is upgrade: boolean only, no URL. Path params percent-encode ../ ; $(). AuthenticatedClient always sent Bearer on the 755-op mock. Client sent none, including php/shell. That is openapi-python-client typing, not a server 200.

Enrollment SSH JSON interpolation, AutoAddPolicy, the unix socket, GET export-key, ACME dump, ACB userkey, Snort {file}, and verify_ssl=False are the other eight leftovers. They have their own packs. This post is the fleet token.

How the official examples mint a fleet

example.py logs in once. Then:

Python
resp = get_controlled_devices.sync(client=client)
for device in resp.devices:
    if device.state != "online":
        continue
    devApi = AuthenticatedClient(
        base_url=CONTROLLER_URL+"/api/device/pfsense/{}/api".format(device.device_id),
        verify_ssl=False,
        headers={"Content-Type": "application/json"},
        cookies=cookies,
        token=token,
    )

Same cookie jar. Same session token. New base URL. That is the entire per-device "ACL."

helper_funcs.py createDeviceApiChild takes an arbitrary device_id string and copies self.token. There is no check that the parent login is allowed onto that id. There is no second mint.

example.js does the same in the other language:

JavaScript
let devClient = newClient("device/pfsense/" + device.device_id + "/api");
devClient.setBearer(client.getBearer());

example-upgrade-devices.py newDevApiClient is token=client.token again. Tags filter which rows the sample prints. Tags are bulk labels, not an authorization boundary. GET /mim/devices?tags= is still a listing of every matching enrolled box for that login.

Controller base is https://<controller>:8443/api. Per-device base is the same host plus /api/device/pfsense/{device_id}/api, then the same 486 paths / 755 operations. php, shell, editfile, export-key, ACME, aliases, firewall, interfaces, VPN, system. The schema does not grow a second, narrower surface for "the other firewall."

LoginResponse has no device list

LoginResponse in pfapi_openapi.yml and py/pfapi/models/login_response.py:

  • token
  • user
  • version
  • alerts

That is the whole object. I grepped it for acl, allow_list, allowed_devices, device_id, devices, entitlements, permissions, roles, tenant. Empty. The generated model is four fields plus additional_properties. The lab fails if a fifth ACL-shaped name appears.

JWT claims the examples print are exp (access token short-lived; refresh cookie ~24h via POST /login/refresh). The examples split(".")[1] JSON-decode exp with no signature check. That is display. It is also honest about what they think is in the token: expiry, not a device allow-list.

LDAP nexus_group_prefix documents roles view, logs, usermgmt, services, firewall, interfaces, devices, system, superuser with optional read/modify/delete. Public docs (2026-09-17, after this pin, still the current model) match: mim_system is "System configuration and console access"; mim_view is "Global view only, no confidential information"; mim_devices is "Device configuration"; mim_superuser == local admins.

None of those names take a device list. OpenAPI maps no operation to a role. Forum thread January 2026 launch: a user asked for operators who manage registered instances without Nexus controller settings. That is instance-vs-controller split, not per-device tenancy. Netgate did not reply on-thread. Docs later published the functional mim_* table. Per-device assignment is still absent from this pin's spec.

device_id is type: string with no format: uuid and no pattern. hw_uuid is a different field. Toolkit truncates display at 50 characters, so production ids can exceed UUID-36. Likely hex of the device key (ED25519 hex is 64 chars). Blind numeric walk of /api/device/pfsense/1/api is the wrong attack. GET /mim/devices removes the guess.

Stolen JWT (leftover 9) is this leftover

Read leftover 9 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 this leftover. One POST /login Bearer, every device_id. The same stolen token is leftovers 4 and 5: GET export-key / export-p12, GET /services/acme with account_key and issued privateKey. A helpdesk password is enough if leftover 9 is not in play. A MITM of the sample is enough if it is.

I am not ranking Diagnostics: Command as unpublished RCE. I am ranking the missing object ACL that turns a stolen or issued controller JWT into php/shell/export-key on every enrolled box.

The lab

Host is macOS. No Plus image. Python 3.13-slim mock of the published login and per-device proxy contract. Compose project pfsense-api-fleet-jwt. Loopback 127.0.0.1:19161. Runs as nobody. Health on /health. Pin YAML excerpt is bundled in poc.py so the pack does not need a clone of Netgate/pfsense-api. If a local pin is present, the lab also parses LoginResponse and the LDAP role string out of it.

The PoC:

  1. Parse bundled (and optional pin) LoginResponse. Fail if an ACL-shaped field exists. Record schema-no-device-acl=yes.
  2. GET /health until 200.
  3. Unauth GET /api/mim/devices. Expect 401.
  4. POST /api/login with base64 admin / password. Take the JWT. Fail if the body or the decoded claims contain a device ACL.
  5. Wrong Bearer against device-b /system. Expect 401. Witness must not leak.
  6. Auth GET /api/mim/devices. Expect both fw-site-a-... and fw-site-b-....
  7. Same token, GET /api/device/pfsense/{A}/api/system and {B}/api/system. Both 200. Witness only on B.
  8. Same token, POST .../diag/cmd/php on A and B. Both 200. Witness in B's body.

Last line:

Plain text
SUCCESS PFSENSE-API-FLEET-JWT one-token=yes device-a=200 device-b=200 schema-no-device-acl=yes plus-rbac=not-executed PFSENSE-API-FLEET-JWT-WITNESS

Replay:

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

or cd lab && ./run.sh. Bind it to loopback. The loopback client is lab/run.sh with a banner on it.

I used a mock on purpose. A licensed Nexus controller with two enrolled instances would have proved live mim_view vs mim_system vs admins. I do not have that box. The public client already copies one token onto every device_id. The public schema already has no device ACL. Plus RBAC stays not-executed. That sentence is in the SUCCESS line so nobody pretends I ran the paid binary.

Wrong turns, in the order I made them

Treating this as a SaaS cross-tenant IDOR. Netgate runs the controller in the customer tenancy. Tenancy clusters were still planned on the marketing page. Helpdesk on one controller, two firewalls, is the leftover. A second customer's box on a different controller is out of model.

Treating OpenAPI silence on securitySchemes as unauthenticated RCE. The generator types Client as legal for php/shell. The mock still 401s without a Bearer. Spec leftover. Not this bug.

Assuming a live mim_view vs mim_system split. The names exist. Docs exist. The spec does not map ops to roles. Forum asked for instance-vs-controller split and did not get an on-thread answer. I cannot prove a helpdesk without system still reaches php. Confidence on that half is 55%. The High that does not need Plus is: same JWT, no per-device ACL, listing oracle, official examples copy the token. mim_system console on every enrolled firewall is still High object-ACL leftover if the product claims per-admin entitlements without saying "every system role is every device."

Ranking POST /diag/cmd/php as unpublished Critical. Netgate treats Diagnostics: Command as intended admin power. Superuser php on a box you own is the product. Superuser php on a box the login was not bound to is this leftover.

Guessing sequential device_id. Schema is a free string. Display width >50. Separate hw_uuid. Numeric walk is the wrong lab. Listing is the oracle.

Pinning the CE REST package. jaredhendrickson/pfsense-api is a different server. pfrest.org is a different docs site. I wrote that on the skip list before I opened a controller path.

Theatre: a reverse shell. The oracle is one token, two device ids, both 200, witness only on B, schema with no device ACL.

What an attacker can do

A controller login that can list devices talks to every enrolled instance with the same JWT. CSRF is not in play (Bearer API). PHP / shell / export-key on every box is intended admin power once the proxy accepts the fleet token. The leftover is the missing per-device bind.

Privilege Method / path Lab-proven or inferred
List every enrolled device_id (name, address, state) GET /mim/devices with the login Bearer Recon (lab-proven). Mock returned both site-a and site-b. Unauth is 401.
Read system status of firewall A and firewall B with one token GET /api/device/pfsense/{device_id}/api/system Recon (lab-proven). device-a=200, device-b=200. Witness only on B.
POST the diagnostics PHP wrapper on both ids POST /api/device/pfsense/{device_id}/api/diag/cmd/php Integrity (lab on mock only). Both 200. Plus RBAC was not executed, so a live role gate is unproven.
Run PHP / shell / file edit on every enrolled box if the login includes console or system POST /diag/cmd/php, POST /diag/cmd/shell, GET/POST /diag/editfile, download/upload Integrity (inferred from OAS + examples). Intended admin power once the proxy accepts the fleet token. Typical pfSense PHP-FPM is root.
Export CA / leaf private keys and PKCS12 on every listed instance GET /system/certificates/{refid}/export-key, GET /system/certauth/{refid}/export-key, GET /system/certificates/{refid}/export-p12 Recon (inferred). Same JWT, leftover 4 schema. Plus RBAC not executed.
Dump ACME account and issued keys on every listed instance GET /services/acme and related account/cert GETs Recon (inferred). Same JWT, leftover 5 schema.
Change aliases, firewall, interfaces, VPN, services on every listed instance same per-device prefix, remaining 700+ ops Integrity (inferred). Same JWT, same schema as the local instance.
Reboot / halt / restore config on every listed instance POST /diag/control, POST /diag/backup/restore Availability + integrity (inferred). Same proxy. Not exercised.
Replay a JWT stolen from leftover 9 MITM of sample verify_ssl=False HTTPS to :8443, then every row above Integrity (inferred chain). Leftover 9 captures the Bearer. This leftover makes it fleet-wide.

Requires: a controller login that is allowed onto the device proxy, or a stolen JWT from leftover 9. Network path to the MIM controller (:8443 in product, loopback :19161 in this lab). Not unauth. Not CSRF.

Adjacent surface that stays closed

Surface Why
Unauth listing / php / shell Mock 401 without Bearer and with a wrong Bearer. Spec silence on securitySchemes is not a server 200.
SaaS cross-tenant One customer tenancy. Netgate runs the controller in that tenancy.
mim_user / no Nexus group Documented as closed if the login gate holds.
Functional mim_* role names They exist. They are global, not per device_id. Live Plus checks were not executed (plus-rbac=not-executed).
Offline devices Samples skip state != online. Not an auth boundary.
Blind numeric walk of device_id Ids are long strings. Listing removes the guess.
Enrollment SSH JSON interpolation Leftover 1. Different pack.
Enrollment AutoAddPolicy Leftover 2. Different pack.
Device unix socket without JWT Leftover 3. Local, different pack.
CE REST package jaredhendrickson/pfsense-api is a different server.
CE WebGUI QinQ / OpenVPN wizard / CARP / limiter / IPsec banner Already mailed. Field names in this OAS are the same sinks, not new CVEs.
Intended admins / mim_superuser fleet console Product model for the controller owner.

The fix

Bind the JWT to device tags or an allow-list so a login meant for firewall A cannot call /api/device/pfsense/{B}/api. Split controller admin from instance console so a helpdesk role cannot POST /diag/cmd/php on every enrolled box. Return roles and allowed devices on LoginResponse. Treat GET /mim/devices as an authorization boundary, not a display filter. Until then, a stolen or issued controller JWT is php/shell/export-key on every enrolled box.

References

abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me

pfSense Plus MIM, one controller JWT is a fleet token · Abraxas Labs