作者:Lucifaer
作者博客:https://lucifaer.com/2019/09/25/%E6%B5%85%E8%B0%88RASP/

本篇將近一個月對rasp的研究成果進行匯總,具體討論RASP的優劣勢以及一些個人的理解和看法。

0x01 概述

RASP是Runtime application self-protection的縮寫,中文翻譯為應用程序運行時防護,其與WAF等傳統安全防護措施的主要區別于其防護層級更加底層——在功能調用前或調用時能獲取訪問到當前方法的參數等信息,根據這些信息來判定是否安全。

RASP與傳統的基于流量監測的安全防護產品來說,優勢點在于可以忽略各種繞過流量檢測的攻擊方式(如分段傳輸,編碼等),只關注功能運行時的傳參是否會產生安全威脅。簡單來說,RASP不看過程,只看具體參數導致方法實現時是否會產生安全威脅。簡單類比一下,RASP就相當于應用程序的主防,其判斷是更加精準的。

雖然RASP有很多優勢,但是由于其本身的實現也導致了很多問題使其難以推廣:

  • 侵入性過大。對于JAVA的RASP來說,它的實現方式是通過Instrumentation編寫一個agent,在agent中加入hook點,當程序運行流程到了hook點時,將檢測流程插入到字節碼文件中,統一進入JVM中執行。在這里如果RASP本身出現了什么問題的話,將會直接對業務造成影響。
  • 效率問題。由于需要將檢測流程插入到字節碼文件中,這樣會在運行時產生大量不屬于業務流程本身的邏輯,這樣會增加業務執行的流程,對業務效率造成一定的影響。
  • 開發問題。針對不同的語言,RASP底層的實現是不一樣的,都需要重新基于語言特性進行專門的開發,開發的壓力很大。
  • 部署問題。以Java RASP來舉例子,Java RASP有兩種部署方式,一種需要在啟動前指定agent的位置,另一種可以在運行時用attach的方式進行部署,但是他們都存在不同的問題。
    • 在啟動前指定agent的位置就以為著在進行部署時需要重啟服務,會影響到正常的業務。
    • 在運行時進行attach部署時,當后期RASP進行版本迭代重新attach時,會產生重復添加代碼的情況(由于JVM本身機制的問題,基本無法將修改的字節碼重新轉換到運行時的字節碼上,所以沒辦法動態添加代理解決該問題)。

目前RASP的主方向還是Java RASP,受益于JVMTI,現在的Java RASP是很好編寫的,效果也是比較錯的。同時也受限于JVMTI,Java RASP的技術棧受到了一定的限制,很難在具體實現上更進一步,只能在hook點和其他功能上進行完善。

跳出乙方視角來審視RASP,其最好的實踐場景還是在甲方企業內部,從某個角度來說RASP本來就是高度侵入業務方代碼的一種防護措施,在紛繁復雜的業務場景中,只有甲方根據業務進行定制化開發才能達到RASP的最高價值,如果乙方來做很容易變成“紙上談兵”的產品。

下面將以Java RASP為核心對RASP技術進行詳細的闡述,并用跟蹤源碼的方式來解析百度OpenRASP的具體實現方式。

0x02 Java RASP技術棧

Java RASP核心技術棧:

  • Instrumentation通過JVMTI實現的Agent,負責獲取并返回當前JVM虛擬機的狀態或轉發控制命令。
  • 字節碼操作框架,用于修改字節碼(如ASM、Javassist等)

其余技術棧:

  • Log4j日志記錄
  • 插件系統(主要是用于加載檢測規則)
  • 數據存儲及轉發(轉發到soc平臺或自動封禁平臺進行封禁) 等

0x03 Java RASP實現方式

編寫Java RASP主要分為兩部分:

  • Java Agent的編寫
  • 利用字節碼操作框架(以下都以ASM來舉例)完成相應hook操作

3.1 Java Agent簡介

在Java SE 5及后續版本中,開發者可以在一個普通Java程序運行時,通過-javaagent參數指定一個特定的jar文件(該文件包含Instrumentation代理)來啟動Instrumentation的代理程序,這個代理程序可以使開發者獲取并訪問JVM運行時的字節碼,并提供了對字節碼進行編輯的操作,這就意味著開發者可以將自己的代碼注入,在運行時完成相應的操作。在Java SE 6后又對改功能進行了增強,允許開發者以用Java Tool API中的attach的方式在程序運行中動態的設置代理類,以達到Instrumentation的目的。而這兩個特性也是編寫Java RASP的關鍵。

