Patch diff an old vulnerability in Synology NAS
2023-1-4 09:26:0 Author: paper.seebug.org(查看原文) 阅读量:32 收藏

作者:cq674350529
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:[email protected]

前言

之前在浏览群晖官方的安全公告时,翻到一个Critical级别的历史漏洞Synology-SA-18:64。根据漏洞公告,该漏洞存在于群晖的DSM(DiskStation Manager)中,允许远程的攻击者在受影响的设备上实现任意代码执行。对群晖NAS设备有所了解的读者可能知道,默认条件下能用来在群晖NAS上实现远程代码执行的漏洞很少,有公开信息的可能就是与Pwn2Own比赛相关的几个。由于该漏洞公告中没有更多的信息,于是打算通过补丁比对的方式来定位和分析该公告中提及的漏洞。

环境准备

群晖环境的搭建可参考之前的文章《A Journey into Synology NAS 系列一: 群晖NAS介绍》,这里不再赘述。根据群晖的安全公告,以DSM 6.1为例,DSM 6.1.7-15284-3以下的版本均受该漏洞影响,由于手边有一个DSM 6.1.7的虚拟机,故这里基于DSM 6.1.7-15284版本进行分析。

补丁比对

首先对群晖的DSM更新版本进行简单说明,方便后续进行补丁比对。以DSM 6.1.7版本为例,根据其发行说明,存在1个大版本6.1.7-152843个小版本6.1.7-15284 Update 16.1.7-15284 Update 26.1.7-15284 Update 3。其中,大版本6.1.7-15284对应初始版本,其镜像文件中包含完整的系统文件,而后续更新的小版本则只包含与更新相关的文件。另外,Update 2版本中包含Update 1中的更新,Update 3中也包含Update 2中的更新,也就是说最后1个小版本Update 3包含了全部的更新。

从群晖官方的镜像仓库中下载6.1.7-152846.1.7-15284-26.1.7-15284-3这三个版本对应的pat文件。在Update x版本的pat文件中除了包含与更新相关的模块外,还有一个描述文件DSM-Security.json。比对6.1.7-15284-26.1.7-15284-3这2个版本的描述文件,如下。

可以看到,在6.1.7-15284 Update 3中更新的模块为libfindhostnetatalk-3.x,与对应版本发行说明中的信息一致。

借助Bindiff插件对版本6.1.7-152846.1.7-15284 Update 3中的libfindhost模块进行比对,如下。可以看到,主要的差异在函数FHOSTPacketRead()中。后面的其他函数很短,基本上就1~2block,可忽略。

两个版本中函数FHOSTPacketRead()内的主要差异如下,其中在6.1.7-15284 Update 3中新增加了3block

对应的伪代码如下。可以看到,在6.1.7-15284 Update 3中,主要增加了对变量v34的额外校验,而该变量会用在后续的函数调用中。因此,猜测漏洞与v34有关。

漏洞分析

libfindhost.so主要是与findhostd服务相关,用于在局域网内通过Synology Assistant工具搜索、配置和管理对应的NAS设备,关于findhostd服务及协议格式可参考之前的文件《A Journey into Synology NAS 系列二: findhostd服务分析》。其中,发送数据包的开始部分为magic (\x12\x34\x56\x78\x53\x59\x4e\x4f),剩余部分由一系列的TLV组成,TLV分别对应pkt_iddata_lengthdata

另外,在libfindhost.so中存在一大段与协议格式相关的数据grgfieldAttribs,表明消息剩余部分的格式和含义。具体地,下图右侧中的每一行对应结构pkt_item,其包含6个字段。其中,pkt_id字段表明对应数据的含义,如数据包类型、用户名、mac地址等;offset字段对应将数据放到内部缓冲区的起始偏移;max_length字段则表示对应数据的最大长度。

实际上,libfindhost.so中的grgfieldAttribs,每一个pkt_item包含8个字段;而在Synology Assistant中,每一个pkt_item包含6个字段。不过,重点的字段应该是前几个,故这里暂且只关注前6个字段。

findhostd进程会监听9999/udp, 9998/udp, 9997/udp等端口,其会调用FHOSTPacketRead()来对接收的数据包进行初步校验和解析。以DSM 6.1.7-15284版本为例, FHOSTPacketRead()的部分代码如下。首先,在(1)处会校验接收数据包的头部,校验通过的话程序流程会到达(2),在while循环中依次对剩余部分的pkt_item进行处理。在(2)处会从数据包中读取对应的pkt_id,之后在grgfieldAttribs中通过二分法查找对应的pkt_item,查找成功的话程序流程会到达(3)。在(3)处会读取对应pkt_item中的pkt_index字段,如果pkt_index=2,程序流程会到达(4)。如果v39 == pkt_id,则会执行++v36,否则在(5)处会将pkt_id赋值给v39。之后,在(6)处会根据pkt_index的值调用相应的FHOSTPacketReadXXX()

