lighttpd历史漏洞分析

最近分析了不少IoT设备使用的嵌入式web服务器,发现不少直接使用lighttpd或者魔改lighttpd的例子,对这个开源的httpd产生了些许兴趣,所以来分析一波近两年内lighttpd的历史漏洞,顺便整理一下自己的思路
为什么选择这两个并不年轻的漏洞呢?这大概是因为嵌入式设备更新缓慢导致漏洞长时间存在,漏洞价值依旧较高。

什么是lighttpd

Lighttpd(读作lighty)是一款以BSD许可证开源的Web Server,一个专门针对高性能网站,安全、快速、兼容性好并且灵活的Web Server。正因为具有非常低的内存开销、CPU 占用率低、效能好、模块类型丰富等特点,lighttpd成为了使用量最广的嵌入式web Server之一。

Lighttpd目前支持 FastCGI, CGI, Auth, 输出压缩(output compress), URL 重写, Alias 等重要功能。

Lighttpd起源于针对C10K问题的概念验证程序,目前仍然在被维护,已经更新到了1.4.56版本。

CVE-2019-11072

该漏洞源于1.4.52版本的功能更新,添加了多个httpd启动参数选项,开始支持如下功能

server.http-parseopts = (
        "header-strict"            => "enable",
        "host-strict"              => "enable",
        "host-normalize"           => "enable",
        "url-normalize"            => "enable",
        "url-normalize-unreserved" => "enable",
        "url-normalize-required"   => "enable",
        "url-ctrls-reject"         => "enable",
        "url-path-2f-decode"       => "enable",
        "url-path-dotseg-remove"   => "enable",
        "url-query-20-plus"        => "enable"
)

url-path-2f-decode功能中,%2f将被解码为/,具体的实现位于src/burl.c文件中,如下所示,其中(buffer*)b->ptr存储的是URL路径,qs变量指的是query string长度,i变量指的是%2F所在下标。

static int burl_normalize_2F_to_slash_fix (buffer *b, int qs, int i)
{
    char * const s = b->ptr;
    const int blen = (int)buffer_string_length(b);
    const int used = qs < 0 ? blen : qs;
    int j = i;
    for (; i < used; ++i, ++j) {
        s[j] = s[i];
        if (s[i] == '%' && s[i+1] == '2' && s[i+2] == 'F') {
            s[j] = '/';
            i+=2;
        }
    }
    if (qs >= 0) {
        memmove(s+j, s+qs, blen - qs);
        j += blen - qs;
    }
    buffer_string_set_length(b, j);
    return qs;
}

而URL路径整体的标准化实现代码如下

int burl_normalize (buffer *b, buffer *t, int flags)
{
    int qs;

  #if defined(__WIN32) || defined(__CYGWIN__)
    /* Windows and Cygwin treat '\\' as '/' if '\\' is present in path;
     * convert to '/' for consistency before percent-encoding
     * normalization which will convert '\\' to "%5C" in the URL.
     * (Clients still should not be sending '\\' unencoded in requests.) */
    if (flags & HTTP_PARSEOPT_URL_NORMALIZE_PATH_BACKSLASH_TRANS) {
        for (char *p = b->ptr; *p != '?' && *p != '\0'; ++p) {
            if (*p == '\\') *p = '/';
        }
    }
  #endif

    qs = (flags & HTTP_PARSEOPT_URL_NORMALIZE_REQUIRED)
      ? burl_normalize_basic_required(b, t)
      : burl_normalize_basic_unreserved(b, t);
    if (-2 == qs) return -2;

    if (flags & HTTP_PARSEOPT_URL_NORMALIZE_CTRLS_REJECT) {
        if (burl_contains_ctrls(b)) return -2;
    }

    if (flags & (HTTP_PARSEOPT_URL_NORMALIZE_PATH_2F_DECODE
                |HTTP_PARSEOPT_URL_NORMALIZE_PATH_2F_REJECT)) {
        qs = burl_normalize_2F_to_slash(b, qs, flags);
        if (-2 == qs) return -2;
    }

    if (flags & (HTTP_PARSEOPT_URL_NORMALIZE_PATH_DOTSEG_REMOVE
                |HTTP_PARSEOPT_URL_NORMALIZE_PATH_DOTSEG_REJECT)) {
        qs = burl_normalize_path(b, t, qs, flags);
        if (-2 == qs) return -2;
    }

    if (flags & HTTP_PARSEOPT_URL_NORMALIZE_QUERY_20_PLUS) {
        if (qs >= 0) burl_normalize_qs20_to_plus(b, qs);
    }

    return qs;
}

