Research/pfsense-api-unix-sock-noauth
0-dayNo CVEHighPublic

pfSense Plus MIM, device unix socket enrolls with no JWT

Official example-add-devices.py curls /var/run/pfnet-controller.sock with no Authorization. A local process that can connect(2) the sock enrolls a controller and dumps device identity. Mock-labbed. Plus not executed. Sock DAC unknown. No CVE yet.

Name
pfSense Plus MIM, device unix socket enrolls with no JWT
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). Device unix socket MIM enroll. Plus binary not executed. Real sock DAC unknown.
Published
14 Oct 2026
Updated
14 Oct 2026
Tags
0-day, pfsense, pfsense-plus, mim, nexus, unix-socket, privilege-leftover, local, cwe-306

After G1 and G2 land on the box, this sock is the local plane

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

This is Netgate/pfsense-api main b4acd7ca21dbca739a530403a1f62a7440f7726f (2026-05-25, Add Snort-3 API definitions). pfSense Plus Nexus Multi-instance Management client toolkit. No CVE yet. CWE-306. 7.8 High (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) if the sock mode lets another local uid connect. Honest: DAC unknown. Plus not executed. Local privilege leftover.

This is Part 3 of the Nexus MIM series. Part 1 is the enrollment script interpolating controller JSON into POSIX sh over SSH. Part 2 is Paramiko AutoAddPolicy() on that same SSH. Both are remote. Both land a script on the firewall. The script's entire job, once it is there, is two curls against a unix socket:

Plain text
curl -s -f --unix-socket /var/run/pfnet-controller.sock -H "Content-Type: application/json" -d "{}" http://unix/api/mim/controllers
curl -s -f --unix-socket /var/run/pfnet-controller.sock http://unix/api/mim/device/identity

No Authorization. No cookie. No peer-cred check visible at the client. HTTPS login still issues a Bearer JWT. That token is not applied to the on-device sock. The MIM plane on the firewall is a different trust boundary from :8443 plus mim_* groups.

If G1 or G2 already gave you a shell as SSHUSER, you do not need this leftover to enroll a hostile controller. You already have the box. The leftover is the other direction: any local process that can connect(2) the sock gets the same enroll and identity calls the official admin sample uses, without being that admin, without a JWT, and without going through User Manager.

Why this tree, and why this leftover third

I wanted the API implementation of a firewall controller, not another WebGUI PHP hunt. The tree operators actually copy is github.com/Netgate/pfsense-api. It is not the third-party CE REST package at jaredhendrickson/pfsense-api / pfrest.org. It is the public OpenAPI, the generated Python, and a handful of official samples for pfSense Plus Nexus Multi-instance Management. The server those operations talk to is closed-source and ships with Plus 25.11+ as the controller on :8443. I did not download Plus. I did not talk to a live controller.

I only wanted Critical or High. Remote first. Local is in-scope when it is a separate trust boundary. Medium stays on the floor unless it chains. Diagnostics php/shell on a superuser JWT is intended admin power. Missing securitySchemes on 755 operations is a schema gap. The unix socket is neither of those.

Enrollment is one script. G1 is the JSON that becomes shell. G2 is the host-key policy that delivers the script. G3 is what the script talks to once it is running as a local process. You can skip G1 and G2 entirely if you already have a uid on the box that can open /var/run/pfnet-controller.sock. Package, helper, jail escape, leftover PHP, a service account that was never supposed to be MIM superuser. The published protocol does not ask who you are.

Pinning b4acd7ca

HEAD on 2026-10-14. No tags. Latest commit adds Snort-3 API definitions. pfapi_openapi.yml is OpenAPI 3.0.3, 486 paths, 755 operations. The mim tag is 20 ops. The interesting six for this leftover live on the device: SetController, GetControllers, GetMIMController, DeleteMIMController, GetDeviceIdentity, SetDevicePublicKey.

Auth in the samples: POST /login with base64 username and password, JWT access token, 24h refresh cookie. Device calls on the controller go to /api/device/pfsense/{device_id}/api plus the same 755 ops. Local enroll uses /var/run/pfnet-controller.sock with http://unix/api/.... Netgate's own forum posts curl that socket with no token. Operators treat it as raw HTTP. The sample agrees with them.