javaagent提供了兩種模式:

  • premain:允許在main開始前修改字節碼,也就是在大部分類加載前對字節碼進行修改。
  • agentmain:允許在main執行后通過com.sun.tools.attach的Attach API attach到程序運行時中,通過retransform的方式修改字節碼,也就是在類加載后通過類重新轉換(定義)的方式在方法體中對字節碼進行修改,其本質還是在類加載前對字節碼進行修改

這兩種模式除了在main開始前后調用的區別外,還有很多細枝末節的區別,這一點就導致了兩種模式的泛用性不同:

  • agent運作模式不同:premain相當于在main前類加載時進行字節碼修改,agentmain是main后在類調用前通過重新轉換類完成字節碼修改。可以發現他們的本質都是在類加載前完成的字節碼修改,但是premain可以直接修改或者通過redefined進行類重定義,而agentmian必須通過retransform進行類重新轉換才能完成字節碼修改操作。
  • 部署方式不同:由于agent運作模式的不同,所以才導致premain需要在程序啟動前指定agent,而agentmain需要通過Attach API進行attach。而且由于都是在類加載前進行字節碼的修改,所以如果premain模式的hook進行了更新,就只能重啟服務器,而agentmain模式的hook如果進行了更新的話,需要重新attach

因為兩種模式都存在一定的限制,所以在實際運用中都會有相應的問題:

  • premain:每次修改需要重啟服務。
  • agentmain:由于attach的運行時中的進程,因JVM的進程保護機制,禁止在程序運行時對運行時的類進行自由的修改,具體的限制如下:

    • 父類應為同一個類
    • 實現的接口數要相同
    • 類訪問符要一致
    • 字段數和字段名必須一致
    • 新增的方法必須是private static/final
    • 可是刪除修改方法

    這樣的限制是沒有辦法用代理模式的思路來避免重復插入的。同時為了實現增加hook點的操作我們必須將自己的檢測字節碼插入,所以只能修改方法體。這樣一來如果使用agentmain進行重復的attach,會造成將相同代碼多次插入的操作,會產生重復告警,極大的增加業務壓力。

單單針對agentmain所出現的重復插入的問題,有沒有方式能直接對運行時的java類做字節碼插入呢?其實是有的,但是由于各種原因,其會較大的增加業務壓力所以這里不過多敘述,想要了解詳情的讀者,可以通過搜索HotswapDCE VM來了解兩種不同的熱部署方式。

3.2 ASM簡介

ASM是一個Java字節碼操作框架,它主要是基于訪問者模式對字節碼完成相應的增刪改操作。想要深入的理解ASM可以去仔細閱讀ASM的官方文檔,這里只是簡單的介紹一下ASM的用法。

在開始講ASM用法前,需要簡單的介紹一下訪問者模式,只有清楚的訪問者模式,才能理解ASM為什么要這么寫。

3.2.1 訪問者模式

在面向對象編程和軟件工程中,訪問者模式是一種把數據結構和操作這個數據結構的算法分開的模式。這種分離能方便的添加新的操作而無需更改數據結構。

實質上,訪問者允許一個類族添加新的虛函數而不修改類本身。但是,創建一個訪問者類可以實現虛函數所有的特性。訪問者接收實例引用作為輸入,使用雙重調用實現這個目標。

上面說的的比較籠統,直接用代碼來說話:

package com.lucifaer.ASMDemo;

interface Person {
    public void accept(Visitor v) throws InterruptedException;
}

class Play implements Person{

    @Override
    public void accept(Visitor v) throws InterruptedException {
        v.visit(this);
    }
    public void play() throws InterruptedException {
        Thread.sleep(5000);
        System.out.println("This is Person's Play!");
    }
}

interface Visitor {
    public void visit(Play p) throws InterruptedException;
}

class PersonVisitor implements Visitor {

    @Override
    public void visit(Play p) throws InterruptedException {
        System.out.println("In Visitor!");
        long start_time = System.currentTimeMillis();
        p.play();
        long end_time = System.currentTimeMillis();
        System.out.println("End Visitor");
        System.out.println("Spend time: " + (end_time-start_time));
    }
}

public class VisiterMod {
    public static Person p = new Play();

    public static void main(String[] args) throws InterruptedException {
        PersonVisitor pv = new PersonVisitor();
        p.accept(pv);
    }
}

