美文网首页
【网络安全】Agent内存马的自动分析与查杀

【网络安全】Agent内存马的自动分析与查杀

作者: H_00c8 | 来源:发表于2022-02-25 17:21 被阅读0次
    image.png

    前言

    出发点是Java Agent内存马的自动分析与查杀,实际上其他内存马都可以通过这种方式查杀

    本文主要的难点主要是以下三个,我会在文中逐个解答

    1. 如何dumpJVM真正的当前的字节码
    2. 如何解决由于LAMBDA表达式导致非法字节码无法分析的问题
    3. 如何对字节码进行分析以确定某个类是内存马

    背景

    对于Java内存马的攻防一直没有停止,是Java安全领域的重点

    回顾TomcatSpring内存马:FilterController等都需要注册新的组件

    针对于需要注册新组件的内存马查杀起来比较容易:

    例如c0ny1师傅的java-memshell-scanner项目,利用了Tomcat API删除添加的组件。优点在于一个简单的JSP文件即可查看所有的组件信息,结合人工审查(类名和ClassLoader等信息)对内存马进行查杀,也可以对有风险的Class进行dump后反编译分析

    或者LandGrey师傅基于Alibaba Arthas编写的copagent项目,分析JVM中所有的Class,根据危险注解和类名等信息dump可疑的组件,结合人工反编译后进行分析

    但实战中,可能并不是以上这种注册新组件的内存马

    例如师傅们常用的冰蝎内存马,是Java Agent内存马。以下这段是冰蝎内存马一段代码,简单分析后可以发现冰蝎内存马是利用Java Agent注入到javax.servlet.http.HttpServletservice方法中,这是JavaEE的规范,理论上部署在Tomcat的都要符合这个规范,简单来理解这是Tomcat处理请求最先且总是经过的地方,在该类加入内存马的逻辑,可以保证稳定触发

    image

    类似的逻辑,可以使用Java Agent将内存马注入org.apache.catalina.core.ApplicationFilterChain类中,该类位于Filter链头部,也就是说经过Tomcat的请求都会交经过该类的doFilter方法处理,所以在该方法中加入内存马逻辑,也是一种稳定触发的方式(据说这是老版本冰蝎内存马的方式)

    还可以对类似的类进行注入,例如org.springframework.web.servlet.DispatcherServlet类,针对于Spring框架的底层进行注入。或者一些巧妙的思路,比如注入Tomcat自带的Filter之一org.apache.tomcat.websocket.server.WsFilter类,这也是Java Agent内存马可以做到的

    上文简单地介绍了各种内存马的利用方式与普通内存马的查杀,之所以最后介绍Java Agent内存马的查杀,是因为比较困难。宽字节安全的师傅提出查杀思路:基于javaAgent内存马检测查杀指南

    引用文章讲到Java Agent内存马检测的难点:

    调用retransformClass方法的时候参数中的字节码并不是调用redefineClass后被修改的类的字节码。对于冰蝎来讲,根本无法获取被冰蝎修改后的字节码。我们自己写Java Agent清除内存马的时候,同样也是无法获取到被redefineClass修改后的字节码,只能获取到被retransformClass修改后的字节码。通过JavaassistASM工具获取到类的字节码,也只是读取磁盘上响应类的字节码,而不是JVM中的字节码

    宽字节安全的师傅找到了一种检测手段:sa-jdi.jar

    借用公众号师傅的图片,这是一个GUI工具,可以查看JVM中所有已加载的类。区别在于这里获取到的是真正的当前的字节码,而不是获取到原始的,本地的字节码,所以是可以查看被Java Agent调用redefineClass后被修改的类的字节码。进一步可以dump下来认为存在风险的类然后反编译人工审核

    image

    介绍

    以上是背景,接下来介绍我做了些什么,能够实现怎样的效果

    不难看出,以上内存马查杀手段都是半自动结合人工审核的方式,当检测出内存马后

    是否可以找到一种方式,做到一条龙式服务:

    • 检测(同时支持普通内存马和Java Agent内存马的检测)
    • 分析(如何确定该类是内存马,仅根据恶意类名和注解等信息不完善)
    • 查杀(当确定内存马存在,如何自动地删除内存马并恢复正常业务逻辑)

    大致看来,实现起来似乎不难,然而实际中遇到了很多坑,接下来我会逐个介绍

    【→>所有资源关注我,查看“资料”获取<←】
    1、网络安全学习路线
    2、电子书籍(白帽子)
    3、安全大厂内部视频
    4、100份src文档
    5、常见安全面试题
    6、ctf大赛经典题目解析
    7、全套工具包
    8、应急响应笔记

    SA-JDI分析

    我尝试通过Java Agent技术来获取当前的字节码,发现如师傅所说拿不到被修改的字节码

    所以为了可以检测Agent马需要从sa-jdi.jar本身入手,想办法dump得到当前字节码(这样不止可以分析被修改了字节码的Agent马也可以分析普通类型的内存马)

    注意到其中一个类:sun.jvm.hotspot.tools.jcore.ClassDump并通过查资料发现该类功能正是dump当前的Class(根据类名也可猜测出)其中的main方法提供一个dump class的命令行工具

    于是我想了一些办法,用代码实现了命令行工具的功能,并可以设置一个Filter

    ClassDump classDump = new ClassDump ();
    // my filter
    classDump . setClassFilter ( filter );
    classDump . setOutputDirectory ( "out" );
    // protected start method
    Class <?> toolClass = Class . forName ( "sun.jvm.hotspot.tools.Tool" );
    Method method = toolClass . getDeclaredMethod ( "start" , String []. class );
    method . setAccessible ( true );
    // jvm pid
    String [] params = new String []{ String . valueOf ( pid )};
    try {
        method . invoke ( classDump , ( Object ) params );
    } catch ( Exception ignored ) {
        logger . error ( "unknown error" );
        return ;
    }
    logger . info ( "dump class finish" );
    // detach
    Field field = toolClass . getDeclaredField ( "agent" );
    field . setAccessible ( true );
    HotSpotAgent agent = ( HotSpotAgent ) field . get ( classDump );
    agent . detach (); 
    

    上文提到设置一个Filter是用于确定需要对哪些类进行dump操作(dump过多会导致性能等问题)

    public class NameFilter implements ClassFilter  {
        @Override
        public boolean canInclude ( InstanceKlass instanceKlass )   {
            String klassName = instanceKlass . getName (). asString ();
            // 在黑名单中的类需要dump
            if ( blackList . contains ( klassName )) {
                return true ;
            }
            // 包含了关键字的类也需要dump
            for ( String k : Constant . keyword ) {
                if ( klassName . contains ( k )) {
                    return true ;
                }
            }
            return false ;
        }
    } 
    

    以上包含了类的黑名单和关键字:

    • 黑名单:Java Agent内存马通常会Hook的地方,需要dump下来进行分析
    • 关键字:类名如果出现memshellshell等关键字认为可能是普通内存马,需要分析
    public class Constant {
        // BLACKLIST (Analysis Target)
        // CLASS_NAME#METHOD_NAME
        public static List < String > blackList = new ArrayList <>();
        // SHELL KEYWORD
        public static List < String > keyword = new ArrayList <>();
    
        static {
            blackList . add ( "javax/servlet/http/HttpServlet#service" );
            blackList . add ( "org/apache/catalina/core/ApplicationFilterChain#doFilter" );
            blackList . add ( "org/springframework/web/servlet/DispatcherServlet#doService" );
            blackList . add ( "org/apache/tomcat/websocket/server/WsFilter#doFilter" );
    
            keyword . add ( "shell" );
            keyword . add ( "memshell" );
            keyword . add ( "agentshell" );
            keyword . add ( "exploit" );
            keyword . add ( "payload" );
            keyword . add ( "rebeyond" );
            keyword . add ( "metasploit" );
        }
    } 
    

    另外如果想在Maven项目中加入JDK/lib下的依赖,需要特殊配置

     <dependency>
        <groupId> sun.jvm.hotspot </groupId>
        <artifactId> sa-jdi </artifactId>
        <version> jdk-8 </version>
        <scope> system </scope>
        <systemPath> ${env.JAVA_HOME}/lib/sa-jdi.jar </systemPath>
    </dependency> 
    

    在打包成工具Jar包时默认情况下不会加入system scope的依赖,所以需要特殊处理

     <artifactId> maven-assembly-plugin </artifactId>
    <configuration>
        <appendAssemblyId> false </appendAssemblyId>
        <descriptors>
            <descriptor> assembly.xml </descriptor>
        </descriptors>
        <archive>
            <manifest>
                <mainClass> org.sec.Main </mainClass>
            </manifest>
        </archive>
    </configuration> 
    

    编写assembly.xml文件

     <!-- 省略部分 -->
    <dependencySets>
        <dependencySet>
            <outputDirectory> / </outputDirectory>
            <unpack> true </unpack>
            <scope> system </scope>
        </dependencySet>
    </dependencySets> 
    

    接着就可以通过代码的方式,根据黑名单和关键字来确定需要dump哪些类然后进行dump操作了

    我在测试中遇到一个小问题,值得分享:HttpServlet是正常可以dump的但是ApplicationFilterChain类没有找到。这是因为SpringBoot的懒加载问题,需要手动请求下某个接口就可以了

    解决非法字节码

    接下来我遇到了一个比较大的坑,通过sa-jdidump下来的字节码是非法的

    在对ApplicationFilterChain类分析的时候,会报如下的错

    image

    起初我怀疑是自己用了最新版ASM框架:9.2

    于是逐渐降级,发现降级到7.0后不再报错,但ClassReader不报错,在分析时候会报错

    经过对比,发现是以下的情况

    image

    不报错版本

    image

    稍微分析了下,发现是ApplicationFilterChain类包含了LAMBDA

    不止这个类,不少的类都有可能会包含LAMBDA

    image

    发现通过sa-jdi获取的字节码在存在LAMBDA的情况下是非法字节码,无法进行分析

    这时候如果还想进行分析,只有两个选择:

    • 自己解析CLASS文件做分析(本末倒置)
    • 改写ASM源码使跳过LAMBDA

    根据Java基础知识可以得知:LAMBDAINVOKEDYNAMIC指令相关,于是我改了ASM的代码

    (这里不解释为什么这么改了,是经过多次调试确定的)

    org/objectweb/asm/ClassReader#274

    bootstrapMethodOffsets = null ; 
    

    org/objectweb/asm/ClassReader#2456

    case Opcodes . INVOKEDYNAMIC :
      {
        return ;
      } 
    

    改了源码后,就可以正常对非法字节码进行分析了。目前来看没有什么大问题,可以正常分析,但不确定这样的修改是否会存在一些隐患和BUG。总之目前能继续了

    分析字节码

    分析字节码并不需要太深入做,因为大部分可能出现的内存马都是Runtime.exec或冰蝎反射调ClassLoader.defineClass实现的,针对于这两种情况做分析,足以应对绝大多数情况

    以下代码是读取dump的字节码并针对两种情况对所有方法分析

    List < Result > results = new ArrayList <>();
    int api = Opcodes . ASM9 ;
    int parsingOptions = ClassReader . SKIP_DEBUG | ClassReader . SKIP_FRAMES ;
    for ( String fileName : files ) {
        byte [] bytes = Files . readAllBytes ( Paths . get ( fileName ));
        if ( bytes . length == 0 ) {
            continue ;
        }
        ClassReader cr ;
        ClassVisitor cv ;
        try {
            // runtime exec analysis
            cr = new ClassReader ( bytes );
            cv = new ShellClassVisitor ( api , results );
            cr . accept ( cv , parsingOptions );
            // classloader defineClass analysis
            cr = new ClassReader ( bytes );
            cv = new DefineClassVisitor ( api , results );
            cr . accept ( cv , parsingOptions );
        } catch ( Exception ignored ) {
        }
    }
    for ( Result r : results ) {
        logger . info ( r . getKey () + " -> " + r . getTypeWord ());
    } 
    

    对于Runtime.exec型的分析最为简单,仅判断已dump的字节码中所有方法中是否存在该方法的调用即可(理论上会存在误报,但黑名单类不可能存在该方法,关键字类本身就是可疑的,所以这样做并无不妥)

     @Override
    public void visitMethodInsn ( int opcode , String owner , String name , String descriptor , boolean isInterface ) {
        boolean runtimeCondition = owner . equals ( "java/lang/Runtime" ) && 
            name . equals ( "exec" ) &&
            descriptor . equals ( "(Ljava/lang/String;)Ljava/lang/Process;" );
        if ( runtimeCondition ) {
            Result result = new Result ();
            result . setKey ( this . owner );
            result . setType ( Result . RUNTIME_EXEC_TIME );
            results . add ( result );
        }
        super . visitMethodInsn ( opcode , owner , name , descriptor , isInterface );
    } 
    

    但这种情况不适用于冰蝎反射调ClassLoader.defineClass

    代码不长,但对应的字节码较复杂

    Method m = ClassLoader . class . getDeclaredMethod ( "defineClass" , 
                                                   String . class ,  ByteBuffer . class ,  ProtectionDomain . class );
    m . invoke ( null ); 
    

    对应字节码

    LDC Ljava/lang/ClassLoader;.class // 重点关注
    LDC "defineClass" // 重点关注
    ICONST_3
    ANEWARRAY java/lang/Class
    DUP
    ICONST_0
    LDC Ljava/lang/String;.class
    AASTORE
    DUP
    ICONST_1
    LDC Ljava/nio/ByteBuffer;.class
    AASTORE
    DUP
    ICONST_2
    LDC Ljava/security/ProtectionDomain;.class
    AASTORE
    INVOKEVIRTUAL java/lang/Class.getDeclaredMethod (Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method; // 重点关注
    ASTORE 1
    L1
    LINENUMBER 11 L1
    ALOAD 1
    ACONST_NULL
    ICONST_0
    ANEWARRAY java/lang/Object
    INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object; // 重点关注
    POP
    

    这种操作需要多个步骤,并不是简单的一个INVOKE那么简单,不特殊处理的话,由于反射和ClassLoader相关操作都算是比较常见的,有一定的误报可能

    于是继续掏出栈帧分析大法,具体不再介绍,之前文章 已有详细解释

    根据字节码,在defineClassLjava/lang/ClassLoader;通过LDC指令入栈之前,应该认为这是恶意操作,模拟JVM指令执行后应该在栈顶设置污点

     @Override
    public void visitLdcInsn ( Object value ) {
        if ( value instanceof String ) {
            if ( value . equals ( "defineClass" )) {
                super . visitLdcInsn ( value );
                this . operandStack . set ( 0 , "LDC_STRING" );
                return ;
            }
        } else {
            if ( value . equals ( Type . getType ( "Ljava/lang/ClassLoader;" ))) {
                super . visitLdcInsn ( value );
                this . operandStack . set ( 0 , "LDC_CL" );
                return ;
            }
        }
        super . visitLdcInsn ( value );
    } 
    

    后续主要是对于两个INVOKE进行分析

    • 如果getDeclaredMethod传入的是上文LDC处设置的污点,认为方法返回值也是污点,给栈顶的返回值设置REFLECTION_METHOD标志
    • 如果Method.invoke方法中的Method被标记了REFLECTION_METHOD则可以确定这是内存马
    • 开头一部分代码主要是根据方法参数的实际情况对参数在操作数栈中的索引位置进行确定,是一种动态和自动的确认方式,而不是直接根据经验或者调试写死索引,算是优雅写法
    public void visitMethodInsn ( int opcode ,  String owner ,  String name ,  String descriptor ,  boolean isInterface ) {
        Type [] argTypes = Type . getArgumentTypes ( descriptor );
        if ( opcode != Opcodes . INVOKESTATIC ) {
            Type [] extendedArgTypes = new Type [ argTypes . length + 1 ];
            System . arraycopy ( argTypes , 0 , extendedArgTypes , 1 , argTypes . length );
            extendedArgTypes [ 0 ] = Type . getObjectType ( owner );
            argTypes = extendedArgTypes ;
        }
        boolean reflectionMethod = owner . equals ( "java/lang/Class" ) &&
            opcode == Opcodes . INVOKEVIRTUAL && name . equals ( "getDeclaredMethod" );
        boolean methodInvoke = owner . equals ( "java/lang/reflect/Method" ) &&
            opcode == Opcodes . INVOKEVIRTUAL && name . equals ( "invoke" );
        if ( reflectionMethod ) {
            int targetIndex = 0 ;
            for ( int i = 0 ; i < argTypes . length ; i ++) {
                if ( argTypes [ i ]. getClassName (). equals ( "java.lang.String" )) {
                    targetIndex = i ;
                    break ;
                }
            }
            if ( operandStack . get ( argTypes . length - targetIndex - 1 ). contains ( "LDC_STRING" )) {
                super . visitMethodInsn ( opcode , owner , name , descriptor , isInterface );
                operandStack . set ( TOP , "REFLECTION_METHOD" );
                return ;
            }
        }
        if ( methodInvoke ) {
            int targetIndex = 0 ;
            for ( int i = 0 ; i < argTypes . length ; i ++) {
                if ( argTypes [ i ]. getClassName (). equals ( "java.lang.reflect.Method" )) {
                    targetIndex = i ;
                    break ;
                }
            }
            if ( operandStack . get ( argTypes . length - targetIndex - 1 ). contains ( "REFLECTION_METHOD" )) {
                super . visitMethodInsn ( opcode , owner , name , descriptor , isInterface );
                Result result = new Result ();
                result . setKey ( owner );
                result . setType ( Result . CLASSLOADER_DEFINE );
                results . add ( result );
                return ;
            }
        }
        super . visitMethodInsn ( opcode , owner , name , descriptor , isInterface );
    } 
    

    检测效果如下:

    先写个内存马注入的Agent注入到HttpServlet中(关于这个不是文章重点)

    image

    然后跑起来我写的工具

    • 其中红色框内是注入的Agent内存马,可以分析出
    • 发现上面还有两个内存马结果,这是我模拟的普通内存马,直接写入到代码中做测试的
    image

    自动修复

    接下来是内存马的修复,自行写一个Java Agent即可

    暂时只处理ApplicationFilterChainHttpServlet的情况(也是最常见的情况)

    public class RepairAgent {
        public static void agentmain ( String agentArgs ,  Instrumentation ins ) {
            ClassFileTransformer transformer = new RepairTransformer ();
            ins . addTransformer ( transformer , true );
            Class <?>[] classes = ins . getAllLoadedClasses ();
            for ( Class <?> clas : classes ) {
                if ( clas . getName (). equals ( "org.apache.catalina.core.ApplicationFilterChain" )
                        || clas . getName (). equals ( "javax.servlet.http.HttpServlet" )) {
                    try {
                        ins . retransformClasses ( clas );
                    } catch ( Exception e ) {
                        e . printStackTrace ();
                    }
                }
            }
        }
    } 
    

    处理的逻辑并不复杂

    • 由于ApplicationFilterChain中包含了LAMBDA所以我直接简化了代码,变成简单的一句internalDoFilter($1,$2)做修复(慎重选择,为什么这样做我将在总结里解释)
    • 修改方法的参数需要用$1 $2这样表示,不能写reqresp
    • 这里HttpServlet的情况稍复杂,其中有两个service方法,实际上对任何一个进行修改都可以导致内存马的效果,所以我要做的事情是恢复这两个方法,而不是只针对某一个
    • 注意任何非java.lang下的类都需要完整类名
    public class RepairTransformer implements ClassFileTransformer {
    
        @Override
        public byte [] transform ( ClassLoader loader ,
                                String className ,
                                Class <?> classBeingRedefined ,
                                ProtectionDomain protectionDomain ,
                                byte [] classfileBuffer ) {
            className = className . replace ( "/" , "." );
            ClassPool pool = ClassPool . getDefault ();
            if ( className . equals ( "org.apache.catalina.core.ApplicationFilterChain" )) {
                try {
                    CtClass c = pool . getCtClass ( className );
                    CtMethod m = c . getDeclaredMethod ( "doFilter" );
                    m . setBody ( "{internalDoFilter($1,$2);}" );
                    byte [] bytes = c . toBytecode ();
                    c . detach ();
                    return bytes ;
                } catch ( Exception e ) {
                    e . printStackTrace ();
                }
            }
            if ( className . equals ( "javax.servlet.http.HttpServlet" )) {
                try {
                    CtClass c = pool . getCtClass ( className );
                    CtClass [] params = new CtClass []{
                            pool . getCtClass ( "javax.servlet.ServletRequest" ),
                            pool . getCtClass ( "javax.servlet.ServletResponse" ),
                    };
                    CtMethod m = c . getDeclaredMethod ( "service" , params );
                    m . setBody ( "{" +
                            "        javax.servlet.http.HttpServletRequest  request;\n" +
                            "        javax.servlet.http.HttpServletResponse response;\n" +
                            "\n" +
                            "        try {\n" +
                            "            request = (javax.servlet.http.HttpServletRequest) $1;\n" +
                            "            response = (javax.servlet.http.HttpServletResponse) $2;\n" +
                            "        } catch (ClassCastException e) {\n" +
                            "            throw new javax.servlet.ServletException(lStrings.getString("http.non_http"));\n" +
                            "        }\n" +
                            "        service(request, response);" +
                            "}" );
    
                    CtClass [] paramsProtected = new CtClass []{
                            pool . getCtClass ( "javax.servlet.http.HttpServletRequest" ),
                            pool . getCtClass ( "javax.servlet.http.HttpServletResponse" ),
                    };
                    CtMethod mProtected = c . getDeclaredMethod ( "service" , paramsProtected );
                    mProtected . setBody ( "{" +
                            "String method = $1.getMethod();\n" +
                            "\n" +
                            "        if (method.equals(METHOD_GET)) {\n" +
                            "            long lastModified = getLastModified($1);\n" +
                            "            if (lastModified == -1) {\n" +
                            "                doGet($1, $2);\n" +
                            "            } else {\n" +
                            "                long ifModifiedSince;\n" +
                            "                try {\n" +
                            "                    ifModifiedSince = $1.getDateHeader(HEADER_IFMODSINCE);\n" +
                            "                } catch (IllegalArgumentException iae) {\n" +
                            "                    ifModifiedSince = -1;\n" +
                            "                }\n" +
                            "                if (ifModifiedSince < (lastModified / 1000 * 1000)) {\n" +
                            "                    maybeSetLastModified($2, lastModified);\n" +
                            "                    doGet($1, $2);\n" +
                            "                } else {\n" +
                            "                    $2.setStatus(javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED);\n" +
                            "                }\n" +
                            "            }\n" +
                            "\n" +
                            "        } else if (method.equals(METHOD_HEAD)) {\n" +
                            "            long lastModified = getLastModified($1);\n" +
                            "            maybeSetLastModified($2, lastModified);\n" +
                            "            doHead($1, $2);\n" +
                            "\n" +
                            "        } else if (method.equals(METHOD_POST)) {\n" +
                            "            doPost($1, $2);\n" +
                            "\n" +
                            "        } else if (method.equals(METHOD_PUT)) {\n" +
                            "            doPut($1, $2);\n" +
                            "\n" +
                            "        } else if (method.equals(METHOD_DELETE)) {\n" +
                            "            doDelete($1, $2);\n" +
                            "\n" +
                            "        } else if (method.equals(METHOD_OPTIONS)) {\n" +
                            "            doOptions($1, $2);\n" +
                            "\n" +
                            "        } else if (method.equals(METHOD_TRACE)) {\n" +
                            "            doTrace($1, $2);\n" +
                            "\n" +
                            "        } else {\n" +
                            "            String errMsg = lStrings.getString("http.method_not_implemented");\n" +
                            "            Object[] errArgs = new Object[1];\n" +
                            "            errArgs[0] = method;\n" +
                            "            errMsg = java.text.MessageFormat.format(errMsg, errArgs);\n" +
                            "\n" +
                            "            $2.sendError(javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED, errMsg);\n" +
                            "        }"
                            + "}" );
    
                    byte [] bytes = c . toBytecode ();
                    c . detach ();
                    return bytes ;
                } catch ( Exception e ) {
                    e . printStackTrace ();
                }
            }
            return new byte [ 0 ];
        }
    } 
    

    当我们写好了Agent后,需要加入自动修复的逻辑

    List < Result > results = Analysis . doAnalysis ( files );
    if ( command . repair )   {
        RepairService . start ( results , pid );
    } 
    

    如果分析出了结果,且用户选择了修复功能,才会进入修复逻辑(暂只修复这两个最常见的类)

    public static void start ( List < Result >  resultList ,  int pid ) {
        logger . info ( "try repair agent memshell" );
        for ( Result result : resultList ) {
            String className = result . getKey (). replace ( "/" , "." );
            if ( className . equals ( "org.apache.catalina.core.ApplicationFilterChain" ) ||
                className . equals ( "javax/servlet/http/HttpServlet" )) {
                try {
                    start ( pid );
                    return ;
                } catch ( Exception ignored ) {
                }
            }
        }
    } 
    

    修复的核心代码:把打包好的Agent拿过来,做一下AtachLoad将字节码替换为正常情况即可

    public static void start ( int pid ) {
        try {
            String agent = Paths . get ( "RepairAgent.jar" ). toAbsolutePath (). toString ();
            VirtualMachine vm = VirtualMachine . attach ( String . valueOf ( pid ));
            logger . info ( "load agent..." );
            vm . loadAgent ( agent );
            logger . info ( "repair..." );
            vm . detach ();
            logger . info ( "detach agent..." );
        } catch ( Exception e ) {
            e . printStackTrace ();
        }
    } 
    

    注意使用VirtualMachineAPI需要加入tools.jar,由于上文已经配置了打包插件,所以可以直接打入Jar包,使用时候java -jar xxx.jar --pid 000这样会比较方便

     <dependency>
        <groupId> com.sun.tools </groupId>
        <artifactId> tools </artifactId>
        <version> jdk-8 </version>
        <scope> system </scope>
        <systemPath> ${env.JAVA_HOME}/lib/tools.jar </systemPath>
    </dependency> 
    

    通过以上这些修复手段可以做到的效果:

    • 启动某SpringBoot应用
    • 通过Agent注入内存马,访问后内存马可用
    • 通过工具检测到内存马,尝试修改,使字节码被还原
    • 再次访问后内存马失效,不需要重启

    总结

    关于Dump字节码

    经过我的一些测试,使用sa-jdi库不能保证dump所有的字节码,会出现莫名其妙的异常,猜测是某些字节码不允许被dump下来。但测试了常见TomcatSpringBoot等程序,发现基本没有问题

    关于非法字节码

    只要是包含LAMBDA的字节码都是非法字节码,无法正常处理,需要用修改源码后的ASM来做。这种方式终究不是完美的办法,是否存在能够dump下来合法字节码的方式呢(经过一些尝试没有找到办法)

    关于检测

    可以看到,字节码分析的过程比较简单,尤其是Runtime.exec的普通执行命令内存马,很容易绕过,但个人认为这已足够,因为之前的一些条件已经限制了分析的类是不可能包含Runtime.exec的黑名单类,且大多数用户都是脚本小子,使用免杀型内存马的可能性不大。大多数用户可能直接用了现成的工具,例如冰蝎型内存马的检测方式已完成,暂时来看这样做是足够的,没有必要加入各种免杀检测手段

    关于查杀

    使用Agent恢复字节码的修复方式理论上没有问题。但其中的ApplicationFilterChain类的doFilter方法中包含了LAMBDA和匿名内部类,这两者都是Javassist框架不支持的内容,可以用ASM来做,但可能难度较高

    另外对于普通型内存马的修复,通过Agent技术只能覆盖方法体,不可以增加或删除方法。所以理论上可以根据方法的返回值类型,做返回NULL的处理进行修复

    关于拓展

    例如代码中我定义的黑名单和关键字,可以根据实战经验自行添加新的类,以实现更完善的效果。在查杀方面我做了最常见的两种,可以根据实际情况自行添加更多的逻辑

    相关文章

      网友评论

          本文标题:【网络安全】Agent内存马的自动分析与查杀

          本文链接:https://www.haomeiwen.com/subject/upixrrtx.html