Android漏洞掃描工具Code Arbiter

美團雲發表於2017-08-28
  • 本文轉自美團點評技術學院,未經作者許可,不允許私自轉載。
  • 美團雲知乎機構賬號每日分享雲端計算產品,技術內容。 歡迎關注!
  • 加入美團雲技術交流群(QQ群:469243579),每日分享更多精彩技術文章。
目前Android應用程式碼漏洞掃描工具種類繁多,效果良莠不齊,這些工具有一個共同的特點,都是在應用打包完成後對應用進行解包掃描。這種掃描有非常明顯的缺點,掃描週期較長,不能向開發者實時反饋程式碼中存在的安全問題,並且對於問題程式碼的定位需要手動搜尋匹配原始碼,這樣就更不利於開發者對問題程式碼進行及時的修改。Code Arbiter正是為解決上述兩個問題而開發的,專門對Android Studio中的原始碼進行安全掃描。

1 背景介紹

為實現對Android Studio中的原始碼進行掃描,最方便的方式便是將掃描工具以IDE外掛的形式進行工作。此時一個很自然的想法便是從頭構建一個Android Studio外掛,但是進行仔細的評估後會發現,這樣做難度並不小:
  1. 工作量大,許多知識需要學習,如IDE開放API介面、外掛UI構建等,同時許多底層模組需要從頭構建;
  2. 外掛的穩定性、檢測問題的準確性上都不一定能夠達到已有開源工具的效果。
因此我們轉而考慮在已有漏洞檢測外掛的基礎上進行擴充套件,以滿足需求。經過調研,最終入圍的兩款檢測外掛是PMD和FindBugs,其中PMD是對Java原始碼進行掃描,而FindBugs則是對Java原始碼編譯後的class檔案進行掃描。考慮到可擴充套件性及檢測的準確性,最終選定了FindBugs。FindBugs是一個靜態分析工具,它檢查類或者JAR檔案,將位元組碼與一組缺陷模式進行對比來發現可能的問題,可以以獨立的JAR包形式執行,也可以作為整合開發工具的外掛形式存在。

擴充套件優化

那麼,怎麼擴充套件FindBugs呢?調研發現FindBugs外掛具有著極強的可擴充套件性,只需要將擴充套件的JAR包匯入FindBugs外掛,重啟,即可完成相關功能的擴充套件。

下面的問題是如何構建可安裝的JAR包。繼續調研,發現FindBugs有一款專門對安全問題進行檢測的擴充套件外掛Find Security Bugs,該外掛主要用於對Web安全問題進行檢測,也有極少對Android相關安全問題的檢測規則。考慮以下幾個原因,需要對該外掛的原始碼進行重構。
  • 對Android安全問題的檢測太少,只包含外部檔案使用、Webview、Broadcast使用等寥寥幾項;
  • 檢測的細粒度上考慮不夠完全,會造成大量的誤報,無法滿足檢測精度的要求;
  • 檢測問題的上報只支援英文模式,且問題展示的邏輯性不夠嚴謹,不便於開發者進行問題排查。
基於以上三個原因,我們需要對Find Security Bugs的原始碼進行重寫、優化,通過增加檢測項來檢測儘可能多的安全問題,通過優化檢測規則來減少檢測的誤報,問題展示使用中文進行描述,同時優化問題描述的邏輯性,使得開發者能夠更易理解並修改相關問題,至此外掛實現及優化的方案確定。

2 工具實現介紹

FindBugs檢測的是class檔案,因此當待檢測的原始碼未生成編譯檔案時,FindBugs會先將原始碼編譯生成.class檔案,然後對這個class檔案進行分析。FindBugs會完成對class檔案的自動建模,在此模型的基礎上對程式碼進行分析。按照在實際編寫檢測程式碼過程中的總結,把檢測的實現方式分成四種方式,下面分別進行介紹。

2.1 逐行檢查

逐行檢查主要是針對程式碼中使用的一些不安全方法或引數進行檢測,其實現方式是重寫sawOpcode()方法,下面以Android中使用外部儲存問題作為示例進行講解。

Android中獲取外部儲存資料夾地址的方法主要包括下面這些方法:

