前言 看到一篇文章就来复现一下,补充一些细节利用点。https://forum.butian.net/article/674
回顾历史
CVE-2017-12615 Tomcat PUT 文件上传
CVE-2020-9484:Tomcat Session 反序列化漏洞
CVE-2024-50379 :Tomcat PUT 条件竞争文件上传
这三个漏洞是 Tomcat 历史一些相对比较严重的 RCE 漏洞,也算前世今生了。
环境配置 IDEA 部署 Tomcat 在新版有一些改变,主要是 AddFrameWork 按钮没了。
你得全局搜才搜的出来,创建一下基本框架,然后新建 lib 目录加入项目依赖 随后修改一下 Tomcat 里的 conf/Context.xml 文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?xml version="1.0" encoding="UTF-8" ?> <Context > <WatchedResource > WEB-INF/web.xml</WatchedResource > <WatchedResource > WEB-INF/tomcat-web.xml</WatchedResource > <WatchedResource > ${catalina.base}/conf/web.xml</WatchedResource > <Manager className ="org.apache.catalina.session.PersistentManager" > <Store className ="org.apache.catalina.session.FileStore" /> </Manager > </Context >
主要是加入如下内容
1 2 3 4 <Manager className ="org.apache.catalina.session.PersistentManager" > <Store className ="org.apache.catalina.session.FileStore" /> </Manager >
这一步是开启 Tomcat 的 Session 储存功能。
随后配置 Web.xml,加入默认的 DefaultServlet
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?xml version="1.0" encoding="UTF-8" ?> <web-app xmlns ="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version ="4.0" > <servlet > <servlet-name > defalut</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 > readonly</param-name > <param-value > false</param-value > </init-param > <load-on-startup > 1</load-on-startup > </servlet > <servlet-mapping > <servlet-name > defalut</servlet-name > <url-pattern > /</url-pattern > </servlet-mapping > </web-app >
DefaultServlet 自带的一个 Servelet 会处理一些默认类型的请求,如 PUT、POST、GET。
CVE-2017-12615、CVE-2024-50379均涉及该方法,也就是 doPUT,其实逻辑很简单,节选一段核心代码。
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 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 { resp.sendError(409 ); } } finally { if (resourceInputStream != null ) { try { ((InputStream)resourceInputStream).close(); } catch (IOException var13) { } } } } } }
WebResource oldResource = this.resources.getResource(path)
这里直接获取 Web 根目录了,然后将文件上传,这也是最开始的 PUT 文件上传漏洞点。而今天的漏洞点出自上述的 else 分支。
Tomcat 在处理不完整的 PUT 请求有单独的逻辑。这个和请求头 Content-Range 有关
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 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 (!contentRange.getUnits().equals("bytes" )) { response.sendError(400 ); return null ; } else { Range range = new Range (); range.start = contentRange.getStart(); range.end = contentRange.getEnd(); range.length = contentRange.getLength(); if (!range.validate()) { response.sendError(400 ); return null ; } else { return range; } } } }
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Range
其实你可以理解为和分块差不多一个意思。
1 2 3 4 5 6 7 PUT /CVE_2025_24813_war_exploded/a/session HTTP/1.1 Host : 127.0.0.1:8080Content-Length : 1000Content-Range : bytes 0-1000/1200 testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest
简单利用该方法上传一个session 文件,内容随意。我们主要是看看细节
start、end、length和请求头对应,随后会进入 executePartialPut 内。
在这里会将斜杠替换为.
,因此我们可以上传一个 xxx.session 文件。具体处理逻辑在底部
核心逻辑在这里,以 range.length
作为文件的长度,range.start
作为起始点开始处理流,这也是为什么 length 必须要大于 body 的总长度,不然无法将内容完全上传。
之后会返回 409 错误,但此时文件已经上传
我们需要注意一点
File tempDir = (File)this.getServletContext().getAttribute(“javax.servlet.context.tempdir”);
linux 系统下的缓存文件夹是/tmp,这里我是 macos,因此会在一些偏僻的位置,想要复现的同学可以自行输出一下。
至此完成了文件上传分析的部分。
FileStore#load 这部分是反序列化触发点,CVE-2020-9484出自这里,所以不难看出这其实是一个组合漏洞,当时CVE-2020-9484是因为存在目录穿越问题所以可以穿越加载一个自定义的序列化文件。
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 ; } }
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处理穿越问题。因此无法目录穿越了,只能加载缓存目录下的.session
后缀文件并且将其反序列化。反序列化的触发点是没删除的。
漏洞复现 写了一个 POC
成功复现。