1. 程式人生 > >Android 混淆那些事兒

Android 混淆那些事兒

本文主要講述了程式碼混淆和資源混淆的原理,Studio預設的混淆方案,混淆的引數,以及如何對Apk進行程式碼混淆(自定義混淆檔案)和資源混淆(結合微信混淆和美團混淆兩種方案),避免Apk被逆向。

為什麼要混淆

我們的apk在打包釋出之前,都要進行混淆處理來避免原始碼和資原始檔被小白使用者通過反編譯拿到。未混淆程式碼的反編譯操作非常簡單,網上有很多教程, 也可以通過使用Android Studio自帶的apk分析工具(Build—Analyze APK)直接看到未混淆Apk的原始碼和原始的資原始檔。對比圖如下,從圖中可以看到未混淆apk所有的程式碼都一目瞭然,隨便改改資源和程式碼,就能變成一個新的apk。為了避免我們的勞動成果被竊取,也避免出現安全漏洞和隱患,此篇文章從混淆的原理到程式碼和資原始檔的混淆實踐做一下闡述。

混淆前:

混淆前程式碼效果

混淆後:

混淆後代碼效果

混淆的原理

Java 是一種跨平臺、解釋型語言,Java 原始碼編譯成的class檔案中有大量包含語義的變數名、方法名的資訊,很容易被反編譯為Java 原始碼。為了防止這種現象,我們可以對Java位元組碼進行混淆。混淆不僅能將程式碼中的類名、欄位、方法名變為無意義的名稱,保護程式碼,也由於移除無用的類、方法,並使用簡短名稱對類、欄位、方法進行重新命名縮小了程式的size。

ProGuard由shrink、optimize、obfuscate和preverify四個步驟組成,每個步驟都是可選的,需要哪些步驟都可以在指令碼中配置。 參見ProGuard官方介紹

  • 壓縮(Shrink)
    : 偵測並移除程式碼中無用的類、欄位、方法、和特性(Attribute)。
  • 優化(Optimize): 分析和優化位元組碼。
  • 混淆(Obfuscate): 使用a、b、c、d這樣簡短而無意義的名稱,對類、欄位和方法進行重新命名。

上面三個步驟使程式碼size更小,更高效,也更難被逆向工程。

  • 預檢(Preveirfy): 在java平臺上對處理後的程式碼進行預檢。

混淆流程圖如下:

混淆流程圖

Proguard讀入input jars(or wars,zips or directories),經過四個步驟生成處理之後的jars(or wars,ears,zips or directories),Optimization步驟可選擇多次進行。

為了確定哪些程式碼應該被保留,哪些程式碼應該被移除或混淆,需要確定一個或多個Entry Point。Entry Point經常是帶有main methods,applets,midlets的classes,它們在混淆過程中會被保留。我們來看一下Proguard的幾個步驟如何處理Entry Points。

  1. 在壓縮階段,Proguard從上述Entry Points開始遍歷搜尋哪些類和類成員被使用。其他沒有被使用的類和類成員會移除。
  2. 在優化階段,Proguard進一步設定非Entry Point的類和方法為private、static和final來進行優化,不使用的引數會被移除,某些方法會被標記被內聯。
  3. 在混淆階段,Proguard重新命名非Entry Points的類和類成員。
  4. 預檢階段是唯一沒有觸及Entry Points的階段。

Android Studio 預設的混淆方案及欄位解讀

開啟混淆

參見google官方文件壓縮程式碼和資源

要通過Proguard啟動程式碼壓縮,在build.gradle檔案內相應的構建型別中新增minifyEnabled true。

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'),
                    'proguard-rules.pro'
        }
    }
    ...
}

除了 minifyEnabled 屬性外,還有用於定義 ProGuard 規則的 proguardFiles 屬性:

proguardFiles getDefaultProguardFile('proguard-android.txt'),
                    'proguard-rules.pro'

google的官方文件介紹:

getDefaultProguardFile(‘proguard-android.txt’) 方法可從 Android SDK tools/proguard/ 資料夾獲取預設的 ProGuard 設定。要想做進一步的程式碼壓縮,請嘗試使用位於同一位置的 proguard-android-optimize.txt 檔案。它包括相同的 ProGuard 規則,但還包括其他在位元組碼一級(方法內和方法間)執行分析的優化,以進一步減小 APK 大小和幫助提高其執行速度。