// in libfindhost.so
__int64 FHOSTPacketRead(__int64 a1, char *recv_data, int recv_data_size, char *dst_buf)
{
  v4 = a1;
  // ...
  remain_pkt_len = recv_data_size;
  // ...
  v6 = dst_buf;

  memset(dst_buf, 0, 0x2F50uLL);
  v7 = *(unsigned int *)FHOSTHeaderSize_ptr;
  v8 = *(_DWORD *)FHOSTHeaderSize_ptr;
  // ...
  v37 = memcmp(recv_data, src, *(unsigned int *)FHOSTHeaderSize_ptr);   // (1) check packet header
  // ...
  pkts_ptr = &recv_data[v7];
  v33 = pkts_ptr;
  v34 = remain_pkt_len - v8;
  // ...
  v11 = v6 + 0x74;
  v12 = (char *)off_7FFFF7DD7FE0;   // grgfieldAttribs
  v38 = v6;
  v39 = 0;
  v36 = 0;
  s = v11;
  while ( 1 )
  {
    pkt_id = (unsigned __int8)*pkts_ptr;    // (2) get pkt_item_id
    v15 = pkts_ptr + 1;
    wrap_remain_pkt_len = remain_pkt_len - 1;
    v17 = 76LL;
    v18 = 0LL;
    wrap_pkt_id = (unsigned __int8)*pkts_ptr;
    // ... try to find target pkt_item in grgfieldAttribs via binary search
    pkt_index_in_table = *((_DWORD *)v21 + 1);  // (3) find the target pkt_item
    // ...
    v31 = *((unsigned int *)v21 + 6);
    if ( (_DWORD)v31 != 2 )
      v31 = 1LL;
    if ( pkt_index_in_table == 2 )              // index
    {
      if ( v39 == pkt_id )      // (4)
      {
        ++v36;      // cause out-of-bounds wirte later
      }
      else
      {
        v39 = (unsigned __int8)*pkts_ptr;   // (5)
        v36 = 0;
      }
    }
    else
    {
      v39 = 0;
      v36 = 0;
    }
    v24 = (*((__int64 (__fastcall **)(__int64, char *, _QWORD, char *, _QWORD, __int64, _QWORD))off_7FFFF7DD7FC0    // (6)
           + 3 * pkt_index_in_table
           + 1))(
            a1,
            pkts_ptr + 1,
            wrap_remain_pkt_len,
            &v38[*((_QWORD *)v21 + 1)],         // *((_QWORD *)v21 + 1): pkt_item_offset
            *((_QWORD *)v21 + 2),               // *((_QWORD *)v21 + 2): pkt_item_max_len
            v31,
            v36);
    // ...

地址off_7FFFF7DD7FC0实际指向的内容如下。其中,函数FHOSTPacketReadString()会使用传入的第7个参数v36。另外,FHOSTPacketReadArray()内部直接调用FHOSTPacketReadString(),因此这两个函数是等价的。

LOAD:00007FFFF7DD7FC0 off_7FFFF7DD7FC0 dq offset grgfieldParsers

LOAD:00007FFFF7DD9340 grgfieldParsers dq 0                    ; DATA XREF: LOAD:off_7FFFF7DD7FC0↑o
LOAD:00007FFFF7DD9348                 dq offset FHOSTPacketReadString
LOAD:00007FFFF7DD9350                 dq offset FHOSTPacketWriteString
LOAD:00007FFFF7DD9358                 dq 1
LOAD:00007FFFF7DD9360                 dq offset FHOSTPacketReadInteger
LOAD:00007FFFF7DD9368                 dq offset FHOSTPacketWriteInteger
LOAD:00007FFFF7DD9370                 dq ?
LOAD:00007FFFF7DD9378                 dq offset FHOSTPacketReadArray
LOAD:00007FFFF7DD9380                 dq offset FHOSTPacketWriteArray

函数FHOSTPacketReadString()的部分代码如下。正常情况下,程序流程会到达(7)处,读取数据包中对应data_length字段,如果其值小于剩余数据包的总长度,程序流程会到达(8)。如果(8)处的条件成立,在(9)处会调用snprintf()将对应的data拷贝到内部缓冲区的指定偏移处,其中snprintf()的第1个参数为(char *)(a4 + a7 * pkt_max_length),用到了传进来的v36/a7参数。

__int64 FHOSTPacketReadString(__int64 a1, _BYTE *a2, signed int remain_pkt_length, __int64 a4, unsigned __int64 pkt_max_length, __int64 a6, unsigned int a7)
{
  // ...
  if ( remain_pkt_length > 0 )
  {
    data_length = (unsigned __int8)*a2;    // (7) get data_length
    v8 = 0;
    if ( remain_pkt_length > (int)data_length )
    {
      LOBYTE(v8) = 1;
      if ( *a2 )
      {
        LOBYTE(v8) = 0;
        if ( data_length < pkt_max_length )    // (8)
        {
          v8 = data_length + 1;
          snprintf((char *)(a4 + a7 * pkt_max_length), (int)data_length + 1, "%s", a2 + 1);    // (9) out-of-bounds write
        }
      }
    }
    // ...

回到前面的(4)/(5)处,可以发现,如果发送的数据包中包含多个对应pkt_index=0x2pkt_item,如pkt_id=0xbc/0xbd/0xbe/0xbf,则可以触发多次++v36。由于缺乏对v36的适当校验,通过发送伪造的数据包,可造成后续在调用FHOSTPacketReadString()出现越界写。进一步地,在(6)处传递的v38FHOSTPacketRead()函数的第4个参数有关,而在findhostd程序中调用FHOSTPacketRead()时第4个参数为指向栈上的缓冲区,因此,利用该越界写操作可覆盖栈上的返回地址,从而劫持程序的控制流。

DSM 6.1.7-15284版本中的findhostd文件似乎经过混淆了,无法直接采用IDA Pro等工具进行分析,可以在gdbdumpfindhostd进程,然后对其进行分析。另外,在较新的版本如VirtualDSM 6.2.4-25556中,对应的findhostd文件未被混淆,可直接分析。

// in findhostd
__int64 handler_recv_data(__int64 a1, __int64 a2, __int64 a3)
{
  // ...
  int v124[3042]; // [rsp+1970h] [rbp-2F88h] BYREF

  // ...
  memset(v124, 0LL, 0x2F50LL);  // local buffer on stack
  if ( (int)FHOSTPacketRead((__int64)v113, a2, (unsigned int)a1, (__int64)v124) <= 0 )
  {
    // ...

另外,由于Synology Assistant客户端对协议数据包的处理过程与findhostd类似,因此其早期的版本也会受该漏洞影响。

漏洞利用

查看findhostd启用的缓解机制,如下,同时设备上的ASLR等级为2。其中,显示"NX disabled",不知道是否和程序被混淆过有关。在设备上查看进程的内存地址空间映射,确实看到[stack]部分为rwxp。考虑到通用性,这里还是采用ret2libc的思路来获取设备的root shell

$ checksec.exe --file ./findhostd
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments

由于越界写发生在调用snprintf()时,故存在'\x00'截断的问题。通过调试发现,利用越界写覆盖栈上的返回地址后,在返回地址的不远处存在发送的原始数据包内容,因此可借助stack pivot将栈劫持到指向可控内容的地方,从而继续进行rop

在实际进行利用的过程中,本来是想将cmd直接放在数据包中发送,然后定位到其在栈上的地址,再将其保存到rdi寄存器中,但由于未找到合适的gadgets,故采用将cmd写入findhostd进程的某个固定地址处的方式替代。同时,发现区域0x00411000-0x00610000不可写(正常应该包含.bss区域?),而.got.plt区域可写,故将cmd写到了该区域。

[email protected]_6_1:/# cat /proc/`pidof findhostd`/maps
00400000-00411000 r-xp 00000000 00:00 0
00411000-00610000 ---p 00000000 00:00 0                                 # no writable permission 
00610000-00611000 r-xp 00000000 00:00 0
00611000-00637000 rwxp 00000000 00:00 0                       [heap]
00800000-00801000 rwxp 00000000 00:00 0
...
7ffffffde000-7ffffffff000 rwxp 00000000 00:00 0               [stack]   # executable stack?
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0       [vsyscall]

最终效果如下。

One More Thing

获取到设备的root shell后,相当于获取了设备的控制权,比如可以查看用户共享文件夹中的文件等。但是如何登录设备的Web管理界面呢?这里给出一种简单的方案:利用synousersynogroup命令增加1个管理员用户,然后使用新增的用户进行登录即可。当然,synouser命令支持直接更改现有用户的密码,且无需原密码,但改了之后正常用户就不知道其密码了 :(

# 增加一个用户名为cq, 密码为cq674350529的用户
$ synouser --add cq cq674350529 "test admin" 0 "" 31
# 查看当前管理员组中的现有用户
$ synogroup --get administrators
# 将新增加的用户cq添加到管理员组中,xxx为当前管理员组中的现有用户
$ synogroup --member administrators xxx xxx cq
# 之后, 便可利用该账户登录设备的Web管理界面
# 删除新增加的用户
$ synouser --del cq

小结

本文基于群晖DSM 6.1.7-15284版本,通过补丁比对的方式对群晖安全公告Synology-SA-18:64中提及的漏洞进行了定位和分析。该漏洞与findhostd服务相关,由于在处理接收的数据包时缺乏适当的校验,通过发送伪造的数据包,可触发out-of-bounds write,利用该操作可覆盖栈上的返回地址,从而劫持程序控制流,达到任意代码执行的目的。通常情况下,findhostd服务监听的端口不会直接暴露到外网,故该漏洞应该是在局域网内才能触发。

相关链接


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2038/


文章来源: https://paper.seebug.org/2038/
如有侵权请联系:admin#unsafe.sh