Pwn2Own2020 Synology NAS Netatalk Heap Overflow Analysis

前言

Pwn2Own Tokyo 2020比赛上,有2个团队攻破了群晖DS418Play型号的NAS设备,其中DEVCORE团队利用一个堆溢出漏洞在设备上实现了代码执行。根据ZDI公告,漏洞存在于Netatalk组件中,在解析DSI结构体时由于缺乏对某个长度字段的适当校验,在后续进行拷贝时会出现堆溢出。目前群晖已发布了补丁,该漏洞的触发相对比较简单,参考@Angelboy分享,在本地环境中完成了对漏洞的利用。

环境准备

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

另外,HITCON CTF 2021比赛中出了一道类似的题目metatalk,如果只是想复现和学习该漏洞的话,也可以基于该题目提供的docker环境来进行分析,相关文件及writeup可参考《hitcon CTF 2021 - metatalk》

AFP协议介绍

Netatalk是一个免费开源的Apple Filing Protocol(AFP)服务程序,属于Apple File Service(AFS)的一部分,用来为早期的macOS提供文件服务,类似于Windows上的Samba。该组件在很多NAS设备上都存在,群晖设备上的Netatalk组件来源于该开源组件,并在其基础上进行了定制化修改,不过大体功能和代码与原始组件类似。

AFP协议建立在Data Stream Interface(DSI)之上,DSI是一个会话层,用于在TCP层上承载AFP协议的流量。DSI数据包的格式如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* What a DSI packet looks like:
0 32
|-------------------------------|
|flags |command| requestID |
|-------------------------------|
|error code/enclosed data offset|
|-------------------------------|
|total data length |
|-------------------------------|
|reserved field |
|-------------------------------|
CONVENTION: anything with a dsi_ prefix is kept in network byte order.
*/

struct dsi_block {
uint8_t dsi_flags; /* packet type: request or reply */
uint8_t dsi_command; /* command */
uint16_t dsi_requestID; /* request ID */
union {
uint32_t dsi_code; /* error code */
uint32_t dsi_doff; /* data offset */
} dsi_data;
uint32_t dsi_len; /* total data length */
uint32_t dsi_reserved; /* reserved field */
};

其中,dsi_command字段可能的取值及含义如下。

NameCodeDirectionDescription
DSICloseSession1BothCloses an established session
DSICommand2From clientAttached payload contains an AFP command
DSIGetStatus3From clientGet information about the server
DSIOpenSession4From clientEstablish a new session
DSITickle5BothEnsure the connection is active
DSIWrite6From clientWrite data to the server
DSIAttention8From serverGet the attention of the client

AFP协议数据包的第一个字段为command,成功解析数据包后会根据该字段来查找对应的处理函数,部分command与处理函数的对应关系如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* AFP functions */
#define AFP_BYTELOCK 1
#define AFP_CLOSEVOL 2
#define AFP_CLOSEDIR 3
// ...
#define AFP_LOGIN 18
#define AFP_LOGINCONT 19
#define AFP_LOGOUT 20
// ...
#define AFP_MOVE 23
#define AFP_OPENVOL 24
#define AFP_OPENDIR 25
// ...

为了便于理解协议的交互流程,从macOS上访问该服务并进行抓包分析,大概的交互流程如下。首先,通过发送DSIGetStatus请求获取afp server的相关信息,比如支持的AFP协议版本、UAMS列表等,之后通过发送DSIOpenSession请求建立会话;会话建立之后,通过发送DSICommand请求执行AFP相关的操作,包括FPLoginFPGetUserInfoFPOpenVol等;最后通过发送DSICloseSession请求来结束会话。

在了解了协议的通信格式和交互流程后,下面对漏洞进行定位和分析。

漏洞定位和分析

