D-Link DIR-605L 拒绝服务错误报告 (CVE-2017-9675)

原文: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官网下载了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已知有关管道的漏洞,可用于获取权限升级。下面的代码段来自这个漏洞(https://www.exploit-db.com/exploits/33322/),将漏洞来源与在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(https://wiki.sei.cmu.edu/confluence/display/c/POS38-C.+Beware+of+race+conditions+when+using+fork+and+file+descriptors):

当fork子进程时,文件描述符会被复制到子进程中,这可能会导致文件的并发操作。对同一个文件进行并发操作会导致数据以不确定的顺序下被读写,造成竞争条件和不可预知的行为。

结 论

到这里我的分析就结束了,除了我对C语言和MIPS的有限知识外,二进制文件模拟环境的难度降低了对我测试理论的能力要求,并得出了一个明确的结论。接下来,我将对Boa的补丁版本进行逆向并确定修复。

参 考 链 接

[1] Mitre: CVE-2017-9675

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-9675

[2] DIR-605L Firmware Downloads

http://support.dlink.com/productinfo.aspx?m=DIR-605L

[3] D-Link DIR-605L Security Advisory

ftp://ftp2.dlink.com/SECURITY_ADVISEMENTS/DIR-605L/REVB/DIR-605L_REVB_RELEASE_NOTES_v2.08UIBETAB01_EN.pdf

[4] Boa 0.94.14rc21 Source

http://www.boa.org/boa-0.94.14rc21.tar.gz

[5] Linux Kernel 2.6.x ‘pipe.c’ Privilege Escalation

https://www.exploit-db.com/exploits/33322/

[6] POS38-C. Beware of race conditions when using fork and file descriptors

https://www.securecoding.cert.org/confluence/display/c/POS38-C.+Beware+of+race+conditions+when+using+fork+and+file+descriptors

原文发布于微信公众号 - Seebug漏洞平台(seebug_org)

原文发表时间:2017-11-28

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏人工智能LeadAI

今天不如来复习下Python基础

01 python是什么? Python是一种解释型语言。这就是说,与C语言和C的衍生语言不同,Python代码在运行之前不需要编译。其他解释型语言还包括PH...

3955
来自专栏编程

近期做的比较好的web

? 本文作者:p0desta。 感谢p0desta。来稿,本文稿费100元。持续小广告:各位大佬有安全方面新的创作都可以向小编砸过来,将文章以Word形式发送...

2088
来自专栏SDNLAB

OpenFlow协议库开发者指南

介绍 OpenFlow协议库是OpenDaylight的一个组件,调解OpenDaylight controller和支持OpenFlow协议的硬件设备之间通...

5348
来自专栏玄魂工作室

看代码学PHP渗透(3) - 实例化任意对象漏洞

大家好,我们是红日安全-代码审计小组。最近我们小组正在做一个PHP代码审计的项目,供大家学习交流,我们给这个项目起了一个名字叫 PHP-Audit-Labs 。...

5731
来自专栏有趣的django

35.Django2.0文档

第四章 模板  1.标签 (1)if/else {% if %} 标签检查(evaluate)一个变量,如果这个变量为真(即,变量存在,非空,不是布尔值假),系...

56710
来自专栏阿杜的世界

Redis学习札记

Redis支持两种持久化方式,一种是RDB方式(快照:根据指定的规则“定时”将内存中的数据存储在硬盘上),另一种是AOF方式(在每次执行命令后都将命令本身记录下...

1083
来自专栏C++

python笔记:#002#第一个python程序

1154
来自专栏Java Web

Java I/O不迷茫,一文为你导航!

学习过计算机相关课程的童鞋应该都知道,I/O 即输入Input/ 输出Output的缩写,最容易让人联想到的就是屏幕这样的输出设备以及键盘鼠标这一类的输入设备,...

1052
来自专栏前端那些事

Express4.x API (三):Response (译)

Express4.x API 译文 系列文章 技术库更迭较快,很难使译文和官方的API保持同步,更何况更多的大神看英文和中文一样的流畅,不会花时间去翻译--,所...

17310
来自专栏北京马哥教育

MongoDB多纬度监控方法详解

一、mongostat工具方法 mongostat是mongdb自带的状态检测工具,在命令行下使用。它会间隔固定时间获取mongodb的当前运行状态,并输出。如...

4535

扫码关注云+社区