pfSense Plus MIM samples set verify_ssl=False
Official Netgate/pfsense-api samples disable TLS verify of the Plus MIM controller. Generated Client default is True. An on-path peer of :8443 steals the base64 admin login and the access JWT. That token is the chain into G4 G5 G6 G7, intended php/shell, and G1 JSON. Labbed.
- Name
- pfSense Plus MIM samples set verify_ssl=False
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- medium
- Disclosure Status
- public
- Vendor
- Netgate
- Affected
- github.com/Netgate/pfsense-api main b4acd7ca21dbca739a530403a1f62a7440f7726f official samples and helper_funcs.py. Library default verify_ssl=True. Operator copies a sample against :8443. Plus binary not executed.
- Published
- 14 Oct 2026
- Updated
- 14 Oct 2026
- Tags
- 0-day, pfsense, pfsense-plus, mim, tls, mitm, privilege-leftover, cwe-295
TLS was on. The peer check was not. That is the chain.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/pfsense-api-example-ssl-verify (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"). No CVE yet. CWE-295. 6.8 Medium (CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N). Remote privilege leftover. Official samples. Not the generated library default.
This is Part 9 of the pfSense Plus MIM API toolkit series. Read this leftover first 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. An on-path peer of :8443 sees the base64 admin login and the access JWT, plus the 24-hour refresh cookie. That JWT is leftover 6 (one Bearer for every device_id). The same token is leftovers 4 and 5 (GET private keys, GET ACME secrets). The same MITM can leak leftover 7's ACB userkey. Combined with leftover 1, the MITM shapes the JSON that example-add-devices.py interpolates into SSH. Intended php/shell is the documented Diagnostics: Command wrap once you already have the Bearer. I am not ranking php/shell as unpublished RCE. I am ranking the sample that hands the Bearer to whoever answered TLS.
The library verifies. The samples do not. Operators copy samples.
Why this tree
I had just mailed Netgate five pfSense CE WebGUI leftovers: QinQ parent-if, OpenVPN wizard CRLF, CARP VHID, limiter number, IPsec login_banner. Those are PHP apply sinks on a public CE tree. Plus is paid. The public GitHub tree that talks to Plus is not the firewall source. It is Netgate/pfsense-api: OpenAPI 3.0.3, 486 paths, 755 operations, generated Python, a handful of official samples. The server those operations talk to is closed-source and ships with pfSense Plus 25.11+ as the Nexus Multi-instance Management controller on :8443.
The interesting client for a firewall controller is the one the vendor ships. Operators copy example-add-devices.py. They point it at :8443. They paste SSHPASS. They disable TLS verify because the sample does. That is the threat model for leftovers 1, 2, 3, and this one.
This is not the third-party CE REST package at jaredhendrickson/pfsense-api / pfrest.org. I wrote that on the skip list before I opened a controller. I did not download Plus. I did not talk to a live controller. The leftover that can be proven from the public samples, I proved.
I only wanted Critical or High. Medium stays on the floor unless it chains. This one chains. That is why it is Part 9 and not a footnote.
Pinning b4acd7ca
HEAD on 2026-10-14. No tags. Latest commit adds Snort-3 API definitions. pfapi_openapi.yml is 42685 lines. Python package generated by openapi-python-client. Vendor examples and helper_funcs.py are hand-written.
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. Hardcoded lab URL in every example: https://10.100.0.38:8443. RFC1918. Self-signed controller is the documented lab story. JS even comments "To be able to use with self-signed Controller TLS certificates." Python samples do not comment. They just pass False.
helper_funcs.get_settings() loads PASSWORD from env first, else from example.conf, else exits. There is no VERIFY_SSL knob. Unlike the password.
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.
CVE-2026-105221 is gist RubyGem VERIFY_NONE on every GitHub HTTPS call. There the product skipped verify. Library http_connection always OpenSSL::SSL::VERIFY_NONE. UI:N. Internet GitHub. High 7.4. I labbed that. This is not that.
Diagnostics: Command PHP/shell is intended admin power. Netgate already treated that as such (CVE-2025-69691 disputed). I am not ranking POST /diag/cmd/php or /diag/cmd/shell as unpublished RCE unless a non-superuser role can call them. I could not prove that without Plus. I list them as inferred privilege after a stolen Bearer.
QinQ if_device, IPsec login_banner, OpenVPN dns_domain / netbios_scope are the same PHP apply sinks I mailed as CE WebGUI leftovers. I am not re-filing them as API CVEs.
CE ACB key derived from SSH is CVE-2024-57273 / SA-25_03. Leftover 7 is the API query-param sibling, not that derivation.
OpenAPI LoginCredentials are "base64 encoded." That is obfuscation for logs, not a hash. With verify on, MITM does not see them. Encoding is not a leftover.
Examples split(".")[1] JSON-decode exp with no signature check. That is how every JWT debugger prints expiry. The client is not the resource server. Unsigned payload parse is not "client accepts forged JWTs."
Refresh token in cookies is designed. OpenAPI: "A JWT refresh token is also applied to cookie." POST /login/refresh requires it. Helper comment: "Cookie jar contains the 24-hour refresh token."
example.conf has USER=admin and #PASSWORD=<admin-password>. No committed secret.
Generated _follow_redirects=False. Bearer and cookies are not auto-followed onto a 3xx Location. Combined with verify-on, a MITM 3xx dies at cert check. Combined with verify-off, the client still will not silently POST login to a Location the attacker injects unless the operator also flips follow.
httpx timeout None is client UX for slow firmware upgrades, not a security class.
paramiko AutoAddPolicy() in example-add-devices.py is leftover 2, not this one.
Surfaces that returned nothing
I stood a stdlib mock on 127.0.0.1:55980, invoked every generated _get_kwargs, sent each op twice (Client with no token, AuthenticatedClient with Bearer mock-token), then tore it down. Witness PFSENSE-API-MOCK-WITNESS. 755/755 sent. AuthenticatedClient always carried Bearer. Client never did, including php, shell, upload, editfile, and export-key. Path probes of ../ ; $() never left the wire unescaped. OpenAPI security on any op: none.
The mock is a client contract. It cannot prove the Plus controller returns 200 without a JWT. Missing securitySchemes is a spec leftover. I ranked it Low. I did not pack it.
Instantiating generated Client(base_url=...) with no kwargs yields _verify_ssl is True. Both Client and AuthenticatedClient pass verify=self._verify_ssl into httpx.Client / httpx.AsyncClient. py/README.md shows a PEM bundle path first, then False with this is a security risk. A caller who constructs the published package the way the generator documented it gets verified HTTPS. That is the opposite of a library leftover.
I almost stopped there. The hunt note said DROP as High. Intended for lab self-signed. Confidence 92% the vendor would WONTFIX as sample hygiene. Then I counted the constructions.
Thirteen Python sites, all False
| File | Count | Notes |
|---|---|---|
helper_funcs.py |
5 | login Client, session AuthenticatedClient, createDeviceApiChild, clone both branches |
example.py |
3 | login, session, per-device |
example-upgrade-devices.py |
3 | login, session, newDevApiClient |
example-add-devices.py |
2 | login, session |
example-set-alias.py |
via helper | no local Client() |
example-upgrade-devices-async.py |
via helper | no local Client() |
JS:
// To be able to use with self-signed Controller TLS certificates:
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;Process-global. Any other HTTPS in that node process also skips verify. Still labeled self-signed. Bearer is a local setBearer / callApi wrapper because the generated JS client does not wire it. That is usage, not a vuln. The process-global bit is the leftover: Node does not have a per-client verify flag in this sample. It turns the check off for the whole runtime.
Python samples do not even comment "self-signed." They pass False and point at 10.100.0.38:8443.
client = Client(base_url=CONTROLLER_URL+"/api",
verify_ssl=False,
headers={"Content-Type": "application/json"})example.py:47. Then again at :78 for AuthenticatedClient. Then again at :120 for the per-device child. The same three-step in the upgrade sample. The helper is the closest thing to a leftover inside a leftover: it is the shared session object used by example-set-alias.py and example-upgrade-devices-async.py, has no VERIFY_SSL env, and every construction hardcodes False. An operator who copies the helper into a real management script ships skip-verify. That is copy-paste risk. It is also the official way the vendor shows you how to talk to the controller.
Generated default they override:
_verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl")py/pfapi/client.py:42 and the same field on AuthenticatedClient. Honest generator. Dishonest samples.
Login username and password ride that socket as base64 JSON. YWRtaW4= is admin. Decode is one base64 -d. TLS is supposed to be the confidentiality layer. With verify off, TLS is a lock you hung on the door and then left the key in.
Why this is the chain, not a footnote
CVSS 6.8 is honest. AC:H because the attacker has to sit between the workstation and :8443. UI:R because an operator has to copy and run the sample. Contrast gist 7.4: UI:N, library always off, Internet GitHub. Firewall-management plus port 8443 makes the impact of a real skip-verify default High. The default of the published package is not skip-verify. Rank the code operators actually run. Operators run samples.
Once the sample POSTs /login through an attacker cert, the rest of the series is a stolen Bearer plus schema:
- Leftover 6, fleet JWT. One
POST /loginBearer is reused for everydevice_id. Official examples copy it onto/api/device/pfsense/{id}/api.LoginResponsehas no per-device allow-list. LDAP roles in the schema are global:view,logs,usermgmt,services,firewall,interfaces,devices,system,superuser. A helpdesk login that can list devices can talk to firewall B. If that role includes console,POST /diag/cmd/phpis the intended admin wrap on every enrolled box. Plus RBAC was not executed. The public client and the public schema do this. Pack: pfsense-api-fleet-jwt. - Leftover 4, GET export-key.
GET /system/certauth/{refid}/export-key,GET /system/certificates/{refid}/export-key,GET /system/certificates/{refid}/export-p12(spec: "without password protection"). A read-shaped MIM JWT pulling CA and leaf private keys is a different class from "admin clicked Diagnostics: Command." Impersonate VPN, WebGUI TLS, IPsec. Pack: pfsense-api-export-key-get. - Leftover 5, ACME dump.
GET /services/acmeschema includesaccount_key, issuedprivateKey,custom_key, and DNS credentials. Steal Let's Encrypt account and issued keys for every hostname the firewall enrolls. Pack: pfsense-api-acme-key-dump. - Leftover 7, ACB userkey.
GET /services/acb?userkey=is a capability URL. Full config sits in those blobs. Same class as CE CVE-2024-57273 if the key leaks. MITM of a sample that GETs ACB config can leak the liveuserkey. Then leftover 7 no longer needs the JWT. Pack: pfsense-api-acb-userkey. - Intended php/shell.
POST /diag/cmd/php,POST /diag/cmd/shell,POST /diag/editfile,POST /diag/backup/restore,POST /diag/controlhalt/reboot. Superuser /mim_systemJWT is a fleet console. I am not ranking that as unpublished Critical. I am ranking the sample that delivers that JWT to an on-path peer. - Leftover 1, enrollment JSON into SSH.
example-add-devices.pydumpsControllerSummaryJSON, escapes only",.formats it into a POSIXcurl -d "..."script, then Paramikoexec_commandasSSHUSER(defaultadmin).$()survives. Combined withverify_ssl=False, a MITM of:8443shapes the JSON that becomes shell on every address inpfsense_addresses. Pack: pfsense-api-enroll-json-ssh.
The short version: steal the controller JWT from a sample, and you have a fleet token that can export keys, dump ACME, fetch ACB if you also have userkey, and run intended php/shell. Enroll a box with the same sample and you also get SSH injection of whatever JSON the MITM fed you.
That is why this leftover is Medium on its own and the series enabler in practice.
How the lab was built
I did not compose pfSense Plus. The leftover is the client construction, not the paid binary. I needed a TLS peer whose certificate would fail the generated default and pass the sample override.
Self-signed leaf, CN pfsense-controller.lab, SAN DNS:pfsense-controller.lab. No loopback IP in the identity. Bind HTTPS on 127.0.0.1:19191. CONTROL: Python ssl default context and httpx verify=True must die CERTIFICATE_VERIFY_FAILED and never reach HTTP. INJECT: httpx verify=False, the sample construction, POSTs /login with base64 admin / lab-controller-secret. The stand-in captures that body, mints a JWT whose payload carries PFSENSE-API-EXAMPLE-SSL-VERIFY-WITNESS, and sets Set-Cookie: refresh=.... CONTROL capture count must stay zero. INJECT must decode the captured username and password.
Compose project pfsense-api-example-ssl-verify. python:3.12-slim plus openssl and httpx. Stack tears down after the run. Host python fallback if compose fails. Same last line either way.
I did not intercept a live :8443. I did not replay the stolen Bearer onto a controller. I did not POST php/shell. The oracle is CONTROL fail plus INJECT token plus captured base64 admin.
Wrong turns, in the order I made them
Treating this as gist-class. gist 6.0.0 set VERIFY_NONE in the library on every GitHub call. This generated client defaults True. The leftover lives in samples and the helper. Rank the code that ships, not the hypothetical of copying every example into production. Then remember that operators copy examples into production. Recalibrate to Medium, not DROP.
DROP as High, then leaving it on the floor. The hunt note said do not lab. Confidence 88% WONTFIX as self-signed lab samples. Medium stays on the floor unless it chains. This one feeds G1 JSON and G4 through G7. I labbed it.
Treating OpenAPI base64 login as a password-hash bug. Schema says encoded, not encrypted. echo YWRtaW4= | base64 -d is admin. That is not CWE-261. That is CWE-295 making the encoding matter.
Treating JWT split(".")[1] as signature-skip auth. The client reads exp from a token the controller just issued. It does not accept untrusted JWTs as login. A forged payload only matters if TLS is already gone, in which case the attacker already owns the session.
Composing Plus. The leftover is proven without the paid binary. A live controller would let me replay the Bearer. I do not have a live controller. I said that.
Claiming unauth Internet-to-8443. Plus MIM bind/ACL is the closed server. This leftover needs the operator client on the path. Not a WAN 8443 without a sample.
Claiming skip-verify is unauth RCE. It is MITM of an operator who copied the sample. Shell/PHP diag is inferred schema privilege after a stolen admin Bearer. Adjacent closed stays closed.
A reverse shell. Theatre. The oracle is a witness string in a JWT the skip-verify client accepted, plus the captured base64 login the stand-in decoded.
Taking CONTROL httpx ImportError as SUCCESS. If httpx is missing, the lab falls back to urllib with a default SSL context. Same fail-closed: verify True must still die. I mention that once because a missing dependency looking like a green check is how labs lie.
What an attacker can do
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
| Surface | Why closed |
|---|---|
Generated Client(base_url=...) with no kwargs |
verify_ssl=True. Lab CONTROL: CERTIFICATE_VERIFY_FAILED, zero HTTP captures. |
| Cross-host 3xx credential forward | generated follow_redirects=False. |
| Forged JWT accepted by this client as login | client is not the resource server; payload parse is exp display. |
| Unauth Internet hit on 8443 without the operator client | Plus MIM bind/ACL is the closed server, not this toolkit. |
example.conf committed password |
PASSWORD line is commented. |
| CE REST package (jaredhendrickson / pfrest.org) | different product. |
| pfSense Plus itself | not in this GitHub tree; this lab does not compose Plus. |
Skip-verify samples do not by themselves give unauthenticated RCE on a pfSense box. They give MITM of an operator who copied the sample. Shell/PHP diag is inferred schema privilege after a stolen admin Bearer.
The lab last line
python3 pfsense-api-example-ssl-verify-Abraxas-Labs.pyor cd lab && ./run.sh. Loopback HTTPS 127.0.0.1:19191. Does not compose pfSense Plus.
SUCCESS pfsense-api-example-ssl-verify control_verify_true=fail inject_verify_false=token captured_b64_admin=yes PFSENSE-API-EXAMPLE-SSL-VERIFY-WITNESSPin SHA b4acd7ca21dbca739a530403a1f62a7440f7726f.
The fix
Leave the samples on the library default verify_ssl=True. Document pinning the controller CA (verify_ssl="/path/to/certificate_bundle.pem"). The generated README already shows that path first and calls False a security risk. The samples never read it.
Drop NODE_TLS_REJECT_UNAUTHORIZED=0 from example.js, or scope it to the controller agent instead of the process. Give helper_funcs.py a VERIFY_SSL knob that defaults True, the same way PASSWORD already has an env override. An operator with a factory self-signed controller can still pass a PEM bundle. They should not have to pass False to make the example run.
Until then, do not copy example.py or helper_funcs.py onto a workstation that can reach :8443 across a network you do not already trust with the controller admin password. Pin the controller CA. Leave the generated default alone.
I am not going to print a mitmproxy recipe you can paste at someone else's management plane. The sample constructions are the useful part. If you own the workstation, run the proof of concept against loopback.
References
- Proof of concept: abraxas/pfsense-api-example-ssl-verify · pfsense-api-example-ssl-verify-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - Series hub: pfsense-api-nexus-mim
- Chain: pfsense-api-enroll-json-ssh · pfsense-api-export-key-get · pfsense-api-acme-key-dump · pfsense-api-fleet-jwt · pfsense-api-acb-userkey
- @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-295
- Tree: Netgate/pfsense-api
b4acd7ca21dbca739a530403a1f62a7440f7726f·example.py·helper_funcs.py·example.js·py/pfapi/client.py - Sibling CWE-295 (library default off, not this class): CVE-2026-105221 gist TLS