whip1ash

PHP-FPM RCE (CVE-2019-11043) Analysis [详细]

2019-10-30

Abstract

Nginx配置不当,导致换行符(“\n”)能够使FastCGI的PATH_INFO参数为空,PHP-FPM未考虑PATH_INFO为空情况,导致下溢(underflow),使其能够覆盖PHP全局变量,从而导致RCE。

目前Exp只影响PHP7全版本,根据洞主说法,也能够在PHP 5.6能够导致Crash。

本文粗略结构如下:

  1. 漏洞复现
  2. Exp分析
  3. 漏洞分析

Reproduction

Env

Ubuntu 18.04
PHP 7.2.19
Nginx 1.14.0

Exp地址: https://github.com/neex/phuip-fpizdam
Clone下来后go build .

Nginx Config

  1. location匹配的时候不能只匹配php后缀,需要匹配/
1
2
#location ~ \.php$ { => 只匹配php后缀
location ~ [^/]\.php(/|$){
  1. 取消nginx的try_files检查。
1
2
# Check that the PHP script exists before passing it
#try_files $fastcgi_script_name =404;
  1. fastcgi的参数定义要在PATH_INFO之前
1
2
include fastcgi.conf;
fastcgi_param PATH_INFO $path_info;

nginx.conf

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
worker_connections 768;
# multi_accept on;
}

http {

##
# Basic Settings
##

sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# server_tokens off;

# server_names_hash_bucket_size 64;
# server_name_in_redirect off;

include /etc/nginx/mime.types;
default_type application/octet-stream;

##
# SSL Settings
##

ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;

##
# Logging Settings
##

access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;

##
# Gzip Settings
##

gzip on;

##
# Virtual Host Configs
##

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}

sites-enabled/default

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
# Default server configuration
#
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;

# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;

server_name _;

location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
#location ~ \.php$ {
location ~ [^/]\.php(/|$){
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php7.2-fpm.sock;

}
location ~ /\.ht {
deny all;
}
}

snippets/fastcgi-php.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
# 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;

include fastcgi.conf;
fastcgi_param PATH_INFO $path_info;
fastcgi_index index.php;

Analysis Exp

在服务器抓包并结合工具源码,发现攻击分为以下几个部分。

  1. 发送如下URL,探测是否支持类似于 /index.php/aaaa 的解析方式,并获取该页面的HTTP状态码,后续基于此状态码。
    1
    /index.php/path%0Ainfo.php?QQQ...

QQQ… => 有若干个Q

基于 index.php/PHP%0Ais_the_shittiest_lang.php?QQQ...,每一次长度加五(追加Q),整体长度在1700-2200字节左右,判断是否能导致FPM崩溃,确定能够导致崩溃的追加字符串长度(QSL => QueryStringLength)。

-w1648

  1. 之后发十个包,判断服务是否正常,多次发包是为了测试每一个worker正常。

  1. 循环使用上述得到的QSL,逐字节增加D-Pisos头的长度,循环256次。使用以下URL。
    GET /index.php/PHP_VALUE%0Asession.auto_start=1;;;?QQQQQQQQQQQ…

-w1409

当session.auto_start生效时会返回Set-Cookie,此时确定Payload生效的QSL和D-Pisos。循环50次,猜测是为了命中所有的worker。

  1. Attack,覆盖PHP环境变量,RCE。

依次发送payload,设置PHP环境变量,并带上a参数,执行/bin/sh -c 'which+which',如果回显中包含/bin/which则表示攻击成功。通过报错,将一句话写入/tmp/a文件。

attack.go

-w1130

Code Analysis

一些准备知识

漏洞概述

洞主和PHP的沟通过程

PHP-FPM概述、基本实现、初始化、请求处理、进程管理

Preparation

环境准备

1
2
3
4
5
Makefile
CFLAGS="-O0 -g -fsanitize=address -fno-omit-frame-pointer" LIBS='-ldl'

Compile
./configure --prefix=/home/whp1ash/Workspace/php-fpm --enable-fpm --enable-debug

编译源码,安装pwndbg

https://wiki.maeteno.me/post/php/install-php-to-ubuntu-16
http://ddrv.cn/a/319827
https://www.hongweipeng.com/index.php/archives/739/

修改php-fpm的配置文件,使其只启动一个workpool,方便调试。

关于gdb调试php-fpm

gdb挂到worker poll的线程去调试,而不是挂到master。

进来后会卡在accept。

打断点,比如 b init_request_info。 在init_request_info函数下断点,然后请求一个URL。最后continue(c),会发现命中断点。
ps: 刚开始我直接

1
2
source php-src/.gdbinit 
b sapi/fpm/fpm/fpm_main.c:1154