看上去没啥问题,但其实暗藏玄机!burl_normalize_2F_to_slash_fix这句memmove(s+j, s+qs, blen - qs)中的qs在解码后并未缩小,后续burl_normalize_path再次使用qs时,在裁剪query string时有可能导致负溢,后续裁剪URL代码如下

static int burl_normalize_path (buffer *b, buffer *t, int qs, int flags)
{
    const unsigned char * const s = (unsigned char *)b->ptr;
    const int used = (int)buffer_string_length(b);
    int path_simplify = 0;
    for (int i = 0, len = qs < 0 ? used : qs; i < len; ++i) {
        if (s[i] == '.' && (s[i+1] != '.' || ++i)
            && (s[i+1] == '/' || s[i+1] == '?' || s[i+1] == '\0')) {
            path_simplify = 1;
            break;
        }
        while (i < len && s[i] != '/') ++i;
        if (s[i] == '/' && s[i+1] == '/') { /*(s[len] != '/')*/
            path_simplify = 1;
            break;
        }
    }

    if (path_simplify) {
        if (flags & HTTP_PARSEOPT_URL_NORMALIZE_PATH_DOTSEG_REJECT) return -2;
        if (qs >= 0) {
            buffer_copy_string_len(t, b->ptr+qs, used - qs);
            buffer_string_set_length(b, qs);
        }

        buffer_path_simplify(b, b);

        if (qs >= 0) {
            qs = (int)buffer_string_length(b);
            buffer_append_string_len(b, CONST_BUF_LEN(t));
        }
    }

    return qs;
}

注意这里的used - qs,后文会提到

现在我们开启url-path-2f-decode功能,把gdb挂到lighttpd测试一下,lighttpd在配置文件中指定开启url-path-2f-decode功能。

