利用空字节写入漏洞攻破Synology DiskStation

本文详细分析了Synology DiskStation DS1823xs+中CVE-2024-10442漏洞的发现和利用过程。通过空字节写入漏洞,攻击者能够实现远程代码执行并获得root权限。文章深入探讨了漏洞原理、ASLR绕过技术和控制流劫持方法。

利用空字节写入漏洞攻破Synology DiskStation

在Synology DS1823xs+ NAS上实现root权限的远程代码执行

发布日期:2025年4月23日

去年10月,我们参加了Pwn2Own Ireland 2024并成功利用了Synology DiskStation DS1823xs+,获得了root权限的远程代码执行能力。该问题已被修复并分配了CVE-2024-10442。

DiskStation是Synology推出的一系列流行的NAS(网络附加存储)产品。过去在Pwn2Own比赛中它曾被成功利用几次,不过在去年的Pwn2Own Toronto 2023比赛中未被触及。

而Ireland 2024见证了三个成功的参赛作品,都使用了独特的漏洞。

本文将详细介绍我们研究Synology DiskStation并为比赛编写漏洞利用程序的经历。

准备攻击Synology DiskStation,Pwn2Own Ireland 2024

审查Synology软件包

如前所述,过去一两年的Pwn2Own没有针对Synology DiskStation的参赛作品。2024年,ZDI决定将一些非默认但由Synology开发的第一方软件包纳入比赛范围:

对于Synology DiskStation目标,将安装以下软件包并纳入比赛范围:

  • MailPlus
  • Drive
  • Virtual Machine Manager
  • Snapshot Replication
  • Surveillance Station
  • Photos

“软件包"是可选的附加应用程序/服务等,可以通过DiskStation Manager中的Synology Package Center轻松安装在设备上。

对我们来说,这意味着更多的攻击面,而且由于这是这些软件包首次被纳入范围,我们认为有很好的机会找到一些相对较浅的漏洞,因为这些软件包可能没有经过太多的安全审查。事实证明这非常正确。

我们查看的第一个软件包是Virtual Machine Manager,我们直接从物理DiskStation上的内置Package Center安装了它。

服务发现

然后我们可以通过测试设备上的SSH shell使用netstat枚举任何新的网络监听器。这揭示了一些仅限本地主机的服务,除了一个绑定到所有接口的服务,运行以下命令(以root身份):

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

这个监听器实际上是Replication Service的一部分,这是一个独立的软件包,是Virtual Machine Manager的依赖项(也是Snapshot Replication的依赖项)。考虑到高权限级别和与此服务通信的便利性,我们对此产生了兴趣。

分析二进制文件

下一步是检查二进制文件。由于我们在真实设备上安装了该服务,我们能够通过SSH提取文件。

或者,可以直接从Synology下载DSM(核心操作系统)和软件包的软件下载。然后可以使用提取工具解析自定义的Synology存档并输出软件包、固件映像或更新的内容。请注意,此特定工具是原生第一方Synology共享库的FFI包装器,可以从真实设备上提取,或使用单独的工具从DSM存档中提取。

发现漏洞

有了相关的二进制文件,我们可以开始查看在端口5566上监听的TCP服务的代码。

主要的二进制文件synobtrfsreplicad只是一个驱动程序的垫片,用于调用libsynobtrfsreplicacore.so.7中的功能,该库启动TCP监听器。

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

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

1
2
3
4
unsigned cmd // 命令操作码
unsigned seq // 序列号  
unsigned len
char data[len]

定义了两个全局变量来促进解析这些命令消息。一个用于命令本身,另一个是一个类似环形缓冲区的结构,用于保存最多3个可变大小的命令负载。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct{
    unsigned char sector; // 环形缓冲区索引
    char bufs[3][65536]; // 3个负载的环形缓冲区
    unsigned buf_lens[3]; // 3个缓冲区的填充长度
} g_recvbuf;

struct{
    ReplicaCmdHeader header; // 操作码,序列号,长度
    char *data; // 将指向g_recvbuf的3个缓冲区之一
} g_cmd;

读取消息的命令循环大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
        // ... 处理命令 ...
    }
}

// 读取消息头部和负载的函数
int recvCmd(ReplicaCmd* cmd) {
    int err = raw_tcp_recv(cmd->header, 12);
    if (err)
        return err;
    if (cmd->header.len > 0x10000)
        return err;
    // 读取实际负载数据
    err = raw_tcp_recv(cmd->data, cmd->header.len);
    // ...
}