发现无法断点。不知原因,目测是gdb的工作目录和源码没有关联。

Analysis

先看漏洞点。
-w1245

env_path_info是一个指针,指向nginx的PATH_INFO参数匹配传进来的值,当传入”\n”(%0A)时,正则挂掉,传进来空,此处为空字符串,而不是空指针,需要注意一下。

pilen就是env_path_info的长度,为0。

slen有点麻烦,上个图,此处假设传入的url是这样的。http://10.211.55.8//index.php/PHP%0Ais_the_shittiest_lang.php?QQQQQQQQQ...

-w1167

接下来是漏洞触发点。

-w1110

env_path_info是一个指针,指向空字符串。pilen是0,slen是一个可控的数值,假设为30。
在这里PHP没有考虑到env_path_info为空的情况,path_info = env_path_info - 30,也就指向了env_path_info向上30个字节的地方。

正常的情况下应该是这样的。

看图中第二个断点处,这里的逻辑执行了path_info[0] = 0;这就意味着把一个字节给置空了,这里就把R给清0了。

这一块内存看起来都是存储的FastCGI的参数信息,置空并没有什么作用。通过EXP可以看出探测的模式下的URL都是index.php/PHP%0Ais_the_shittiest_lang.php?QQQ...,通过上面分析可以知道,这也就意味着slen没有变,一直在把PATH_INFO向前数三十字节的地方置空。这里跟PHP存储FastCGI的结构fcgi_hash有关。

-w705

PHP-FPM在解析FastCGI协议的时候,主要分三步:头信息读取、body信息读取、数据后置处理。这里只需要了解第一步:头信息读取。关于FastCGI协议结构如下图(图自【PHP7源码分析】PHP中$_POST揭秘)

头信息读取阶段只读取FCGI_BEGIN_REQUEST和FCGI_PARAMS数据包。因此在这个阶段只能拿到http请求的header以及fastcgi_param变量。在main/fastcgi.c中fcgi_read_request负责完成这个阶段的读取工作。 – 【PHP7源码分析】PHP中$_POST揭秘

fcgi_read_request中的fcgi_get_params方法完成这个操作。

1
2
3
4
5
//解析FCGI_PARAMS的data,将key-value对存储到req.env中
if (!fcgi_get_params(req, buf, buf+len)) {
req->keep = 0;
return 0;
}

-w1289

解析出来的数据是放入req.env中,以fcgi_hash结构存储。

这个hashtable的实现采用了普遍采用的链地址法思路,不过bucket的内存分配(malloc)并不是每次都需要进行的,而是在hash初始化的时候,一次性预分配一个大小为128的连续的数组。上面的buckets指针指向这段内存。同时hashtable还维护了一个按照元素插入顺序逆序排列的全局单链表,list指向了这个链表的头元素。每一个bucket元素包括对key进行hash之后的hash_value、key的length、key的字符指针、value的length、value的字符指针、相同slot中下个bucket元素指针,全局单链表的下一个bucket元素指针。bucket中key和value并不直接存储字符数组(因为长度未知),而只是存储字符指针,真正的字符数组存储在hashtable的data指向的内存中。 – 【PHP7源码分析】PHP中$_POST揭秘

流程如图,图自【PHP7源码分析】PHP中$_POST揭秘

上面说了这么多,总结成一句话就是:
宏观的来看,目前我们在fcgi_hash这个结构中,能够把fcgi_hash的data属性,也就是fcgi_data_seg结构中的一个字节置0。
data[1]指向数据存储的开头。

fcgi_hash_strndup函数往此结构中写数据。

可以看出当大小不够的时候会开辟新内存。

这一切都变的明朗了,储存fastcgi的参数地方内存是动态分配的。初始时候会分配最大的内存块4128,那么会存在这样一种情况,在分配PATH_INFO的时候,前面初始化的内存块用完了。重新开始分配一块内存,存储fastcgi的参数的是个_fcgi_hash_bucket结构,先存储是PATH_INFO这个字段名,然后存储对应的值。我们可以画一下出现这种情况的内存分布:

1
2
3
4
5
6
7
8
9
10
char *pos  
------------- +8
char *end
------------- +8
char *next
------------- +8
PATH_INFO\x00
------------- +10
\x00 <---- env_path_info
-------------

这就是为什么exp中一直padding Q,就是为了让 env_path_info-30 写到pos上,让pos的第五位置0,成为一个无效指针,使上图的ret成为一个无效地址,memcpy的时候segmentfault,使得worker崩溃,返回502。

但是crash不是目的,RCE才是。

通过fcgi_hash_strndup函数可以看出,pos指向的是data块(存储PATH_INFO等数据的内存)下一个未使用的地址,data块是连续的。