proguard-rules.pro 檔案用於新增自定義 ProGuard 規則。預設情況下,該檔案位於模組根目錄(build.gradle 檔案旁),內容為空。

通過試驗,gradle 2.2之後,defaultProguardFile沒有使用sdk目錄下的proguard-android.txt,而是使用了gradle自帶的proguard-android.txt,不同的gradle版本帶有不同的預設混淆檔案,在專案根目錄的build/intermediates/proguard-files/proguard-android.txt-2.3.3(筆者用的gradle版本)即為gradle自帶的混淆檔案。在proguard-android.txt-2.3.3檔案中也寫有說明,gradle 2.2之後自帶混淆檔案:

 Starting with version 2.2 of the Android plugin for Gradle, this file is distributed together with
 the plugin and unpacked at build-time. The files in $ANDROID_HOME are no longer maintained and
 will be ignored by new version of the Android plugin for Gradle.

構建輸出

構建時Proguard都會輸出下列檔案:

(1)dump.txt — 說明APK中所有類檔案的內部結構
(2)mapping.txt — 提供原始與混淆過的類、方法和欄位名稱之間的轉換
(3)seeds.txt — 列出未進行混淆的類和成員
(4)usage.txt — 列出從APK移除的程式碼

這些檔案儲存在/build/outputs/mapping/release目錄下。

解碼混淆過的堆疊追蹤

使用混淆後,一定要儲存好mapping檔案,程式csh時通過指令碼進行解碼。
retrace工具位於/tools/proguard/目錄中,解碼命令為:

retrace.bat|retrace.sh [-verbose] mapping.txt [<stacktrace_file>] 

例如mac平臺下:

retrace.sh -verbose mapping.txt obfuscated_trace.txt

預設的混淆方案及欄位解讀

下面結合預設混淆檔案中的內容來解釋混淆的引數: 參見Proguard官方欄位解讀

不使用大小寫混寫類名
-dontusemixedcaseclassnames

預設情況下混淆的類名可以包含大小寫字元的混合。

不忽略公共類庫
-dontskipnonpubliclibraryclasses

指定不去忽略非public的library classes。從Proguard 4.5開始,是預設的設定。

-dontoptimize
-dontpreverify

預設optimize和preverify選項是關閉的,因為Android的dex並不像Java虛擬機器需要optimize(優化)和previrify(預檢)兩個步驟。

指定哪個屬性不要混淆,可一次指定多個屬性
-keepattributes [attribute_filter]

通常Exceptions, Signature, Deprecated, SourceFile, SourceDir, LineNumberTable, LocalVariableTable, LocalVariableTypeTable, Synthetic, EnclosingMethod, RuntimeVisibleAnnotations, RuntimeInvisibleAnnotations, RuntimeVisibleParameterAnnotations, RuntimeInvisibleParameterAnnotations, and AnnotationDefault屬性需要被保留,根據專案具體使用情況保留。

這裡需要特別注意的一點是,gradle預設的keepattributes屬性不全,只保留了Annotation,Signature,InnerClasses,EnclosingMethod,為了混淆之後定位csh程式碼方便,我們需要在proguard_rules.pro中手動新增丟擲異常時保留程式碼行號,並且重新命名丟擲異常時的檔名稱,這樣能方便定位問題:

丟擲異常時保留程式碼行號
-keepattributes SourceFile,LineNumberTable

重新命名丟擲異常時的檔名稱
-renamesourcefileattribute SourceFile

keep選項非常重要,keep指定了哪些類,哪些方法不被混淆,從而保證了程式的正常執行。官方的keep用法有6種:

Keep From being removed or renamed From being renamed
Classes and class members keep keepnames
Class members only keepclassmembers keepclassmembernames
Classes and class members keepclasseswithmembers keepclasseswithmembernames

左邊不帶names的選項為From being removed or renames,即不會被移除或重新命名,即使類或類成員未被使用。帶有names的選項為From being renamed,不會被重新命名,如果是無用的類或類成員,會被移除。

(1)-keep(names)選項 指定類和類成員(變數和方法)不被混淆

-keep [,modifier,...] class_specification

eg.

指定類名不被改變
-keep public class com.google.vending.licensing.ILicensingService

指定使用了Keep註解的類和類成員都不被改變
-keep @android.support.annotation.Keep class * {*;}

關於Keep註解的解釋參見文末參考連結

(2)-keepclassmembers(names) 指定類成員不被混淆,類名會被混淆

