PoC Archive PoC Archive
Critical CVE-2026-34908, CVE-2026-34909, CVE-2026-34910 patched

Ubiquiti UniFi OS Unauthenticated RCE Chain (CVE-2026-34908 / CVE-2026-34909 / CVE-2026-34910)

by Bishop Fox · 2026-06-28

Metadata

FieldValue
Date Added2026-06-28
Last Updated2026-06-05
Author / ResearcherBishop Fox
CVE / AdvisoryCVE-2026-34908, CVE-2026-34909, CVE-2026-34910
Categorynetwork
SeverityCritical
CVSS Score10.0 (CVSSv3)
StatusPoC
Tagsunauth-rce, nginx-bypass, path-traversal, command-injection, CISA-KEV, Mirai, Gaafgyt, chain, UniFi, Ubiquiti, network
RelatedN/A

Affected Target

FieldValue
Software / SystemUbiquiti UniFi OS Server
Versions AffectedUniFi OS Server ≤ 5.0.6
Language / PlatformPython (checker); Linux (target)
Authentication RequiredNo (unauthenticated)
Network Access RequiredYes (TCP 11443 / HTTPS)

Summary

A three-CVE unauthenticated RCE chain in Ubiquiti UniFi OS Server ≤ 5.0.6 allows a remote attacker to achieve root-level command execution with no credentials. CVE-2026-34908 and CVE-2026-34909 (improper access control + path traversal) are chained to bypass nginx authentication, and CVE-2026-34910 (command injection via improper input validation) delivers code execution. All three CVEs carry CVSS 10.0. The chain is actively weaponized by Mirai/Gaafgyt botnets and was added to CISA KEV on June 23, 2026.


Vulnerability Details

Root Cause

UniFi OS fronts its services with nginx, which enforces authentication via a subrequest. The auth check treats any request whose raw URI begins with /api/auth/validate-sso/ as public, but nginx routes to the backend using the normalized URI (percent-decoded, ../ collapsed). A request like:

GET /api/auth/validate-sso/..%2f..%2f..%2fproxy/users/api/v2/ucs/update/latest_package

is treated as public (raw prefix matches) yet routed to an authenticated internal endpoint (normalized path). CVE-2026-34909 (path traversal) provides access to arbitrary OS files, and CVE-2026-34910 injects commands via the pkg_name parameter of the package-update handler reached through the bypass.

Attack Vector

  1. Send crafted GET request with percent-encoded path traversal to bypass nginx auth (CVE-2026-34908/34909).
  2. Reach the authenticated package-update endpoint unauthenticated.
  3. Inject OS commands via unsanitized pkg_name parameter (CVE-2026-34910) → root shell.

Impact

Unauthenticated remote code execution as root on UniFi OS Server devices. Actively exploited by Mirai/Gaafgyt azsxd v2.0 botnet; rogue admin accounts (username “John Sim”) observed in automated attacks.


Environment / Lab Setup

Target:   Ubiquiti UniFi OS Server ≤ 5.0.6, port 11443
Attacker: Python 3.7+ (standard library only)

Proof of Concept

Detection Script

See cve_2026_34908_check.py in this folder. Safe detector — no commands executed, no state changed.

1
2
3
python3 cve_2026_34908_check.py 192.168.1.10

python3 cve_2026_34908_check.py -f targets.txt --json > results.json

Expected Output (vulnerable host)

[!] 192.168.1.10:11443: VULNERABLE
      auth bypass reached the vulnerable handler (no command executed); baseline correctly 401

Expected Output (patched 5.0.8+)

[+] 192.168.1.11:11443: PATCHED
      nginx rejected the normalized-path divergence (HTTP 400) — 5.0.8+ behavior

Detection & Indicators of Compromise

1
2
3
4
5
grep -r "John Sim" /var/log/

grep "ucs/update/latest_package" /var/log/nginx/access.log

netstat -anp | grep ESTABLISHED

YARA (botnet implant signature):

rule Mirai_azsxd_v2 {
  strings:
    $s1 = "azsxd" ascii
    $s2 = "John Sim" wide ascii
    $s3 = "latest_package" ascii
  condition:
    2 of them
}

Remediation

ActionDetail
PatchUpgrade to UniFi OS Server 5.0.8 or later
Fix scopenginx raw-vs-normalized URI comparison (closes auth bypass); input validation in package-update path (closes command injection)
VerifyCheck running version in UniFi OS UI → Settings → System → Updates

References


Notes

Auto-ingested from https://github.com/BishopFox/CVE-2026-34908-check on 2026-06-28. Three CVEs form one chain — ingested as a single entry. Botnet exploitation (Mirai/Gaafgyt azsxd v2.0) confirmed ITW; CISA KEV added June 23, 2026. The included PoC is the Bishop Fox detection-only checker; the botnet weaponization has no clean public repo.