发送畸形请求(query string为%2F?

果然crash了

再次reproduce,并在burl_normalize_2F_to_slash_fix处步入调试。
进入循环前query string长度为4, %2F下标为1;

循环过后成功将%2F解码,并拷贝余下的query string

随后进入burl_normalize_path,到buffer_copy_string_len(t, b->ptr+qs, used - qs);这一步,有意思的事情发生了

拷贝长度发生了负溢,used小于qs。至于原因,得从前文给出的burl_normalize_path说起,used取解码后query string长度,而qs却并未更新,依旧是解码前的长度,从而导致used小于qs,引发负溢。以下是崩溃的backtrace

至于这个漏洞的后续利用,笔者并没有进行后续的探索,从backtrace来看,是在malloc(-1)返回0后触发断言导致崩溃,除非能够有其他的堆溢出覆写top chunk size,走house of force这种古董级ptmalloc利用方法,不然是不能RCE只能DoS的。

CVE-2018-19052

该漏洞应该算是 nginx alias 配置不当导致目录穿越漏洞的复刻版,影响范围为1.4.50之前全部版本。

这里我们直接看下1.4.49和1.4.50版本的diff

@@ -161,26 +161,41 @@
	if (buffer_is_empty(con->physical.path)) return HANDLER_GO_ON;
	mod_alias_patch_connection(srv, con, p);
	/* not to include the tailing slash */
	basedir_len = buffer_string_length(con->physical.basedir);
	if ('/' == con->physical.basedir->ptr[basedir_len-1]) --basedir_len;
	uri_len = buffer_string_length(con->physical.path) - basedir_len;
	uri_ptr = con->physical.path->ptr + basedir_len;
	for (k = 0; k < p->conf.alias->used; k++) {
		data_string *ds = (data_string *)p->conf.alias->data[k];
		int alias_len = buffer_string_length(ds->key);
		if (alias_len > uri_len) continue;
		if (buffer_is_empty(ds->key)) continue;
		if (0 == (con->conf.force_lowercase_filenames ?
					strncasecmp(uri_ptr, ds->key->ptr, alias_len) :
					strncmp(uri_ptr, ds->key->ptr, alias_len))) {
			/* matched */

+			/* check for path traversal in url-path following alias if key
+			 * does not end in slash, but replacement value ends in slash */
+			if (uri_ptr[alias_len] == '.') {
+				char *s = uri_ptr + alias_len + 1;
+				if (*s == '.') ++s;
+				if (*s == '/' || *s == '\0') {
+					size_t vlen = buffer_string_length(ds->value);
+					if (0 != alias_len && ds->key->ptr[alias_len-1] != '/'
+					    && 0 != vlen && ds->value->ptr[vlen-1] == '/') {
+						con->http_status = 403;
+						return HANDLER_FINISHED;
+					}
+				}
+			}

			buffer_copy_buffer(con->physical.basedir, ds->value);
			buffer_copy_buffer(srv->tmp_buf, ds->value);
			buffer_append_string(srv->tmp_buf, uri_ptr + alias_len);

很明显,加了个结尾是否为/的检测

不多比比,直接上payload测一波,先写个配置文件

#debug.log-request-handling = "enable"
#debug.log-request-header = "enable"
#debug.log-response-header = "enable"
#debug.log-condition-handling = "enable"
server.document-root         = "/tmp/lighttpd2/"

## 64 Mbyte ... nice limit
server.max-request-size = 65000

## bind to port (default: 80)
server.port                 = 8090

## bind to localhost (default: all interfaces)
server.bind                = "localhost"
server.errorlog            = "/dev/null"
server.breakagelog         = "/dev/null"
server.name                = "www.example.org"
server.tag                 = "Apache 1.3.29"

server.dir-listing          = "enable"

server.indexfiles = (
	"index.html",
)

server.modules = ( "mod_alias" )
alias.url = ( "/docs" => "/tmp/lighttpd2/docs/" )

server.http-parseopts = (
#        "header-strict"            => "enable",
#        "host-strict"              => "enable",
#        "host-normalize"           => "enable",
#        "url-normalize"            => "enable",
#        "url-normalize-unreserved" => "enable",
#        "url-normalize-required"   => "enable",
#        "url-ctrls-reject"         => "enable",
#        "url-path-2f-decode"       => "enable"
#        "url-path-dotseg-remove"   => "enable",
#        "url-query-20-plus"        => "enable"
      )

ssi.extension = (
	".shtml",
)

accesslog.filename = "/dev/null"

mimetype.assign = (
	".png"  => "image/png",
	".jpg"  => "image/jpeg",
	".jpeg" => "image/jpeg",
	".gif"  => "image/gif",
	".html" => "text/html",
	".htm"  => "text/html",
	".pdf"  => "application/pdf",
	".swf"  => "application/x-shockwave-flash",
	".spl"  => "application/futuresplash",
	".txt"  => "text/plain",
	".tar.gz" =>   "application/x-tgz",
	".tgz"  => "application/x-tgz",
	".gz"   => "application/x-gzip",
	".c"    => "text/plain",
	".conf" => "text/plain",
)

setenv.add-environment = (
	"TRAC_ENV" => "tracenv",
	"SETENV" => "setenv",
)
setenv.set-environment = (
	"NEWENV" => "newenv",
)
setenv.add-request-header = (
	"FOO" => "foo",
)
setenv.set-request-header = (
	"FOO2" => "foo2",
)
setenv.add-response-header = (
	"BAR" => "foo",
)
setenv.set-response-header = (
	"BAR2" => "bar2",
)

这里的alias.url = ( "/docs" => "/tmp/lighttpd2/docs/" )就是漏洞所在,没有用/去结束这个目录路径,为了展示漏洞效果,我在/tmp/lighttpd2目录下放了个.htpasswd文件(嵌入式web server常见操作——把明文密码放在主目录下等着被人信息泄露 此处狗头


结语

嵌入式web服务器和主流web服务器的安全性还是有相当差距的,但因为寄主是物联网设备,寄主对安全保护能力的要求并不高,导致嵌入式web服务器常常成为攻击的切入点。也许“一杯茶,一支烟,一个破站日一天”并不是因为安全,而是因为你没有选对切入点,或许从目标站点相关的物联网设备下手,能取得意想不到的收获。

本文写得仓促,见谅见谅。