-keepclassmembers [,modifier,...] class_specification

eg.keep setters in views 使得animations仍然能夠工作

-keepclassmembers public class * extends android.view.View {
    void set*(***);
    *** get*();
}

(3)-keepclasseswithmembers(names) 指定類和類成員都不被混淆

-keepclasseswithmembers [,modifier,...] class_specification

eg.包含native方法的類名和native方法都不能被混淆,如果native方法未被呼叫,則被移除。由於native方法與對應so庫中的方法名稱對應,方法名被混淆會導致調用出現問題,所以native方法不能被混淆

-keepclasseswithmembernames class * {
    native <methods>;
}

通用Options:
(1)-verbose 列印混淆詳細資訊

(2)-dontnote選項:指定不去輸出列印該類產生的錯誤或遺漏

-dontnote com.android.vending.licensing.ILicensingService

-dontnote android.support.**

(3)-dontwarn選項:指定不去warn unresolved references和其他重要的problem

-dontwarn android.support.**

如上面(2)(3)所示,android.support的libraries需要保留

至此,gradle自帶的proguard-android.txt檔案相關欄位已解析完畢。下面將介紹我們自定義的proguard-rules.pro檔案需要新增什麼引數。

自定義混淆檔案

一般而言,我們會定義我們自己的proguard-rules.pro,下面列出自定義的一個proguard-rules.pro供大家參考。在看自定義的混淆檔案之前,先講解一下Filters和assumenosideeffects,以便更好地理解下面的指令。

(1)Filters

?   matches any single character in a name.(匹配一個字元)
*   matches any part of a name not containing the directory separator.(匹配一個名字,除了目錄分隔符外的任意部分)
**  matches any part of a name, possibly containing any number of directory separators.(匹配任意名,可能包含任意路徑分隔符)
!  exclude
<field>     匹配類中的所有欄位
<method>    匹配類中所有的方法
<init>      匹配類中所有的建構函式

eg.

-keep class com.lily.test.** 本包和所包含子包下的類名都保持
-keep class com.lily.test.* 保持該包下的類名
-keep class com.lily.test.** {*;} 保持包和子包的類名和裡面的內容均不被混淆

(2)-assumenosideeffects 指令: 下文會用在android log的移除上
assumeosideeffects是Optimization過程中的選項,所以為保證指令的有效,需要開啟optimization。這個指令的含義是Proguard會在optimization過程中刪除對這些方法的呼叫,需要注意:Only use this option if you know what you’re doing!

下面是自定義混淆檔案的一個範例,四大元件,native方法,反射用到的類,一些引入的第三方庫等都不能進行混淆:

# 程式碼混淆壓縮比,在0~7之間
-optimizationpasses 5

# 混合時不使用大小寫混合,混合後的類名為小寫
-dontusemixedcaseclassnames

# 指定不去忽略非公共庫的類
-dontskipnonpubliclibraryclasses

# 不做預校驗,preverify是proguard的四個步驟之一,Android不需要preverify,去掉這一步能夠加快混淆速度。
-dontpreverify

-verbose

#google推薦演算法
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*

# 避免混淆Annotation、內部類、泛型、匿名類
-keepattributes *Annotation*,InnerClasses,Signature,EnclosingMethod

# 重新命名丟擲異常時的檔名稱
-renamesourcefileattribute SourceFile

# 丟擲異常時保留程式碼行號
-keepattributes SourceFile,LineNumberTable

# 處理support包
-dontnote android.support.**
-dontwarn android.support.**

# 保留四大元件,自定義的Application等這些類不被混淆
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Appliction
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.preference.Preference
-keep public class com.android.vending.licensing.ILicensingService

# 保留本地native方法不被混淆
-keepclasseswithmembernames class * {
    native <methods>;
}

# 保留列舉類不被混淆
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# 保留Parcelable序列化類不被混淆
-keep class * implements android.os.Parcelable {
    public static final android.os.Parcelable$Creator *;
}

#第三方jar包不被混淆
-keep class com.github.test.** {*;}

#保留自定義的Test類和類成員不被混淆
-keep class com.lily.Test {*;}
#保留自定義的xlog資料夾下面的類、類成員和方法不被混淆
-keep class com.test.xlog.** {
    <fields>;
    <methods>;
}

