Research/pfsense-api-enroll-autoadd
0-dayNo CVEHighPublic

pfSense Plus MIM enrollment SSH AutoAddPolicy

Netgate pfsense-api example-add-devices.py ships Paramiko AutoAddPolicy and never loads known_hosts. First-contact SSH accepts an unknown host key, presents SSHPASS, and json.loads stdout into POST /mim/devices. Labbed on loopback. No Plus image. No CVE yet.

Name
pfSense Plus MIM enrollment SSH AutoAddPolicy
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
pfSense Plus MIM API (Netgate)
Affected
Netgate/pfsense-api main b4acd7ca example-add-devices.py. Plus Nexus Multi-instance Management client toolkit. Not the CE REST package. AC:H is first-contact / on-path SSH.
Published
14 Oct 2026
Updated
14 Oct 2026
Tags
0-day, pfsense, pfsense-plus, mim, nexus, paramiko, autoadd, privilege-leftover, cwe-322, remote

The comment is opt-out. The default is AutoAdd.

I am @abraxas_null. This is Part 2 of the pfSense Plus Nexus MIM leftover series. The hub is pfsense-api-nexus-mim. Part 1 is the enrollment script interpolating ControllerSummary JSON into a POSIX curl -d (pfsense-api-enroll-json-ssh). This post is the SSH host-key policy on the same script. The proof of concept is on GitHub: abraxas/pfsense-api-enroll-autoadd (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 main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions). No CVE yet. CWE-322. 8.1 High (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H). Remote privilege leftover. Paramiko AutoAddPolicy on the documented enrollment path. AC:H is first-contact / on-path.

The README of that repo is honest about what the sample is for: "demonstrates how to register the Controller to each pfSense device and add them to the Controller." You set CONTROLLER_URL, SSHUSER, pfsense_addresses, SSHPASS, PASSWORD. You run python3 ./example-add-devices.py. The script SSHes to every address, execs a unix-socket curl that plants ControllerSummary, then POSTs the returned DeviceIdentity to /mim/devices.

Between SSHClient() and connect there is one policy line:

Python
# comment this line out if all pfsense devices are in known_hosts:
sshclient.set_missing_host_key_policy(paramiko.AutoAddPolicy())

There is no load_system_host_keys(). There is no dedicated known_hosts file. Paramiko's default missing-key policy is RejectPolicy. This sample replaces it with AutoAdd and never loads a pin list, so first contact has nothing to reject against. The comment is the intended mode. Shipping default is the other one.

That is the leftover. Not "SSH is trusted therefore drop." The same function puts long-lived MIM trust material on that channel and disables host-key pinning.

Why this tree, and why it is not CE

I had just finished a pfSense CE 2.9.0-DEVELOPMENT wave on public master. QinQ parent-if, OpenVPN wizard CRLF, CARP VHID, limiter number, IPsec login banner. Those are WebGUI PHP apply sinks. Plus is paid. I did not download Plus and I did not talk to a live controller.

What is public is github.com/Netgate/pfsense-api. OpenAPI 3.0.3, 486 paths, 755 operations, generated Python (py/pfapi), a handful of sample scripts. Title on the YAML is pfSense API 1.0. The controller lives on :8443. The product name in the README is pfSense Plus Multi-instance Management. Netgate's docs call the same plane Nexus.

This is not jaredhendrickson/pfsense-api / pfrest.org. That is a third-party CE package with a different server and a different CVE history. I wrote that on the skip list before I opened a generated op.

I only wanted Critical or High. Remote first. Local unix-socket leftover is in-scope because that socket is a separate trust boundary from the JWT HTTPS controller. Medium stays on the floor unless it chains. Intended admin PHP/shell through a fleet JWT is not unpublished RCE. Netgate already treats Diagnostics: Command as intended (CVE-2025-69691 disputed). I am not re-filing that.

The enrollment script is the interesting door. It is the documented path from "I have a controller and a list of firewalls" to "those firewalls are in MIM." It talks SSH. It talks a unix socket on the device. It talks HTTPS to :8443 with verify_ssl=False. Three leftovers sit in one file. This post is the host-key one.

Pinning b4acd7ca, not a tag

The repo has no tags. main on the day I cloned was b4acd7ca21dbca739a530403a1f62a7440f7726f, "Add Snort-3 API definitions", 2026-05-25. Apache-2.0. I cloned that peel and did not chase later commits. The leftover is in example-add-devices.py, which is hand-written, not generated. Generated py/pfapi can churn. This sample did not have to.

src/etc/version is not in this tree. There is no Plus PHP. There is no pfnet-controller binary. The OpenAPI is the contract. The samples are the client that operators actually run.