cve_2026_34908_check.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# cve_2026_34908_check.py
#
# Safe detector for the UniFi OS Server <= 5.0.6 unauthenticated RCE chain
# (Ubiquiti Security Advisory Bulletin 064):
#
#   * CVE-2026-34908 / CVE-2026-34909 — auth-gateway bypass (improper access
#     control / path traversal). The auth subrequest treats any request whose
#     RAW URI starts with "/api/auth/validate-sso/" as public, but nginx routes
#     by the NORMALIZED URI (it decodes %2f -> "/" and collapses "../"). Encoding
#     a traversal makes those diverge, granting unauthenticated access to internal
#     "/proxy/<service>/" backends.
#   * CVE-2026-34910 — unauthenticated command injection reached via the bypass.
#     CVE-2026-33000 is the same sink reached with a valid token instead of bypass.
#
# Fixed in UniFi OS Server 5.0.8.
#
# Produced by Bishop Fox Team X and released for defensive use. Use only against
# systems you are authorized to test.

"""
Detection methodology — non-destructive behavioral probing (no command is ever run).

Per target the detector sends up to three plain GETs: a baseline control, the
auth-bypass probe, and (only when the result is otherwise unclassifiable) a
root-page UniFi OS fingerprint. The core check exercises the auth-bypass primitive
against the vulnerable endpoint. On a vulnerable host, that request:
    - passes nginx auth (the RAW URI is treated as the public
      "/api/auth/validate-sso/" prefix), and
    - is routed to the internal package-update handler (the NORMALIZED path
      collapses onto "/proxy/users/api/v2/ucs/update/latest_package"),
which then rejects the missing parameter ("query param pkg_name required").
We therefore confirm both the auth bypass and that the vulnerable handler is
reachable without sending a command injection payload.

Response classification:
    HTTP 200 + handler error markers -> VULNERABLE   (bypass reached the sink handler)
    HTTP 400                          -> PATCHED      (5.0.8 nginx rejects the divergence)
    HTTP 401, UniFi OS fingerprint    -> INCONCLUSIVE (bypass blocked / auth enforced)
    HTTP 401, no fingerprint          -> UNAFFECTED   (not a UniFi OS Server)
    anything else, no fingerprint     -> UNAFFECTED   (not a UniFi OS Server)
    anything else, UniFi OS present   -> INCONCLUSIVE (UniFi OS, but unexpected response)

INCONCLUSIVE covers two confirmed-UniFi-OS cases that the verdict's detail string
distinguishes: (1) the bypass was blocked / auth enforced (HTTP 401) — not confirmed
vulnerable, verify the version manually; and (2) the probe returned an unexpected
response that fits no known pattern. Both warrant human review.

A baseline request to the same endpoint WITHOUT the bypass is also sent and must
return 401; this guards against false positives on open/misconfigured instances.
Whenever the probe does not positively confirm vulnerable or patched, the detector
fingerprints the root page (the same surface the nuclei UOS templates match) before
reporting a not-vulnerable verdict: only hosts that carry the UniFi OS portal markers
are reported INCONCLUSIVE; hosts without the markers are reported UNAFFECTED (not a
UniFi OS Server at all).

The probe tests the vulnerable behavior directly: a positive verdict means the auth
bypass actually reached the sink on the live target.

Exit status: 1 if any target is VULNERABLE, else 0 (argparse usage errors exit 2).
"""

import argparse
import http.client
import json
import os
import socket
import ssl
import sys
from urllib.parse import urlsplit

DEFAULT_PORT = 11443

# --- non-exploit probes ------------------------------------------------------
# Auth-bypass primitive. NOTE: contains NO `pkg_name`, NO `by_cmd`, and NO shell
# metacharacters -> the vulnerable handler errors on the missing parameter before
# any command string is constructed. Reaching it proves the bug without running it.
BYPASS_PROBE = (
    "/api/auth/validate-sso/..%2f..%2f..%2f"
    "proxy/users/api/v2/ucs/update/latest_package"
)

# The same endpoint WITHOUT the bypass. Must be 401 on a sane host (false-positive guard).
BASELINE = "/proxy/users/api/v2/ucs/update/latest_package"

# Markers that identify the vulnerable handler's own "missing parameter" response,
# i.e. proof we landed on the package-update handler (not the SPA or another route).
VULN_HANDLER_MARKERS = ("pkg_name", "CODE_SYSTEM_ERROR", "parameters are invalid")

# Root-page markers that positively identify the UniFi OS management portal. Used
# only to tell "not a UniFi OS Server at all" (UNAFFECTED) apart from "UniFi OS
# present but unclassifiable".
UOS_FINGERPRINT_MARKERS = (
    "<title>UniFi OS</title>",
    "window.UNIFI_OS_MANIFEST",
    'id="portal-root"',
    'id="site-manager_portal-container"',
)


