最近分析了不少IoT设备使用的嵌入式web服务器,发现不少直接使用lighttpd或者魔改lighttpd的例子,对这个开源的httpd产生了些许兴趣,所以来分析一波近两年内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版本。
该漏洞源于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的。
该漏洞应该算是 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服务器常常成为攻击的切入点。也许“一杯茶,一支烟,一个破站日一天”并不是因为安全,而是因为你没有选对切入点,或许从目标站点相关的物联网设备下手,能取得意想不到的收获。
本文写得仓促,见谅见谅。