比如,现在pos的地址是 0x7ff658202333 , 引起crash时,pos的地址为 0x7f0058202333 。 现在要精准控制下一次写入的地方,作者在这里使末尾置0,也就是变成0x7ff658202300 ,等于pos可以往前移ff,但是往前移ff不一定是所需要的PHP_VALUE。这里就需要爆破了。

结合Exp可知,接下来下面发送这个url,并且padding D-Pisos头。

1
2
3
4
/index.php/PHP_VALUE%0Asession.auto_start=1;;;?QQQQQQQQQQQ...

D-Gisos: 8=====================================D
Ebut: mamku tvoyu

重点来了,这里的padding虽然能让我们在0-ff范围内有一个写的操作,但是不能够写到PHP_VALUE中,这里利用了PHP哈希表的一个缺陷,PHP的哈希表是利用链接法来解决冲突的。

链接法通过使用一个链表来保存slot值的方式来解决冲突,也就是当不同的key映射到一个槽中的时候使用链表来保存这些值。 所以使用链接法是在最坏的情况下,也就是所有的key都映射到同一个槽中了,这样哈希表就退化成了一个链表, 这样的话操作链表的时间复杂度则成了O(n),这样哈希表的性能优势就没有了, 所以选择一个合适的哈希函数是最为关键的。
由于目前大部分的编程语言的哈希表实现都是开源的,大部分语言的哈希算法都是公开的算法, 虽然目前的哈希算法都能良好的将key进行比较均匀的分布,而这个假使的前提是key是随机的,正是由于算法的确定性, 这就导致了别有用心的黑客能利用已知算法的可确定性来构造一些特殊的key,让这些key都映射到 同一个槽位导致哈希表退化成单链表,导致程序的性能急剧下降,从而造成一些应用的吞吐能力急剧下降, 尤其是对于高并发的应用影响很大,通过大量类似的请求可以让服务器遭受DoS(服务拒绝攻击), 这个问题一直就存在着,只是最近才被各个语言重视起来。
哈希冲突攻击利用的哈希表最根本的弱点是:开源算法和哈希实现的确定性以及可预测性, 这样攻击者才可以利用特殊构造的key来进行攻击。要解决这个问题的方法则是让攻击者无法轻易构造 能够进行攻击的key序列。 - 深入理解PHP内核 - 哈希表(HashTable)

总结一下,就是说攻击者可以伪造一个key,使得FCGI_HASH_FUNC(key)与FCGI_HASH_FUNC(PHP_VALUE)相等。这就是Ebut头的作用,Ebut在FastCGI中转换成HTTP_EBUT,和PHP_VALUE有相同的长度和hash。

在明确了这一点后,就可以继续分析。

所以现在的思路是,通过padding,可以使得pos指向一个地方,这个地方能够使我们url中的PHP_VALUE%0Asession.auto_start=1; 把内存中的HTTP_EBUT的key和value覆盖成PHP_VALUE%0Asession.auto_start=1;。这样,在读取PHP_VALUE的hash表时,将会读取到HTTP_EBUT的hash表,将这个hash表的内容当成PHP_VALUE处理。

PHP读取hash表的操作如图。
-w1325

这里需要hash值,key的长度,内存中的value都一样,也就是都等于PHP_VALUE,不过没有关系,后面可以通过覆盖将HTTP_EBUT覆盖成PHP_VALUE。

再次拿出漏洞点的图。
-w1299

可以看到在置0后,立马就写了个ORIG_SCRIPT_NAME。这个值是什么呢,如下图。

这就很清晰了,也就是让pos指向的地方,恰好让ORIG_SCRIPT_NAME中的PHP_VALUE%0Asession.auto_start=1;覆盖掉HTTP_EBUT就好了。

这样的话,在1336行读取PHP_VALUE的时候将会把session.auto_start读出来,返回set-cookie。

-w1249

后面就不在继续了,能够控制PHP_VALUE后,就可以RCE了。

写在后面

前前后后大概花费了两天的时间分析,今天早上发文前,有看到@wonderkun师傅在先知的发文,调试过程很详细,觉得我那里没讲明白的朋友可以结合看一下。

https://xz.aliyun.com/t/6672

PS: PHP关于fastcgi这几个结构体确实比较复杂,需要一点点分析调试。

纸上得来终觉浅,绝知此事要躬行。

Reference

  1. https://bugs.php.net/bug.php?id=78599
  2. https://lab.wallarm.com/php-remote-code-execution-0-day-discovered-in-real-world-ctf-exercise/
  3. https://forum.90sec.com/t/topic/558
  4. https://paper.seebug.org/1063/
  5. https://segmentfault.com/a/1190000016868502
  6. http://www.php-internals.com/book/?p=chapt03/03-01-01-hashtable

扫描二维码,分享此文章