Research/opnsense-uidfield-system-uid0
0-dayNo CVEHighPublic

OPNsense 26.7.6 UidField two-step uid 0

OPNsense core 26.7.6 delegated user-manager two-steps scope=system then uid=0. pw useradd -o writes a second wheel UID 0 and keys land in /root/.ssh/authorized_keys. Not page-all. Labbed.

Name
OPNsense 26.7.6 UidField two-step uid 0
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
OPNsense (Deciso B.V.)
Affected
OPNsense core 26.7.6 (a842fe48) UidField / User.xml. Privilege page-system-usermanager. stable/26.7 later reverted the UID-0 write as ea4f01fe; that commit is not this pin.
Published
13 Oct 2026
Updated
13 Oct 2026
Tags
0-day, opnsense, uidfield, privilege-leftover, cwe-269, cwe-284

The changelog called it a feature. The form called it info. setNodes still took POST.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/opnsense-uidfield-system-uid0 (loopback client). The lab stack is lab/. Authorized lab only. It talks to loopback.

This is OPNsense core 26.7.6 "Xenial Xenops" (a842fe48c21288d11a422cb944678e8eb308becf). No CVE yet. CWE-269 / CWE-284. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). Remote authenticated privilege leftover. Privilege page-system-usermanager. Not page-all.

26.7.6 presented "allow an arbitrarily specified UID for system scope users upon creation" as something admins wanted. The GUI draws scope and uid as type=info. Info is a renderer. setNodes still takes POST. UserController never sets $internalSaveRequiresAdmin. A limited user-manager, or that user's Local API key, can two-step scope=system then uid=0. auth sync user runs pw useradd -o -u 0. Home /root. Group wheel. Keys in /root/.ssh/authorized_keys.

The day after this pin, stable/26.7 reverted the UID-0 write as ea4f01fe. No GHSA. The rename check is still order-dependent on 26.7.6.

Why OPNsense

I wanted a large FreeBSD firewall with a PHP WebGUI that people treat as "the admin already has root." That is true for page-all. It is not true for a delegated page ACL. WebGUI sits on LAN by default. WAN GUI is off unless enabled. API keys inherit the owning user's pages. configd runs backend actions as root.

I only wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. Stored XSS stays on the floor unless it chains to root. Plugins (opnsense/plugins) and the FreeBSD tree (opnsense/src) are out. Business Edition is a different tree.

Pinning 26.7.6, not master, not 27.1.a, not EOL 26.1

Public GitHub tag 26.7.6. Annotated tag SHA 216a9748e16d54a4256231ca46f136e78dd49de1 is not a commit. It peels to a842fe48c21288d11a422cb944678e8eb308becf (2026-10-08). I did not pin master. I did not pin 27.1.a ALPHA. I did not pin 26.1 "Witty Woodpecker" (ended 15 Jul 2026).

git describe --tags is 26.7.6.

The skip list is half the hunt

The 2026 public wave is closed on this peel. mwexec($cmd) is gone. CSRF GET mutators from GHSA-pp58 are POST-gated. LDAP usernames are ldap_escaped. OpenVPN CN path/XSS-to-root is htmlSafe / StrictTextField. GeoIP write is rooted.

I did not re-file:

  • CVE-2026-44194 / GHSA-f59w email-as-shell in sync_user.php. mwexecf %s. UsernameField quoted-email / backtick local-part fails the email regex.
  • CVE-2026-44193 XMLRPC restore. Different surface.
  • GHSA-p9pr comma privs. Public High.
  • GHSA-vw8q user-config-readonly. save() still throwReadOnly().
  • GHSA-x3q7 empty-password Save. 26.7.5.
  • CSV upload. Strips uid/scope.
  • Password page. Session-bound.
  • page-all choosing a UID for a system-scope user. Changelog feature on this pin.

Twenty-eight hunts. Auth/lockout, ACL/CSRF/readonly, mwexec/configd, captive portal, API keys, XMLRPC HA, firmware, config XPath, UidField/users, OpenVPN, IPsec/Trust, DHCP/Kea, Unbound/Dnsmasq, Suricata, legacy src/www/, NTP GPS/PPS, diagnostics/pcap, backup/restore, GeoIP/aliases, templates, WireGuard/cron/Monit, LDAP, wizard/HA, script wrappers, lighttpd/crash/syslog, XSS-to-root, ZFS/tunables/configd socket, MVC validation.

Default compiled core unpublished unauth RCE: none. Seven High leftovers. This is G1.

How UidField actually assigns 0

User.xml scope is a bare TextField default user. UidField::setValue auto-assigns 2000+ only while scope is not system. For system scope it parent::setValue($value) with MinMax 0..65535. No UniqueConstraint against config or /etc/passwd.

