漏洞概述
2017年9月19日,Apache Tomcat官方確認并修復(fù)了兩個高危漏洞,其中就有遠程代碼執(zhí)行漏洞(CVE-2017-12615)。當存在漏洞的Tomcat 運行在 Windows 主機上,且啟用了HTTP PUT請求方法,攻擊者將有可能可通過精心構(gòu)造的攻擊請求數(shù)據(jù)包向服務(wù)器上傳包含任意代碼的 JSP 的webshell文件,JSP文件中的惡意代碼將能被服務(wù)器執(zhí)行,導(dǎo)致服務(wù)器上的數(shù)據(jù)泄露或獲取服務(wù)器權(quán)限。
- 影響范圍:Apache Tomcat 7.0.0 – 7.0.79
漏洞分析與復(fù)現(xiàn)
環(huán)境:
- Windows8.1
- Tomcat 7.0.56
- JDK 1.8.0_221
- IntelliJ IDEA 2019.1.3
- BurpSuite 2.0.11
漏洞分析
查看conf/web.xml,可以發(fā)現(xiàn)tomcat默認readonly為true,需要手動設(shè)置為false才可以出觸發(fā)此漏洞。
<!-- readonly Is this context "read only", so HTTP -->
<!-- commands like PUT and DELETE are -->
<!-- rejected? [true] -->
手動添加:
<init-param>
<param-name>readonly</param-name>
<param-name>false</param-name>
</init-param>
但是,即使是設(shè)置了readonly為false,tomcat默認也不會允許用戶上傳jsp或者jspx一類的文件,而這個涉及到Servlet的一個處理邏輯問題。
可能有人沒接觸過Java Web,這里提一下Servlet和JSP的定義:
“Servlet”是“Server Applet”的縮寫,意為“小服務(wù)程序”或者“服務(wù)連接器”。
廣義上說,Servlet是Java的一個接口類;狹義上說,Servlet是指實現(xiàn)這個接口的類。
JSP(Java Server Pages,Java服務(wù)器頁面)是一種類似于PHP的東西,其本質(zhì)是Servlet(JSP在第一次訪問的時候會被翻譯成Servlet,再編譯成.class執(zhí)行)。
在默認情況下,tomcat使用org.apache.catalina.servlets.JspServlet類來處理后綴是jsp或者是jspx的請求,而PUT、DELETE等HTTP操作和其他請求都是由org.apache.catalina.servlets.DefaultServlet來實現(xiàn)的。
所以,因為JspServlet類中沒有PUT上傳的邏輯,所以不能直接觸發(fā)。而這個漏洞實際上是通過構(gòu)造特殊的文件后綴名來繞過tomcat的檢測,改用DefaultServlet處理惡意的PUT請求,從而上傳jsp的webshell。
下載tomcat的源碼,打開DefaultServlet類,可以看到doPut()方法。
Servlet中的doXxx()方法是重寫HttpServlet中的方法,例如doGet(),doPost()等等。只有在重寫了某個方法之后,這個Servlet才能支持對應(yīng)方式的請求,否則在請求的時候就會報405。
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (readOnly) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
可以看到,要啟用這個doPut()方法,必須設(shè)置readOnly為false。如果是true就直接報錯了。
第578~582行是寫入文件的部分:
if (exists) {
resources.rebind(path, newResource);
} else {
resources.bind(path, newResource);
}
一番尋找后發(fā)現(xiàn)這個bind()和rebind()方法都在FileDirContent.java文件中,查看bind()方法的源碼,實際上也是調(diào)用了rebind()方法:
@Override
public void bind(String name, Object obj, Attributes attrs)
throws NamingException {
// Note: No custom attributes allowed
File file = new File(base, name);
if (file.exists())
throw new NameAlreadyBoundException
(sm.getString("resources.alreadyBound", name));
rebind(name, obj, attrs);
}
查看rebind()方法的源碼,其寫入文件的核心部分如下:
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
try {
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);
}
} finally {
if (os != null)
os.close();
is.close();
}
真正寫入文件是使用FileOutputStream來寫入的。而創(chuàng)建文件當然是使用了Java的File類。
查看其源碼:
public File(String pathname) {
if (pathname == null) {
throw new NullPointerException();
}
this.path = fs.normalize(pathname);
this.prefixLength = fs.prefixLength(this.path);
}
這里有一個normalize()方法比較可疑,進去看看:
public abstract String normalize(String path);
這是個抽象方法,在一個接口里。它的其中一個實現(xiàn)如下:
@Override
public String normalize(String path) {
int n = path.length();
char slash = this.slash;
char altSlash = this.altSlash;
char prev = 0;
for (int i = 0; i < n; i++) {
char c = path.charAt(i);
if (c == altSlash)
return normalize(path, n, (prev == slash) ? i - 1 : i);
if ((c == slash) && (prev == slash) && (i > 1))
return normalize(path, n, i - 1);
if ((c == ':') && (i > 1))
return normalize(path, n, 0);
prev = c;
}
if (prev == slash) return normalize(path, n, n - 1);
return path;
}
在第10行可以看到,如果文件名后面有“/”,會將其去掉。所以,使用以下文件后綴名可以成功寫入.jsp文件:
- .jsp/
這是Java本身處理邏輯的問題,與使用的操作系統(tǒng)無關(guān)。所以,這樣構(gòu)造文件后綴名,就可以成功利用這個漏洞。
而實際上還有兩種后綴名也可以成功寫入,但僅限于操作系統(tǒng)是Windows的條件下:
- evil.jsp%20
- evil.jsp::$DATA
要知道為什么這兩種文件名也可以,就得繼續(xù)跟進,查看FileOutputStream類的源碼,看看它具體是怎樣創(chuàng)建一個文件的。
這個類的其中一個構(gòu)造器如下:
public FileOutputStream(File file, boolean append)
throws FileNotFoundException
{
String name = (file != null ? file.getPath() : null);
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkWrite(name);
}
if (name == null) {
throw new NullPointerException();
}
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}
this.fd = new FileDescriptor();
fd.attach(this);
this.append = append;
this.path = name;
open(name, append);
}
寫入文件是使用的這個open()方法:
private void open(String name, boolean append)
throws FileNotFoundException {
open0(name, append);
}
這個open()方法又調(diào)用了open0()方法:
private native void open0(String name, boolean append) throws FileNotFoundException;
從這個native關(guān)鍵字可以看出,這個方法并不是使用Java實現(xiàn)的。實際上,使用了native關(guān)鍵字表明這是一個JNI(Java Native Interface)方法,這里調(diào)用的是JVM底層實現(xiàn)的C代碼。
然而我太菜了,并不懂JVM的底層實現(xiàn)以及怎么分析它的源碼,只好借用了網(wǎng)上的一張圖。最終創(chuàng)建文件是使用的圖上這個CreateFileW()函數(shù):

這個CreateFileW()函數(shù)實際上是Windows API的一部分,所以歸根結(jié)底還是Windows對于文件名處理的問題。
這也解釋了以下兩種后綴名最后都會被處理為.jsp:
- .jsp%20
- .jsp::$DATA
漏洞復(fù)現(xiàn)
將readOnly手動設(shè)置為false之后,開啟tomcat,使用PUT方法上傳一個文件。
先改一下tomcat的端口以免和BurpSuite沖突:
<Connector port="9090" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
構(gòu)造請求并用BurpSuite抓包修改:

訪問webapps/ROOT目錄,發(fā)現(xiàn)shell.jsp文件。

訪問shell.jsp,可以發(fā)現(xiàn)成功訪問。

修補方案
- 在不需要用到
PUT請求時,將readonly屬性設(shè)置為true,避免文件上傳操作。 - 升級
tomcat。