#assume no side effects:刪除android.util.Log輸出的日誌
-assumenosideeffects class android.util.Log {
    public static *** v(...);
    public static *** d(...);
    public static *** i(...);
    public static *** w(...);
    public static *** e(...);
}

#保留Keep註解的類名和方法
-keep,allowobfuscation @interface android.support.annotation.Keep
-keep @android.support.annotation.Keep class *
-keepclassmembers class * {
    @android.support.annotation.Keep *;
}

資原始檔的混淆:

上面講述瞭如何進行程式碼混淆,再來講講如何對資原始檔進行混淆。對資原始檔進行混淆操作本質上是通過修改resources.arsc(參見文末連結詳見resources.arsc作用及檔案格式)。現針對兩種資源混淆方案進行簡要說明。第一種是微信的資源混淆方案,第二種是美團的資源混淆方案,兩篇文章中都對原理進行了詳細的闡述。
(1)微信的資源混淆方案:
微信的資源混淆是自己做了一個安裝包解壓並且用7z極限壓縮打包器,修改的內容也是resources.arsc,優點是可以最大地混淆,不依賴原始碼與編譯過程,無需在編譯過程中修改原始檔(java、xml、資原始檔),無需改變Android打包流程。整體的流程如下:
混淆流程
使用微信的資源混淆方案有兩種方法,第一種方式為修改gradle,第二種方式為直接使用命令列。下圖為使用命令列最簡單的方法生成資源混淆的apk,下載github工程後,進入tool_output資料夾,試驗的apk為test.apk

java -jar AndResGuard-cli-1.2.3.jar test.apk

混淆過程中會輸出log,混淆後會出現和apk同名的資料夾,裡面包含了混淆後mapping的對應檔案,新簽名打包的apk和混淆後的資原始檔目錄。如下圖所示:
混淆後生成的檔案
混淆前資原始檔:
混淆前資原始檔
混淆後資原始檔:
混淆後資原始檔
可以看到資原始檔的路徑以及檔名都被混淆了。
(2)美團的資源混淆方案:
採用更改AAPT(Android Asset Packaging Tool)(參見文末連結詳細解讀AAPT)原始碼的方式,參考了Proguard Obfuscator,對APK中資原始檔名使用簡短無意義名稱進行替換,如下面程式碼所示,在AAPT生成resources.arsc和*.ap*時把資原始檔的名稱進行替換。下面是美團修改後的Resource.cpp檔案

static status_t makeFileResources(Bundle* bundle, const sp<AaptAssets>& assets,
                                      ResourceTable* table,
                                      const sp<ResourceTypeSet>& set,
                                      const char* resType)
    {
        String8 type8(resType);
        String16 type16(resType);

        bool hasErrors = false;

        ResourceDirIterator it(set, String8(resType));
        ssize_t res;
        while ((res=it.next()) == NO_ERROR) {
            if (bundle->getVerbose()) {
                printf("    (new resource id %s from %s)\n",
                       it.getBaseName().string(), it.getFile()->getPrintableSource().string());
            }
            String16 baseName(it.getBaseName());
            const char16_t* str = baseName.string();
            const char16_t* const end = str + baseName.size();
            while (str < end) {
                if (!((*str >= 'a' && *str <= 'z')
                        || (*str >= '0' && *str <= '9')
                        || *str == '_' || *str == '.')) {
                    fprintf(stderr, "%s: Invalid file name: must contain only [a-z0-9_.]\n",
                            it.getPath().string());
                    hasErrors = true;
                }
                str++;
            }
            String8 resPath = it.getPath();
            resPath.convertToResPath();

            String8 obfuscationName;
            String8 obfuscationPath = getObfuscationName(resPath, obfuscationName);

            table->addEntry(SourcePos(it.getPath(), 0), String16(assets->getPackage()),
                            type16,
                            baseName, // String16(obfuscationName),
                            String16(obfuscationPath), // resPath
                            NULL,
                            &it.getParams());
            assets->addResource(it.getLeafName(), obfuscationPath/*resPath*/, it.getFile(), type8);
        }

        return hasErrors ? UNKNOWN_ERROR : NO_ERROR;
    }

修改的部分在:

String8 obfuscationName;
String8 obfuscationPath = getObfuscationName(resPath, obfuscationName);

assets->addResource(it.getLeafName(), obfuscationPath/*resPath*/, it.getFile(), type8);

混淆時常見的問題解決

本文為騰訊Bugly公眾號投稿,作者:lilycai,未經作者同意,請勿轉載。