// RESEARCH_TRANSMISSION ID: UNIFI_OS_RCE

UniFi OS 未授权 RCE 链分析

UniFi OS三连CVSS10.0分析

BEGIN_PAYLOAD 20260828

背景信息

UniFi OS 服务器是 Ubiquiti 提供的 UniFi OS 软件版本。UniFi OS 是一款管理平台,负责协调 UniFi 系列应用程序的工作——包括网络管理、安全保护,以及身份识别和更新服务等功能。该服务器由一系列本地主机上的后端服务组成,这些服务通过 Nginx 前端进行连接。Nginx 负责终止 TLS 通信,验证请求内容,并将这些请求转发到相应的内部服务。正是这一代理功能至关重要:整个安全机制依赖于 Nginx 能够准确判断哪些请求是公开的,哪些需要身份验证。

2026 年 5 月 21 日,Ubiquiti 发布了安全公告 064(SAB-064),其中提到了 UniFi 操作系统设备系列存在的五个漏洞。对于未经认证的攻击者来说,最值得关注的三个漏洞分别是:认证网关中的访问控制缺陷和路径遍历漏洞(CVE-2026-34908 和 CVE-2026-34909,这两个漏洞的 CVSS 评分均为 10.0,属于严重级别;攻击向量分别为 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:Hpackage-update )。此外,还有 package-update 服务中的输入验证缺陷(命令注入漏洞,CVE-2026-34910),该漏洞的 CVSS 评分同样为 10.0,攻击向量与之前的两个漏洞相同。综合这三个漏洞,攻击者就可以实现未经认证的命令执行了。公告指出,访问控制缺陷的幕后主谋是 Duc Anh Nguyen(@heckintosh_),路径遍历漏洞的提出者是 Catchify Security 的 Abdulaziz Almadhi,而命令注入漏洞的提出者则是 John Carroll。

在这篇文章中,我们重点关注未经验证的数据链(CVE-2026-34908、CVE-2026-34909 以及 CVE-2026-34910)。因为攻击者可以在没有认证信息的情况下发起攻击的目标。

受影响UniFi OS硬件&软件版本

UniFi OS Server软件版本≤5.0.6(unifi-core内核5.0.126)存在完整攻击链;5.0.8(内核5.0.153)彻底封堵三条漏洞入口。
各硬件固定受影响基线:

  1. Cloud Gateway/Dream Machine全系列(Pro/SE/Beast):固件低于5.1.12高危
  2. UniFi Protect NVR/UNVR:固件低于5.1.10高危
  3. UniFi Access门禁主机、Talk语音网关:同5.1.12基线
  4. UniFi Express一体化设备:低于4.0.14高危
  5. UNAS网络存储、交换机控制器模块:同步受内核漏洞影响

UniFi OS底层架构:Nginx+unifi-core分层模型

UniFi OS采用反向代理+微服务分层架构,所有前端请求统一经过Nginx转发,身份校验逻辑独立抽离为unifi-core Node.js服务,内部拆分多业务Go子服务:网络管理、视频录像、门禁、系统包更新。

image-20260827235308279

环境搭建

通过如下链接:

https://www.ui.com/download/software/unifi-os-server

选择UniFi OS <=5.0.6以下的版本,我这里选择4.3.6版本,会有更新可能就到了patch的版本。

下载完之后,是一个可执行文件,添加执行权限,使用sudo权限运行。

这个UniFi OS是通过podman这个容器管理软件进行一个管理,需要先进行安装。

安装完之后启动

1
sudo ./2f3a-linux-x64-4.3.6-be3b4ae0-6bcd-435d-b893-e93da668b9d0.6-x64

启动成功之后,会在本地开放一个11443端口,来访问后台管理。

访问后台会进行一系列的初始化操作,当设置完之后,再次访问就如下界面:

image-20260827233225888

到这里之后就搭建完成了。

检测

使用Bishop Fox官方开源检测工具,仅发送无害探测包,不会执行注入命令,支持单目标、批量文件扫描、JSON导出报告。

https://github.com/BishopFox/CVE-2026-34908-check

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

可以看到有漏洞

image-20260827233519359

接下来对rce调用链进行分析

CVE漏洞概述

CVE ID 凭据要求 类型
CVE-2026-34908 授权缺陷(不当访问控制)
CVE-2026-34909 路径穿越
CVE-2026-34910 命令注入

CVE-2026-34908:认证访问控制判断错误

Nginx 负责处理所有请求,并通过向 unifi-core 节点服务发送子请求来实现身份验证。该服务负责判断一个请求是公开的还是需要身份验证。

/usr/share/unifi-core/app/service.js

1
2
3
4
5
6
7
8
9
10
11
12
13
async function authCheck(req, res) {
// 直接读取Nginx透传的原始未编码请求路径
const rawUri = req.headers["x-original-uri"];
// 仅匹配原始字符串前缀放行
if (rawUri.startsWith("/api/auth/validate-sso/")) {
res.writeHead(200);
return res.end();
}
// 其余路径强制鉴权
res.writeHead(401);
res.end();
}

此时构造这个特殊的URI:/api/auth/validate-sso/..%2f..%2f..%2fproxy/users/api/v2/ucs/update/latest_package

直接越过校验逻辑,之后Nginx标准化解码后消除../跳转,实际路由指向包更新私有接口/proxy/users/api/v2/ucs/update/latest_package

CVE-2026-34909:路径穿越与 URI 规范化差异

根据之前的URI解析差异绕过鉴权,后端文件读取接口未过滤../、URL编码穿越字符。

处理阶段 看到的值 结果
认证子请求 /api/auth/validate-sso/... 前缀 满足公开 SSO 前缀条件
路由匹配 规范化后的 /proxy/users/api/v2/ucs/update/latest_package 选择 users runnable
业务处理器 /api/v2/ucs/update/latest_package 开始检查 pkg_name

现在来验证一下:

1
curl -k -i -sS --path-as-is 'https://127.0.0.1:11443/api/auth/validate-sso/..%2f..%2f..%2fproxy/users/api/v2/ucs/update/latest_package'

image-20260828172946494

这个错误来自 latest_package 业务处理器,证明了:

  • 认证子请求没有阻断请求;
  • 编码点段被后续路由处理;
  • 请求已经落到 users 更新接口;
  • pkg_name 参数校验开始执行。

CVE-2026-34910:未认证命令注入

users 更新接口接受 pkg_nameby_cmd;当 by_cmd=true 且包名存在更新通道时,ULP Go 后端会查询 UOS 的 latest-versions

/usr/sbin/ulp-go-app

1
2
3
4
func handlePackageUpdate(pkgName string) {
cmd := fmt.Sprintf("apt-get install %s -y", pkgName)
exec.Command("sh", "-c", cmd).Run()
}

此时给pkgName注入命令就能以ucs-update运行账号自带免密sudo权限,可无密码调用dpkg、systemctl、useradd、chmod等系统工具,注入命令执行后一键切换Root,完整控制操作系统底层。

攻击链路

借用克朗网络安全实验室绘制的流程图

image-20260828173820453

漏洞利用

image-20260828174234139

验证完之后清理环境

1
sudo /usr/local/bin/uosserver-purge  

image-20260828174452147

Reference

https://bishopfox.com/blog/popping-root-on-unifi-os-server-unauthenticated-rce-chain-detection-analysis

https://blog.csdn.net/weixin_42376192/article/details/162356549

https://www.freebuf.com/articles/vuls/488100.html

https://www.ui.com/download/software/unifi-os-server

EOF CHECKSUM_OK

// EXTERNAL_CHANNEL

COMMENTS