原文:http://hypercrux.com/bug-report/2017/06/19/DIR605L-DoS-BugReport/
譯者:Serene

介紹

由于去年掀起的物聯網/可嵌入設備安全事件的浪潮,我開始有興趣尋找附近和家中使用設備的漏洞。因為我知道大多數這些設備都存在安全和隱私問題,所以一開始我自己并沒有很多這樣的設備。我從一箱舊路由器中選擇了D-Link DIR-615L,事實證明這是研究的一個很好的開始。

在幾周的嘗試之后,我發現了一個通過發送GET請求到它的web服務器就能允許我重啟路由器的漏洞,我決定重點研究這個漏洞,并試圖找到漏洞出現的位置和根本原因。由于我對C語言和MIPS匯編了解的知識有限,這些嘗試對我來說是很好的挑戰和學習經驗。總的來說,這是一個有趣的項目,并且我因此得到了第一個CVE,這是我第一次向廠商報告漏洞,D-Link很快作了回應并修復了這個漏洞,太讓人高興了。

以下是我提交給D-Link的報告,包括我的發現以及漏洞的潛在成因。現在已經發布了補丁,我想將更新的可執行文件與有漏洞的可執行文件進行比較,明確補丁程序和修復程序的確切位置,之后會有一個后續的文章來講這個分析結果。

DIR-605L通過HTTP GET拒絕服務

在嘗試通過瀏覽器URL來訪問web根目錄下的已知文件時,服務器的響應掛在http://192.168.1.1/common/請求上,我注意到路由器正在自己重啟/重置:連接完全斷開了,系統LED燈在啟動時閃爍。這個行為只有在目錄尾部“/”被包含時,才會被觸發。更進一步的測試表明,只有GET請求時會導致崩潰,HEAD請求會導致服務器的空的200 OK響應,并不會崩潰。這些結果讓我有理由相信,導致崩潰的原因在Boa web服務器的某個位置。

細節:
  • 設備:D-Link DIR-605L, B型
  • 有漏洞的固件版本:2.08UIB01及以前的版本。2.08UIBETA01版本得以修復。
  • 攻擊向量:未認證的HTTP GET請求
  • 影響:拒絕服務
  • CVE:CVE-2017-9675
PoC:

curl http://192.168.1.1/common/

靜態代碼分析:

我從官網下載了Boa web服務器的匹配版本,路由器上服務器響應的“Server”字符串表明它使用的是0.94.14rc21版本。我知道這是一個修改后的版本,以apmib.so的自定義庫和其它可能的修改構建,但這與我想要得到的源代碼非常接近。路由器上存在的boa二進制文件的一些細節:

hyper@ubuntu:~/squashfs-root-0$ mips-linux-gnu-objdump -f bin/boa

bin/boa:     file format elf32-tradbigmips
architecture: mips:3000, flags 0x00000102:
EXEC_P, D_PAGED
start address 0x00407400

因為漏洞只會由GET請求觸發,我推測漏洞發生在處理GET的函數中的某個地方,并且只在那些處理目錄GET的函數中,另外,只有包含尾部"/"的目錄請求會觸發漏洞,這意味著修改或使用URL字符串的函數可能是罪魁禍首。

在提取下載的文件后,我開始閱讀源代碼,尋找可能包含處理請求的代碼。果然,在src/目錄中有一個命名為 request.c 的文件,于是我從這里開始著手。這個文件中包含了很多處理請求的函數,它們大多數在src / globals.h中定義的request結構上運行。這里有存儲請求的路徑名和打開文件的文件描述符的成員變量,等等。

process_requests()

處理請求自然在process_requests()函數中開始,如果隊列上有待處理的請求,那么另一個名為get_request()的函數會被調用來從隊列中提取請求。這個函數在返回一個到初始化req結構的指針之前,調用其它的一些函數來執行一些基本的清理和處理。如果在幾次超時和錯誤檢查之后所有都恢復正常,那么switch..case語句將開始迭代處理請求。