如果攻击者提供的长度太大,recvCmd会在不读取任何负载的情况下退出。然而,它的返回值为零,表示没有错误,考虑到头部长度无效,这有点奇怪……回到调用者那里,它不知道任何错误,事情正常进行,命令负载被空终止,使用任意大的头部长度。

这个漏洞足够简单,对于我们的初始POC,我们可以使用netcat发送一个仅由A组成的消息(至少12个),以经典的可利用方式:

除非你使用gdb附加到服务,否则设备上没有任何迹象表明出了问题。该故障似乎没有记录到syslog或任何其他DSM日志设施中,并且由于分叉服务器的性质,不会立即丧失功能。

此漏洞提供的原语将允许我们在共享库的BSS(数据段)的任意偏移处重复进行空字节写入。非常像CTF。尽管漏洞相当简单,但利用它会更有趣。

无论如何,由于所有缓解措施都已启用,我们首先必须以某种方式将其转换为信息泄漏。

分叉服务器

在我们继续之前,回想一下我们正在处理一个分叉服务器,这对于打破ASLR非常有用。

每个被分叉的子进程将与父进程具有完全相同的地址空间,崩溃它们没有任何后果:我们只需重新连接到服务并获得一个干净的状态,以新的子进程的形式。

有点像时间循环,每个连接都是一个以累积方式收集地址空间新信息的机会。

在高层,每次迭代具有以下结构:

  • 猜测某个值(例如一个地址)
  • 让二进制文件使用猜测的值,这样如果正确或不正确,它的行为会有所不同(例如错误的地址会导致崩溃)
  • 观察二进制文件的行为以确定值是否正确
  • 如果正确,我们找到了正确的值。否则,使用下一个猜测重复

随着我们继续,我们将看到如何将其应用于此特定二进制文件。

功能概述

由于相关漏洞发生在输入解析期间,我们尚未探索程序的许多功能,在构建漏洞利用程序时我们需要利用这些功能。

从网络读取命令后,命令循环对提供的操作码有一个switch-case。需要输入的操作码从可变长度的命令负载中解析它们。我们查看了所有可用的操作码以大致了解它们的功能:

  • CMD_DSM_VER:无输入 - 返回DSM版本号
  • CMD_SSL:为连接初始化SSL
  • CMD_TEST_CONNECT
  • CMD_NOP
  • CMD_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",该token应该已经存在于/usr/syno/etc/synobtrfsreplica/btrfs_snap_replica_recv_token的JSON文件中。

JSON被用作简单的键值属性存储,其中token是键:

1
2
3
4
{
    "<token>": {"<attribute>":value, ... 其他属性 ...},
    ... 其他token ...
}

据推测,某些外部服务分发这些token并写入文件,但这发生在哪里我们不清楚。

然而,有一个代码路径允许将token添加到JSON文件,可能以非预期的方式。CMD_NAME操作码使用当前的g_token,并将属性写入文件,有两个重要的细微差别:

  • 它不检查g_token是否曾经被初始化(即使用CMD_TOKEN)
  • 如果token尚未作为键存在于JSON对象中,设置属性会添加它

通常,未初始化的g_token将只是一个空字符串,但在内存损坏的情况下,一切都不确定,我们稍后将看到这如何证明是有用的。

ASLR Oracle #1:释放假的堆块

我们的原语是一个空字节写入,我们提供命令负载缓冲区的任意偏移量。偏移量是无符号的,因此我们只能将空值写入负载缓冲区之后的内存。

这就带来了一个问题:负载缓冲区之后是什么,这将是共享库BSS中g_recvbuf全局变量中的三个0x10000大小缓冲区之一。

除了少数std::string实例外,没有太多的全局变量,它们具有以下结构:

1
2
3
4
5
struct std::string {
    char* ptr; // 对于短字符串,指向inline_buffer
    unsigned long length;
    char inline_buffer[16];
}

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

现在,考虑如果我们使用空写入将其中一个指针的两个最低字节清零。前面的负载缓冲区是0x10000字节,足够大以保证部分置空的BSS指针指向此缓冲区内的某个位置,尽管我们不知道确切的偏移量。

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

我们可以损坏的全局字符串之一是_gSnapRecvPath,可以通过CMD_NAME命令的操作之一重新分配。

当重新分配std::string时,如果char*没有指向内联缓冲区,将在分配新值之前对旧的(现在已损坏的)值调用delete。

这让我们可以在负载缓冲区内的假块上调用free。我们自然通过命令负载控制此缓冲区的内容。