在這個例子中做了以下的工作:

  1. 添加void accept(Visitor v)Person類中
  2. 創建visitor基類,基類中包含元素類的visit()方法
  3. 創建visitor派生類,實現基類對PersonPlay的操作
  4. 使用者創建visitor對象,調用元素的accept方法并傳遞visitor實例作為參數

可以看到在沒有改變數據結構的情況下只是實現了Visitor類就可以在visit方法中自行加入代碼實現自定義邏輯,而不會影響到原本Person接口的實現類。

結果為:

3.2.2 ASM的訪問者模式

在ASM中的訪問者模式中,ClassReader類和MethodNode類都是被訪問的類,訪問者接口包括:ClassVistorAnnotationVisitorFieldVistorMethodVistor。訪問者接口的方法集以及優先順序可以在下圖中進行查詢:

通過該圖可以清晰的看出調用順序,對于新手來說可以簡單的理解為下面這樣的調用順序:

  • 需要訪問類,所以要聲明ClassReader,來“獲取”類。
  • 如果需要對類中的內容進行修改,就需要聲明ClassWriter它是繼承于ClassReader的。
  • 然后實例化“訪問者”ClassVisitor來進行類訪問,至此就以“訪問者”的身份進入了類,你可以進行以下工作:

    • 如果需要訪問注解,則實例化AnnotationVisitor
    • 如果需要訪問參數,則實例化FieldVisitor
    • 如果需要訪問方法,則實例化MethodVisitro

    每種訪問其內部的訪問順序可以在圖上自行了解。 ClassReader調用accept方法 完成整個調用流程

3.3 實際例子

在具體展示兩種模式的例子前,先補充一下agent的運行條件,無論用那種模式寫出來的agent,都需要將agent打成jar包,同時在jar包中應用META-INF/MANIFEST.MF中指定agent的相關信息,下面是個例子:

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.lucifaer.javaagentLearning.agent.PreMainTranceAgent
Agent-Class: com.lucifaer.javaagentLearning.agent.AgentMainTranceAgent

Premain-ClassAgent-Class是用來配置不同模式的agent實現類,Can-Redefine-ClassesCan-Retransform-Classes是用來指示是否允許進行類重定義和類重新轉換,這兩個參數在一定的情況下決定了是否能在agent中利用ASM對加載的類進行修改。

3.3.1 premain模式例子

下面用園長的一個demo來展示如何利用premain方式進行表達式監控。完整代碼可以看這里,也可以看我整理后的代碼

public class Agent implements Opcodes {
    private static List<MethodHookDesc> expClassList = new ArrayList<MethodHookDesc>();

    static {
        expClassList.add(new MethodHookDesc("org.mvel2.MVELInterpretedRuntime", "parse",
                "()Ljava/lang/Object;"));
        expClassList.add(new MethodHookDesc("ognl.Ognl", "parseExpression",
                "(Ljava/lang/String;)Ljava/lang/Object;"));
        expClassList.add(new MethodHookDesc("org.springframework.expression.spel.standard.SpelExpression", "<init>",
                "(Ljava/lang/String;Lorg/springframework/expression/spel/ast/SpelNodeImpl;" +
                        "Lorg/springframework/expression/spel/SpelParserConfiguration;)V"));
    }

    public static void premain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("agentArgs : " + agentArgs);
        instrumentation.addTransformer(new ClassFileTransformer() {
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
                final String class_name = className.replace("/", ".");

                for (final MethodHookDesc methodHookDesc : expClassList) {
                    if (methodHookDesc.getHookClassName().equals(class_name)) {
                        final ClassReader classReader = new ClassReader(classfileBuffer);
                        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
                        final int api = ASM5;

                        try {
                            ClassVisitor classVisitor = new ClassVisitor(api, classWriter) {
                                @Override
                                public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {
                                    final MethodVisitor methodVisitor = super.visitMethod(i, s, s1, s2, strings);

                                    if (methodHookDesc.getHookMethodName().equals(s) && methodHookDesc.getHookMethodArgTypeDesc().equals(s1)) {
                                        return new MethodVisitor(api, methodVisitor) {
                                            @Override
                                            public void visitCode() {
                                                if ("ognl.Ognl".equals(class_name)) {
                                                    methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
                                                }else {
                                                    methodVisitor.visitVarInsn(Opcodes.ALOAD, 1);
                                                }
                                                methodVisitor.visitMethodInsn(
                                                        Opcodes.INVOKESTATIC, Agent.class.getName().replace(".", "/"), "expression", "(Ljava/lang/String;)V", false
                                                );
                                            }
                                        };
                                    }
                                    return methodVisitor;
                                }
                            };
                            classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
                            classfileBuffer = classWriter.toByteArray();
                        }catch (Throwable t) {
                            t.printStackTrace();
                        }
                    }
                }
                return classfileBuffer;
            }
        });
    }

    public static void expression(String exp_demo) {
        System.err.println("---------------------------------EXP-----------------------------------------");
        System.err.println(exp_demo);
        System.err.println("---------------------------------調用鏈---------------------------------------");

        StackTraceElement[] elements = Thread.currentThread().getStackTrace();

        for (StackTraceElement element : elements) {
            System.err.println(element);
        }

        System.err.println("-----------------------------------------------------------------------------");
    }
}

