白帽故事 · 2025年8月13日

群晖 DiskStation 空字节写入漏洞

2024年10月,研究人员参加了Pwn2Own Ireland 2024 (Pwn2Own爱尔兰2024) 大赛,并成功攻击了群晖(Synology)DiskStation DS1823xs+,实现了以Root权限远程执行代码。 此漏洞已被修复,并分配了CVE-2024-10442 漏洞编号。

DiskStation 是群晖公司旗下流行的网络存储(NAS)产品系列, 过去,DiskStation 在 Pwn2Own 大赛中曾多次被成功攻击,但在前一年的赛事(Pwn2Own Toronto 2023)中未被触及。 而在Pwn2Own Ireland 2024 (Pwn2Own爱尔兰2024) 中,研究人员观察到三次成功的攻击,每次都利用了独特的漏洞。

本文将详细介绍研究人员在群晖 DiskStation 上进行研究的经验,以及为本次大赛编写漏洞利用程序的过程。

审查群晖套件

如前所述,过去一两年 Pwn2Own 大会中没有针对群晖 DiskStation 的攻击, 2024年,ZDI 决定将群晖开发的一些非默认但官方的第一方套件纳入竞赛范围:

对于群晖 DiskStation 目标,以下套件被安装并纳入竞赛范围:

  • MailPlus
  • Drive
  • Virtual Machine Manager (虚拟机管理器)
  • Snapshot Replication (快照复制)
  • Surveillance Station (监控站)
  • Photos (相册)

“套件”是可选的附加应用程序/服务,可以通过DiskStation Manager中的群晖套件中心轻松安装在设备上。

对研究人员而言,这增加了攻击面, 由于这些套件首次纳入攻击范围,研究人员认为这是寻找相对简单漏洞的好机会,因为这些套件可能尚未经过严格的安全审查,事实证明确实如此。

研究人员审查的第一个套件是 Virtual Machine Manager (虚拟机管理器) ,这是直接通过物理 DiskStation 上内置的套件中心安装的。

file

然后,研究人员可以通过测试设备上的 SSH Shell 使用 netstat 枚举所有网络监听,这显示了一些仅限于本地主机的服务,除了一个绑定到所有接口的单个服务,该服务以 root 权限运行以下命令:

/var/packages/ReplicationService/target/sbin/synobtrfsreplicad --port 5566

这个监听器实际上是 Replication Service (复制服务) 的一部分,这是一个独立的套件,是 Virtual Machine Manager (虚拟机管理器) 的一个依赖项(也是Snapshot Replication (快照复制) 的依赖项)。 鉴于其高权限级别和与该服务通信的便捷性,研究人员对此产生了浓厚兴趣。

file

下一步是检查二进制文件,由于研究人员已在真实设备上安装了该服务,因此能够通过 SSH 拉取文件。

此外,群晖官方也直接提供 DSM(核心操作系统)和套件的软件下载, 然后可以使用 提取工具 来解析自定义的群晖归档文件,并导出套件、固件映像或更新的内容。 需要注意的是,这个特定工具是原生群晖第一方共享库的 FFI (外部函数接口) 封装器,这些共享库可以从真实设备中提取,或使用 另一个工具 从 DSM 归档文件中提取。

发现漏洞

有了相关的二进制文件,研究人员可以开始查看监听端口 5566 的 TCP 服务的代码, 主二进制文件 synobtrfsreplicad 只是一个驱动程序,用于调用 libsynobtrfsreplicacore.so.7 中的功能,该功能启用了 TCP 监听器。

该服务是一个最小化的、基于 Linux 的 Forking Server,主进程不断调用 accept() 并派生 (fork) 一个子进程来处理每个新的远程客户端, 反过来,子进程运行一个基本的命令循环来解析发送到服务的传入消息。

每个命令都有一个简单的二进制格式,包含一个操作码,可选地后跟一个可变大小的数据payload:

unsigned cmd // 命令操作码

unsigned seq // 序列号

unsigned len

char datalenlenlen

定义了两个全局变量来简化这些命令消息的解析。 一个用于命令本身,另一个是一个环形缓冲区 (ring-buffer) 结构,用于存储多达 3 个可变大小的命令Payload。

struct

{

    unsigned char sector; // ring buffer index

    char bufs[3][65536]; // ring buffer of 3 payloads

    unsigned buf_lens[3]; // populated lengths of 3 payloads

} g_recvbuf;

struct

{

    ReplicaCmdHeader header; // opcode, seq, len

    char *data; // will point into one of the 3 g_recvbuf bufs

} g_cmd;

