动态编译
手动编译的时候其实有一个坑:系统不包含servlet相关的库,所以会报错
这个好解决,只需要一个参数javac Webshell.java -cp javax.servlet-api.jar
在网上查了下如何动态编译,这个代码还是比较多的
但都没有设置参数,我们情况特殊需要classpath参数,最终看官方文档得到了答案
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); StandardJavaFileManager fileManager = compiler.getStandardFileManager( null, null, null); Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects( new File("Webshell.java")); // 加入参数 List<String> 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"));
模拟栈帧
JVM在每次方法调用均会创建一个对应的Frame,方法执行完毕或者异常终止,Frame被销毁
而每个Frame的结构如下,主要由本地变量数组(local variables)和操作栈(operand stack)组成
局部变量表所需的容量大小是在编译期确定下来的,表中的变量只在当前方法调用中有效
JVM把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈
参考我在Github的代码,该类构造了Operand Stack和Local Variables Array并模拟操作
在用ASM技术解析class文件的时候,模拟他们在JVM中执行的过程,实现数据流分析
使用代码模拟两大数据结构
public class OperandStack<T> { private final LinkedList<Set<T>> stack; // pop push methods } public class LocalVariables<T> { private final ArrayList<Set<T>> 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等操作,随便选了其中一部分供参考
@Override public void visitInsn(int opcode) { Set<T> 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指令文档:官方文档
上文其实略枯燥,接下来结合实例和大家画图分析,这将会一目了然
检测实现
新建一个ClassVisitor用于分析字节码,以下这三部是ASM规定的分析字节码方式
ClassReader cr = new ClassReader(classData); ReflectionShellClassVisitor cv = new ReflectionShellClassVisitor(); cr.accept(cv, ClassReader.EXPAND_FRAMES);
大家需要注意ASM是观察者模式,需要理解阻断和传递的思想
其实ReflectionShellClassVisitor不是重点,因为我们的JSP Webshell逻辑都写在Webshell.invoke方法中,所以检测逻辑在ReflectionShellMethodAdapter类中
// 继承自ClassVisitor public 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的时候编写如下代码
@Override public 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; } } }