if (retval == 1) {
            switch (current->status) {
            case READ_HEADER:
            case ONE_CR:
            case ONE_LF:
            case TWO_CR:
                retval = read_header(current);
                break;
            case BODY_READ:
                retval = read_body(current);
                break;
            case BODY_WRITE:
                retval = write_body(current);
                break;
            case WRITE:
                retval = process_get(current);
                break;
            case PIPE_READ:
                retval = read_from_pipe(current);
                break;
            case PIPE_WRITE:
                retval = write_from_pipe(current);
                break;
            case IOSHUFFLE:
            [...]
            }
process_requests() -> read_header()

第一次調用是read.c:read_header(current),“current”是指向正在操作的請求結構的指針。在執行一些操作來讀取請求的頭部,并設置上面switch語句中用到的一些標志之后,指向“current”的指針被傳遞給位于request.c中的函數request.c:process_logline()

代碼注釋中的功能描述:

/*
 * Name: process_logline
 *
 * Description: This is called with the first req->header_line received
 * by a request, called "logline" because it is logged to a file.
 * It is parsed to determine request type and method, then passed to
 * translate_uri for further parsing.  Also sets up CGI environment if
 * needed.
 */

request.c:process_logline()解析請求URI并處理錯誤,例如格式錯誤的請求或無效的URI長度等等。這個函數在處理請求URI,這引起了我的注意,因為只有在向函數的請求中包含了尾部“/”,才會觸發該漏洞,所以我想這可能與URI/路徑名解析函數有關。經過一段時間審視代碼后,我得出結論,漏洞不是在這個函數中引起的,繼續往前找。

一旦process_logline()返回read_header(),下一個根據當前請求運行的函數是request.c: process_header_end(),因為req-> status之前已經被設置為BODY_READ。以下代碼段來自read_header()

            } else {
                if (process_logline(req) == 0)
                    /* errors already logged */
                    return 0;
                if (req->http_version == HTTP09)
                    return process_header_end(req);
            }
            /* set header_line to point to beginning of new header */
            req->header_line = check;
        } else if (req->status == BODY_READ) {
#ifdef VERY_FASCIST_LOGGING
            int retval;
            log_error_time();
            fprintf(stderr, "%s:%d -- got to body read.\n",
                    __FILE__, __LINE__);
            retval = process_header_end(req);
#else
            int retval = process_header_end(req);
#endif
            /* process_header_end inits non-POST CGIs */
process_requests() -> read_header() -> process_header_end()

如代碼注釋中的描述所示,在調用get.c:init_get()之前,request.c:process_header_end()函數會對請求執行一些最終檢查。這些測試中大多數是檢查req-> request_uri的無效字符或格式錯誤的輸入。我看了一下這些函數,看看這個漏洞是否位于其中一個,但似乎并非如此。

/*
 * Name: process_header_end
 *
 * Description: takes a request and performs some final checking before
 * init_cgi or init_get
 * Returns 0 for error or NPH, or 1 for success
 */

int process_header_end(request * req)
{
    if (!req->logline) {
        log_error_doc(req);
        fputs("No logline in process_header_end\n", stderr);
        send_r_error(req);
        return 0;
    }

    /* Percent-decode request */
    if (unescape_uri(req->request_uri, &(req->query_string)) == 0) {
        log_error_doc(req);
        fputs("URI contains bogus characters\n", stderr);
        send_r_bad_request(req);
        return 0;
    }

    /* clean pathname */
    clean_pathname(req->request_uri);

    if (req->request_uri[0] != '/') {
        log_error("URI does not begin with '/'\n");
        send_r_bad_request(req);
        return 0;
    }

    [...]

    if (translate_uri(req) == 0) { /* unescape, parse uri */
        /* errors already logged */
        SQUASH_KA(req);
        return 0;               /* failure, close down */
    }
    [...]

    if (req->cgi_type) {
        return init_cgi(req);
    }

    req->status = WRITE;

    return init_get(req);       /* get and head */
}

所有檢查完成后,還有一個檢查看'req-> cgi_type'是否已被初始化。由于沒有設置這個變量,檢查失敗了,而是'req-> status'被設置為WRITE,init_get()被調用,并且它的返回值被用作process_header_end()返回值。

process_requests() -> read_header() -> process_header_end() -> init_get()

從下面get.c:init_get()的描述中看,我可以說這個請求將遵循這個路徑,因為它是一個非腳本GET請求。

/*
 * Name: init_get
 * Description: Initializes a non-script GET or HEAD request.
 */

int init_get(request * req)
{
    int data_fd, saved_errno;
    struct stat statbuf;
    volatile unsigned int bytes_free;

    data_fd = open(req->pathname, O_RDONLY);
    saved_errno = errno;        /* might not get used */

    [...]

    fstat(data_fd, &statbuf);

一個整型變量被聲明來保存打開路徑的結果文件描述符和一個名為statbuf的stat結構。statbuf保存關于打開文件狀態的信息,它被初始化調用fstat()

在測試看路徑是否被成功打開后,接著檢查看是否是一個目錄,在觸發漏洞的請求情況下這將為true。打開文件描述符是關閉的,然后執行檢查來看請求的最后一個字符是不是“/”,這將為false,所以后面的代碼會被跳過。

    if (S_ISDIR(statbuf.st_mode)) { /* directory */
        close(data_fd);         /* close dir */

        if (req->pathname[strlen(req->pathname) - 1] != '/') {
            char buffer[3 * MAX_PATH_LENGTH + 128];
            unsigned int len;

            [...]
        }

        data_fd = get_dir(req, &statbuf); /* updates statbuf */

        if (data_fd < 0)      /* couldn't do it */
            return 0;           /* errors reported by get_dir */
        else if (data_fd == 0 || data_fd == 1)
            return data_fd;
        /* else, data_fd contains the fd of the file... */
    }
}

下一個將要執行的代碼段,將在調用get_dir()時開始。

process_requests() -> read_header() -> process_header_end() -> init_get() -> get_dir()

這一點上,我認為get.c:get_dir()可能包含了導致崩潰的函數調用,因為直到這一點所有發生的事情都適用于非目錄的請求。現有的常規文件沒有請求觸發崩潰,這意味著它一定在與打開目錄有關的函數中。

/*
 * Name: get_dir
 * Description: Called from process_get if the request is a directory.
 * statbuf must describe directory on input, since we may need its
 *   device, inode, and mtime.
 * statbuf is updated, since we may need to check mtimes of a cache.
 * returns:
 *  -1 error
 *  0  cgi (either gunzip or auto-generated)
 *  >0  file descriptor of file
 */

int get_dir(request * req, struct stat *statbuf)
{

    char pathname_with_index[MAX_PATH_LENGTH];
    int data_fd;

    if (directory_index) {      /* look for index.html first?? */

    [...]

這個函數首先檢查請求目錄中的index.html文件,因為這將是false(在請求目錄中沒有名為index.html的文件存在),執行將跳過下面的代碼段。

注意:'dirmaker'是一個指向char數組的指針,它使用在boa.conf中配置的DirectoryMaker值進行初始化。在通過telnet檢查路由器上設置了什么之后,我看到它被配置為使用'/ usr / lib / boa / boa_indexer',這在路由器上是不存在的文件。這可能是也可能不是導致漏洞的原因,我將在下一部分中解釋。

    /* only here if index.html, index.html.gz don't exist */
    if (dirmaker != NULL) {     /* don't look for index.html... maybe automake? */
        req->response_status = R_REQUEST_OK;
        SQUASH_KA(req);

        /* the indexer should take care of all headers */
        if (req->http_version != HTTP09) {
            req_write(req, http_ver_string(req->http_version));
            req_write(req, " 200 OK" CRLF);
            print_http_headers(req);
            print_last_modified(req);
            req_write(req, "Content-Type: text/html" CRLF CRLF);
            req_flush(req);
        }
        if (req->method == M_HEAD)
            return 0;

        return init_cgi(req);
        /* in this case, 0 means success */
    } else if (cachedir) {
        return get_cachedir_file(req, statbuf);
    } else {                    /* neither index.html nor autogenerate are allowed */
        send_r_forbidden(req);
        return -1;              /* nothing worked */
    }
}

在這一塊中,有一個寫入服務器回復HTTP 200響應的內部塊,在這一塊最后有一個檢查來看是否請求方法是HEAD,如果是的,函數返回為0.當我們發送HEAD請求時,這里就是函數停止的位置,并且不會發生崩潰。如果該請求方法不是HEAD,那么這個塊返回為init_cgi()

process_requests() -> read_header() -> process_header_end() -> init_get() -> get_dir() -> init_cgi()

如下面代碼段所示,init_cgi()首先聲明幾個變量將為以后所用,這里有一個檢查看是否已經設置了req-> cgi_type,因為它還沒有設置,所以被跳過了。下一部分的代碼包含了一個檢查,來看是否req->pathname的最后一個字符等于“/”,以及req->cgi_type還沒有設置。這個評估是true,它將use_pipes設置為1,打開一個未命名的管道,它讀取和寫入fd的存儲在管道[]中。

int init_cgi(request * req)
{
    int child_pid;
    int pipes[2];
    int use_pipes = 0;

    SQUASH_KA(req);

    if (req->cgi_type) {
        if (complete_env(req) == 0) {
            return 0;
        }
    }
    DEBUG(DEBUG_CGI_ENV) {
        int i;
        for (i = 0; i < req->cgi_env_index; ++i)
            log_error_time();
            fprintf(stderr, "%s - environment variable for cgi: \"%s\"\n",
                    __FILE__, req->cgi_env[i]);
    }

    /* we want to use pipes whenever it's a CGI or directory */
    /* otherwise (NPH, gunzip) we want no pipes */
    if (req->cgi_type == CGI ||
        (!req->cgi_type &&
         (req->pathname[strlen(req->pathname) - 1] == '/'))) {
        use_pipes = 1;
        if (pipe(pipes) == -1) {
            log_error_doc(req);
            perror("pipe");
            return 0;
        }

如果打開管道時沒有錯誤,fork()會被調用,它的返回值會被儲存。然后switch語句檢查fork()的返回值,如果fork成功,那么case 0是true,并且接下來執行的代碼(在子進程中)會是檢查‘use_pipes’的if語句中的代碼塊,因為這會返回true。

child_pid = fork();
switch (child_pid) {
case -1:
    /* fork unsuccessful */
    /* FIXME: There is a problem here. send_r_error (called by
        * boa_perror) would work for NPH and CGI, but not for GUNZIP.  
        * Fix that. 
        */
    boa_perror(req, "fork failed");
    if (use_pipes) {
        close(pipes[0]);
        close(pipes[1]);
    }
    return 0;
    break;
case 0:
    /* child */
    reset_signals();

    if (req->cgi_type == CGI || req->cgi_type == NPH) {
        /* SKIPPED */
    }

    if (use_pipes) {
        /* close the 'read' end of the pipes[] */
        close(pipes[0]);
        /* tie CGI's STDOUT to our write end of pipe */
        if (dup2(pipes[1], STDOUT_FILENO) == -1) {
            log_error_doc(req);
            perror("dup2 - pipes");
            _exit(EXIT_FAILURE);
        }
        close(pipes[1]);
    }

正如代碼注釋中描述的,之前打開的管道的‘read’端被關閉了,STDOUT使用dup2()綁定到管道的‘write’端。最后,如果所有成功完成,下一個相關的代碼段將是如下所示。

        /*
         * tie STDERR to cgi_log_fd
         * cgi_log_fd will automatically close, close-on-exec rocks!
         * if we don't tie STDERR (current log_error) to cgi_log_fd,
         *  then we ought to tie it to /dev/null
         *  FIXME: we currently don't tie it to /dev/null, we leave it
         *  tied to whatever 'error_log' points to.  This means CGIs can
         *  scribble on the error_log, probably a bad thing.
         */
        if (cgi_log_fd) {
            dup2(cgi_log_fd, STDERR_FILENO);
        }

        if (req->cgi_type) {
            char *aargv[CGI_ARGC_MAX + 1];
            create_argv(req, aargv);
            execve(req->pathname, aargv, req->cgi_env);
        } else {
            if (req->pathname[strlen(req->pathname) - 1] == '/')
                execl(dirmaker, dirmaker, req->pathname, req->request_uri,
                      (void *) NULL);

因為req->cgi_type還沒有設置,所以檢查它的值的if語句之后的代碼塊被跳過了,而是執行else語句后面的塊,這將檢查是否req->pathname最后的字符是‘/’。如果是路徑名導致了崩潰的情況下,這個評估將是true。execl()被這樣調用:

execl(dirmaker, dirmaker, req->pathname, req->request_uri, (void *) NULL);

潛在的根本原因

execl()的錯誤使用

前面提到過,'dirmaker'是一個指向char數組的指針,它使用在boa.conf中配置的DirectoryMaker值進行初始化(在路由器的情況下,這是‘/usr/lib/boa/boa_indexer’,一個不在系統中存在的文件)。這有可能是導致崩潰的潛在原因。

來自http://pubs.opengroup.org/onlinepubs/7908799/xsh/execl.html

如果過程映像文件不是有效的可執行對象,execlp()和execvp()使用該文件內容作為符合system()的命令解釋器的標準輸入。在這種情況下,命令解釋器成為新的過程映像。

另一個可能是傳遞給函數的最后一個參數。

來自手冊exec()

execl(), execlp(), 和 execle()函數中的const char * arg和后續的省略號可以被認為是arg0, arg1, …, argn. 參數列表必須被一個空指針終止,并且因為這些是可變參數函數,指針必須強制轉換(char *)NULL。

看一下調用execl()的方法,表明了最后參數強制轉換(void *) NULL,而不是(char *) NULL,我一直沒找到任何文件表明這是絕對必須的,以及如果使用不同類型的指針,會發生什么情況。

在2.6.x內核中對管道的不安全使用

最后,這個漏洞也可能是管道和文件描述符的不安全使用的結果,如init_cgi()所示。Linux內核版本2.6.x已知有關管道的漏洞,可用于獲取權限升級。下面的代碼段來自這個漏洞,將漏洞來源與在Boa中的潛在漏洞函數相比較,我們可以看到在調用fork()的上下文中,有非常類似的管道使用。

    {
        pid = fork();
        if (pid == -1)
        {
            perror("fork");
            return (-1);
        }
        if (pid)
        {
            char path[1024];
            char c;
            /* I assume next opened fd will be 4 */
            sprintf(path, "/proc/%d/fd/4", pid);
                printf("Parent: %d\nChild: %d\n", parent_pid, pid); 
            while (!is_done(0))
            {
                fd[0] = open(path, O_RDWR);
                if (fd[0] != -1)
                {
                    close(fd[0]);
                }
            }
            //system("/bin/sh");
            execl("/bin/sh", "/bin/sh", "-i", NULL);
            return (0);
        }

來自安全編碼,CERT

當fork子進程時,文件描述符會被復制到子進程中,這可能會導致文件的并發操作。對同一個文件進行并發操作會導致數據以不確定的順序下被讀寫,造成競爭條件和不可預知的行為。

結論

到這里我的分析就結束了,除了我對C語言和MIPS的有限知識外,二進制文件模擬環境的難度降低了對我測試理論的能力要求,并得出了一個明確的結論。接下來,我將對Boa的補丁版本進行逆向并確定修復。

參考


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