根据ZDI公告中的描述信息可知,该堆溢出漏洞与dsi_doff字段有关。在Netatalk的源码中搜索与dsi_doff字段相关的代码,定位到dsi_stream_receive()中,如下。可以看到,如果dsi header部分的dsi_doff字段不为0,在(1)处会将其赋值给dsi->cmdlen,之后在(2)处会调用dsi_stream_read()读取afp数据包的内容(对应dsi部分的载荷),并将其保存到dsi->commands缓冲区中。由于读取内容的长度由dsi->cmdlen指定,而其值外部可控,因此猜测这里就是漏洞点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int dsi_stream_receive(DSI *dsi)
{
// ...
/* read in the header */
if (dsi_buffered_stream_read(dsi, (uint8_t *)block, sizeof(block)) != sizeof(block))
return 0;
// ...
memcpy(&dsi->header.dsi_requestID, block + 2, sizeof(dsi->header.dsi_requestID));
memcpy(&dsi->header.dsi_data.dsi_doff, block + 4, sizeof(dsi->header.dsi_data.dsi_doff));
dsi->header.dsi_data.dsi_doff = htonl(dsi->header.dsi_data.dsi_doff);
memcpy(&dsi->header.dsi_len, block + 8, sizeof(dsi->header.dsi_len));

memcpy(&dsi->header.dsi_reserved, block + 12, sizeof(dsi->header.dsi_reserved));
dsi->clientID = ntohs(dsi->header.dsi_requestID);

/* make sure we don't over-write our buffers. */
dsi->cmdlen = MIN(ntohl(dsi->header.dsi_len), dsi->server_quantum);

/* Receiving DSIWrite data is done in AFP function, not here */
if (dsi->header.dsi_data.dsi_doff) {
LOG(log_maxdebug, logtype_dsi, "dsi_stream_receive: write request");
dsi->cmdlen = dsi->header.dsi_data.dsi_doff; // (1) controllable
}

if (dsi_stream_read(dsi, dsi->commands, dsi->cmdlen) != dsi->cmdlen) // (2) heap overflow
return 0;
// ...

往前查找dsi->commands初始化的地方,如下。在dsi_init_buffer()中,调用malloc(dsi->server_quantum))申请堆空间。默认情况下,dsi->server_quantum字段的值来自于DSI_SERVQUANT_DEF,其值为0x100000,即dsi->commands缓冲区的大小为固定的1M。因此可以确定(2)处为漏洞触发点。