用于读取消息的命令循环大致如下所示:

void runCmdLoop() {

    while(1) {

        g_cmd.data = g_recvbuf.bufs[g_recvbuf.sector];

        int err = recvCmd(&g_cmd);

        if (err)

            bail;

        g_cmd.data[g_cmd.header.len] = 0;

        // ... handle cmd ...

    }

}

// function to read both the header and payload of a message

int recvCmd(ReplicaCmd* cmd) {

    int err = raw_tcp_recv(cmd->header, 12);

    if (err)

        return err;

    if (cmd->header.len > 0x10000)

        return err;

    // read actual payload data

    err = raw_tcp_recv(cmd->data, cmd->header.len);

    // ...

}

如果攻击者提供的长度过大,recvCmd 会在未读取任何payload的情况下退出。 然而,它的返回值为零,表示没有错误,考虑到头部长度无效,这有点奇怪…… 在调用者那里,它没有意识到任何错误,事情照常进行,命令Payload以空字符终止,使用任意大的头部长度。

这个Bug非常简单,研究人员最初的POC 可以使用 netcat 发送一个仅由大写字母 A(至少12个)组成的消息,这是一种经典的攻击方式:

file

除非通过 gdb 连接到该服务,否则设备上没有任何迹象表明出现了问题。 错误似乎没有记录到 syslog 或任何其他 DSM 日志设施中,并且由于 Forking Server 的特性,功能没有立即丢失。

该漏洞提供的原语 (primitive) 将允许研究人员对共享库的 BSS (数据段) 中的任意偏移量进行重复的空字节写入,这像极了 CTF PWN 风格, 尽管该漏洞相对简单,但其利用过程会更有趣一些。

无论如何,由于所有缓解措施均已启用,研究人员首先必须设法将其转化为信息泄露。

Forking Server (派生服务器)

在继续之前,回想一下研究人员正在处理 Forking Server,这对于突破 ASLR (地址空间布局随机化) 非常有用。

每个被派生 (fork) 的子进程都将与父进程具有完全相同的地址空间,并且崩溃它们不会产生任何后果:研究人员只需重新连接到服务并获得一个干净的状态,即一个新的子进程。 这有点像时间循环,每次连接都是以累积方式获取有关地址空间新信息的机会。

从较高层面来看,每次迭代都具有以下结构:

  1. 猜测某个值(例如地址)
  2. 让二进制文件使用猜测的值,使其在正确或不正确时表现不同(例如,错误的地址会导致崩溃)
  3. 观察二进制文件的行为,以确定该值是否正确
  4. 如果正确,则找到了正确的值,否则,继续下一次猜测

研究人员将看到这一切如何应用于这个特定的二进制文件。

功能概述

由于研究人员发现的bug发生在输入解析阶段,因此尚未探索程序的太多功能,而这些功能将在后续构建漏洞利用时用到。

从网络读取命令后,命令循环会根据提供的操作码进行 switch-case (多分支判断), 需要输入的操作码会从可变长度的命令Payload中解析输入,研究人员查看了所有可用的操作码,以大致了解它们的功能:

  • CMD_DSM_VER : 无输入
    • 返回 DSM 版本号
  • CMD_SSL : 为连接初始化 SSL
  • CMD_TEST_CONNECT
  • CMD_NOP
  • CMD_VERSION : 整型输入
    • 设置连接的“version (版本)”以区分兼容性
  • CMD_TOKEN : 字符串“token”输入,该字符串必须作为磁盘上 JSON 文件中的一个键存在
    • 执行初始化并设置全局 std::string g_token
  • CMD_NAME : 字符串“name”输入
    • 可能执行与 btrfs 相关的操作,和/或使用 g_token 修改 JSON 文件
  • CMD_SEND : 原始数据输入
    • 将输入代理到一个文件描述符,该文件描述符似乎在其他地方被设置为 btrfs 命令的管道
  • CMD_UPDATE
  • CMD_STOP : token 字符串输入
    • 从 JSON 中删除 token
  • CMD_COUNT
  • CMD_CLR_BKP
  • CMD_SYNCSIZE
  • CMD_END

很快,研究人员发现许多代码路径都依赖于提供一个有效的“token (令牌) ”,该令牌应该已经存在于磁盘上的 JSON 文件 /usr/syno/etc/synobtrfsreplica/btrfs_snap_replica_recv_token 中。 该 JSON 文件被用作一个简单的键值存储属性,其中令牌是键:

{
    "<token>": {"<attribute>":value, ... other attributes ...},
    ... other tokens ...
}