当调用free时,如果假块的大小足够小,它将被放入glibc tcache。或者,如果大小无效(例如为零),free将调用abort,使进程崩溃。这创建了我们的第一个oracle,我们可以将其与分叉服务器行为结合,以确定假块位于16个可能偏移量(0, 0x1000, … 0xf000)中的哪一个。

对于16个可能的偏移量中的每一个:

  1. 用填充物填充负载缓冲区直到猜测的偏移量,后跟假块的元数据(这只是一个假的大小值)
  2. 触发漏洞两次以空出_gSnapRecvPath的char*的两个低字节
  3. 使用CMD_NAME释放损坏的char*,它可能指向也可能不指向放置在猜测偏移量处的假块
    • 如果套接字保持连接并发送响应,猜测的偏移量正确
    • 如果套接字关闭(即调用了abort),猜测不正确;尝试下一个偏移量

我们现在已经解析了一个半字节的ASLR熵,并且可以可靠地释放负载缓冲区中的假块,该块将被放入tcache。

ASLR Oracle #2:泄漏Token

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

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

在我们的情况下,tcache列表之前将是空的(next = 0),因此写入的值将是&chunk->next » 12。换句话说,我们已将移位的BSS指针放入负载缓冲区。我们现在想找出某种方法来泄漏这个值。

一旦假块被释放并且移位的BSS指针被写入,我们将空出第二个全局std::string g_token的char*的两个低字节。此损坏将使g_token指向与_gSnapRecvPath完全相同的位置。即,指向移位的BSS指针。

回想我们之前关于CMD_NAME功能的讨论,它可以将未初始化的g_token添加到磁盘上的JSON文件。这就是该事实证明有用的地方,因为现在"未初始化"的g_token不是保存空字符串,而是指向移位的BSS指针。触发此代码路径,JSON文件现在包含我们想要泄漏的值。

还要注意,在将g_token写出到磁盘之前,我们可以额外触发一次空字节写入以截断移位的BSS指针。通过这种方式,我们可以写出指针的每个段。例如,如果移位的指针是0x766554433,我们可以写出每个段,从33, 3344, … 到完整的3344556607。

一旦JSON文件包含泄漏,我们可以按预期使用CMD_TOKEN,它期望一个字符串参数指示要使用的token。此token将在JSON文件中查找,并根据是否找到返回不同的错误代码。这创建了我们的第二个oracle,我们可以使用它来实现逐字节暴力破解:

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

一旦此逐字节暴力破解完成,我们将泄漏移位的BSS指针,这给了我们共享库的基地址。

由于mmap映射在虚拟内存中是连续的,这也给了我们所有共享库的地址,最值得注意的是libc。

劫持控制流

有了泄漏,我们准备制作最终负载以劫持控制流。

我们已经能够释放负载缓冲区中的假块,并且通过发送额外的命令,我们可以任意损坏此空闲块。

此时,我们可以以标准方式滥用tcache链表:

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

我们只需要找到一些匹配两个连续分配模式的代码。

幸运的是,事实证明CMD_TOKEN处理程序符合此模式,并且在执行两个分配后,包含我们输入参数的std::string被析构,在我们的输入上调用delete。

这给我们带来了以下策略:

  1. 损坏假tcache块的next指针指向共享库的delete的GOT条目附近
  2. 发送CMD_TOKEN命令
  3. 处理程序将从损坏的tcache分配两次,用system覆盖delete的GOT条目
  4. 随后的析构函数调用delete,而是使用受控的输入字符串调用system

从这里开始游戏结束。我们可以简单地执行/bin/sh并将stdio重定向到已连接的客户端套接字(避免需要连接回)。

我们提交的完整漏洞利用代码已在此处提供。

修复

该漏洞被分配为CVE-2024-10442。Synology于2024年11月5日相对快速地发布了Replication Service的补丁(Pwn2Own Ireland于10月22日举行),你可以在此处找到公告。ZDI的公告可以在此处找到。

该补丁修改了recvCmd函数,在提供的头部长度太大时返回错误而不是零。

1
2
if (cmd->header.len > 0x10000)
    return 1; // 而不是之前的return 0

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

结论

尽管容易找到,但这个漏洞的利用很有趣,因为空字节写入作为一个原语相对较弱。它感觉像是你在CTF挑战中找到的那种错误,而tcache操作和暴力破解oracle也符合CTF的氛围。

更严肃地说,即使它在一个非默认软件包中,在远程可访问的服务(以root身份运行)中存在如此简单的漏洞有点令人担忧,特别是考虑到Synology是一个相当流行的消费和企业级NAS,并且这些设备暴露在互联网上并不罕见。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计