Research/pfsense-api-enroll-json-ssh
0-dayNo CVEHighPublic

pfSense Plus MIM enrollment interpolates controller JSON into SSH

Official example-add-devices.py dumps ControllerSummary JSON, escapes only quotes, and Paramiko-execs it as admin SSH. $() survives inside curl -d. Labbed on the pinned interpolation. No Plus image. No CVE yet.

Name
pfSense Plus MIM enrollment interpolates controller JSON into SSH
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Netgate
Affected
pfSense Plus MIM API toolkit public main b4acd7ca21dbca739a530403a1f62a7440f7726f example-add-devices.py. Generated Client default verify_ssl=True. Samples set False. Plus controller/daemon not in the tree.
Published
14 Oct 2026
Updated
14 Oct 2026
Tags
0-day, pfsense, pfsense-api, nexus, mim, privilege-leftover, remote, cwe-78

json.dumps is not a shell escaper

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

This is Netgate/pfsense-api public main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions). The official OpenAPI client and samples for pfSense Plus Nexus Multi-instance Management. Not the third-party CE REST package. No CVE yet. CWE-78. 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. Controller JSON interpolates into enrollment SSH as admin.

example-add-devices.py is the documented way to register the controller on each device. README says so. The sample logs into :8443, fetches GET /mim/controller/identity, json.dumpss it, backslash-escapes only ", then .formats that blob twice into a POSIX script whose first line is curl ... -d "{}" against /var/run/pfnet-controller.sock. Paramiko exec_command sends the whole script as SSHUSER. Sample default is admin. pfSense admin SSH is typically root-equivalent.

json.dumps keeps the quotes closed. The later replace keeps them closed. $(), backticks, and ${...} are not JSON metacharacters. They stay live inside a still-open double-quoted -d string. OpenSSH exec is a shell. The firewall runs whatever substitution rode in on vpn_listenaddr.

This is Part 1 of a leftover series on the same pin. The hub is pfsense-api-nexus-mim. The other packs are enroll-autoadd, unix-sock-noauth, example-ssl-verify, fleet-jwt, and export-key-get. They are not the same bug. They eat each other's leftovers.

I did not compose pfSense Plus. There is no public Plus image. The lab is the pinned interpolation plus a mock unix socket on 127.0.0.1:19101.

Why this tree

I had just finished a pfSense CE 2.9.0-DEVELOPMENT wave. Five leftovers went out on the CE WebGUI: QinQ parent interface, OpenVPN wizard CRLF, CARP VHID into ifconfig, limiter number, IPsec login banner. PHP-FPM is root on CE. That is a different GitHub org and a different product SKU.

Plus is the paid SKU. Nexus Multi-instance Management is the fleet plane: a controller on :8443 and a daemon on each firewall that speaks Netgard and a unix-socket MIM API. The public GitHub tree for that plane is Netgate/pfsense-api. OpenAPI 3.0.3, generated Python, a handful of samples. Apache-2.0. No tags. No SECURITY.md.

It is easy to confuse with jaredhendrickson/pfsense-api / pfrest.org. That is a community REST package for CE. Different server, different auth, different leftover class. I wrote that on the skip list before I opened a controller model.

I only wanted Critical or High. Remote first. Local unix-socket leftovers stay in-scope when that socket is a different trust boundary from JWT HTTPS. Medium stays on the floor unless it chains. Intended admin power is listed so it is not inflated into a CVE. POST /diag/cmd/php and POST /diag/cmd/shell are the MIM wrap of GUI Diagnostics: Command. Netgate already treats admin PHP/shell as intended. I am not ranking those as unpublished RCE.

A finding on this hunt needed something else: sample code that the README tells operators to run, interpolating untrusted controller JSON into a shell that is not the operator's.

Pinning b4acd7ca

No release tags. main HEAD on the day I cloned was b4acd7ca21dbca739a530403a1f62a7440f7726f, "Add Snort-3 API definitions", 2026-05-25. Schema title pfSense API 1.0. 486 paths, 755 operations. Generated py/pfapi from openapi-python-client. Samples are hand-written.

The HTTPS controller and the on-device pfnet-controller binary are Plus-only. They are not in this repo. I did not download Plus. I did not talk to a live controller. Privilege that needs a licensed box is marked inferred. Privilege that is the sample's own string handling is labbed.

CONTROLLER_URL in the enrollment sample is hardcoded https://10.100.0.38:8443. RFC1918 lab copy-paste. SSHUSER = 'admin'. pfsense_addresses is a Python list. The README's run line is:

Shell
PYTHONPATH=py SSHPASS=ssh_password PASSWORD=controller_password python3 ./example-add-devices.py

That is the product's own enrollment path, not a gist someone left in an issue.

The skip list is half the hunt

NVD has no CVE for Netgate/pfsense-api. GitHub advisories on that repo are empty. Issues #1-#5 are schema enums, alias underscores, PyPI. I did not re-file any of that.

Related CE WebGUI leftovers already public from the 2.9.0-DEVELOPMENT review, do not re-file as MIM CVEs:

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

CVE-2025-69691 (disputed) is Diagnostics: Command. I am not filing POST /diag/cmd/php or POST /diag/cmd/shell as unpublished RCE unless a non-superuser role can call them. The server that would prove that is Plus-only.

CE Auto Config Backup userkey (CVE-2024-57273 / SA-25_03) is a sibling class, not this leftover. gist CVE-2026-105221 is a library default VERIFY_NONE. The generated pfapi.Client default is verify_ssl=True. That is the opposite of gist. The samples turn it off. That is G9, not G1.

Twelve hunts, six leftovers that stand alone

I split the tree: login/JWT/SSO, diagnostics SSRF and php/shell, MIM enrollment, client TLS and examples, system users/certs/logs, packages (shellcmd, HAProxy, Snort, ACB, ACME), VPN/firewall/interfaces as leftovers of CE G1-G5, OpenAPI missing securitySchemes, per-device IDOR / fleet JWT, git/firmware/feeds, plus a loopback mock of every generated operation.

The mock sat on 127.0.0.1:55980. 755/755 generated _get_kwargs sent. Witness PFSENSE-API-MOCK-WITNESS. Torn down. AuthenticatedClient always sent Authorization: Bearer. Client sent none, including php/shell. Path params percent-encode ../ ; $(). Library default verify_ssl=True. follow_redirects default False. OpenAPI security: MISSING on all 755 ops because the spec has no securitySchemes at all, login included. That is a documentation leftover, not proven unauth HTTPS. Confidence the server is open: 15%. I dropped it.

Surfaces that returned nothing at this bar:

  • Generated client default TLS verify. Docs warn against False and show a PEM bundle path first.
  • JWT split(".")[1] for exp. Display only. Not signature-skip auth.
  • Login username/password base64. OpenAPI transport encoding, not a hash.
  • Refresh cookie. Designed 24-hour refresh.
  • follow_redirects=False. Bearer does not chase a 3xx Location.
  • Speed test unbounded duration / upload_mb. Authenticated DoS. Medium.
  • Device cert create with caller-supplied CA/key. Already Nexus admin.
  • Tags, including *. Documented.
  • DeviceIdentity.controller_add as a curl the operator might paste. This sample does not exec it. It POSTs JSON.
  • ThreatGate remote feeds. Services-modify SSRF. Medium unless it chains.
  • Secrets on GET (LDAP bind, RADIUS, SAML, HA XMLRPC). Spec-likely. Medium recon.
  • /system/logs/{filename} unconstrained string. Client encodes ../. Server join unknown. Medium/Low.

What stayed High on the enrollment path, and only the enrollment path, is this interpolation. Then AutoAddPolicy on the same Paramiko client. Then the unix socket the script curls with no JWT. Those three are one documented run line. They are three reports because each stands alone and each has its own fix.

G9 verify_ssl=False is Medium by itself. It is also how an on-path peer writes G1's JSON. I kept it as a sibling because the generated library got TLS right and every sample undid it. G4 export-key GET and G6 fleet JWT are inferred from the schema. They need two roles on a licensed box. They still stand alone.

The leftover

Login, then this:

Python
summaryResp = get_mim_controller_summary.sync(client=client)
controller_info = json.dumps(summaryResp.to_dict())
print("CONTROLLER_SUMMARY:", controller_info)

controller_info_escaped = controller_info.replace('"', '\\"')

device_script = '''
curl -s -f --unix-socket /var/run/pfnet-controller.sock -H "Content-Type: application/json" -d "{}" http://unix/api/mim/controllers >/dev/null
if [ $? != 0 ]; then
	# try with deprecated API
	curl -s -f --unix-socket /var/run/pfnet-controller.sock -H "Content-Type: application/json" -d "{}" http://unix/api/system/controller >/dev/null
	...
fi
curl -s -f --unix-socket /var/run/pfnet-controller.sock http://unix/api/mim/device/identity
...
'''.format(controller_info_escaped, controller_info_escaped)

