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()stillthrowReadOnly(). - GHSA-x3q7 empty-password Save. 26.7.5.
- CSV upload. Strips uid/scope.
- Password page. Session-bound.
page-allchoosing 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:
POST /api/auth/user/addwithuser[scope]=system(name/password). Add assigns 2000+. Scope is nowsystem.POST /api/auth/user/set/<uuid>withuser[uid]=0.setActionalwaysconfigdpRun('auth sync user', [$name])->local_user_set->pw useradd -o -u 0home/rootgroupwheelshellopnsense-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:
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-WITNESSStack 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:
cd lab
./run.shLoopback 127.0.0.1:18810. Witness OPNSENSE-UIDFIELD-SYSTEM-UID0-WITNESS.
References
- Pack: github.com/abraxas/opnsense-uidfield-system-uid0
- Product: github.com/opnsense/core
a842fe48c21288d11a422cb944678e8eb308becf UidField.phpauth.inclocal_user_set- Named page (not this leftover): CVE-2026-44194 / GHSA-f59w
- Later revert (not this pin):
ea4f01fe - CWE-269 / CWE-284
Abraxas Labs
https://abraxaslabs.tech
https://github.com/abraxas
https://x.com/abraxas_null (@abraxas_null)
abraxas.null@proton.me