I cloned b4acd7ca21dbca739a530403a1f62a7440f7726f and did not update it. The leftover is in example-add-devices.py device_script and in the mim paths that script names. The Plus daemon that binds the sock is not in this tree.

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 (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. This leftover is not php/shell.

QinQ if_device, IPsec login_banner, OpenVPN dns_domain / netbios_scope are the same PHP apply sinks I already 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 any of that through the API field names.

CE ACB key derived from SSH is CVE-2024-57273 / SA-25_03. That is leftover 7 in this series, not this sock.

OpenAPI security: MISSING on all 755 ops, including login, is a documentation leftover. Generated Client will POST php/shell with no Bearer. That is a generator consequence. Examples always login.sync then AuthenticatedClient(token=...). Netgate docs: Nexus uses User Manager; mim_superuser / mim_<capability>_<role>. Not an unauth HTTPS finding. I spent a hunt proving that so I would not inflate this sock into "the whole API is open."

verify_ssl=False on the samples is leftover 9. It enables G1. It is not this fd.

Printing JWT and CONTROLLER_SUMMARY to stdout is an operational leak to the operator's terminal. Below bar.

DeviceIdentity.controller_add is documented as a curl the operator can paste. This sample does not exec it. It POSTs JSON. I left the paste footgun on the floor.

jaredhendrickson/pfsense-api is a different product.

Surfaces that returned nothing

Twelve hunts on this pin. Login/JWT/SSO, diagnostics, MIM enrollment, client TLS, users/certs, packages, VPN/firewall, OpenAPI security, device IDOR, git/firmware/feeds, plus a loopback mock of every generated operation.

Most of that bar is intended admin, already-public CE sinks, or not_labbed Medium. ThreatGate remotes SSRF, secrets on GET (LDAP/RADIUS/SAML/HA/git), and /system/logs/{filename} stayed Medium. Speed test unbounded integers stayed Medium. Device cert create with caller-supplied CA is already Nexus admin. Tags including * are documented.

The 755-op mock on 127.0.0.1:55980 proved a client contract: AuthenticatedClient always sent Bearer, Client never did, path params percent-encode ../. It cannot prove the Plus controller returns 200 without a JWT. It also cannot prove the on-device daemon's DAC. That mock is how I knew HTTPS was a different plane from this sock.

Public Nexus auth docs describe User Manager JWT on :8443. They do not describe a token on /var/run/pfnet-controller.sock. The sample is the protocol.

The leftover

example-add-devices.py is the documented way to register the controller to each pfSense device. README: set CONTROLLER_URL, SSHUSER, pfsense_addresses, SSHPASS, PASSWORD. Run it.

The HTTPS half uses AuthenticatedClient. Bearer on every call to CONTROLLER_URL+"/api". That is leftover 9's TLS-off client talking to leftover 6's fleet token, and it is how G1 gets a ControllerSummary to interpolate. Then the script SSHs onto each firewall and runs this:

Python
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
if [ $? != 0 ]; then
	# try with deprecated API
	curl -s -f --unix-socket /var/run/pfnet-controller.sock http://unix/api/system/mim/devid
	...
fi
'''.format(controller_info_escaped, controller_info_escaped)

Count the -H flags. There is one: Content-Type: application/json on the POST. There is no Authorization. The GET has no headers at all. The deprecated aliases are the same shape.

OpenAPI says what those calls grant if the daemon honors them.

POST /mim/controllers (SetController): "Adding the controller will initiate a Netgard connection to it. The device will continue to issue a connection request every 10 seconds while the Controller has not added the device to its configuration." Body is ControllerIdentity: key, vpn_pubkey, vpn_listenaddr, vpn_netkey, cert.

GET /mim/device/identity (GetDeviceIdentity): "this device's public keys for API Noise handshaking and VPN connection."

Sibling writes the same spec puts on the device, not exercised by the sample but sitting on the same unauthenticated client class:

  • GET /mim/controllers lists controllers plus device info. Schema includes ControllerInfo.noise_secret.
  • GET /mim/controllers/id/{key} is one record.
  • DELETE /mim/controllers/id/{key} unenrolls. Key hex or base64-URL.
  • POST /mim/device/pubkey (SetDevicePublicKey): "intended to be used on the device to change its ED25519 public key." Body DevicePublicKeyOption has privkey as a first-class field, next to pubkey.

HTTPS JWT does not apply on this fd in the official client. AuthenticatedClient is only constructed for the controller URL. The socket curls are raw. Forum usage of curl -s --unix-socket /var/run/pfnet-controller.sock http://unix/api/interfaces matches: operators talk to this socket as HTTP with no token.

That is the leftover. Application auth is missing on a control plane that enrolls who the firewall trusts and can replace the Noise identity the controller will handshake.

DAC is the honest caveat. Bind mode, owner, group, and SO_PEERCRED are not in the public client tree. If the daemon binds 0600 root, G3 is already-root local: not LPE against root, still a confused deputy for any helper, package, or escape that can see /var/run. If the sock is 0666 or 0660 a non-admin group, it is Local High against the HTTPS mim_* model. I scored 7.8 on the second reading and wrote DAC unknown on the first. The lab proves the protocol, not the inode.

The lab

I did not run pfSense Plus. The leftover proven here is the published enroll protocol: no JWT on that fd. The gadget is a unix HTTP mock at /var/run/pfnet-controller.sock plus TCP 127.0.0.1:19121 for ping, hits, and reset. Compose project pfsense-api-unix-sock-noauth. Python 3.13-slim. Witness PFSENSE-API-UNIX-SOCK-NOAUTH-WITNESS.

poc.py execs the official curl shape inside the gadget with docker compose exec. Same two lines as device_script. Then it asks the mock whether those hits had authorization_present=false. Then it curls the identity path again with Authorization: Bearer lab-jwt. The mock 401s that control and does not put the witness in the body. Present vs absent is distinguishable. A stub that always 200s would have failed the last check.

Replay:

Plain text
git clone https://github.com/abraxas/pfsense-api-unix-sock-noauth
cd pfsense-api-unix-sock-noauth
python3 pfsense-api-unix-sock-noauth-Abraxas-Labs.py

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

Plain text
SUCCESS PFSENSE-API-UNIX-SOCK-NOAUTH auth-absent=yes identity-witness=yes bearer-401=yes PFSENSE-API-UNIX-SOCK-NOAUTH-WITNESS

Docker unix-socket mock. No Plus image. I wrote that into the SUCCESS path so nobody can pretend I booted Plus.

Wrong turns, in the order I made them

Treating missing securitySchemes as unauth HTTPS. Login is missing it too. All 755 ops are security: MISSING. Examples always Bearer first. Netgate docs put User Manager on :8443. I almost filed "the controller is open." It is not shown. The sock is.

Assuming the sock is world-writable. Mode is not in this repo. Forum curls do not prove 0666. Scoring 7.8 without the caveat would have been the wrong kind of honest. The leftover is no application auth on that fd. DAC is a separate fact.

Claiming a Plus 200. The mock accepted the official curl shape. The Plus binary was not under test. A 200 from my http.server is not a 200 from pfnet-controller. I put mock-only in the IOC line.

Treating G1/G2 as this leftover. G1 is interpolation into sh. G2 is AutoAddPolicy. Both are remote, both need the enrollment workstation. This leftover is local, needs connect(2), and does not need Paramiko. They share a script. They are not the same bug. Suggested vendor order was 1, then 2, then 3 because 1 is fleet RCE during the documented enroll. 3 still stands if you never run the sample.

Treating intended enrollment as the finding. The admin who SSHes in and curls the sock is how MIM is supposed to join a box. The leftover is that the daemon, as published, does not ask for a device JWT or a peer cred on that join. Anyone who can open the fd is device-side MIM superuser.

Inflating this to host root shell. Enroll and pubkey POST are the MIM plane. php/shell is a different tag on a superuser JWT. I did not stack them in the class.

A reverse shell. Theatre. The oracle is auth-absent plus a witness in identity plus a Bearer 401.

Pinning jaredhendrickson/pfsense-api. Wrong product. CE REST package. Different server, different authz, different sock.

Labbing Plus. Paid. Not in the tree. I do not have a license for a proof that needs the closed daemon. The public protocol is enough to show the missing header. The rest is inferred and labeled.

What an attacker can do

Actor is any process on the pfSense device that can connect(2) to /var/run/pfnet-controller.sock. Not remote TCP by this example. HTTPS :8443 JWT and mim_* groups do not apply on this fd.

Lab-proven (mock of the published enroll protocol)

Method Sink Privilege
Official device_script POST with no Authorization POST http://unix/api/mim/controllers Integrity: mock accepted enroll JSON. Hit log authorization_present=false.
Official device_script GET with no Authorization GET http://unix/api/mim/device/identity Recon: mock returned DeviceIdentity-shaped JSON including PFSENSE-API-UNIX-SOCK-NOAUTH-WITNESS.
Same GET with Authorization: Bearer lab-jwt same identity path Lab control: 401, witness not in body. Distinguishes present vs absent. Not a Plus result.

Inferred on a live Plus daemon (binary not under test)

These are the same OpenAPI ops the official sample and the mim tag put on the device. Proven only as protocol, not as a Plus 200.

Method Sink Privilege
POST /mim/controllers (fallback POST /api/system/controller) with attacker ControllerIdentity (key, vpn_pubkey, vpn_listenaddr, vpn_netkey, cert) SetController Integrity: enroll a hostile controller. Spec: device "will continue to issue a connection request every 10 seconds" (Netgard VPN to the attacker).
GET /mim/device/identity (fallback GET /api/system/mim/devid) GetDeviceIdentity Recon: device Noise/VPN public keys, address, controller_add curl text.
GET /mim/controllers GetControllers Recon: list controllers plus device info (ControllerInfo.noise_secret, keys).
GET /mim/controllers/id/{key} GetMIMController Recon: one controller record.
DELETE /mim/controllers/id/{key} DeleteMIMController Integrity / availability: unenroll the real controller (key hex or base64-URL).
POST /mim/device/pubkey with attacker privkey / pubkey SetDevicePublicKey (DevicePublicKeyOption includes privkey) Integrity: replace the device ED25519 identity. Controller handshake then trusts the attacker key.

Does not grant a host root shell by itself. Grants the MIM plane: who the device trusts as controller, and the device Noise private key if pubkey POST is honored.

Requires: local connect(2) to /var/run/pfnet-controller.sock. DAC (mode, owner, group, SO_PEERCRED) is not in the public client tree. If the daemon binds 0600 root, this is already-root local (confused deputy for a helper, package, or escape that can see /var/run). If the sock is reachable to another uid, CVSS 7.8 as scored.

G1/G2 chain: a remote attacker who already has SSH as SSHUSER is using the intended enroll path. That is not extra privilege from this leftover. This leftover is the local uid that never had SSH and never had a JWT.

Adjacent surface that stays closed

Surface Why
Unauth remote TCP to /mim/controllers on Nexus HTTPS :8443 Documented as User Manager JWT. This leftover is the unix fd.
HTTPS POST /login Bearer Still issued. AuthenticatedClient still sends it to CONTROLLER_URL+"/api".
Generated library verify_ssl=True Sample sets False. Library default is True.
Path params ../ Client percent-encodes.
OpenAPI security: MISSING on 755 ops Schema gap. Not a proven unauth HTTPS skip.
Plus binary accepting the request Not under test. Mock only.
Intended admin Diagnostics php/shell Superuser JWT. Different tag. Not this leftover.
Host root shell from enroll alone MIM plane, not php/shell.

The fix

Require a device JWT or SO_PEERCRED on /var/run/pfnet-controller.sock. Bind the sock 0600 root. Do not honor POST /mim/controllers, POST /mim/device/pubkey, or identity GET from an unauthenticated unix peer.

Until then, treat that fd the way you treat a second, unauthenticated control plane on the firewall. G1 and G2 are how the official sample reaches it from the network. G3 is how anything already on the box uses it without asking User Manager.

I am not going to print a curl you can paste at someone else's /var/run/pfnet-controller.sock. The missing header is the useful part. If you own the device, run the proof of concept against loopback.

References

pfSense Plus MIM, device unix socket enrolls with no JWT · Abraxas Labs