据推测,某些外部服务会分发这些令牌并将它们写入文件,但这种写入操作在哪里发生,研究人员尚不清楚。

然而,存在一条代码路径允许向 JSON 文件添加令牌,这可能以一种非预期的方式,CMD_NAME 操作码使用当前的 g_token,并向文件写入一个属性,其中有两个重要的微妙之处:

  • 它不检查 g_token 是否被初始化 (即是否通过 CMD_TOKEN 初始化)
  • 如果令牌尚未作为 JSON 对象中的键存在,设置属性会将其添加

通常情况下,未初始化的 g_token 将只是一个空字符串,但考虑到内存损坏的情况,一切皆有可能,研究人员将在稍后看到这一点如何证明是有用的。

ASLR Oracle #1: 释放一个伪造的堆块

研究人员的原语 (primitive) 是一个空字节写入,可以向命令Payload缓冲区中的任意偏移量写入, 偏移量是无符号的,因此只能将空字节写入Payload缓冲区后续的内存区域。

这就引出了一个问题:Payload 缓冲区后面是什么? 它将是共享库 BSS 中 g_recvbuf 全局变量中的三个 0x10000 大小的缓冲区之一, 除了少数几个 std::string 实例外,没有太多全局变量,它们的结构如下:

struct std::string {

char* ptr; // 对于短字符串,指向 inline_buffer

unsigned long length;

char inline_buffer161616;

}

默认构造函数将长度设置为0,并将 char* 指向内联缓冲区,换句话说,研究人员将在 BSS 中拥有许多 std::string 实例,它们的指针设置为自己的 BSS 地址,再加上偏移量16。

现在,考虑如果使用空字节写入将其中一个指针的两个最低字节清零,在它之前的Payload缓冲区是0x10000字节,这足够大,可以保证部分置空的 BSS 指针指向该缓冲区内的某个位置,尽管还无法得知确切的偏移量。

file

由于 ASLR 具有页面粒度 (12位) ,因此此偏移量中将有 4 位 (一个半字节) 的熵 (即它可以是 0、0x1000、0x2000 …… 0xf000)。

研究人员可以破坏的全局字符串之一是 _gSnapRecvPath,它可以作为 CMD_NAME 命令执行的操作之一重新分配。

当重新分配 std::string 时,如果 char* 没有指向内联缓冲区,那么在分配新值之前,旧的 (现在已损坏的) 值将被调用 delete这允许研究人员对Payload缓冲区内的伪造块调用 free 函数。 研究人员可以利用命令Payload自然地控制此缓冲区的内容。

当调用 free 时,如果伪造块的大小足够小,它将被放入 glibc Tcache, 另外,如果大小无效 (例如为零),free 将调用 abort,从而导致进程崩溃。

这构成了研究人员的第一个 Oracle (预言机) ,可以将其与 Forcking Server 行为结合起来,以确定伪造块位于 16 个可能偏移量 (0、0x1000 …… 0xf000) 中的哪一个。

对于 16 个可能的偏移量:

  1. 用填充物填充Payload 缓冲区,直到猜测的偏移量,然后伪造块的元数据 (这只是一个伪造的大小值)
  2. 两次触发Bug,将 _gSnapRecvPathchar* 的低两字节置空
  3. 使用 CMD_NAME 释放已损坏的 char*,它可能指向伪造块,也可能不指向位于猜测偏移量的伪造块
    • 如果套接字保持连接并发送响应,则猜测的偏移量是正确的
    • 如果套接字关闭 (即调用了 abort) 则猜测不正确;使用下一个偏移量重试

研究人员已经解决了 ASLR 的一个小节熵,并能够可靠地释放Payload缓冲区中的伪造块,该块将被放置到 Tcache 中。

ASLR Oracle #2: 令牌泄漏

Tcache 是一个空闲块的单向链表,每个空闲块都有一个 next 指针。 由于 glibc 中的一些强化尝试,next 指针的填充方式如下:

chunk->next = (&chunk->next >> 12) ^ next

在研究人员的情况中,Tcache 列表之前是空的 (next = 0),因此写入的值将是 &chunk->next >> 12。 换句话说,研究人员已将一个移位的 BSS 指针放入Payload缓冲区。 研究人员现在需要找出某种方法来泄漏这个值。

一旦伪造块被释放并且移位的 BSS 指针被写入,研究人员将把第二个全局 std::stringg_tokenchar* 的低2字节置空。 这种损坏将使 g_token 指向与 _gSnapRecvPath 完全相同的位置。 也就是说,指向经过移位的 BSS 指针处。