這里采用的是流式寫法,沒有將其中的ClassFileTransformer抽出來。

整個流程簡化如下:

  • 根據className來判斷當前agent攔截的類是否是需要hook的類,如果是,則直接進入ASM修改流程。
  • ClassVisitor中調用visitMethod方法去訪問hook類中的每個方法,根據方法名判斷當前的方法是否是需要hook的方法,如果是,則調用visitCode方法在訪問具體代碼時獲取方法的相關參數(這里是獲取表達式),并在執行邏輯中插入expression方法的調用,在運行時將執行流經過新添加的方法,就可以打印出表達式以及調用鏈了。

效果如下:

3.3.2 agentmain模式例子

下面用一個我自己寫的例子來說一下如何利用agentmain模式增加執行流。

AgentMain.java

public class AgentMain {

    public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
//        for (Class clazz : inst.getAllLoadedClasses()) {
//            System.out.println(clazz.getName());
//        }
        CustomClassTransformer transformer = new CustomClassTransformer(inst);
        transformer.retransform();
    }
}

CustomClassTransformer.java

public class CustomClassTransformer implements ClassFileTransformer {
    private Instrumentation inst;
    public CustomClassTransformer(Instrumentation inst) {
        this.inst = inst;
        inst.addTransformer(this, true);
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("In Transform");
        ClassReader cr = new ClassReader(classfileBuffer);
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {
            @Override
            public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {
//                return super.visitMethod(i, s, s1, s2, strings);
                final MethodVisitor mv = super.visitMethod(i, s, s1, s2, strings);
                if ("say".equals(s)) {
                    return new MethodVisitor(Opcodes.ASM5, mv) {
                        @Override
                        public void visitCode() {
                            super.visitCode();
                            mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                            mv.visitLdcInsn("CALL " + "method");
                            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                        }
                    };
                }
                return mv;
            }
        };
        cr.accept(cv, ClassReader.EXPAND_FRAMES);
        classfileBuffer = cw.toByteArray();
        return classfileBuffer;
    }

    public void retransform() throws UnmodifiableClassException {
        LinkedList<Class> retransformClasses = new LinkedList<Class>();
        Class[] loadedClasses = inst.getAllLoadedClasses();
        for (Class clazz : loadedClasses) {
            if ("com.lucifaer.test_agentmain.TestAgentMain".equals(clazz.getName())) {
                if (inst.isModifiableClass(clazz) && !clazz.getName().startsWith("java.lang.invoke.LambdaForm")) {
                    inst.retransformClasses(clazz);
                }
            }
        }
    }
}

可以看到agentmain模式和premain的大致寫法是沒有區別的,最大的區別在于如果想要利用agentmain模式來對運行后的類進行修改,需要利用Instrumentation.retransformClasses方法來對需要修改的類進行重新轉換

想要agentmain工作還需要編寫一個方法來利用Attach API來動態啟動agent:

public class AttachAgent {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        for (VirtualMachineDescriptor vmd : list) {
            if (vmd.displayName().endsWith("TestAgentMain")) {
                VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
                virtualMachine.loadAgent("/Users/Lucifaer/Dropbox/Code/Java/agentmain_test/out/artifacts/agentmain_test_jar/agentmain_test.jar", "Attach!");
                System.out.println("ok");
                virtualMachine.detach();
            }
        }
    }
}

效果如下:

3.3.3 agentmain坑點

這里有一個坑點也導致沒有辦法在agentmain模式下動態給一個類添加一個新的方法,如果嘗試添加一個新的方法就會報錯。下面是我編寫利用agentmain模式嘗試給類動態增加一個方法的代碼:

public class DynamicClassTransformer implements ClassFileTransformer {
    private Instrumentation inst;
    private String name;
    private String descriptor;
    private String[] exceptions;
    public DynamicClassTransformer(Instrumentation inst) {
        this.inst = inst;
        inst.addTransformer(this, true);
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("In transformer");
        ClassReader cr = new ClassReader(classfileBuffer);
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {
            @Override
            public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {
                final MethodVisitor mv = super.visitMethod(i, s, s1, s2, strings);
                if ("say".equals(s)) {
                    name = s;
                    descriptor = s1;
                    exceptions = strings;
                }
                return mv;
            }

        };
//        ClassVisitor cv = new DynamicClassVisitor(Opcodes.ASM5, cw);
        cr.accept(cv, ClassReader.EXPAND_FRAMES);
        MethodVisitor mv;
        mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "say2", "()V", null, null);
        mv.visitCode();
        Label l0 = new Label();
        mv.visitLabel(l0);
        mv.visitLineNumber(23, l0);
        mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("2");
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        Label l1 = new Label();
        mv.visitLabel(l1);
        mv.visitLineNumber(24, l1);
        mv.visitInsn(Opcodes.RETURN);
        Label l2 = new Label();
        mv.visitLabel(l2);
        mv.visitLocalVariable("this", "Lcom/lucifaer/test_agentmain/TestAgentMain;", null, l0, l2, 0);
        mv.visitMaxs(2, 1);
        mv.visitEnd();
        classfileBuffer = cw.toByteArray();

        FileOutputStream fos = null;
        try {
            fos = new FileOutputStream("agent.class");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        try {
            assert fos != null;
            fos.write(classfileBuffer);
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            fos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return classfileBuffer;
    }

    public void retransform() throws UnmodifiableClassException {
        LinkedList<Class> retransformClasses = new LinkedList<Class>();
        Class[] loadedClasses = inst.getAllLoadedClasses();
        for (Class clazz : loadedClasses) {
            if ("com.lucifaer.test_agentmain.TestAgentMain".equals(clazz.getName())) {
                if (inst.isModifiableClass(clazz) && !clazz.getName().startsWith("java.lang.invoke.LambdaForm")) {
                    inst.retransformClasses(clazz);
                }
            }
        }
    }
}

結果如下:

這里嘗試添加一個public方法是直接失敗的,原因就在于原生的JVM在運行時時為了程序的線程及邏輯安全,禁止向運行時的類添加新的public方法并重新定義該類。JVM默認規則是只能修改方法體中的邏輯,所以這就意味著會有這么一個問題:當多次attach時,代碼會重復插入,這樣是不符合熱部署邏輯的。

當然目前市面上也有一定的解決方案,如JRebelSpring-Loaded,它們的實現方式是在method callfield access的方法做了一層代理,而這一點對于RASP來說,無疑是加重了部署難度,反而與熱部署簡單快捷的方式背道而馳。

0x04 OpenRASP的具體實現方式

以上大致將Java RASP的相關內容介紹完畢后,這部分來深入了解一下OpenRASP的Java RASP這一部分是怎么寫的,執行流是如何。

4.1 OpenRASP執行流

OpenRASP的執行流很簡單主要分為以下幾部分:

  1. agent初始化
  2. V8引擎初始化
  3. 日志配置模塊初始化
  4. 插件模塊初始化
  5. hook點管理模塊初始化
  6. 字節碼轉換模塊初始化

其中具體實現管理hook點以及添加hook點的部分主要集中于5、6這一部分,這里同樣是我們最為關注的地方。

4.2 初始化流程

在這一部分不會對OpenRASP流程進行一步步的跟蹤,只會將其中較為關鍵的點進行分析。

4.2.1 agent初始化

通過前面幾節的介紹,其實是可以發現RASP類的編寫共同點的——其入口就是premainagentmain方法,這些都會在META-INFO/MANIFEST.MF中標明:

所以其入口就是com.baidu.openrasp.Agent

這里在模塊加載前做了一個非常重要的操作——將Java agent的jar包加入到BootStrap class path中,如果不進行特殊設定,則會默認將jar包加入到System class path中,對于研究過類加載機制的朋友們來說一定不陌生,這樣做得好處就是可以將jar包加到BootStrapClassLoader所加載的路徑中,在類加載時可以保證加載順序位于最頂層,這樣就可以不受到類加載順序的限制,攔截攔截系統類。

當將jar包添加進BootStrap class path后,就是完成模塊加載的初始化流程中,這里會根據指定的jar包來實例化模塊加載的主流程:

這里的ENGINE_JAR是rasp-engine.jar,也就是源碼中的engine模塊。這里根據配置文件中的數值通過反射的方式實例化相應的主流程類:

然后就可以一目了然的看到模塊初始化主流程了:

在主流程中,我們重點關注紅框部分,這一部分完成了hook點管理模塊初始化,以及字節碼轉換模塊的初始化。

4.2.2 hook點管理模塊初始化

hook點管理的初始化過程非常簡單,就是遍歷com.baidu.openrasp.plugin.checkerCheckParameter的Type,將其中的元素添加進枚舉映射中:

在Type這個枚舉類型中,定義了不同類型的攻擊類型所對應的檢測方式:

4.2.3 字節碼轉換模塊初始化

字節碼轉換模塊是整個Java RASP的重中之重,OpenRASP是使用的Javassist來操作字節碼的,其大致的寫法和ASM并無區別,接下來一步步跟進看一下。

com.baidu.openrasp.EngineBoot#initTransformer中完成了字節碼轉換模塊的初始化:

這里可以看到在實例化了ClassFileTransformer實現的CustomClassTransformer后,調用了一個自己寫的retransform方法,在這個方法中對Instrumentation已加載的所有類進行遍歷,將其進行類的重新轉換:

這里主要是為了支持agentmain模式對類進行重新轉換。

在解釋完了retranform后,我們來整體看一下OpenRASP是如何添加hook點并完成相應hook流程的。這一部分是在com.baidu.openrasp.transformer#CustomClassTransformer中:

我們都清楚inst.addTransformer的功能是在類加載時做攔截,對輸入的類的字節碼進行修改,也就是具體的檢測流程插入都在這一部分。但是OpenRASP的hook點是在哪里加入的呢?其實就是在addAnnotationHook這里完成的:

這里會到com.baidu.openrasp.hook下對所有的類進行掃描,將所有由HookAnnotation注解的類全部加入到HashSet中,例如OgnlHook:

至此就完成了字節碼轉換模塊的初始化。

4.3 類加載攔截流程

前文已經介紹過RASP的具體攔截流程是在ClassFileTransformer#transform中完成的,在OpenRASP中則是在CustomClassTransformer#transform中完成的:

可以看到先檢測當前攔截類是否為已經注冊的需要hook的類,如果是hook的類則直接利用javassist的方式創建ctClass,想要具體了解javassist的使用方式的同學,可以直接看javassist的官方文檔,這里不再過多表述。

可以看到在創建完ctClass后,直接調用了當前hook的transformClass方法。由于接下來涉及到跟進具體的hook處理類中,所以接下來的分析是以跟進OgnlHook這個hook來跟進的。

OgnlHook是繼承于AbstractClassHook的,在AbstractClassHook中預定義了很多虛方法,同時也提供了很多通用的方法,transformClass方法就是在這里定義的:

這里直接調用了每個具體hook類的hookMethod方法來執行具體的邏輯,值得注意的是這里的最終返回也是一個byte數組,具體的流程和ASM并無兩樣。跟進OgnlHook#hookMethod

這里首先生成需要插入到代碼中的字節碼,然后調用其自己寫的inserAfter來將字節碼插入到hook點的后面(其實就是決定是插在hook方法最頂部,還是return前的最后一行,這決定了調用順序)。

可以簡單的看一下插入的字節碼是如何生成的:

很簡單,就是插入一段代碼,這段代碼將反射實例化當前hook類,調用methodName所指定的方法,并將paramString所指定的參數傳入該方法中。所以接下來看一下OgnlHook#checkOgnlExpression方法所執行的邏輯:

判斷獲取的表達式是不是String類型,如果是,將表達式放入HashMap中,然后調用HookHandler.doCheck方法:

在這里說一句題外話,可以看到在這里的邏輯設定是當服務器cpu使用率超過90%時,禁用全部的hook點。這也是RASP要思考解決的一個問題,當負載過高時,一定要給業務讓步,也就一定要停止防護功能,不然會引發oom,直接把業務搞崩。所以如何盡量的減少資源占用也是RASP需要解決的一個大問題。

這里就是檢測的主要邏輯,主要完成:

  • 檢測計時
  • 獲取檢測結果
  • 根據檢測結果判斷是否要進行攔截

具體看一下如何獲取的檢測結果:

這里的checkers是在hook點管理模塊初始化時設置的枚舉類映射,所以這里調用的是:

V8Checker().check()方法,繼承樹如下:

所以具體的實現是在AbstractChecker#check中:

也就是V8Checker#checkParam

這里就一目了然了,是調用JS插件來完成檢測的:

easygame,就是在JS插件(其實就是個js文件)中尋找相應的規則進行規則匹配。這個js文件在OpenRASP根目錄/plugins/official/plugin.js中:

如果符合匹配規則則返回block,完成攻擊攔截。

至此整個攔截流程分析完畢。

4.4 小結

從上面的分析中可以看出OpenRASP的實現方式還是比較簡單的,其中非常有創新點的是利用js來編寫規則,通過V8來執行js。利用js來編寫規則的好處是更加方便熱部署以及規則的通用性,同時減少了為不同語言重復制定相同規則的問題

同樣,OpenRASP也不免存在RASP本身存在的一些缺陷,這些缺陷將在“缺陷思考”這一節中具體的描述。

0x05 缺陷思考

雖然Java RASP是以Java Instrumentation的工作方式工作在JVM層,可以通過hook引發漏洞的關鍵函數,在關鍵函數前添加安全檢查,這看上去像是一個“all in one”的通用解,但是其實存在很多問題。

5.1 “通用解”的通用問題

所有“通用解”的最大問題都出現在通用性上。在真實場景中RASP的應用環境比其在實驗環境中復雜的多,如果想要一個RASP真正的運行在業務上就需要從乙方和甲方的角度雙向思考問題,以下是我想到的一些問題,可能有些偏頗,但是還是希望能給一些參考性的意見:

5.1.1 語言環境的通配適用性

企業內部的web應用紛繁復雜,有用Java編寫的應用,有用Go編寫的,還有用PHP、Python寫的等等...,那么如何對這些不同語言所構建的應用程序都實現相應的防護?

對于甲方來說,我購置一套安全防護產品肯定是要能起到通用防護的作用的,肯定不會只針對Java購進一套Java RASP,這樣做未免也太虧了。

對于乙方來說,每一種語言都有不同的特性,都要用不同的方式構建RASP,對于開發和安全研究人員來說工作量是相當之大的,強如OpenRASP團隊目前也只是支持PHP和Java兩個版本的。

這很大程度上也是影響到RASP推廣的一個原因。看看傳統的WAF、旁路流量監測等產品,它并不受語言的限制,只關心流量中是否存在具有威脅的流量就好,巧妙的減少了一個變量,從而加強了泛用性,無論什么樣的環境都可以快速部署發揮作用,對于企業來說,肯定是更愿意購入WAF的。

5.1.2 部署的通配適用性

由于開發人員所擅長的技能不同或不同項目組的技能樹設定的不同,企業內部往往會存在使用各種各樣框架實現的代碼。而在代碼部署上,如果沒有一開始就制定嚴格的規范的話,部署環境也會存在各種各樣的情況。就拿Java來說,企業內部可能存在Struts2寫的、Spring寫的、RichFaces寫的等等...,同時這些應用可能部署在不同的中間件上:Tomcat、Weblogic、JBoss、Websphere等等...,不同的框架,不同的中間件部署方式都或多或少的有所不同,想要實現通配,真的不容易。

5.1.3 規則的通用性

這一點其實已經被OpenRASP較好的解決了,統一利用js做規則,然后利用js引擎解析規則。所以這一點不多贅述。

5.2 自身穩定性的問題

“安全產品首先要保證自己是安全的”,這句話說出來感覺是比較搞笑的,但是往往很多的安全產品其自身安全性就很差,只是仗著黑盒的不確定性才保持自己的神秘感罷了。對于RASP來說這句話更是需要嚴格奉行。因為RASP是將檢測邏輯插入到hook點中的,只要到達了相應的hook點,檢測邏輯是一定會被執行的,如果這個時候RASP實現的檢測邏輯本身出現了問題,嚴重的話會導致整個業務崩潰,或直接被打穿。

5.2.1 執行邏輯穩定性

就像上文所說的一樣,如果在RASP所執行的邏輯中出現了嚴重的錯誤,將會直接將錯誤拋出在業務邏輯中,輕則當前業務中斷,重則整個服務中斷,這對于甲方來說就是嚴重的事故,甚至比服務器被攻擊還嚴重。

簡單來舉個例子(當然在真實寫RASP的時候不會這么寫,這里只是展示嚴重性),如果在RASP的檢測邏輯中存在exit()這樣的利用,將直接導致程序退出:

這也就是為什么很多甲方并不喜歡RASP這種方式,因為歸根到底,RASP還是將代碼插入到業務執行流中,不出問題還好,出了問題就會影響業務。相比來說,WAF最多就是誤封,但是并不會down掉業務,穩定性上是有一定保障的。

5.2.2 自身安全穩定性

試想一個場景,如果RASP本身存在一定的漏洞,那是不是相當的可怕?即使原來的應用是沒有明顯的安全威脅的,但是在RASP處理過程中存在漏洞,而恰巧攻擊者傳入一個利用這樣漏洞的payload,將直接在RASP處理流中完成觸發。

舉個實際的例子,比如在RASP中使用了受漏洞影響的FastJson庫來處理相應的json數據,那么當攻擊者在發送FastJson反序列化攻擊payload的時候就會造成目標系統被RCE。

這其實并不是一個危言聳聽的例子,OpenRASP在某版本使用的就是FastJson來處理json字符串,而當時的FastJson版本就是存在漏洞的版本。所以在最新的OpenRASP中,統一使用了較為安全的Gson來處理json字符串。

RASP的處理思路就決定了其與業務是聯系非常緊密的,可以說就是業務的“一部分”,所以如果RASP自己的代碼不規范不安全,最終將導致直接給業務寫了一個漏洞。

5.2.3 規則的穩定性

RASP的規則是需要經過專業的安全研究人員反復打磨并且根據業務來定制化的,需要盡量將所有的可能性都考慮進去,同時盡量的減少誤報。但是由于規則貢獻者水平的參差不齊,很容易導致規則遺漏,從而根本無法攔截相關的攻擊,或產生大量的攻擊誤報。這樣對于甲方來說無疑是一筆穩賠的買賣——花費大量時間進行部署,花費大量服務器資源來啟用RASP,最終的安全效果卻還是不盡如人意。

如果想要盡量的完善規則,只能更加貼近業務場景,針對不同的情況做不同的規則判別。所以說規則和業務場景是分不開的,對乙方來說不深入開發、不深入客戶是很難做好安全產品的,如果只是停留在實驗階段,是永遠沒有辦法向工程化和產品化轉換的。

5.3 部署復雜性的問題

在0x03以及0x04中不難看理想中最佳的Java RASP實踐方式是使用agentmain模式進行無侵入部署,但是受限于JVM進程保護機制沒有辦法對目標類添加新的方法,所以就會造成多次attach造成的重復字節碼插入的問題。目前主流的Java RASP推薦的部署方式都是利用premain模式進行部署,這就造成了必須停止相關業務,加入相應的啟動參數,再開啟服務這么一個復雜的過程。

對于甲方來說,重啟一次業務完成部署RASP的代價是比較高的,所以都是不愿意采取這樣的方案的。而且在甲方企業內部存在那么多的服務,一臺臺部署顯然也是不現實的。目前所提出的自動化部署方案也受限于實際業務場景的復雜性,并不穩定。

0x06 總結

就目前來說RASP解決方案已經相對成熟,除非JDK出現新的特性,否則很難出現重大的革新。

目前各家RASP廠商主要都是針對性能及其他的輔助功能進行開發和優化,比如OpenRASP提出了用RASP構建SIEM以及實現被動掃描器的思路,這其實是一個非常好的思路,RASP配合被動掃描器能很方便的對企業內部的資產進行掃描,從而實現一定程度上的漏洞管控。

但是RASP不是萬能的,并不能高效的防御所有的漏洞,其優劣勢是非常明顯的,應當正確的理解RASP本身的司職聯合其他的防御措施構建完整的防御體系才能更好的做好安全防護。

個人認為RASP的最佳實踐場所是甲方內部,甲方可以通過資產梳理對不同的系統進行相應的流量管控,這樣RASP就能大大減少泛性檢測所帶來的的誤報,同時更進一步的增加應用的安全性。

總體來說RASP是未來Web應用安全防護的方向,也同時是一個Web安全的發展趨勢,其相較于傳統安全防護產品的優勢是不言而喻的,只要解決泛用性、穩定性、部署難等問題,可以說是目前能想出的一種較為理想方案了。

0x07 Reference


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1041/