setNodes walks model XML order: uid, then name, then disabled, then scope. A single add cannot stamp 0. uid is applied while scope is still user. The auto-2000+ value is already isSet().

Two-step works:

  1. POST /api/auth/user/add with user[scope]=system (name/password). Add assigns 2000+. Scope is now system.
  2. POST /api/auth/user/set/<uuid> with user[uid]=0.
  3. setAction always configdpRun('auth sync user', [$name]) -> local_user_set -> pw useradd -o -u 0 home /root group wheel shell opnsense-shell.

Authorized keys land in /root/.ssh/authorized_keys.

Rename leftover of the same field: User.php blocks rename only while scope == system after setNodes. One POST user[name]=hax plus user[scope]=user skips it.

Wrong turns, in order

I first treated the changelog line as intended for anyone with user-manager. Then I treated type=info as a server-side reject. It is not. Then I tried a single add with scope=system and uid=0 in one POST. setNodes applies uid first. The auto-assign fires. Then I confused this with 44194. Email-as-shell is closed. This is UID reuse. Then I looked at CSV upload. It strips uid and scope. Then I wondered whether page-all was required. It is not. UserController never throws for full admin.

ea4f01fe showed up the day after the pin. I almost dropped the leftover as already fixed. It is not this pin. No GHSA. The rename order bug is still here.

The lab

PHP 8.3 gadget of the pinned UidField::setValue plus local_user_set. PATH stub pw so the oracle is argv plus /root/.ssh/authorized_keys, not host pw. Loopback identity 127.0.0.1:18810. Compose project opnsense-uidfield-system-uid0.

CONTROL: same POSTs with scope=user uid=0. uid stays 2000. TRIGGER: two-step scope=system then uid=0. after-add=2000 after-set=0 pw-o-u0=yes. Witness in /root/.ssh/authorized_keys.

Last run:

Plain text
SUCCESS opnsense-uidfield-system-uid0 two-step-uid=0 scope-user-uid0-ignored=yes pw-o-u0=yes keys=/root/.ssh/authorized_keys OPNSENSE-UIDFIELD-SYSTEM-UID0-WITNESS

Stack tears down on EXIT. Gadget, not a full OPNsense FreeBSD VM. Live sshd accept and stock-root usermod/rename stay inferred.

What an attacker can do

Method Sink Privilege
two-step add scope=system then set uid=0 then auth sync user pin UidField::setValue then stub pw useradd -o -u 0 (auth.inc:345-397) Integrity (lab-proven): second Unix UID 0, home /root, group wheel. Lab: after-add=2000 after-set=0 pw-o-u0=yes.
same set + authorizedkeys file_put_contents /root/.ssh/authorized_keys (auth.inc:407-416) Integrity (lab-proven): witness in /root/.ssh/authorized_keys.
SSH as that name if sshd is on (AllowGroups wheel; PermitRootLogin does not apply to a non-root username) sshd Recon + integrity (inferred): login as the new wheel name, read /root. Gadget did not start sshd.
same POSTs with scope=user uid=0 UidField auto-assign 2000+ Negative (lab-proven): uid stays 2000. scope-user-uid0-ignored=yes.
set stock root uuid uid=80 pw usermod -u 80 Integrity + availability (inferred): move stock root off UID 0. Gadget did not usermod root.
set root uuid name=hax + scope=user rename check skipped; auth sync user on the new name Integrity (inferred): system user rename the 26.7.6 check was meant to stop.

Requires: WebGUI session or Local API key with page-system-usermanager. CSRF on the session path. Not page-all. Default config.xml.sample only grants page-all to group admins.

Adjacent surface that stays closed

POST /api/auth/user/* without page-system-usermanager. user-config-readonly. CSV upload (strips uid/scope). CVE-2026-44194 email-as-shell. Password page is session-bound. page-all doing the same POST (changelog feature on this pin). Unauth. Unix su with * in master.passwd (GUI hash stays in config.xml unless installer force).

The fix

Reject UID 0 and colliding service UIDs unless the caller is a full admin. Apply scope before uid in setNodes, or refuse a POST that flips both. Keep the 26.7.6 rename check on the pre-setNodes scope, not the posted one. ea4f01fe later reverted the UID-0 write on stable/26.7. That commit is not this pin.

Replay:

Plain text
cd lab
./run.sh

Loopback 127.0.0.1:18810. Witness OPNSENSE-UIDFIELD-SYSTEM-UID0-WITNESS.

References

Abraxas Labs https://abraxaslabs.tech https://github.com/abraxas https://x.com/abraxas_null (@abraxas_null) abraxas.null@proton.me

OPNsense 26.7.6 UidField two-step uid 0 · Abraxas Labs