也可以通过补丁比对的方式来定位漏洞点,对应的程序为afpd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void dsi_init_buffer(DSI *dsi)
{
if ((dsi->commands = malloc(dsi->server_quantum)) == NULL) {
// ...

DSI *dsi_init(AFPObj *obj, const char *hostname, const char *address, const char *port)
{
DSI *dsi;
if ((dsi = (DSI *)calloc(1, sizeof(DSI))) == NULL)
return NULL;

dsi->attn_quantum = DSI_DEFQUANT;
dsi->server_quantum = obj->options.server_quantum;
// ...

#define DSI_SERVQUANT_DEF 0x100000L /* default server quantum (1 MB) */

通过查找dsi_stream_receive()的交叉引用,以及对afp相关代码的处理流程进行分析,该漏洞的触发方式如下,且无需认证:

  1. 发送DSIOpenSession请求建立一个新的会话;
  2. 会话建立成功之后,发送DSICommand请求并指定dsi_doff0x1a0000,即可触发漏洞。

漏洞利用

根据上面的分析可知,发生溢出的堆块大小为0x100000L,程序afpd使用的glibc版本为2.20,故该堆空间是通过mmap()进行分配,常规的一些小堆的利用方式在这里不适用。

漏洞利用思路主要参考了@Angelboy分享@Kileakmetatalk writeup

afpd开启的保护机制,以及运行时的部分地址空间布局如下。可以看到,dsi->commands的地址为0x7ffff7edf000,在其下方有一段大小为0x1b000的空间,其对应Thread Local Storage(TLS),里面保存了tls相关的结构体、TLS destructors、线程局部变量和线程的main arena指针等信息。

glibc TLS的相关信息可参考这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ checksec --file afpd
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled

# memory layout
(gdb) info proc mappings
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x400000 0x459000 0x59000 0x0 /usr/bin/afpd
0x658000 0x659000 0x1000 0x58000 /usr/bin/afpd
0x659000 0x65e000 0x5000 0x59000 /usr/bin/afpd
0x65e000 0x6c7000 0x69000 0x0 [heap]
0x7fffeb9be000 0x7fffec5bf000 0xc01000 0x0
# ...
0x7ffff368d000 0x7ffff3828000 0x19b000 0x0 /usr/lib/libc-2.20-2014.11.so
0x7ffff3828000 0x7ffff3a28000 0x200000 0x19b000 /usr/lib/libc-2.20-2014.11.so
0x7ffff3a28000 0x7ffff3a2c000 0x4000 0x19b000 /usr/lib/libc-2.20-2014.11.so
0x7ffff3a2c000 0x7ffff3a2e000 0x2000 0x19f000 /usr/lib/libc-2.20-2014.11.so
# ...
0x7ffff7b49000 0x7ffff7bc8000 0x7f000 0x0 /usr/lib/libatalk.so.17.0.0
0x7ffff7bc8000 0x7ffff7dc8000 0x200000 0x7f000 /usr/lib/libatalk.so.17.0.0
0x7ffff7dc8000 0x7ffff7dc9000 0x1000 0x7f000 /usr/lib/libatalk.so.17.0.0
0x7ffff7dc9000 0x7ffff7dcc000 0x3000 0x80000 /usr/lib/libatalk.so.17.0.0
0x7ffff7dcc000 0x7ffff7ddc000 0x10000 0x0
0x7ffff7ddc000 0x7ffff7dfd000 0x21000 0x0 /usr/lib/ld-2.20-2014.11.so
0x7ffff7edf000 0x7ffff7fe0000 0x101000 0x0 # <=== heap via mmap(), dsi->commands
0x7ffff7fe0000 0x7ffff7ffb000 0x1b000 0x0 # Thread Local Storage(TLS)
0x7ffff7ffb000 0x7ffff7ffc000 0x1000 0x0 [vdso]
0x7ffff7ffc000 0x7ffff7ffd000 0x1000 0x20000 /usr/lib/ld-2.20-2014.11.so
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x21000 /usr/lib/ld-2.20-2014.11.so
0x7ffffffde000 0x7ffffffff000 0x21000 0x0 [stack]
0xffffffffff600000 0xffffffffff601000 0x1000 0x0 [vsyscall]

TLS中,可能被用于利用的目标有很多,比如线程的main arena指针、point_guardtls_dtor_list等,这里采用伪造tls_dtor_list的方式来实现控制流劫持。

exit()流程分析

首先看下exit()函数,如下,其会调用__run_exit_handlers()。在__run_exit_handlers()中,其会先调用TLS destructors,然后再调用所有通过atexit/on_exit注册的函数,这里重点关注前者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// stdlib/exit.c
void exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true);
}

/* Call all functions registered with `atexit' and `on_exit',
in the reverse of the order in which they were registered
perform stdio cleanup, and terminate program execution with STATUS. */
void attribute_hidden __run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit)
{
/* First, call the TLS destructors. */
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
__call_tls_dtors ();

/* We do it this way to handle recursive calls to exit () made by
the functions registered with `atexit' and `on_exit'. We call
everyone on the list and use the status value in the last
exit (). */
// ...

__call_tls_dtors()中,其会遍历tls_dtor_list列表,针对每个dtor_list,在(5)处调用func(cur->obj)。如果能伪造tls_dtor_list,则可以指定其func字段和obj字段,从而实现控制流劫持,且第1个参数可控。另外,在(4)处涉及到pointer demangle,后面会进行说明。根据tls_dtor_list的定义可知,其是一个带有关键字__thread的静态变量,而__thread的作用是告诉编译器将其放入Thread Local Storage(TLS)中,对应前面提到的dsi->commands下方的内存空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// stdlib/cxa_thread_atexit_impl.c
typedef void (*dtor_func) (void *);

struct dtor_list
{
dtor_func func;
void *obj;
struct link_map *map;
struct dtor_list *next;
};

static __thread struct dtor_list *tls_dtor_list;
static __thread void *dso_symbol_cache;
static __thread struct link_map *lm_cache;

void __call_tls_dtors (void)
{
while (tls_dtor_list)
{
struct dtor_list *cur = tls_dtor_list; // (3)
dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (func); // (4)
#endif

tls_dtor_list = tls_dtor_list->next;
func (cur->obj); // (5)
// ...

那在实际中是如何访问tls_dtor_list变量的呢?查看对应libc-2.20-2014.11.so__call_tls_dtors的代码,如下。大体流程与上面的源码类似,而tls_dtor_list的获取是通过调用__tls_get_addr()实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
__int64 _call_tls_dtors()
{
// ...
result = __tls_get_addr(&stru_7FFFF3A2BD90);
for ( i = *(_QWORD **)(result + 64); i; i = *(_QWORD **)(result + 64) )
{
v2 = __tls_get_addr(&stru_7FFFF3A2BD90);
v3 = i[1];
v4 = (void (__fastcall *)(__int64))(__readfsqword(0x30u) ^ __ROR8__(*i, 17));
*(_QWORD *)(v2 + 64) = i[3];
v4(v3);
// ...
}

查看__tls_get_addr()的实现,其会通过THREAD_DTV()来获取当前线程的dtv,最后返回(char *) p + GET_ADDR_OFFSET。其中,THREAD_DTV为定义的宏变量,对应THREAD_GETMEM (__pd, header.dtv),即获取当前线程dtv的地址。从THREAD_GETMEM的定义可知,其返回的值为[fs:xxx],即获取fs段寄存器偏移xxx处的值,而xxx对应header.dtv在结构体pthread中的偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// elf/dl-tls.c
/* The generic dynamic and local dynamic model cannot be used in
statically linked applications. */
void *
__tls_get_addr (GET_ADDR_ARGS)
{
dtv_t *dtv = THREAD_DTV ();

if (__glibc_unlikely (dtv[0].counter != GL(dl_tls_generation)))
return update_get_addr (GET_ADDR_PARAM);

void *p = dtv[GET_ADDR_MODULE].pointer.val;

if (__glibc_unlikely (p == TLS_DTV_UNALLOCATED))
return tls_get_addr_tail (GET_ADDR_PARAM, dtv, NULL);

return (char *) p + GET_ADDR_OFFSET;
}

// sysdeps/x86_64/nptl/tls.h
/* Return the address of the dtv for the current thread. */
# define THREAD_DTV() \
({ struct pthread *__pd; \
THREAD_GETMEM (__pd, header.dtv); })

/* Read member of the thread descriptor directly. */
# define THREAD_GETMEM(descr, member) \
({ __typeof (descr->member) __value; \
if (sizeof (__value) == 1) \
asm volatile ("movb %%fs:%P2,%b0" \
: "=q" (__value) \
: "0" (0), "i" (offsetof (struct pthread, member))); \
else if (sizeof (__value) == 4) \
asm volatile ("movl %%fs:%P1,%0" \
: "=r" (__value) \
: "i" (offsetof (struct pthread, member))); \
else \
{ \
if (sizeof (__value) != 8) \
/* There should not be any value with a size other than 1, \
4 or 8. */ \
abort (); \
\
asm volatile ("movq %%fs:%P1,%q0" \
: "=r" (__value) \
: "i" (offsetof (struct pthread, member))); \
} \
__value; })

结构体pthread的定义如下,其中header字段为tcbhead_t类型,对应的定义如下。可知,上面提到的偏移xxx实际上就是dtv字段在tcbhead_t结构体中的偏移,THREAD_GETMEM宏定义其实就是获取的dtv字段的值。

Linux x86_64中,glibcfs段寄存器指向tcbhead_t结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* Thread descriptor data structure.  */
struct pthread
{
union
{
#if !TLS_DTV_AT_TP
/* This overlaps the TCB as used for TLS without threads (see tls.h). */
tcbhead_t header;
#else
struct
{
int multiple_threads;
int gscope_flag;
# ifndef __ASSUME_PRIVATE_FUTEX
int private_futex;
# endif
} header;
#endif

// sysdeps/x86_64/nptl/tls.h
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
// ...
} tcbhead_t;

gdb中进行调试,如下。可以看到,fs寄存器的地址为0x00007ffff7fe1780,位于dsi->commands缓冲区的下方。因此,可以通过溢出覆盖tcbhead_t结构体,修改其中的dtv字段来伪造tls_dtor_list,从而进行控制流劫持。

正常情况下,在gdb中无法查看fs段寄存器的值(显示为0),采用pwndbg插件中的fsbase命令也无效,可以通过系统调用arch_prctl(0x1003, writable_addr)来获取其值。其中,0x1003对应ARCH_GET_FS0x1004则对应ARCH_GET_GS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(gdb) info proc mappings
# ...
0x7ffff7edf000 0x7ffff7fe0000 0x101000 0x0 # <=== heap via mmap(), dsi->commands
0x7ffff7fe0000 0x7ffff7ffb000 0x1b000 0x0 # Thread Local Storage(TLS)
0x7ffff7ffb000 0x7ffff7ffc000 0x1000 0x0 [vdso]
0x7ffff7ffc000 0x7ffff7ffd000 0x1000 0x20000 /usr/lib/ld-2.20-2014.11.so
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x21000 /usr/lib/ld-2.20-2014.11.so
0x7ffffffde000 0x7ffffffff000 0x21000 0x0 [stack]
0xffffffffff600000 0xffffffffff601000 0x1000 0x0 [vsyscall]
(gdb) x/4i $rip
=> 0x7ffff7b751bd <dsi_stream_receive+461>: call 0x7ffff7b59d60 <dsi_stream_read@plt>
0x7ffff7b751c2 <dsi_stream_receive+466>: cmp rax,QWORD PTR [rbx+0x106f8]
0x7ffff7b751c9 <dsi_stream_receive+473>: jne 0x7ffff7b75029 <dsi_stream_receive+57>
0x7ffff7b751cf <dsi_stream_receive+479>: cmp DWORD PTR [rbp+0x48],0x5
(gdb) i r $rsi
rsi 0x7ffff7edf010 140737352953872 # dsi->commands when call dsi_stream_read()
(gdb) x/4gx $rsi-0x10
0x7ffff7edf000: 0x0000000000000000 0x0000000000101002
0x7ffff7edf010: 0x0402000010000400 0x0000000080000000
(gdb) call (int)arch_prctl(0x1003, 0x67d000) # get fs value via arch_prctl syscall, 0x67d000 is an arbitrary writable address
(gdb) x/gx 0x67d000
0x67d000: 0x00007ffff7fe1780 # fs value
(gdb) x/10gx 0x00007ffff7fe1780
0x7ffff7fe1780: 0x00007ffff7fe1780 0x00007ffff7fe2090 # tcbhead_t
0x7ffff7fe1790: 0x00007ffff7fe1780 0x0000000000000000
0x7ffff7fe17a0: 0x0000000000000000 0xb0f28a0309ec8500 # stack_guard
0x7ffff7fe17b0: 0x6223ecce6a95bec7 0x0000000000000000
0x7ffff7fe17c0: 0x0000000000000000 0x0000000000000000

至于如何触发exit()函数呢?查看afp相关代码的处理流程,当发送DSICloseSession请求时,其会调用exit(0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* -------------------------------------------
afp over dsi. this never returns.
*/
void afp_over_dsi(AFPObj *obj)
{
// ...
/* get stuck here until the end */
while (1) {
// ...
/* Blocking read on the network socket */
cmd = dsi_stream_receive(dsi);

if (cmd == 0) {
// ...
}
switch(cmd) {
case DSIFUNC_CLOSE: // DSICloseSession
LOG(log_debug, logtype_afpd, "DSI: close session request");
afp_dsi_close(obj);
LOG(log_note, logtype_afpd, "done");
exit(0);
// ...

按照上面的思路,可以修改dtv字段来伪造tls_dtor_list,那么在哪里伪造tls_dtor_list呢?即用什么地址来覆盖dtv字段呢?因此还需要找一块内容可控的地址空间。

寻找内容可控的地址空间

这里再次对afp相关代码的处理流程进行分析。前面提到过,在发送DSICommand请求时,如果AFP协议数据包的第一个字段为command,成功解析数据包后会根据该字段来查找对应的处理函数。在正常流程中,会话建立后的第一个请求是AFP FPLogin/AFP FPLoginExt,以AFP FPLogin请求为例,会调用afp_login()来进行处理。

afp_login()中,会先获取请求中的version信息并进行校验,校验通过后会获取请求中的uams信息并查找对应的uams模块,查找成功后在(6)处会调用对应的login()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int afp_login(AFPObj *obj, char *ibuf, size_t ibuflen, char *rbuf, size_t *rbuflen)
{
// ...
if (ibuflen < 2)
return send_reply(obj, AFPERR_BADVERS );

ibuf++;
len = (unsigned char) *ibuf++;
ibuflen -= 2;

i = get_version(obj, ibuf, ibuflen, len);
if (i)
return send_reply(obj, i );

if (ibuflen <= len)
return send_reply(obj, AFPERR_BADUAM);

ibuf += len;
ibuflen -= len;

len = (unsigned char) *ibuf++;
ibuflen--;

if (!len || len > ibuflen)
return send_reply(obj, AFPERR_BADUAM);

if (NULL == (afp_uam = auth_uamfind(UAM_SERVER_LOGIN, ibuf, len)) )
return send_reply(obj, AFPERR_BADUAM);
ibuf += len;
ibuflen -= len;

if (AFP_OK != (i = create_session_key(obj)) )
return send_reply(obj, i);

i = afp_uam->u.uam_login.login(obj, &pwd, ibuf, ibuflen, rbuf, rbuflen); // (6)

"DHX2"为例,会调用uams_dhx2模块中的passwd_login()函数,如下。其中,在(7)处调用uam_afpserver_option()usernameulen进行初始化,其内部会将obj结构体中的username数组的地址赋值给参数usernameusername数组的大小赋值给ulen。之后在(8)处调用memcpy()将请求中的数据拷贝到username中。而obj是一个全局未初始化静态变量,故其存在于程序afpd.bss部分,由于afpd未开启PIE机制,故obj的地址是固定的,username数组的起始地址也是固定的。因此,借助AFP FPLogin/AFP FPLoginExt请求,可以往某个固定地址写入可控内容,即找到了一处内容可控的地址空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static int passwd_login(void *obj, struct passwd **uam_pwd,
char *ibuf, size_t ibuflen,
char *rbuf, size_t *rbuflen)
{
char *username;
size_t len, ulen;

*rbuflen = 0;

/* grab some of the options */
if (uam_afpserver_option(obj, UAM_OPTION_USERNAME, (void *) &username, &ulen) < 0) { // (7)
LOG(log_info, logtype_uams, "DHX2: uam_afpserver_option didn't meet uam_option_username -- %s",
strerror(errno));
return AFPERR_PARAM;
}

len = (unsigned char) *ibuf++;
// ...
memcpy(username, ibuf, len ); // (8)

#define MAXUSERLEN 256

typedef struct AFPObj {
const char *cmdlineconfigfile;
int cmdlineflags;
const void *signature;
struct DSI *dsi;
struct afp_options options;
dictionary *iniconfig;
char username[MAXUSERLEN];
// ...
} AFPObj;

// etc/afpd/main.c
static AFPObj obj;

pointer demangle

在实现往某个固定地址写入可控内容后,便可以按照上述思路来伪造tls_dtor_list了。不过,在__call_tls_dtors()中还有一个小问题:在(4)处涉及到pointer demangle,在伪造tls_dtor_list时不能直接使用函数地址如system()来填充func字段,而是需要一个编码后的地址。

glibc的版本有关,可能在更早的glibc版本中这里不涉及pointer demangle

1
2
3
4
5
6
7
8
9
10
11
12
13
void __call_tls_dtors (void)
{
while (tls_dtor_list)
{
struct dtor_list *cur = tls_dtor_list; // (3)
dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (func); // (4)
#endif

tls_dtor_list = tls_dtor_list->next;
func (cur->obj); // (5)
// ...

具体地,看一下宏定义PTR_DEMANGLEPTR_MANGLE,如下。PTR_MANGLE(var)的作用相当于rol((var ^ pointer_guard), 0x11, 64),而PTR_DEMANGLE(var)相当于ror(var, 0x11, 64) ^ pointer_guard,而pointer_guard则对应tcbhead_t结构体中的pointer_guard字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
// sysdeps/unix/sysv/linux/x86_64/sysdep.h
# define PTR_MANGLE(var) asm ("xor %%fs:%c2, %0\n" \
"rol $2*" LP_SIZE "+1, %0" \
: "=r" (var) \
: "0" (var), \
"i" (offsetof (tcbhead_t, \
pointer_guard)))
# define PTR_DEMANGLE(var) asm ("ror $2*" LP_SIZE "+1, %0\n" \
"xor %%fs:%c2, %0" \
: "=r" (var) \
: "0" (var), \
"i" (offsetof (tcbhead_t, \
pointer_guard)))

因此,为了能够伪造tls_dtor_listfunc字段,还需要想办法获取pointer_guard

stack_guard/pointer_guard泄露

想要获取到pointer_guard,一种方式是利用信息泄露来获取,或者通过溢出覆盖的方式将TLS区域中的该字段修改为已知的值,这里先讨论后面一种思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
// sysdeps/x86_64/nptl/tls.h
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
// ...
} tcbhead_t;

回顾下tcbhead_t结构体的内容,在pointer_guard字段前存在另一个字段stack_guard,这个字段正是对应stack canary的值。如果通过溢出的方式覆盖pointer_guard字段,肯定也会覆盖stack_guard字段。afpd程序启用了stack canary机制,在调用dsi_stream_read()造成溢出返回后,由于stack canary校验失败,会触发___stack_chk_fail(),程序崩溃。因此需要先想办法获取stack_guard

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.text:00007FFFF7B7502B loc_7FFFF7B7502B:                       ; CODE XREF: dsi_stream_receive+1EA↓j
.text:00007FFFF7B7502B mov rcx, [rsp+48h+var_30]
.text:00007FFFF7B75030 xor rcx, fs:28h ; <=== fs:28h, 即对应tcbhead_t结构体中的stack_guard字段
.text:00007FFFF7B75039 jnz loc_7FFFF7B75234
.text:00007FFFF7B7503F add rsp, 20h
.text:00007FFFF7B75043 pop rbx
.text:00007FFFF7B75044 pop rbp
.text:00007FFFF7B75045 pop r12
.text:00007FFFF7B75047 pop r13
.text:00007FFFF7B75049 pop r14
.text:00007FFFF7B7504B retn
; ...
.text:00007FFFF7B75234 loc_7FFFF7B75234: ; CODE XREF: dsi_stream_receive+49↑j
.text:00007FFFF7B75234 call ___stack_chk_fail

幸运地是,afpd程序处理会话采用了fork机制,利用这一机制可以泄露出stack_guard。具体地,针对每个新的会话,afpd程序会fork1个子进程,然后交由子进程进行处理。而子进程中的stack_guardpointer_guard等字段来自于父进程,同时子进程的崩溃对父进程没有影响。因此,可以采用类似Blind Rop的思路,通过逐字节覆盖stack_guard的方式来进行泄露:当覆盖stack_guard中的某个字节时,如果填充的字节与原始值相同,程序会按照正常的流程继续执行,socket连接正常;如果不一致则会造成后续stack canary校验失败,触发___stack_chk_fail(),程序崩溃,socket连接被关闭。基于这一差异,结合afpdfork机制,可以按字节逐位对stack_guard进行爆破,从而泄露stack_guard

在获取到stack_guard后,可以采用类似的思路对pointer_guard进行泄露。不过,由于在exit()后续流程中只有func字段那里涉及到pointer demangle,可以通过溢出覆盖的方式直接将其修改为指定的值。

在采用类似思路泄露pointer_guard时会存在一个小问题:正常情况下,PTR_DEMANGLE(var)的结果对应某个函数地址,而在进行爆破的过程中,只能区分出PTR_DEMANGLE(var)的结果为一个有效的指令地址,也就是说结果的低2个字节可以在一定范围内变动,比如上面汇编代码中的0x7FFFF7B7502B0x7FFFF7B75234均为有效的指令地址。因此,爆破出的pointer_guard的低2个字节可能会存在多种可能(取决于具体的情况),不过可能的组合数应该不会太多,爆破出来后逐一尝试即可。

到目前为止,所有的问题都解决了,可以采用伪造tls_dtor_list的方式来实现控制流劫持了,如下。其中,由于afpd程序中没有调用system(),而采用函数如popen()afprun()等时存在其他问题,故通过execl("/bin/bash", "bash", "-c", <cmd>, 0)来实现命令执行。虽然调用execl()时参数传递相对麻烦,但通过合适的布局,可使其前5个参数均可控。

下图中标注的execl_addr仅为方便描述,实际填充的值应为PTR_MANGLE(execl_addr)

小结

本文基于群晖DSM 6.1.7-15284版本,对Pwn2Own Tokyo 2020比赛上DEVCORE团队使用的堆溢出漏洞进行了分析。漏洞的触发相对比较简单,漏洞的利用思路则参考了@Angelboy的议题《Your NAS is not your NAS !》,介绍通过伪造tls_dtor_list来实现代码执行的目的,并对其中的一些关键点如寻找内容可控的地址空间、stack_guard泄露、pointer demangle等进行了细致分析。之前对tls_dtor_list这块不太了解,在完成漏洞利用的过程中学到了很多,感兴趣地可以自己动手搭建环境试试。

补充

根据前面的利用思路,溢出并进行合适的布局后,需要调用exit()来触发。在@Angelboy给出的hitcon metatalk writeup中,利用思路与其议题分享中的类似,但是缺少了爆破stack_guard这一环节。在请教@Angelboy后,其主要是利用了timeout handler机制。在netatalk中,函数alarm_handler()用于SIGALRM信号的处理,当触发alarm_handler()并满足一定条件后,其内部会调用exit()。因此,通过构造数据包使得dsi_doff字段的值大于实际发送的afp payload的长度,造成调用dsi_stream_read()时等待并超时,就有可能在溢出之后自动触发exit(),避免了dsi_stream_receive()返回时存在的___stack_chk_fail()问题。

在题目metatalk中,对应的sleep timedisconnect time均设置为0,而在Synology NAS DSM 6.1.7-15284中,afp.conf中的部分配置为:timeout = 8, sleep time = 8, disconnect time = 48alarm_handler相关的代码如下,可知,alarm_handler()大约每30秒触发一次,若想触发afp_dsi_die(),在其他条件均满足时,似乎至少需要触发alarm_handler() 8次才行。

针对该方法,暂未在实际设备上进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// in afp_config_parse()
options->tickleval = atalk_iniparser_getint (config, INISEC_GLOBAL, "tickleval", 30);
options->timeout = atalk_iniparser_getint (config, INISEC_GLOBAL, "timeout", 4);
options->sleep = atalk_iniparser_getint (config, INISEC_GLOBAL, "sleep time", 10);
options->disconnected = atalk_iniparser_getint (config, INISEC_GLOBAL, "disconnect time",24);

// in dsi_getsession()
case DSIFUNC_OPEN: /* setup session */
/* set up the tickle timer */
dsi->timer.it_interval.tv_sec = dsi->timer.it_value.tv_sec = tickleval;
dsi->timer.it_interval.tv_usec = dsi->timer.it_value.tv_usec = 0;
dsi_opensession(dsi);
*childp = NULL;
return 0;

// in afp_over_dsi_sighandlers()
action.sa_handler = alarm_handler;
if ((sigaction(SIGALRM, &action, NULL) < 0) ||
(setitimer(ITIMER_REAL, &dsi->timer, NULL) < 0)) {
afp_dsi_die(EXITERR_SYS);
}

static void alarm_handler(int sig _U_)
{
// ...
dsi->tickle++;
if (dsi->flags & DSI_SLEEPING) {
if (dsi->tickle > AFPobj->options.sleep) {
LOG(log_note, logtype_afpd, "afp_alarm: sleep time ended");
afp_dsi_die(EXITERR_CLNT);
}
return;
}

if (dsi->flags & DSI_DISCONNECTED) {
// check username instead of euid
if (!dsi->AFPobj || !dsi->AFPobj->username)
{
LOG(log_note, logtype_afpd, "afp_alarm: unauthenticated user, connection problem");
afp_dsi_die(EXITERR_CLNT);
}
if (dsi->tickle > AFPobj->options.disconnected) {
LOG(log_error, logtype_afpd, "afp_alarm: reconnect timer expired, goodbye");
afp_dsi_die(EXITERR_CLNT);
}
return;
}

/* if we're in the midst of processing something, don't die. */
if (dsi->tickle >= AFPobj->options.timeout) {
LOG(log_error, logtype_afpd, "afp_alarm: child timed out, entering disconnected state");
if (dsi_disconnect(dsi) != 0)
afp_dsi_die(EXITERR_CLNT);
return;
}
// ...
}

相关链接

  • Synology DiskStation Manager Netatalk dsi_doff Heap-based Buffer Overflow Remote Code Execution Vulnerability
  • HITCON21-你的 NAS 不是你的 NAS !
  • Blog: Your NAS is not your NAS !
  • hitcon CTF 2021 - metatalk
  • Data Stream Interface
  • Glibc TLS的实现与利用
  • Notes on abusing exit handlers, bypassing pointer mangling and glibc ptmalloc hooks