I mock-tested all 755 generated ops on 127.0.0.1:55980. AuthenticatedClient always sent Bearer. Client sent none, including php and shell. Path params percent-encode ../. Library default verify_ssl is True. All official examples set False. That mock is not this leftover. It told me the generated library is not a gist-class VERIFY_NONE product. The samples are.

The skip list is half the hunt

No CVEs are assigned to Netgate/pfsense-api. GitHub issues on that repo are schema enums, alias underscores, PyPI. There is no SECURITY.md.

Related CE WebGUI leftovers already have their own posts. Do not re-file them as MIM CVEs just because the OpenAPI names the same fields:

Limiter number is an integer in this OAS. CARP VHID is not shown as a string leftover here. QinQ if_device, IPsec login_banner, OpenVPN dns_domain / netbios_scope are the same PHP apply sinks. Skip.

Vendor treats authenticated Diagnostics: Command PHP/shell as intended admin power. POST /diag/cmd/php and POST /diag/cmd/shell are in the schema. I am not ranking them as unpublished Critical. Same for /diag/editfile, backup restore, ports/shellcmd. A JWT from POST /login is a fleet token. That is the product.

gist CVE-2026-105221 is VERIFY_NONE in the library. Here the generated Client default is verify_ssl=True and the docs say False is a security risk. Samples force False so a factory/self-signed controller on 8443 will talk. That is Part G9, not a gist-class SDK default, and not this SSH sink.

OpenAPI security: MISSING on all 755 ops is a schema gap. The spec has no securitySchemes anywhere, login included. Generated wrappers accept unauthenticated Client. Examples always login.sync then AuthenticatedClient(token=...). Not an unauth HTTPS leftover.

Twelve surfaces, then this policy line

I split the tree. Login/JWT/SSO. Diagnostics PHP/shell/files. Diagnostics ping/DNS/pcap. MIM enrollment, unix socket, SSH. Client TLS and examples. Users, certs, logs. Packages, shellcmd, HAProxy, ACB, ACME. VPN/firewall/interfaces leftovers of CE. OpenAPI missing security. Device IDOR / fleet JWT. Git sync, firmware, ThreatGate feeds. Plus a loopback mock of every generated operation.

Most of that is either intended admin, a Medium GET of a secret the role can already read, or a server-join question I cannot prove without Plus.

Enrollment is the exception. The official sample is a three-act play:

  1. HTTPS login to the controller with verify_ssl=False. GET /mim/controller/identity. json.dumps the summary. Quote-escape ". .format it into a POSIX script. That is G1.
  2. Paramiko to each pfsense_addresses[] with AutoAddPolicy. exec_command that script. That is this post.
  3. json.loads stdout as DeviceIdentity. POST /mim/devices. The controller then "connect[s] to the device and perform[s] an authentication handshake."

Act 2 is the host-key leftover. Act 1 is why the exec payload is interesting. Act 3 is why a fake identity JSON is not theatre.

The unix socket on the device (/var/run/pfnet-controller.sock) is a third leftover. The sample curls it with no Authorization. That is a local trust-boundary finding. Not this post.

What the sample actually does with SSH

example-add-devices.py:149-187:

Python
sshclient = paramiko.client.SSHClient()

# comment this line out if all pfsense devices are in known_hosts:
sshclient.set_missing_host_key_policy(paramiko.AutoAddPolicy())

if SSHPASS:
    sshclient.connect(pfip, username=SSHUSER, password=SSHPASS)
else:
    sshclient.connect(pfip, username=SSHUSER)
stdin, stdout, stderr = sshclient.exec_command(device_script)
val = stdout.read().decode()
device_info = json.loads(val)

Then DeviceIdentity.from_dict(device_info) and add_controlled_device.sync.

Paramiko SSHClient starts with RejectPolicy and an empty host-key list. AutoAddPolicy accepts an unknown key and adds it to the in-memory HostKeys. The sample never calls load_system_host_keys() or load_host_keys(filename), so there is nothing in that list on first contact. The comment assumes you already populated known_hosts and you remembered to comment the line. If you only did the first half, AutoAdd still wins: a missing key is added, a changed key is still a mismatch. TOFU with an empty pin list is "accept everything once."

SSHUSER defaults to admin. SSHPASS is the env the README tells you to set. If it is unset, Paramiko falls through to agent and keys. Either is presented to the peer that won the host-key check.