getExternalCacheDir()
getExternalCacheDirs()
getExternalFilesDir()
getExternalFilesDirs()
getExternalMediaDirs()
Environment.getExternalStorageDirectory()
Environment.getExternalStoragePublicDirectory()複製程式碼
檢測的方式便是,如果發現存在該方法的呼叫,則作為一個問題進行上報,實現完整程式碼如下所示:

public class ExternalFileAccessDetector extends OpcodeStackDetector {

    private static final String ANDROID_EXTERNAL_FILE_ACCESS_TYPE = "ANDROID_EXTERNAL_FILE_ACCESS";
    private BugReporter bugReporter;
    public ExternalFileAccessDetector(BugReporter bugReporter) {
        this.bugReporter = bugReporter;
    }

    @Override
 public void sawOpcode(int seen) {
        //printOpCode(seen);
 if (seen == Constants.INVOKEVIRTUAL && (
        getNameConstantOperand().equals("getExternalCacheDir") ||
        getNameConstantOperand().equals("getExternalCacheDirs") ||
        getNameConstantOperand().equals("getExternalFilesDir") ||
        getNameConstantOperand().equals("getExternalFilesDirs") ||
        getNameConstantOperand().equals("getExternalMediaDirs")
            )) {
// System.out.println(getSigConstantOperand());
 bugReporter.reportBug(new BugInstance(this, ANDROID_EXTERNAL_FILE_ACCESS_TYPE, Priorities.NORMAL_PRIORITY).addClass(this).addMethod(this).addSourceLine(this));
        }
        else if(seen == Constants.INVOKESTATIC && getClassConstantOperand().equals("android/os/Environment") && (getNameConstantOperand().equals("getExternalStorageDirectory") || getNameConstantOperand().equals("getExternalStoragePublicDirectory"))) {
            bugReporter.reportBug(new BugInstance(this, ANDROID_EXTERNAL_FILE_ACCESS_TYPE, Priorities.NORMAL_PRIORITY).addClass(this).addMethod(this).addSourceLine(this));
        }
    }
}複製程式碼
該類的實現是繼承OpcodeStackDetector類,是FindBugs中的一個抽象類,封裝了對於獲取程式碼特定引數的方法呼叫。sawOpcode方法引數可以理解為待檢測程式碼行的行號,通過printOpCode(seen)可以列印該程式碼行的具體內容。Constants.INVOKEVIRTUAL表示該行呼叫類的例項方法,Constants.INVOKESTATIC表示呼叫類的靜態方法。getNameConstantOperand方法表示獲取被呼叫方法的名稱,getClassConstantOperand方法表示獲取呼叫類的名稱,getSigConstantOperand方法表示獲取方法的所有引數。bugReporter.reportBug用於上報檢測到的漏洞資訊,其中BugInstance的三個引數分別表示:檢測器、漏洞型別、漏洞等級,其中漏洞等級分為五個級別,如下表所示:

名稱  引數  含義
HIGH_PRIORITY  1  高危風險
NORMAL_PRIORITY  2  中危風險
LOW_PRIORITY  3  低危風險
EXP_PRIORITY  4  安全提醒
IGNORE_PRIORITY  5  可忽略風險複製程式碼
addClass、addMethod、addSourceLine用於指定該漏洞所在的類、方法、行,方便報告漏洞時定位關鍵程式碼。

2.2 逐方法檢查

逐方法檢查首先獲取待檢測類的所有內容,然後對類中的方法進行逐個檢查,多用於對方法體進行檢測,其實現的方法主要是通過重寫visitClassContext方法,下面以對Android TrustManager的空實現的檢測為例進行說明。

TrustManager的空實現,主要是指對於檢測Server端證照是否可信的方法checkServerTrusted,是否是空實現。下面展示問題程式碼,如果是空實現那麼將導致客戶端接收任意證照,從而造成加密後的HTTPS訊息被中間人解密。

@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

}複製程式碼
檢測的方式是通過遍歷類中的所有方法,找到checkServerTrusted方法,對方法整體進行檢測,確定其是否為空實現,部分程式碼如下所示:

public class WeakTrustManagerDetector implements Detector {
...
public WeakTrustManagerDetector(BugReporter bugReporter) {
        this.bugReporter = bugReporter;
    }