Then, for each address:

Python
sshclient = paramiko.client.SSHClient()
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)

Three mistakes in twenty lines, and only one of them is this pack.

This pack: the dump is interpolated into a double-quoted curl -d. Quote-escape is not shell-escape. Command substitution inside those quotes runs as SSHUSER.

Next pack: AutoAddPolicy() means the SSH peer is whoever answers. The exec payload is the controller identity. enroll-autoadd.

Pack after that: the curls on the device hit /var/run/pfnet-controller.sock with no Authorization. Same MIM enroll and identity ops the HTTPS controller gates with JWT. unix-sock-noauth.

Login Client and AuthenticatedClient both set verify_ssl=False. A TLS MITM of CONTROLLER_URL can return a JSON body that still type-parses as ControllerSummary. Every field is str or Unset. from_dict copies unknown keys into additional_properties. to_dict dumps them back out. That is how G9 feeds G1. example-ssl-verify.

json.dumps of a parsed object does not emit a raw newline. U+000A becomes \n. A parsed ControllerSummary does not split the script on a new line. It also JSON-escapes " in values, and the later replace keeps the -d "..." string closed. I spent a pass hoping for quote-breakout. That is not the class. The class is POSIX command substitution inside a string that stays closed on purpose.

OpenSSH exec of this string is sh -c of SSHUSER. The README and Netgate's own MIM API video treat this as the working enrollment path, so the account is a real shell, not the pfSense console menu.

How this leftover feeds the others

Enrollment is a conveyor. G1 is the motor.

  1. Operator runs the sample. TLS verify is off (G9). An on-path peer of :8443 shapes ControllerSummary. $() in vpn_listenaddr is enough.
  2. The sample interpolates that JSON into device_script (this leftover) and Paramiko-execs it. Host keys are not pinned (G2). The SSH peer that receives the script can be the attacker, or the real box running attacker substitutions.
  3. On the device, curl posts the blob to the unix socket with no JWT (G3). If the substitution expanded before curl, the socket sees the expanded value. If the attacker is already local on the firewall, they skip SSH entirely and POST their own controller identity.
  4. The sample parses stdout as DeviceIdentity and POST /mim/devices to the real controller. One controller JWT is a fleet token (G6). A helpdesk login that can list devices can, if the role includes console/system, POST /diag/cmd/php on every enrolled box. GET /system/certificates/{refid}/export-key is a read-shaped way to pull CA and leaf keys (G4).

G1 does not need G2, G3, G6, or G4 to be a shell as admin. G1 is already that. The series is the same conveyor with the guards taken off at every hop. Shaped JSON plus AutoAddPolicy plus a unix socket with no JWT is how an on-path peer of the enrollment workstation becomes the controller the fleet trusts.

I am not chaining them into one CVE. They stand alone. They also share a run line.

The lab

Host is macOS. One container. No Plus image.

enroll.py is the pinned json.dumps / quote-replace / .format sequence. The DEVICE_SCRIPT_TEMPLATE is copied from example-add-devices.py:111-130 with the tabs preserved. SSHUSER stays admin. Pin SHA is in the ping JSON so a drift fails closed.

lab_server.py binds HTTP on 0.0.0.0:19101 published to 127.0.0.1:19101, and a unix socket at /var/run/pfnet-controller.sock. POST /api/mim/controllers and the deprecated /api/system/controller store the body. GET identity returns a tiny JSON device. POST /case with name=control|inject plus a summary object formats the script and runs it under bash -c. The oracle is /oracle/witness.