device_script is leftover G1. Controller identity JSON in a curl -d against http://unix/api/mim/controllers, then a GET of http://unix/api/mim/device/identity. No Bearer. Deprecated /api/system/controller and /api/system/mim/devid fallbacks. The MITM is the SSH server that receives that string and that returns whatever stdout it wants.

I did not need a live Plus box to prove AutoAdd. I needed a peer with an unknown host key, RejectPolicy as the control, AutoAdd as the inject, and identity JSON on exec stdout. That is the lab.

The lab

Host is macOS. python:3.12-slim-bookworm. Compose project pfsense-api-enroll-autoadd. Loopback 127.0.0.1:19111. No Plus image. No :8443. No unix socket. The gadget is a Paramiko ServerInterface that accepts password admin / sshsecret and, on exec, writes a DeviceIdentity-shaped JSON blob with PFSENSE-API-ENROLL-AUTOADD-WITNESS in alias and witness.

CONTROL: RejectPolicy, same connect, same unknown host. Paramiko raises SSHException with not found in known_hosts. That is reject-control=yes. If RejectPolicy had connected, the leftover would be "Paramiko is broken," not "the sample opted into AutoAdd."

INJECT: the official-sample path. New SSHClient. The AutoAdd line copied with the comment. connect with password, agent off, keys off, so the SSHPASS path is the one that fires. exec_command of the enrollment script. stdout is JSON. json.loads finds the witness.

Last line:

Plain text
SUCCESS PFSENSE-API-ENROLL-AUTOADD autoadd=yes reject-control=yes identity-json=yes PFSENSE-API-ENROLL-AUTOADD-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/pfsense-api-enroll-autoadd
cd pfsense-api-enroll-autoadd
python3 pfsense-api-enroll-autoadd-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 gadget on purpose. A live Plus controller would have proved POST /mim/devices of a fake identity. It would also have meant running a paid image I do not have and talking to a management plane I am not going to stand up for a host-key policy. AutoAdd vs Reject on an unknown key is the sample bug. The parse-and-POST path is source. I marked the POST inferred.

The pin check looks for set_missing_host_key_policy(paramiko.AutoAddPolicy()) and exec_command(device_script) in a local clone if one is sitting next to the pack. Missing clone is pin-source=absent and the lab still runs. Mismatch fails. That is so a later sample rewrite cannot silently keep the SUCCESS line.

Wrong turns, in the order I made them

Treating AutoAdd as intended-lab-only. The comment reads like a lab footnote. It is opt-out. The README is the enrollment path Netgate documents. Shipping default is AutoAdd. KEEP.

Assuming known_hosts is loaded. I spent a pass looking for load_system_host_keys or a HostKeys file next to the sample. There is none. AutoAdd on an empty list is not TOFU against a pin file. It is accept-unknown.

Folding this into G1. G1 is JSON into sh. G2 is host-key policy. Same file, same exec_command, different class. CWE-78 is not CWE-322. A RejectPolicy sample still interpolates JSON. An AutoAdd sample with a heredoc still accepts a MITM. Split them.

Live-labbing Plus. Paid. Not in the tree. The leftover is in the public sample. A Paramiko gadget is the honest lab.

Claiming this is unauth HTTPS to :8443. Login still runs. JWT still issues. This is on-path to device SSH while an operator runs the sample. AV:N because the SSH path is a network path. AC:H because first-contact / on-path. UI:N because the operator is running the documented script, not clicking through a host-key prompt. Paramiko AutoAdd does not prompt.

Treating TOFU as "SSH is trusted therefore DROP." SSH with pinned keys is trusted. SSH with AutoAdd and secrets on exec is how you enroll an attacker as the device. The leftover is the combination.

A reverse shell. Theatre. The oracle is RejectPolicy fail plus AutoAdd connect plus identity JSON with the witness. Credential theft, controller-key dump, and POST /mim/devices sit on the same path. I marked them inferred.

Pinning the CE package. jaredhendrickson/pfsense-api is a different product. The GitHub names look like cousins. They are not.

Scoring G9 as the High. Samples force verify_ssl=False. That is how G1 becomes remote: a TLS MITM can shape ControllerSummary. By itself it is Medium copy-paste against a self-signed controller, library default True. Chain, do not inflate.

What an attacker can do

Sit on-path to a first-contact SSH address in pfsense_addresses while the operator runs the sample. AutoAdd is still on.