    @Override
 public void visitClassContext(ClassContext classContext) {
        JavaClass javaClass = classContext.getJavaClass();

        //The class extends X509TrustManager
  boolean isTrustManager = InterfaceUtils.isSubtype(javaClass,"javax.net.ssl.X509TrustManager");
        boolean isHostnameVerifier = InterfaceUtils.isSubtype(javaClass,"javax.net.ssl.HostnameVerifier");

// if (!isTrustManager && !isHostnameVerifier) return;
 if (!isTrustManager && !isHostnameVerifier){
            for (Method m : javaClass.getMethods()) {
                allow_All_Hostname_Verify(classContext, javaClass, m);
            }
        }

        Method[] methodList = javaClass.getMethods();

        for (Method m : methodList) {
            MethodGen methodGen = classContext.getMethodGen(m);

            if (DEBUG) System.out.println(">>> Method: " + m.getName());

            if (isTrustManager &&
                    (m.getName().equals("checkClientTrusted") ||
                     m.getName().equals("checkServerTrusted") ||
                     m.getName().equals("getAcceptedIssuers"))) {
                if(isEmptyImplementation(methodGen)) {
                    bugReporter.reportBug(new BugInstance(this, WEAK_TRUST_MANAGER_TYPE, Priorities.NORMAL_PRIORITY).addClassAndMethod(javaClass, m));
                }
......複製程式碼
classContext.getJavaClass用於獲取整個類的所有內容;javaClass.getMethods用於獲取該類中的所有方法,以一個方法列表的形式返回;classContext.getMethodGen用於獲取該方法的內容;isEmptyImplementation將方法的內容匯入該函式進行檢測,用於確定方法是否是空實現,該方法的程式碼如下所示:

private boolean isEmptyImplementation(MethodGen methodGen){
    boolean invokeInst = false;
    boolean loadField = false;

    for (Iterator itIns = methodGen.getInstructionList().iterator();itIns.hasNext();) {
        Instruction inst = ((InstructionHandle) itIns.next()).getInstruction();
        if (DEBUG)
            System.out.println(inst.toString(true));

        if (inst instanceof InvokeInstruction) {
            invokeInst = true;
        }
        if (inst instanceof GETFIELD) {
            loadField = true;
        }
    }
    return !invokeInst && !loadField;
}複製程式碼
該方法主要用於檢測方法中是否包含方法呼叫、域操作,如果沒有包含則認為是一個空實現的方法。因此該方法對於只包含 return true/false 語句的方法體同樣認為是一個空實現。

2.3 汙點分析

資料流分析主要用於分析特定方法載入的引數是否能夠被使用者控制,即進行汙點分析。做汙點分析首先需要定義汙染源(source點),汙染源可以理解為能夠被使用者控制的輸入資料,這裡定義的Android汙染源主要包括使用者的輸入、Intent傳入的資料,下面展示定義的部分汙染源(source點):

- EditText
android/widget/EditText.getText()Landroid/text/Editable;:TAINTED
- Intent
android/content/Intent.getAction()Ljava/lang/String;:TAINTED
android/content/Intent.getStringExtra(Ljava/lang/String;)Ljava/lang/String;:TAINTED
......
- Bundle
android/os/Bundle.get(Ljava/lang/String;)Ljava/lang/Object;:TAINTED
android/os/Bundle.getString(Ljava/lang/String;)Ljava/lang/String;:TAINTED
......複製程式碼
定義好汙染源後就需要確定汙染的觸發點(sink點),可以理解為會觸發危險操作的函式。定義sink點的方式有兩種,一種是直接從檔案中匯入,以命令注入為示例,程式碼如下:

public class CommandInjectionDetector extends BasicInjectionDetector {

    public CommandInjectionDetector(BugReporter bugReporter) {
        super(bugReporter);
        loadConfiguredSinks("command.txt", "COMMAND_INJECTION");
 }複製程式碼
從程式碼中可以清楚的看到其匯入方式是繼承BasicInjectionDetector類,然後再該類的構造方法中通過loadConfiguredSinks方法,匯入包含sink點的檔案,下面展示該示例檔案中的內容:

java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process;:0
java/lang/Runtime.exec([Ljava/lang/String;)Ljava/lang/Process;:0
java/lang/Runtime.exec(Ljava/lang/String;[Ljava/lang/String;)Ljava/lang/Process;:0,1
java/lang/Runtime.exec([Ljava/lang/String;[Ljava/lang/String;)Ljava/lang/Process;:0,1
java/lang/Runtime.exec(Ljava/lang/String;[Ljava/lang/String;Ljava/io/File;)Ljava/lang/Process;:1,2
java/lang/Runtime.exec([Ljava/lang/String;[Ljava/lang/String;Ljava/io/File;)Ljava/lang/Process;:1,2
java/lang/ProcessBuilder.<init>([Ljava/lang/String;)V:0
java/lang/ProcessBuilder.<init>(Ljava/util/List;)V:0
java/lang/ProcessBuilder.command([Ljava/lang/String;)Ljava/lang/ProcessBuilder;:0
java/lang/ProcessBuilder.command(Ljava/util/List;)Ljava/lang/ProcessBuilder;:0
dalvik/system/DexClassLoader.loadClass(Ljava/lang/String;)Ljava/lang/Class;:0複製程式碼
另一種是自定義匯入,其實現是通過覆蓋BasicInjectionDetector類中的getInjectionPoint方法,以WebView.loadurl方法為例,示例程式碼如下所示:

@Override
 protected InjectionPoint getInjectionPoint(InvokeInstruction invoke, ConstantPoolGen cpg, InstructionHandle handle) {
        assert invoke != null && cpg != null;
        String method = invoke.getMethodName(cpg);
        String sig    = invoke.getSignature(cpg);
// System.out.println(invoke.getClassName(cpg));
 if(sig.contains("Ljava/lang/String;")) {
            if("loadUrl".equals(method)){
                if(sig.contains("Ljava/util/Map;")){
                    return new InjectionPoint(new int[]{1}, WEBVIEW_LOAD_DATA_URL_TYPE);
                }else{
                    return new InjectionPoint(new int[]{0}, WEBVIEW_LOAD_DATA_URL_TYPE);
                }
            }else if("loadData".equals(method)){
                return new InjectionPoint(new int[]{2}, WEBVIEW_LOAD_DATA_URL_TYPE);
            }else if("loadDataWithBaseURL".equals(method)){
                //BUG
 return new InjectionPoint(new int[]{4}, WEBVIEW_LOAD_DATA_URL_TYPE);
            }
        }
        return InjectionPoint.NONE;
    }複製程式碼
通過例項化InjectionPoint類構造新的sink點,其構造方法中的第一個參數列示該方法接收汙染資料引數的位置,如方法為webView.loadUrl(url),其第一個引數就是new int[]{0},其它的以此類推。

上報發現漏洞的情況,則通過覆蓋getPriorityFromTaintFrame方法的實現,示例程式碼如下所示:

@Override
 protected int getPriorityFromTaintFrame(TaintFrame fact, int offset)
            throws DataflowAnalysisException {
        Taint stringValue = fact.getStackValue(offset);
// System.out.println(stringValue.getConstantValue());
 if (stringValue.isTainted() || stringValue.isUnknown()) {
            return Priorities.NORMAL_PRIORITY;
        } else {
            return Priorities.IGNORE_PRIORITY;
        }
    }
通複製程式碼
過fact.getStackValue獲取檢測的函式變數,如果該變數被汙染(isTainted)或 變數是否被汙染未知(但是是可控制變數),那麼作為一箇中危風險(Priorities.NORMAL_PRIORITY)進行上報,其它的情況則上報為可忽略風險(Priorities.IGNORE_PRIORITY)。

2.4 自定義程式碼檢測

自定義程式碼檢測實現的前半部分同2.2的逐方法檢測類似,均是獲取類的內容,然後遍歷所有的方法,對方法的內容進行檢測,但是在具體程式碼檢測實現上是通過自定義分析進行。目前自定義檢測只應用到Android中本地拒絕服務的檢測。本地拒絕服務的被觸發的重要原因在於對通過Intent獲取的引數未進行異常捕獲,因此檢測實現的方式便是檢測獲取引數的程式碼行是否被try catch包裹(這個存在誤差,待改進)。對於其程式碼分析,不能使用FindBugs模型進行分析,而是使用最原始的class程式碼進行分析,原始class程式碼的形式通過javap命令進行檢視,下圖展示示例程式碼。

對原始class檔案進行分析存在的缺陷是無法定位具體的程式碼行,那麼在進行問題上報時無法將問題定位到程式碼行,因此第一步需要在原有模型的基礎上對所有包含Intent獲取引數的方法的位置儲存到一個Map結構中,方便後面對方法的定位,程式碼實現如下所示,獲取方法所在的行,然後以方法名作為Key值,以程式碼行相關資訊作為Value值,儲存到Map中。

private Map<String, List<Location>> get_line_location(Method m, ClassContext classContext){
        HashMap<String, List<Location>> all_line_location = new HashMap<>();
        ConstantPoolGen cpg = classContext.getConstantPoolGen();
        CFG cfg = null;
        try {
            cfg = classContext.getCFG(m);
        } catch (CFGBuilderException e) {
            e.printStackTrace();
            return all_line_location;
        }
        for (Iterator<Location> i = cfg.locationIterator(); i.hasNext(); ) {
            Location loc = i.next();
            Instruction inst = loc.getHandle().getInstruction();
            if(inst instanceof INVOKEVIRTUAL) {
                INVOKEVIRTUAL invoke = (INVOKEVIRTUAL) inst;
 if(all_line_location.containsKey(invoke.getMethodName(cpg))){
                        all_line_location.get(invoke.getMethodName(cpg)).add(loc);
                    }else {
                        LinkedList<Location> loc_list = new LinkedList<>();
                        loc_list.add(loc);
                        all_line_location.put(invoke.getMethodName(cpg), loc_list);
                    }
// }
 }
        }
        return all_line_location;
    }複製程式碼
之後獲取Exception包裹的範圍,FindBugs中包含對Exception的建模,因此能夠通過其模型能夠直接獲取其範圍並儲存到一個列表中,程式碼如下所示,其中exceptionTable[i].getStartPC用於獲取try catch 的起始程式碼行,exceptionTable[i].getEndPC用於獲取try catch 的結束程式碼行。

public int[] getExceptionScope(){
        try {
            CodeException[] exceptionTable = this.code.getExceptionTable();
            int[] exception_scop = new int[exceptionTable.length * 2];
            for (int i = 0; i < exceptionTable.length; i++) {
                exception_scop[i * 2] = exceptionTable[i].getStartPC();
                exception_scop[i * 2 + 1] = exceptionTable[i].getEndPC();
            }
            return exception_scop;
        }catch (Exception e){
 }
        return new int[0];
    }複製程式碼
在對程式碼進行逐行檢查時,因為使用的是最原始class檔案形式,因此需要限定其遍歷的範圍,限定的方式是通過程式碼的行號,即上圖中每行程式碼的第一個數值。首先需要獲取程式碼總行數的大小,獲取的方式便是解析FindBugs建模後的第一行程式碼,找到關鍵詞code-length後面的數值,即為程式碼的行數,解析程式碼如下所示:

public int get_Code_Length(String firstLineCode){
        try{
            String[] split1 = firstLineCode.split("code_length");
// System.out.println(split1[split1.length-1]);
 byte[] code_length_bytes = split1[split1.length-1].getBytes();
            byte[] new_code_bytes = new byte[code_length_bytes.length];
            for(int i=0; i<code_length_bytes.length; i++){
// System.out.println();
 if(code_length_bytes[i]<48 || code_length_bytes[i]>57){
                    new_code_bytes[i] = 32;
                }else{
                    new_code_bytes[i] = code_length_bytes[i];
                }
            }
            return Integer.parseInt(new String(new_code_bytes).trim());
        }catch(Exception e){
            e.printStackTrace();
        }
        return 0;
    }複製程式碼
最後對程式碼進行逐行遍歷,遍歷中為防止try catch塊被遍歷到,使用行號來限制遍歷的範圍。檢測程式碼行是否包含通過Intent獲取引數,及該行是否被try catch 包裹,如果上述兩個條件均被觸發,那麼就作為一個問題進行上報。示例程式碼如下,其中get_code_line_index方法用於獲取程式碼的行號,獲取的方式是擷取程式碼行的首字元的數值,以確定是否在程式碼包裹的範圍內。

private void analyzeMethod(JavaClass javaClass, Method m, ClassContext classContext) throws CFGBuilderException {
        HashMap<String, List<Location>> all_line_location = (HashMap<String, List<Location>>) get_line_location(m, classContext);
        Code code = m.getCode();
        StringCodeAnalysis sca = new StringCodeAnalysis(code);
        String[] codes = sca.codes_String_Array();
        int code_length = sca.get_Code_Length(sca.get_First_Code(codes));
        int[] exception_scop = sca.getExceptionScope();
        for(int i=1; i<codes.length; i++){
            int line_index = sca.get_code_line_index(codes[i]);
            if (line_index < code_length){
                if(codes[i].toLowerCase().contains("invokevirtual") &&
                        (codes[i].contains("android.content.Intent.get")  || codes[i].contains("android.os.Bundle.get"))){
                    if(exception_scop.length == 0){
                        ......
                    }else{
                        boolean is_scope = false;
                        for(int j=0; j<exception_scop.length; j+=2){
                            int start = exception_scop[j];
                            int end = exception_scop[j+1];
                            if(line_index >= start && line_index <= end){
                                is_scope = true;
                            }
                            if(is_scope){
                                break;
                            }
                        }
                        if(!is_scope){
                            String method_name = get_method_name(codes[i]);
                            if(all_line_location.containsKey(method_name)){
                                for(Location loc : all_line_location.get(method_name)){
                                    bugReporter.reportBug(new BugInstance(this, LOCAL_DENIAL_SERVICE_TYPE, Priorities.NORMAL_PRIORITY).addClass(javaClass).addMethod(javaClass, m).addSourceLine(classContext, m, loc));
                                }
                            }else {
                                bugReporter.reportBug(new BugInstance(this, LOCAL_DENIAL_SERVICE_TYPE, Priorities.NORMAL_PRIORITY).addClass(javaClass).addMethod(javaClass, m));
 }
                        }
                    }
                }
            }
        }
    }
複製程式碼

3 註冊打包

上面詳細敘述瞭如何構造自己的問題檢測程式碼,完成檢測方法的書寫後,下一步就是在配置檔案中對檢測方法進行註冊,才能使檢測程式碼運轉起來。

需要在兩個檔案中進行註冊,第一個是findbugs.xml,註冊示例如下:

<Detector class="com.h3xstream.findsecbugs.android.LocalDenialOfServiceDetector" reports="LOCAL_DENIAL_SERVICE"/>
<BugPattern type="LOCAL_DENIAL_SERVICE" abbrev="SECLDOS" category="Android安全問題" cweid="276"/>複製程式碼
其中Detector用於註冊該檢測方法的位置及其唯一標識,BugPattern用於對檢測出的問題進行歸類,方便展示,如此處歸類到"Android安全問題"中,那麼在生成報告的時候問題也將被歸類到"Android安全問題"中。

第二個是messages.xml註冊,註冊示例如下,該註冊主要是對該問題進行說明,包括問題的危害及修復方法。

<Detector class="com.h3xstream.findsecbugs.android.LocalDenialOfServiceDetector">
<Details>Local複製程式碼
一切完成就緒後使用Maven進行打包,就生產了供FindBugs整合開發工具外掛使用的JAR包,完成安裝並重啟,即可使用自定義外掛對特定問題進行檢測。

4 結語

本文介紹了Android整合開發環境Android Studio的程式碼實時檢測工具Code Arbiter的產生原因及程式碼實現,最後展示了分析的效果。通過Code Arbiter在生產環境中的應用,其檢測效果還是相當不錯,能夠發現很多編碼過程中存在的問題。但是Code Arbiter仍然存在許多不足,需要優化。後續將在以下兩個方面對工具進行改進:
  1. 擴大漏洞檢測範圍,使Code Arbiter能夠囊括Android編碼常見安全問題;
  2. 優化漏洞檢測規則,提高檢測的準確性,減少誤報。


相關文章