The PoC:

  1. GET /ping. Pin, witness, socket exists, sshuser=admin.
  2. CONTROL summary uses vpn_listenaddr=192.0.2.10:51820. Escaped blob has no $() and no backticks. Unix POST body is that listenaddr unchanged. Host oracle empty. control-clean=yes.
  3. INJECT summary uses vpn_listenaddr=$(printf %s PFSENSE-API-ENROLL-JSON-SSH-WITNESS >/oracle/witness). Escaped blob still contains $(printf and the witness string. Quotes are backslash-escaped (quote-only-escape=yes). keeps_dollar_paren is true (dollar-paren-survived=yes). Bash writes the witness (inject-oracle=yes). The unix POST body does not contain the $() form. Expansion happened in sh before curl.

Paramiko is not in the compose. SSH is not in the compose. Plus is not in the compose. The interpolation is. If the interpolation did not fire, the run is FAIL. If CONTROL wrote the oracle, the run is FAIL. If INJECT $() reached the unix body unexpanded, the run is FAIL, because then I would have been looking at a daemon JSON bug instead of a shell bug.

Loopback 127.0.0.1:19101. Compose project pfsense-api-enroll-json-ssh. Stack tears down after the run.

Last line:

Plain text
SUCCESS pfsense-api-enroll-json-ssh quote-only-escape=yes dollar-paren-survived=yes control-clean=yes inject-oracle=yes sshuser=admin PFSENSE-API-ENROLL-JSON-SSH-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/pfsense-api-enroll-json-ssh
cd pfsense-api-enroll-json-ssh
python3 pfsense-api-enroll-json-ssh-Abraxas-Labs.py

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

Wrong turns, in the order I made them

Treating this repo as the CE REST package. The names collide. Jared Hendrickson's tree is a different server. I wrote the distinction into the first paragraph of the findings pack so I would not file a Plus leftover against a CE issue tracker.

Expecting json.dumps to neutralize $(). JSON string escaping is ", \, and controls. Dollar and backticks are legal in a JSON string. They are also legal in a POSIX double-quoted string. Putting one inside the other is not defense in depth. It is two parsers agreeing to leave the interesting bytes alone.

Expecting a parsed summary to inject a raw newline. from_dict plus json.dumps turns U+000A into \n. The script does not split. I spent a pass on that anyway because a newline would have been a second class (script injection, not substitution). It is not this leftover.

Treating quote-breakout of -d "..." as the bug. The quote pass exists because someone thought about quotes. They thought about the wrong quotes. quote-only-escape=yes is in the SUCCESS line so nobody can pretend I claimed the string opened.

Filing verify_ssl=False as this CVE. That is G9. It enables the remote injection path. It is not the interpolation. The generated library default is True. gist CVE-2026-105221 was the library. This is the sample.

Filing AutoAddPolicy as this CVE. Same function, different CWE. CWE-322 sits next to CWE-78. Mixing them makes a vendor fix one and close the ticket.

Filing the unix socket as this CVE. The sample is the client of that socket. The leftover on the socket is "no application auth on that fd." Different trust boundary, different CVSS (local), different fix (SO_PEERCRED / mode / JWT).

Composing Plus. There is no public image. A licensed box would prove G3 DAC, G4 role split, G6 php-on-device-B. It would not change G1. G1 is the sample.

Calling this unauthenticated HTTPS to the controller. login.sync still runs first. PR:N is the on-path peer of the sample, not a missing Bearer on :8443.

Calling this RCE on the enrollment workstation. Format is local. The shell is remote exec. The workstation is the confused deputy, not the victim process.

A reverse shell. Theatre. The oracle is a witness string in /oracle/witness. Kill / halt / wipe are inferred side effects of the same shell. I marked them inferred.

Inflating Diagnostics: Command. A JWT that can php is intended. A sample that hands $() to admin SSH is not.

What an attacker can do

The leftover is command substitution inside the still-open double-quoted -d string. json.dumps plus the quote pass keep the string closed. $(), backticks, and ${...} survive and run as SSHUSER when OpenSSH execs the script.

Integrity

Method Sink Privilege Lab-proven or inferred
Put $() (or backticks / ${...}) in a ControllerSummary string field, then let the operator run example-add-devices.py controller_info.replace('"', '\\"') then device_script.format(...) then sshclient.exec_command(device_script) (example-add-devices.py ~104-161) Shell as SSHUSER (sample default admin; pfSense admin SSH is typically root-equivalent) on each address in pfsense_addresses Lab-proven on the interpolation: INJECT vpn_listenaddr=$(printf %s PFSENSE-API-ENROLL-JSON-SSH-WITNESS >/oracle/witness) survived quote-only escape (dollar-paren-survived=yes) and wrote the witness (inject-oracle=yes). Paramiko/SSH itself is not in this compose.
TLS MITM of GET /api/mim/controller/identity while the sample uses verify_ssl=False Same ControllerSummary parse then the same script Same shell. All ControllerSummary fields are str or Unset, so a MITM JSON body that type-parses is enough. Inferred from the sample (example-add-devices.py login Client and AuthenticatedClient both set verify_ssl=False). Lab posts the summary object directly. Sibling: example-ssl-verify.
Write substitution characters into name or vpn_listenaddr on the controller, wait for the next enroll Same sink Same shell when an operator next runs the sample Inferred. Needs a prior write of those fields (already controller-admin, or unix-sock-noauth).
Same shell, extra keys via ControllerSummary.additional_properties Same dump includes extra MITM keys (to_dict copies leftovers after from_dict) Extra fields ride into -d and into the shell Inferred from controller_summary.py. Lab injects through vpn_listenaddr only.

Recon

Method Sink Privilege Lab-proven or inferred
Read the enrollment workstation stdout print("CONTROLLER_SUMMARY:", controller_info) Controller identity key, Netgard vpn_pubkey / vpn_netkey / listen addresses Inferred from the sample print. Lab does not print a live controller blob.
Sniff or MITM the SSH exec channel exec_command(device_script) payload is the curl -d JSON Same controller identity on the wire to every device Inferred. AutoAddPolicy on that channel is a sibling report.
CONTROL case on the gadget POST /case name=control Honest vpn_listenaddr reaches the unix socket unchanged Lab-proven. 192.0.2.10:51820 posted as-is, no oracle (control-clean=yes).

Availability

Method Sink Privilege Lab-proven or inferred
Injected command as SSHUSER Same exec_command Stop pfnet-controller, wipe enrollment, reboot, or otherwise drop the device off MIM Inferred side effect of the shell. Lab only writes the witness file.

Requires: the official sample path (operator enroll). UI:N for the on-path peer once that sample is running. Login to the controller still happens first. AC:H because the attacker must sit on the path to CONTROLLER_URL or already write summary strings.

A shell as admin on a pfSense box is not "the JWT can php." It is the enrollment workstation handing the firewall a controller blob through sh -c. From there the shaped JSON is what the device POSTs to /var/run/pfnet-controller.sock with no JWT. AutoAddPolicy means the SSH peer that receives this script is not pinned. G9 is how the on-path peer writes the JSON in the first place.

You get SSHUSER on every address in pfsense_addresses. You do not get unauthenticated HTTPS php. You do not get a shell on the workstation. You do not get quote-breakout. You do get $().

Adjacent surface that stays closed

Surface Why
Generated library default verify_ssl=True This leftover is the sample, not pfapi.Client default.
Quote-breakout of curl -d "..." JSON plus the " replace keeps the string closed (quote-only-escape=yes).
Raw-newline script split from a ControllerSummary that passed from_dict / json.dumps U+000A becomes \n.
RCE on the enrollment workstation Format is local. The shell is the remote exec.
Unauthenticated HTTPS to the Nexus controller login.sync still runs first.
add_controlled_device.sync Posts parsed JSON to HTTPS. Does not exec DeviceIdentity.controller_add.
CONTROL listenaddr No $() / backticks. Writes no oracle.
INJECT $() in the unix POST body Expansion happens in sh before curl (inject-meta-reached-unix is fail).
Paramiko AutoAddPolicy() Sibling pack. Do not mix with this one.
Device unix-socket MIM with no JWT Sibling pack.
pfSense Plus controller/daemon Not in this repo. Not composed.
Intended POST /diag/cmd/php and /diag/cmd/shell Already-admin Nexus console. Not this leftover.
Third-party CE REST package Different GitHub tree.
OpenAPI security: MISSING Spec-wide, including login. Not proven unauth HTTPS.
Path params with ../ Generated client percent-encodes them.

The fix

Stop interpolating controller JSON into sh. Pass the blob via a file or stdin to curl -d @-. Paramiko SFTP plus a remote curl --data-binary @- also works. Escape or refuse $(), backticks, and newlines if someone still insists on a string.

Drop AutoAddPolicy is a sibling report. RejectPolicy / known_hosts is the comment the sample already wrote and then did not follow.

Turn verify_ssl back on, or pin a CA, is another sibling. The generated client already defaults True. The samples should stop setting False, or gate it on an env the way they already gate PASSWORD.

Put JWT (or SO_PEERCRED, or a 0600 root socket) on /var/run/pfnet-controller.sock is a third sibling. The HTTPS controller already has User Manager and mim_* groups. The unix fd should not be a second, unauthenticated control plane.

Until then, operators who run the README enrollment path are interpolating controller JSON into admin SSH. An on-path peer of :8443 writes that JSON. AutoAddPolicy delivers the script. The unix socket accepts it with no Bearer.

I am not going to print a vpn_listenaddr you can paste at someone else's fleet. The missing stdin to curl is the useful part. If you own the sample, run the proof of concept against loopback.

References