漏洞概述
Apache Tomcat是美国阿帕奇(Apache)基金会的一款轻量级Web应用服务器。用于实现对Servlet和JavaServer Page(JSP)的支持。近期,网宿安全演武实验室监测到Apache Tomcat在特定条件下,存在远程代码执行漏洞。(网宿评分:高危、CVSS 3.1评分:8.1)
当同时满足如下利用条件时:开启PUT(默认关闭)、开启tomcat 持久化session配置、依赖库中存在反序列化利用链,攻击者即可构造恶意请求获取服务器权限。目前该漏洞POC状态已在互联网公开,建议客户尽快做好自查及防护。
受影响版本
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
漏洞分析
查看官方commit
(https://github.com/apache/tomcat/commit/0a668e0c27f2b7ca0cc7c6eea32253b9b5ecb29c?diff=split)不难发现,DefaultServlet在处理PUT请求时,对临时文件有了新的操作,主要修改了java/org/apache/catalina/servlets/DefaultServlet.java#executePartialPut(),说明问题很可能出现在这里。
protected File executePartialPut(HttpServletRequest req, Range range,
String path)
throws IOException {
// Append data specified in ranges to existing content for this
// resource - create a temp. file on the local filesystem to
// perform this operation
File tempDir = (File) getServletContext().getAttribute
(ServletContext.TEMPDIR);
// Convert all '/' characters to '.' in resourcePath
String convertedResourcePath = path.replace('/', '.');
File contentFile = new File(tempDir, convertedResourcePath);
if (contentFile.createNewFile()) {
// Clean up contentFile when Tomcat is terminated
contentFile.deleteOnExit();
}
RandomAccessFile randAccessContentFile =
new RandomAccessFile(contentFile, "rw");
WebResource oldResource = resources.getResource(path);
// Copy data in oldRevisionContent to contentFile
if (oldResource.isFile()) {
BufferedInputStream bufOldRevStream =
new BufferedInputStream(oldResource.getInputStream(),
BUFFER_SIZE);
int numBytesRead;
byte[] copyBuffer = new byte[BUFFER_SIZE];
while ((numBytesRead = bufOldRevStream.read(copyBuffer)) != -1) {
randAccessContentFile.write(copyBuffer, 0, numBytesRead);
}
bufOldRevStream.close();
}
randAccessContentFile.setLength(range.length);
// Append data in request input stream to contentFile
randAccessContentFile.seek(range.start);
int numBytesRead;
byte[] transferBuffer = new byte[BUFFER_SIZE];
BufferedInputStream requestBufInStream =
new BufferedInputStream(req.getInputStream(), BUFFER_SIZE);
while ((numBytesRead = requestBufInStream.read(transferBuffer)) != -1) {
randAccessContentFile.write(transferBuffer, 0, numBytesRead);
}
randAccessContentFile.close();
requestBufInStream.close();
return contentFile;
}
在executePartialPut()中,我们主要关注临时文件的创建过程,即:
1、设置存放地址:通过 ServletContext 的 TEMP_DIR(javax.servlet.context.tempdir)属性获取临时文件夹,work\Catalina\localhost\ROOT
2、构造临时文件:将path中所有的“/”替换为“.”,进而构造临时文件,并在已设置的存放地址下创建。
显然,这里并没有对文件内容做安全验证,但如何调用呢?回到
java/org/apache/catalina/servlets/DefaultServlet.java#doPut()
protected void doPut(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
if (readOnly) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
String path = getRelativePath(req);
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();
}
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
}
}
}
}
只有当range不为空,才走到调用executePartialPut()的分支。这里也很好解决,添加合法Content-Range请求头即可。关于Content-Range语法,可以参考:
https://runebook.dev/cn/docs/http/headers/content-range
不过这里需要注意的是,range.length代表的是整个文件的长度,而请求体只占其中一部分,所以得合理地设置Content-Range,具体逻辑详见
java/org/apache/catalina/servlets/DefaultServlet.java# parseContentRange()
protected Range parseContentRange(HttpServletRequest request,
HttpServletResponse response)
throws IOException {
// Retrieving the content-range header (if any is specified
String rangeHeader = request.getHeader("Content-Range");
if (rangeHeader == null)
return null;
// bytes is the only range unit supported
if (!rangeHeader.startsWith("bytes")) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
rangeHeader = rangeHeader.substring(6).trim();
int dashPos = rangeHeader.indexOf('-');
int slashPos = rangeHeader.indexOf('/');
if (dashPos == -1) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
if (slashPos == -1) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
Range range = new Range();
try {
range.start = Long.parseLong(rangeHeader.substring(0, dashPos));
range.end =
Long.parseLong(rangeHeader.substring(dashPos + 1, slashPos));
range.length = Long.parseLong
(rangeHeader.substring(slashPos + 1, rangeHeader.length()));
} catch (NumberFormatException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
if (!range.validate()) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
return range;
}
测试一下,成功上传。
那么PUT成功以后,如何触发恶意文件内容呢?回顾漏洞概述中的触发条件之一,” 开启tomcat 持久化session配置”,触发点对应的代码逻辑如下:
public Session load(String id)
throws ClassNotFoundException, IOException {
// Open an input stream to the specified pathname, if any
File file = file(id);
if (file == null) {
return (null);
}
if (! file.exists()) {
return (null);
}
if (manager.getContext().getLogger().isDebugEnabled()) {
manager.getContext().getLogger().debug(sm.getString(getStoreName()+".loading",
id, file.getAbsolutePath()));
}
ObjectInputStream ois = null;
Loader loader = null;
ClassLoader classLoader = null;
ClassLoader oldThreadContextCL = Thread.currentThread().getContextClassLoader();
try (FileInputStream fis = new FileInputStream(file.getAbsolutePath());
BufferedInputStream bis = new BufferedInputStream(fis)) {
Context context = manager.getContext();
if (context != null)
loader = context.getLoader();
if (loader != null)
classLoader = loader.getClassLoader();
if (classLoader != null) {
Thread.currentThread().setContextClassLoader(classLoader);
ois = new CustomObjectInputStream(bis, classLoader);
} else {
ois = new ObjectInputStream(bis);
}
StandardSession session =
(StandardSession) manager.createEmptySession();
session.readObjectData(ois);
session.setManager(manager);
return (session);
} catch (FileNotFoundException e) {
if (manager.getContext().getLogger().isDebugEnabled())
manager.getContext().getLogger().debug("No persisted data file found");
return (null);
} finally {
if (ois != null) {
// Close the input stream
try {
ois.close();
} catch (IOException f) {
// Ignore
}
}
Thread.currentThread().setContextClassLoader(oldThreadContextCL);
}
}
而session文件的默认存储位置恰好位于executePartialPut()设置的临时目录,所以攻击者携带JSESSIONID=file的Cookie访问tomcat以后,程序就会去临时目录反序列化file.session用于匹配鉴权参数。
漏洞复现
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>
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
context.xml
<Manager className="org.apache.catalina.session.PersistentManager">
<Store className="org.apache.catalina.session.FileStore"/>
</Manager>
webapps\ROOT\WEB-INF\lib\commons-collections-3.2.1.jar
修复方案
目前官方已有可更新版本,建议受影响用户升级至最新版本:
Apache Tomcat >=11.0.3
Apache Tomcat >=10.1.35
Apache Tomcat >=9.0.99
产品支持
网宿全站防护-WAF模块已支持对该漏洞利用攻击的防护,并持续挖掘分析其他变种攻击方式和各类组件漏洞,第一时间上线防护规则,缩短防护“空窗期”。