原文: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的補丁版本進行逆向并確定修復。
參考
- Mitre: CVE-2017-9675
- DIR-605L Firmware Downloads
- D-Link DIR-605L Security Advisory
- Boa 0.94.14rc21 Source
- Linux Kernel 2.6.x ‘pipe.c’ Privilege Escalation
- POS38-C. Beware of race conditions when using fork and file descriptors
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/464/