Method Sink Privilege
Sit on-path to a first-contact SSH address in pfsense_addresses set_missing_host_key_policy(paramiko.AutoAddPolicy()) then connect (example-add-devices.py:151-160) Integrity (lab-proven): unknown host key is accepted. Lab: AutoAddPolicy connected (autoadd=yes). RejectPolicy failed (reject-control=yes).
Same session, answer exec_command(device_script) stdout parsed as DeviceIdentity (example-add-devices.py:161-187) Integrity (lab-proven): exec returned identity JSON containing PFSENSE-API-ENROLL-AUTOADD-WITNESS (identity-json=yes).
Same MITM, read the exec payload device_script is leftover G1's curl -d of ControllerSummary Recon (inferred from source): controller identity key, Netgard vpn_pubkey / vpn_netkey / listen addrs. Not dumped by this lab.
Same MITM, password or key auth SSHUSER / SSHPASS or agent/keys (example-add-devices.py:156-160) Recon (inferred): credentials or keys presented to the fake host. Lab used password auth only as the sample SSHPASS path.
Same stdout JSON, add_controlled_device.sync POST /mim/devices on the real controller Integrity (inferred): registers attacker Noise/VPN keys and address. Plus handshake not executed in this lab.
MITM is the SSH server whatever exec the client sent, as SSHUSER Integrity / availability (inferred): run, drop, or skip the enrollment script. Unix-socket calls on the real device happen only if the MITM forwards them.

Lab-proven vs inferred: AutoAdd connect, RejectPolicy fail, and identity JSON with the witness are lab. Controller-key dump, credential theft, POST /mim/devices of a fake device, and running the real unix-socket curls are inferred from the same sample path.

Requires: operator runs the official sample against an SSH address whose host key is not already pinned, with AutoAdd left on. Network path to that SSH. AC:H is first-contact / on-path.

You do not get unauthenticated HTTPS to the controller. You do not get Diagnostics PHP. You do not get a host-key bypass on a workstation that already pinned the real keys and removed the AutoAdd line. You get to be the SSH peer on first contact, with the enrollment script and the identity JSON sitting on that channel.

Adjacent surface that stays closed

Surface Why
RejectPolicy on the same unknown host Lab: reject-control=yes. Paramiko refuses the key.
Workstation with real device keys in known_hosts and AutoAdd line removed The comment's intended mode. Sample never loads those keys unless you add the call.
HTTPS controller login verify_ssl=False is a sibling sample leftover (G9), not this sink.
Unix-socket MIM on the real firewall Only if the MITM forwards exec. This lab never talks to /var/run/pfnet-controller.sock.
Generated Client default verify_ssl=True Library default is not the leftover. Samples force False.
Plus RBAC / live :8443 This lab is a Paramiko gadget. I did not talk to a Plus controller.
Unauthenticated Internet-to-8443 Login still issues a JWT. Not this finding.
Diagnostics PHP/shell Intended admin power for a fleet JWT. Not this finding.
Path params with ../ Generated client percent-encodes. Mock-proven. Not this sink.

How it chains

G9 is every Python sample and helper_funcs.py passing verify_ssl=False, plus example.js setting NODE_TLS_REJECT_UNAUTHORIZED=0. Library default stays True. The enrollment script uses that helper pattern on both the login Client and the AuthenticatedClient. A TLS MITM of :8443 can shape ControllerSummary before it is interpolated.

G1 is that interpolation. json.dumps plus replace('"', '\\"') plus .format into a double-quoted curl -d. Backticks and $() survive. The shell is SSHUSER on every address in pfsense_addresses.

G2 (this leftover) is how you sit on the SSH path without a host-key prompt. AutoAdd accepts the MITM key. The exec payload is G1's script. stdout is G1's identity GET, attacker-controlled. POST /mim/devices is the controller believing that JSON.

You can exploit G2 without G1: accept the key, steal SSHPASS, return a fake DeviceIdentity. You can exploit G1 without G2 if you already have a way to write ControllerSummary strings, or if you MITM TLS (G9) and the SSH path is already pinned. The documented happy path runs all three in one process.

I am not bundling them into one CVE. They are three classes. They happen to share a file.

The fix

Use paramiko.RejectPolicy() or pin known_hosts. Call load_system_host_keys() and a dedicated known_hosts file before connect. Do not ship AutoAddPolicy() in the official sample. The comment that says "comment this line out if all pfsense devices are in known_hosts" is the intended mode. Make it the default.

Until then, operators who enroll with the sample on a network they do not fully trust are doing first-contact TOFU with controller identity on the exec channel. Commenting the line without loading host keys just restores RejectPolicy, which is better, and which fails closed on a new box until you pin it. That is the point.

I am not going to print an SSH MITM you can paste at someone else's firewall. The missing pin is the useful part. If you own the workstation, run the proof of concept against loopback.

References

pfSense Plus MIM enrollment SSH AutoAddPolicy · Abraxas Labs