
pfSense Plus MIM API toolkit b4acd7ca - Medium - Remote - Privilege leftover - official samples verify_ssl=False
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · pfsense-api-example-ssl-verify
Class: Privilege leftover Reach: Remote
pfSense Plus MIM API (Netgate/pfsense-api b4acd7ca) - Netgate
Generated Client / AuthenticatedClient default verify_ssl=True and pass that into httpx. Docs already call verify_ssl=False a security risk and show a PEM bundle path first. Every official Python sample and helper_funcs.py still construct them with verify_ssl=False. example.js sets NODE_TLS_REJECT_UNAUTHORIZED=0, so the whole Node process skips verify. Login JSON puts username and password in base64. That is the OpenAPI contract, not encryption. TLS is the confidentiality layer.
An on-path peer of :8443 presents any certificate. The sample still POSTs /login. The stand-in captures the base64 admin login and mints a JWT. That token is the chain: G4 export-key, G5 ACME dump, G6 fleet JWT, G7 ACB userkey, intended php/shell, and the JSON that G1 interpolates into SSH.
This is Part 9 of pfSense Plus MIM API toolkit. Read this leftover first even though it is Medium. The stolen JWT is how the rest become reachable from a workstation that copied example.py.
| ID | no CVE yet |
| Class | Privilege leftover (sample TLS verify off) |
| Reach | Remote (on-path to :8443) |
| CWE | CWE-295 |
| CVSS | Medium: 6.8 CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N |
| Product | Netgate/pfsense-api (Plus Nexus MIM client; not the CE REST package) |
| Affected | public main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25) official samples and helper_funcs.py |
| Auth | operator copies and runs a sample against the controller; no extra controller role |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1:19191 only. Does not compose pfSense Plus. |
AC:H is on-path between the operator workstation and the MIM controller. UI:R is copy/run the sample.
Victim: operator workstation running an official sample or helper_funcs.py against https://controller:8443. Attacker: on-path to that HTTPS (LAN, rogue DHCP, compromised VPN hop, ARP). Not unauth Internet-to-firewall.
| Method | Sink | Privilege |
|---|---|---|
Present any cert while the sample POSTs /login or /api/login with verify_ssl=False / NODE_TLS_REJECT_UNAUTHORIZED=0 | httpx verify=False (Python samples) or Node TLS reject-unauthorized 0 | Confidentiality (lab-proven): read the login JSON. Lab: stand-in captured base64 admin / lab-controller-secret (captured_b64_admin=yes). Decode is one base64 -d. |
Same MITM, 200 LoginResponse | sample accepts any cert | Confidentiality (lab-proven): steal the access JWT. Lab: INJECT got a token that carried PFSENSE-API-EXAMPLE-SSL-VERIFY-WITNESS. |
Same MITM, Set-Cookie on login | sample copies client.get_httpx_client().cookies into AuthenticatedClient | Confidentiality (lab-proven cookie, inferred replay): 24-hour refresh JWT. Replay POST /login/refresh with that cookie plus the base64 username until the refresh TTL. Lab captured Set-Cookie: refresh=.... Refresh against a live controller was not executed. |
| Replay stolen Bearer to the real controller (if the attacker can also route to it) | Plus MIM /api and /api/device/pfsense/{device_id}/api | Integrity (inferred): admin-equivalent as the logged-in user on this API. Schema includes POST /diag/cmd/php, POST /diag/cmd/shell, POST /diag/editfile, POST /diag/backup/restore, POST /diag/control halt/reboot. Not exercised here. This lab is a controller stand-in, not Plus. |
| Replay stolen Bearer as leftover 6 (fleet token) | one POST /login JWT reused for every device_id | Integrity (inferred): talk to firewall B after authenticating as a user meant for firewall A. LoginResponse has no per-device allow-list. Pack: pfsense-api-fleet-jwt. Plus RBAC not executed. |
| Replay stolen Bearer as leftover 4 | GET /system/certauth/{refid}/export-key, GET /system/certificates/{refid}/export-key, GET /system/certificates/{refid}/export-p12 | Confidentiality (inferred): pull CA and leaf private keys, PKCS12 documented without password protection, then impersonate VPN / WebGUI TLS / IPsec. Pack: pfsense-api-export-key-get. |
| Replay stolen Bearer as leftover 5 | GET /services/acme | Confidentiality (inferred): steal Let's Encrypt account key and issued cert keys for every hostname the firewall enrolls. Pack: pfsense-api-acme-key-dump. |
Replay stolen Bearer, or leak userkey from a sample that GETs ACB, as leftover 7 | GET /services/acb?userkey= and GET /services/acb/bkp?userkey= | Confidentiality (inferred): list and fetch Auto Config Backup blobs (full config). Pack: pfsense-api-acb-userkey. |
Shape ControllerSummary JSON toward leftover 1 | MITM of verify_ssl=False HTTPS supplies the JSON that example-add-devices.py quote-escapes then .formats into SSH | Integrity (inferred): $() / backticks / newlines in a controller field run as SSHUSER on every address in pfsense_addresses. Pack: pfsense-api-enroll-json-ssh. |
| Inject responses toward the client | skip-verify accepts any cert | Integrity (lab-proven token, inferred fleet): mint a JWT the sample will keep. Lab: INJECT token with the witness. Fake device lists / upgrade progress / login success against a live sample are the same gate. |
POST /diag/control, firmware POST, restore of a broken backup (after stolen Bearer) | intended admin surface once TLS is gone | Availability (inferred): halt / reboot / broken image. Same Bearer. Not labbed. |
Lab-proven vs inferred: CONTROL handshake fail, INJECT token, and captured base64 admin login are lab-proven on 127.0.0.1:19191. Replay of that Bearer onto a live Plus controller, diag php/shell, G4-G7, G1 JSON shaping, and refresh-cookie mint of new access JWTs are inferred from the OpenAPI and the samples.
Requires: operator copies and runs example.py, example-add-devices.py, example-upgrade-devices.py, example-set-alias.py / example-upgrade-devices-async.py via helper_funcs.py, or example.js. Attacker on the path to :8443.
Adjacent surface that stays closed