作者: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-15284
和3
个小版本6.1.7-15284 Update 1
、6.1.7-15284 Update 2
及6.1.7-15284 Update 3
。其中,大版本6.1.7-15284
对应初始版本,其镜像文件中包含完整的系统文件,而后续更新的小版本则只包含与更新相关的文件。另外,Update 2
版本中包含Update 1
中的更新,Update 3
中也包含Update 2
中的更新,也就是说最后1
个小版本Update 3
包含了全部的更新。
从群晖官方的镜像仓库中下载6.1.7-15284
、6.1.7-15284-2
和6.1.7-15284-3
这三个版本对应的pat
文件。在Update x
版本的pat
文件中除了包含与更新相关的模块外,还有一个描述文件DSM-Security.json
。比对6.1.7-15284-2
和6.1.7-15284-3
这2个版本的描述文件,如下。
可以看到,在6.1.7-15284 Update 3
中更新的模块为libfindhost
与netatalk-3.x
,与对应版本发行说明中的信息一致。
借助Bindiff
插件对版本6.1.7-15284
和6.1.7-15284 Update 3
中的libfindhost
模块进行比对,如下。可以看到,主要的差异在函数FHOSTPacketRead()
中。后面的其他函数很短,基本上就1~2
个block
,可忽略。
两个版本中函数FHOSTPacketRead()
内的主要差异如下,其中在6.1.7-15284 Update 3
中新增加了3
个block
。
对应的伪代码如下。可以看到,在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_id
、data_length
和data
。
另外,在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=0x2
的pkt_item
,如pkt_id=0xbc/0xbd/0xbe/0xbf
,则可以触发多次++v36
。由于缺乏对v36
的适当校验,通过发送伪造的数据包,可造成后续在调用FHOSTPacketReadString()
出现越界写。进一步地,在(6)
处传递的v38
与FHOSTPacketRead()
函数的第4
个参数有关,而在findhostd
程序中调用FHOSTPacketRead()
时第4
个参数为指向栈上的缓冲区,因此,利用该越界写操作可覆盖栈上的返回地址,从而劫持程序的控制流。
DSM 6.1.7-15284
版本中的findhostd
文件似乎经过混淆了,无法直接采用IDA Pro
等工具进行分析,可以在gdb
中dump
出findhostd
进程,然后对其进行分析。另外,在较新的版本如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]
最终效果如下。
获取到设备的root shell
后,相当于获取了设备的控制权,比如可以查看用户共享文件夹中的文件等。但是如何登录设备的Web
管理界面呢?这里给出一种简单的方案:利用synouser
和synogroup
命令增加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
服务监听的端口不会直接暴露到外网,故该漏洞应该是在局域网内才能触发。
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2038/