# --- terminal colour ---------------------------------------------------------
class Ansi:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    DIM = "\033[2m"
    RED = "\033[31m"
    GREEN = "\033[32m"
    YELLOW = "\033[33m"
    BLUE = "\033[34m"
    MAGENTA = "\033[35m"


# verdict -> (marker, ansi-style)
VERDICT_STYLE = {
    "VULNERABLE": ("[!]", Ansi.BOLD + Ansi.RED),
    "PATCHED": ("[+]", Ansi.GREEN),
    "UNAFFECTED": ("[-]", Ansi.DIM),
    "INCONCLUSIVE": ("[?]", Ansi.YELLOW),
    "ERROR": ("[x]", Ansi.MAGENTA),
}


def want_color(flag_no_color: bool) -> bool:
    """Honour --no-color, the NO_COLOR convention, and non-TTY output."""
    if flag_no_color or os.environ.get("NO_COLOR"):
        return False
    return sys.stdout.isatty()


def paint(text: str, style: str, enabled: bool) -> str:
    return f"{style}{text}{Ansi.RESET}" if enabled else text


# --- HTTP --------------------------------------------------------------------
def fetch(host, port, target, timeout, max_bytes=4096):
    """Issue one GET with an UN-normalized request-target.

    `http.client` sends the path verbatim, which is required so the literal
    "..%2f" survives to the server (libraries like `requests` would re-normalize
    or re-encode it). TLS verification is disabled because these appliances ship
    self-signed certificates. Returns (status, content_type, body_text).
    """
    ctx = ssl._create_unverified_context()
    conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    try:
        conn.request(
            "GET",
            target,
            headers={
                "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
            },
        )
        resp = conn.getresponse()
        body = resp.read(max_bytes).decode("utf-8", "replace")
        return resp.status, (resp.getheader("Content-Type") or ""), body
    finally:
        conn.close()


def is_unifi_os(host, port, timeout):
    """Confirm the host serves the UniFi OS management portal via the root-page
    fingerprint (same surface the nuclei UOS templates match). Used only to make
    the 'not a UniFi OS Server' verdict conclusive rather than merely inconclusive.
    Reads more of the body than the probe path because the manifest/container
    markers sit further down the document than the <title>.
    """
    try:
        _, _, body = fetch(host, port, "/", timeout, max_bytes=65536)
    except Exception:
        return False
    return any(m in body for m in UOS_FINGERPRINT_MARKERS)


def check_bypass(host, port, timeout):
    """Run the behavioral probe + baseline control. Returns {state, detail}."""
    base_status = None
    try:
        base_status, _, _ = fetch(host, port, BASELINE, timeout)
    except Exception:
        pass  # baseline is only a corroborating control; tolerate its failure

    status, ctype, body = fetch(host, port, BYPASS_PROBE, timeout)
    reached_handler = any(m in body for m in VULN_HANDLER_MARKERS)

    if status == 200 and reached_handler:
        if base_status == 401:
            return {
                "state": "vulnerable",
                "detail": "auth bypass reached the vulnerable handler "
                "(no command executed); baseline correctly 401",
            }
        # Reached the handler but the control wasn't 401 -> still vulnerable, but
        # the host may also be open/misconfigured; flag it for human review.
        return {
            "state": "vulnerable",
            "detail": f"auth bypass reached the vulnerable handler; "
            f"baseline status={base_status} (expected 401) — review",
        }
    if status == 400:
        return {
            "state": "patched",
            "detail": "nginx rejected the normalized-path divergence "
            "(HTTP 400) — 5.0.8+ behavior",
        }
    if status == 401:
        # An auth layer rejected the bypass. On a confirmed UniFi OS box the bypass
        # was blocked but we can't tell patched-vs-mitigated-vs-other, so it's
        # inconclusive; a non-UniFi proxy that 401s everything is simply not affected.
        # Fingerprint the root page to tell the two apart.
        if is_unifi_os(host, port, timeout):
            return {
                "state": "inconclusive",
                "detail": "bypass blocked / auth enforced (HTTP 401) on a "
                "confirmed UniFi OS host — not confirmed vulnerable",
            }
        return {
            "state": "not_unifi",
            "detail": "HTTP 401 but no UniFi OS web fingerprint on / "
            "— target is not a UniFi OS Server",
        }
    # Ambiguous probe response (not the vuln handler, the 5.0.8 nginx guard, or the
    # auth gateway). Use the root-page fingerprint to decide whether this is even a
    # UniFi OS Server: absent -> conclusively not affected; present -> the second
    # inconclusive flavor (UOS, but an unexpected probe response, vs. the auth-enforced
    # 401 above) — leave it for human review. The detail string says which case it is.
    if not is_unifi_os(host, port, timeout):
        return {
            "state": "not_unifi",
            "detail": f"no UniFi OS web fingerprint on / "
            f"(probe HTTP {status}) — target is not a UniFi OS Server",
        }
    return {
        "state": "inconclusive",
        "detail": f"UniFi OS detected, but probe returned an "
        f"unexpected response: HTTP {status} {ctype}".strip(),
    }


