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

搭过php相关环境的同学应该对fastcgi不陌生,那么fastcgi究竟是什么东西,为什么nginx可以通过fastcgi来对接php?

Fastcgi Record

Fastcgi其实是一个通信协议,和HTTP协议一样,都是进行数据交换的一个通道。

HTTP协议是浏览器和服务器中间件进行数据交换的协议,浏览器将HTTP头和HTTP体用某个规则组装成数据包,以TCP的方式发送到服务器中间件,服务器中间件按照规则将数据包解码,并按要求拿到用户需要的数据,再以HTTP协议的规则打包返回给服务器。

类比HTTP协议来说,fastcgi协议则是服务器中间件和某个语言后端进行数据交换的协议。Fastcgi协议由多个record组成,record也有header和body一说,服务器中间件将这二者按照fastcgi的规则封装好发送给语言后端,语言后端解码以后拿到具体数据,进行指定操作,并将结果再按照该协议封装好后返回给服务器中间件。

和HTTP头不同,record的头固定8个字节,body是由头中的contentLength指定,其结构如下:

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;

头由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

刚才我介绍了fastcgi一个record中各个结构的含义,其中第二个字节type我没详说。

type就是指定该record的作用。因为fastcgi一个record的大小是有限的,作用也是单一的,所以我们需要在一个TCP流里传输多个record。通过type来标志每个record的作用,用requestId作为同一次请求的id。

也就是说,每次请求,会有多个record,他们的requestId是相同的。

借用该文章中的一个表格,列出最主要的几种type

14931267923354.jpg

看了这个表格就很清楚了,服务器中间件和后端语言通信,第一个数据包就是type为1的record,后续互相交流,发送type为4、5、6、7的record,结束时发送type为2、3的record。

当后端语言接收到一个type为4的record后,就会把这个record的body按照对应的结构解析成key-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个结构,至于用哪个结构,有如下规则:

  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

为什么我只介绍type为4的record?因为环境变量在后面PHP-FPM里有重要作用,之后写代码也会写到这个结构。type的其他情况,大家可以自己翻文档理解理解。

PHP-FPM(FastCGI进程管理器)

那么,PHP-FPM又是什么东西?

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对:

{
    '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中$_SERVER数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER数组,也是告诉fpm:“我要执行哪个PHP文件”。

PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/index.php

Nginx(IIS7)解析漏洞

Nginx和IIS7曾经出现过一个PHP相关的解析漏洞(测试环境https://github.com/phith0n/vulhub/tree/master/nginx_parsing_vulnerability),该漏洞现象是,在用户访问http://127.0.0.1/favicon.ico/.php时,访问到的文件是favicon.ico,但却按照.php后缀解析了。

用户请求http://127.0.0.1/favicon.ico/.php,nginx将会发送如下环境变量到fpm里:

{
    ...
    'SCRIPT_FILENAME': '/var/www/html/favicon.ico/.php',
    'SCRIPT_NAME': '/favicon.ico/.php',
    'REQUEST_URI': '/favicon.ico/.php',
    'DOCUMENT_ROOT': '/var/www/html',
    ...
}

正常来说,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未授权访问漏洞也就呼之欲出了。PHP-FPM默认监听9000端口,如果这个端口暴露在公网,则我们可以自己构造fastcgi协议,和fpm进行通信。

此时,SCRIPT_FILENAME的值就格外重要了。因为fpm是根据这个值来执行php文件的,如果这个文件不存在,fpm会直接返回404:

14931285844835.jpg

在fpm某个版本之前,我们可以将SCRIPT_FILENAME的值指定为任意后缀文件,比如/etc/passwd;但后来,fpm的默认配置中增加了一个选项security.limit_extensions

; Limits the extensions of the main script FPM will allow to parse. This can
; prevent configuration mistakes on the web server side. You should only limit
; FPM to .php extensions to prevent malicious users to use other extensions to
; exectute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5 .php7

其限定了只有某些后缀的文件允许被fpm执行,默认是.php。所以,当我们再传入/etc/passwd的时候,将会返回Access denied.

14931290357686.jpg

ps. 这个配置也会影响Nginx解析漏洞,我觉得应该是因为Nginx当时那个解析漏洞,促成PHP-FPM增加了这个安全选项。另外,也有少部分发行版安装中security.limit_extensions默认为空,此时就没有任何限制了。

由于这个配置项的限制,如果想利用PHP-FPM的未授权访问漏洞,首先就得找到一个已存在的PHP文件。

万幸的是,通常使用源安装php的时候,服务器上都会附带一些php后缀的文件,我们使用find / -name "*.php"来全局搜索一下默认环境:

14931297810961.jpg

找到了不少。这就给我们提供了一条思路,假设我们爆破不出来目标环境的web目录,我们可以找找默认源安装后可能存在的php文件,比如/usr/local/lib/php/PEAR.php

任意代码执行

那么,为什么我们控制fastcgi协议通信的内容,就能执行任意PHP代码呢?

理论上当然是不可以的,即使我们能控制SCRIPT_FILENAME,让fpm执行任意文件,也只是执行目标服务器上的文件,并不能执行我们需要其执行的文件。

但PHP是一门强大的语言,PHP.INI中有两个有趣的配置项,auto_prepend_fileauto_append_file

auto_prepend_file是告诉PHP,在执行目标文件之前,先包含auto_prepend_file中指定的文件;auto_append_file是告诉PHP,在执行完成目标文件后,包含auto_append_file指向的文件。

那么就有趣了,假设我们设置auto_prepend_filephp://input,那么就等于在执行任何php文件前都要包含一遍POST的内容。所以,我们只需要把待执行的代码放在Body中,他们就能被执行了。(当然,还需要开启远程文件包含选项allow_url_include

那么,我们怎么设置auto_prepend_file的值?

这又涉及到PHP-FPM的两个环境变量,PHP_VALUEPHP_ADMIN_VALUE。这两个环境变量就是用来设置PHP配置项的,PHP_VALUE可以设置模式为PHP_INI_USERPHP_INI_ALL的选项,PHP_ADMIN_VALUE可以设置所有选项。(disable_functions除外,这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中)

所以,我们最后传入如下环境变量:

{
    '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'
}

设置auto_prepend_file = php://inputallow_url_include = On,然后将我们需要执行的代码放在Body中,即可执行任意代码。

效果如下:

14931311523859.jpg

EXP编写

上图中用到的EXP,就是根据之前介绍的fastcgi协议来编写的,代码如下:https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75 。兼容Python2和Python3,方便在内网用。

之前好些人总是拿着一个GO写的工具在用,又不太好用。实际上理解了fastcgi协议,再看看这个源码,就很简单了。

EXP编写我就不讲了,自己读代码吧。

赞赏

喜欢这篇文章?打赏1元

评论

test 回复

感谢复现成功。
python3 fpm-未授权exp.py "127.0.0.1" -c '<?system("whoami");?>' -p "9000" /usr/local/lib/php/peclcmd.php
X-Powered-By: PHP/8.2.5
Content-type: text/html; charset=UTF-8

www-data

test 回复

我确认php-fpm在9000端口生效。但是运行报错
python3 PHP-FPM-RCE.py 127.0.0.1:9000 /www/server/php/70/lib/php/PEAR.php
-c '<?echo "123"?>'
gaierror(-2, 'Name or service not known')
connect failure! please check your fasctcgi-server !!
None
╭─root

阿菜 回复

你好,这个漏洞有cve编号吗

anycall 回复

试了,不生效
$client=new \library\FastCGIClient('127.0.0.1', '9000');
$content = "<?php echo 666;exit;?>";
//($content)
echo $client->request(
array(
'SCRIPT_FILENAME' => 'C://Users//zhong//Documents//index.php',
'SCRIPT_NAME'=> '/index.php',
'PHP_VALUE' => 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE' => 'allow_url_include = On',
'CONTENT_TYPE'=> 'application/text',
'CONTENT_LENGTH' => strlen($content)
),
$content
);

anycall 回复

@anycall php://input 注入的代码不执行

phithon 回复

@anycall 没试过Windows。

anycall 回复

@phithon php7.4版本试了,无法执行 php://input 注入的代码

anycall 回复

@anycall 请问是什么原因

phithon 回复

@anycall 不清楚,自行研究吧。可以使用Vulhub搭建环境来复现:https://github.com/vulhub/vulhub/tree/master/php/fpm

7rac3 回复

P师傅,请问你在windows下测试过嘛?我修改了exp的路径,在windows下测试,存在的php文件执行了,但是-C 的代码没有执行。搜索了很多也没找到结果。我的环境是php 7.2.34 nginx 1.6.3 windows 2012。实在不知道为什么,望回复。

fff 回复

@7rac3 windows 上的php代码要用双引号,我试了可以

flythief 回复

如果配置中用的是socket通信是不是就没办法利用了

phithon 回复

@flythief 你说的是unix domain socket吧,访问不到就不行,但可以利用SSRF这样的漏洞间接访问

围观的白菜哥哥 回复

p牛你好,security.limit_extensions设置为空后。执行
python fpm3.py /etc/passwd
如果不加-c参数的话选择任何文件都返回phpinfo()的内容
不会返回文件内容,而是返回phpinfo.php的内容。但是如果执行
python fpm3.py /etc/passwd -c "<?php echo "aaaa"?>"
passwd内容和aaaa都会输出
我看你的博客的意思不加-c参数的话因该是直接输出文件内容,是我理解错了吗?

phithon 回复

@围观的白菜哥哥 因为我在-c的代码后面加了exit;

围观的白菜哥哥 回复

@phithon 哈哈,我刚去看了代码发现了,结果回来你就回复我了,谢谢谢谢!

安安 回复

Windows下面怎么利用呢?

pikapika 回复

大佬,exp的链接打不开了> <

phithon 回复

@pikapika 翻墙

k4i 回复

想請教一下
最近碰到類似的場景,能透過SSRF改寫ADMIN_VALUE配置
但可能exp沒寫好,總是會噴500 error (But 能成功寫掉ADMIN_VALUE)
可是卻不像前面你所說的只影響一個進程,而是全部進程的ADMIN_VALUE都被改寫
這可能是啥原因呢?
是因為500 error那個request會不斷重複嘗試Request其他進程嗎?
感謝

k4i 回复

@k4i 我知道問題了... 我一直請求同一個exploit php file,所以會變成死循環把每個進程設定都寫掉

phithon 回复

@k4i 嗯,其实大部分问题自己dig一下很容易找到答案~

zzm 回复

想请教一下,如果没有php环境该怎么办?遇到一个站怎么打都是超时。。。本地没啥问题

phithon 回复

@zzm 这是php-fpm的漏洞,目标如果非php或者没有开启fastcgi的端口,是不能成功的。

zzm 回复

@phithon soga~谢谢!

闷声发大财 回复

可以很清真

MT 回复

分析的好详细,学习了

wofeiwo 回复

go 写的那个exp是我写的,的确不太好用,懒得维护了 :)。
还是赞作者的认真分析,研究深入的确很有意思。

security.limit_extensions 这个选项其实是我和php官方通知了暴露在外网可以执行任意命令之后才添加的选项。并不是nginx的漏洞出来时候。

另外,在某些基于语言做的沙盒执行环境里,当年利用fpm还可以做到越狱效果(曾经的sae)。

phithon 回复

@wofeiwo
厉害,GO不好用主要是因为没源码,否则就好办多了。
绕过沙盒的我15年也做过,当时目标没有限制dl函数,直接用fpm设置extension_dir和enable_dl,加载自己的扩展,执行任意命令。一直没舍得往外说,哈哈哈~
师傅您是wooyun里的GaRY么,我记得当时是您首发的这个方法?十分惊艳!

wofeiwo 回复

@phithon 源代码其实在zone的原帖中我是和二进制一起附上的,果然当时golang没人注意么。。?

https://gist.github.com/wofeiwo/96ccb6ad699691732309e30670281b47

wofeiwo 回复

@phithon 顺带关于沙盒逃逸。我当时写了个,主要为了过safe_mode/open_basedir去执行命令,现在已经没有意义了。后来我在zone中有公开过,不过应该没人发现我有更新,lol

https://gist.github.com/wofeiwo/4f41381a388accbf91f8

phithon 回复

@wofeiwo 惊,这两个代码我好像没注意到,但您说过的safe_mode绕过我记得~

噬魂 回复

大佬是不是一定要开放9000端口?

phithon 回复

@噬魂 是的。如果没有对外开放9000端口,利用SSRF漏洞也可以攻击。

captcha