回想一下之前对 CMD_NAME 的功能讨论,它可以将一个未初始化的 g_token 添加到磁盘上的 JSON 文件中。 这一点在这里证明很有用,因为原来“未初始化”的 g_token 包含一个空字符串,现在它指向移位的 BSS 指针,触发此代码路径后,JSON 文件现在包含了研究人员想要泄露的值。

另请注意,在将 g_token 写入磁盘之前,研究人员可以额外触发一次空字节写入以截断移位的 BSS 指针,通过这种方式,可以写入指针的每个段,例如,如果移位的指针是 0x766554433,则可以写入从 333344 …… 到完整的 3344556607 的每个段。

file

一旦 JSON 文件包含泄露的信息,研究人员就可以按预期使用 CMD_TOKEN,它需要一个字符串参数来指示要使用的令牌,这个令牌将在 JSON 文件中进行查找,并根据是否找到而返回不同的错误代码。

这创建了研究人员的第二个 Oracle (预言机) ,可以用来实现逐字节的暴力破解:

  • 对于移位的 BSS 指针的 5 个字节中的每个字节,循环 b 从 0 到 4:
    1. 将指针截断为长度 b+1,然后将截断的片段写入 JSON 文件
    2. 循环遍历可能的字节 0 - 0xff
      • 发送 CMD_TOKEN 和猜测的字节(前面加上从先前迭代中已知的长度为 b 的字节)
      • 返回的错误代码将指示提供的字节是否正确
      • 如果正确,则找到了移位指针索引 b 处的字节
      • 否则,继续尝试下一个可能的字节

一旦这个逐字节的暴力破解完成,研究人员将泄露移位的 BSS 指针,这将提供共享库的基地址, 由于 mmap 映射在虚拟内存中是连续的,这也为研究人员提供了所有共享库(尤其是 libc)的地址。

劫持控制流

有了泄露的信息,研究人员准备好制作最终的Payload来劫持控制流。

目前已经能够释放Payload缓冲区中的伪造块,并且通过发送额外的命令,那么就可以任意地破坏这个空闲块,此时,研究人员可以标准地滥用 tcache 链表:

  1. 用任意地址破坏伪造块的 next 指针
  2. 分配与伪造块相同大小的东西
    • malloc 将返回伪造块,然后将 tcache 列表的新头部设置为任意地址
  3. 再次分配相同大小以使 malloc 返回任意地址

研究人员只需找到一些符合两次连续分配模式的代码。 幸运的是,CMD_TOKEN 处理程序恰好符合这种模式,并且在执行两次分配后,一个临时包含输入参数的 std::string 被销毁,从而对带有研究人员输入的 char* 调用 delete

这让研究人员制定了以下策略:

  1. 破坏伪造的 Tcache 块的 next 指针,使其指向共享库中 delete 的 GOT (全局偏移表) entry附近
  2. 发送 CMD_TOKEN 命令
  3. 处理程序将从已损坏的 Tcache 中分配两次,用 system 覆盖 delete 的 GOT entry
  4. 随后的析构函数调用 delete,但实际上调用的是带有受控输入字符串的 system

至此,攻击结束。 研究人员可以简单地执行 /bin/sh,并将标准输入输出重定向到已连接的客户端套接字(避免了回连的需要)。

完整漏洞利用代码在 此处 提供。

修复措施

该漏洞被分配了 CVE-2024-10442 编号,群晖相对迅速地于 2024 年 11 月 5 日发布了补丁,你可以在 此处 找到该公告。

ZDI 的公告可在 此处 找到。 补丁修改了 recvCmd 函数,使其在提供的头部长度过大时返回错误而不是零。

if (cmd->header.len > 0x10000)

    return 1; // instead of previous return 0

然后,调用者会检测到此错误并退出,而不是继续处理无效命令。

结论

尽管这个漏洞很容易被发现,但其利用过程却很有趣,因为空字节写入是一种相对较弱的原语 (primitive) 。 这感觉就像是在 CTF 挑战中会发现的那种漏洞,并且 Tcache 操作和暴力破解预言机也很符合 CTF 的氛围。

更严肃地说,尽管它位于非默认套件中,但一个远程可访问的服务(以 Root 权限运行)中存在如此简单的漏洞仍令人担忧,特别考虑到群晖是一款相当受欢迎的消费者和商业导向的 NAS 设备,而且这些设备暴露在互联网上并不少见。

原文:https://blog.ret2.io/2025/04/23/pwn2own-soho-2024-diskstation/