def assess(host, port, timeout):
    """Probe one target and return the full result dict."""
    result = {"target": f"{host}:{port}"}
    try:
        result["bypass"] = check_bypass(host, port, timeout)
    except (socket.timeout, TimeoutError):
        return {**result, "verdict": "ERROR", "error": "timeout"}
    except (socket.gaierror, ConnectionError, OSError) as e:
        return {**result, "verdict": "ERROR", "error": str(e)}
    except Exception as e:
        # noqa: BLE001 - report anything unexpected, don't crash a scan
        return {**result, "verdict": "ERROR", "error": str(e)}

    result["verdict"] = {
        "vulnerable": "VULNERABLE",
        "patched": "PATCHED",
        "not_unifi": "UNAFFECTED",
        "inconclusive": "INCONCLUSIVE",
    }.get(result["bypass"]["state"], "INCONCLUSIVE")
    return result


# --- output ------------------------------------------------------------------
def print_human(r, color):
    verdict = r["verdict"]
    marker, style = VERDICT_STYLE.get(verdict, ("[?]", Ansi.YELLOW))
    print(
        f"{paint(marker, style, color)} {paint(r['target'], Ansi.BOLD, color)}: "
        f"{paint(verdict, style, color)}"
    )
    if "error" in r:
        print(f"      error: {r['error']}")
        return
    print(f"      {paint(r['bypass']['detail'], Ansi.DIM, color)}")


def print_brief(r, color):
    """One aligned line per target — convenient for scanning many hosts."""
    verdict = r["verdict"]
    _, style = VERDICT_STYLE.get(verdict, ("[?]", Ansi.YELLOW))
    status = paint(f"{verdict:<13}", style, color)
    note = r["error"] if "error" in r else ""
    note = f"  {paint(note, Ansi.DIM, color)}" if note else ""
    print(f"{status} {r['target']}{note}")


# --- CLI ---------------------------------------------------------------------
def parse_target(raw):
    if "://" in raw:
        u = urlsplit(raw)
        return u.hostname, (u.port or DEFAULT_PORT)
    if ":" in raw:
        host, _, port = raw.rpartition(":")
        return host, int(port)
    return raw, DEFAULT_PORT


def build_parser():
    p = argparse.ArgumentParser(
        prog="cve_2026_34908_check.py",
        description="Safe detector for UniFi OS Server <=5.0.6 "
        "unauthenticated RCE — SA Bulletin 064 "
        "(CVE-2026-34908/34909 + CVE-2026-34910).",
        epilog="examples:\n"
        "  cve_2026_34908_check.py 192.168.1.10\n"
        "  cve_2026_34908_check.py host-a:11443 https://host-b\n"
        "  cve_2026_34908_check.py -f targets.txt --brief\n"
        "  cve_2026_34908_check.py -f targets.txt --json > results.json\n",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("targets", nargs="*", help="host, host:port, or https://host:port")
    p.add_argument(
        "-f",
        "--targets-file",
        metavar="FILE",
        help="file with one target per line ('#' comments allowed)",
    )
    p.add_argument(
        "--timeout",
        type=float,
        default=10.0,
        metavar="SECS",
        help="per-request timeout in seconds (default: 10)",
    )
    p.add_argument(
        "--brief",
        action="store_true",
        help="single-line output per target (for scanning many hosts)",
    )
    p.add_argument("--json", action="store_true", help="emit JSON results")
    p.add_argument("--no-color", action="store_true", help="disable coloured output")
    return p


def main():
    args = build_parser().parse_args()

    targets = list(args.targets)
    if args.targets_file:
        try:
            with open(args.targets_file) as fh:
                targets += [
                    ln.strip()
                    for ln in fh
                    if ln.strip() and not ln.lstrip().startswith("#")
                ]
        except OSError as e:
            print(f"error: cannot read targets file: {e}", file=sys.stderr)
            return 2
    if not targets:
        build_parser().error("no targets given (positional or --targets-file)")

    color = want_color(args.no_color)
    results = []
    for raw in targets:
        host, port = parse_target(raw)
        r = assess(host, port, args.timeout)
        results.append(r)
        if args.json:
            continue
        print_brief(r, color) if args.brief else print_human(r, color)

    if args.json:
        print(json.dumps(results, indent=2))

    return 1 if any(r.get("verdict") == "VULNERABLE" for r in results) else 0


if __name__ == "__main__":
    sys.exit(main())