讀懂 Android 中的程式碼混淆

技術小黑屋發表於2016-07-24

在Android開發工作中,我們都或多或少接觸過程式碼混淆。比如我們想要整合某個SDK,往往需要做一些排除混淆的操作。

本文為本人的一些實踐總結,介紹一些混淆的知識和注意事項。希望可以幫助大家更好的學習和使用程式碼混淆。

什麼是混淆

關於混淆維基百科上該詞條的解釋為

程式碼混淆(Obfuscated code)亦稱花指令,是將計算機程式的程式碼,轉換成一種功能上等價,但是難於閱讀和理解的形式的行為。

程式碼混淆影響到的元素有

  • 類名
  • 變數名
  • 方法名
  • 包名
  • 其他元素

混淆的目的

混淆的目的是為了加大反編譯的成本,但是並不能徹底防止反編譯.

如何開啟混淆

  • 通常我們需要找到專案路徑下app目錄下的build.gradle檔案
  • 找到minifyEnabled這個配置,然後設定為true即可.

一個簡單的示例如下

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

proguard是什麼

Java官網對Proguard的定義

ProGuard is a free Java Class file shrinker, optimizer, obfuscator, and preverifier. It detects and removes unused classes, fields, methods, and attributes. It optimizes bytecode and removes unused instructions. It renames the remaining classes, fields, and methods using short meaningless names. Finally, it preverifies the processed code for Java 6 or higher, or for Java Micro Edition.

  • Proguard是一個集檔案壓縮,優化,混淆和校驗等功能的工具
  • 它檢測並刪除無用的類,變數,方法和屬性
  • 它優化位元組碼並刪除無用的指令.
  • 它通過將類名,變數名和方法名重新命名為無意義的名稱實現混淆效果.
  • 最後它還校驗處理後的程式碼

混淆的常見配置

-keep

Keep用來保留Java的元素不進行混淆. keep有很多變種,他們一般都是

  • -keep
  • -keepclassmembers
  • -keepclasseswithmembers

一些例子

保留某個包下面的類以及子包

-keep public class com.droidyue.com.widget.**

保留所有類中使用otto的public方法

# Otto
-keepclassmembers class ** {
    @com.squareup.otto.Subscribe public *;
    @com.squareup.otto.Produce public *;
}

保留Contants類的BOOK_NAME屬性

-keepclassmembers class com.example.admin.proguardsample.Constants {
     public static java.lang.String BOOK_NAME;
}

更多關於Proguard keep使用,可以參考官方文件

-dontwarn

dontwarn是一個和keep可以說是形影不離,尤其是處理引入的library時.

引入的library可能存在一些無法找到的引用和其他問題,在build時可能會發出警告,如果我們不進行處理,通常會導致build中止.因此為了保證build繼續,我們需要使用dontwarn處理這些我們無法解決的library的警告.

比如關閉Twitter sdk的警告,我們可以這樣做

-dontwarn com.twitter.sdk.**

其他混淆相關的介紹,都可以通過訪問官方文件獲取.

哪些不應該混淆

反射中使用的元素

如果一些被混淆使用的元素(屬性,方法,類,包名等)進行了混淆,可能會出現問題,如NoSuchFiledException或者NoSuchMethodException等.

比如下面的示例原始碼

//Constants.java
public class Constants {
    public static  String BOOK_NAME = "book_name";
}

//MainActivity.java
Field bookNameField = null;
try {
    String fieldName = "BOOK_NAME";
    bookNameField = Constants.class.getField(fieldName);
    Log.i(LOGTAG, "bookNameField=" + bookNameField);
} catch (NoSuchFieldException e) {
    e.printStackTrace();
}

如果上面的Constants類進行了混淆,那麼上面的語句就可能丟擲NoSuchFieldException.

想要驗證,我們需要看一看混淆的對映檔案,檔名為mapping.txt,該檔案儲存著混淆前後的對映關係.

com.example.admin.proguardsample.Constants -> com.example.admin.proguardsample.a:
    java.lang.String BOOK_NAME -> a
    void <init>() -> <init>
    void <clinit>() -> <clinit>
com.example.admin.proguardsample.MainActivity -> com.example.admin.proguardsample.MainActivity:
    void <init>() -> <init>
    void onCreate(android.os.Bundle) -> onCreate

從對映檔案中,我們可以看到

  • Constants類被重新命名為a.
  • Constants類的BOOK_NAME重新命名了a

然後,我們對APK檔案進行反編譯一探究竟.推薦一下這個線上反編譯工具 http://www.javadecompilers.com/apk

注意,使用jadx decompiler後,會重新命名,正如下面註釋/* renamed from: com.example.admin.proguardsample.a */所示.

package com.example.admin.proguardsample;

/* renamed from: com.example.admin.proguardsample.a */
public class C0314a {
    public static String f1712a;

    static {
        f1712a = "book_name";
    }
}

而MainActivity的翻譯後的對應的原始碼為

try {
    Log.i("MainActivity", "bookNameField=" + C0314a.class.getField("BOOK_NAME"));
} catch (NoSuchFieldException e) {
    e.printStackTrace();
}

MainActivity中反射獲取的屬性名稱依然是BOOK_NAME,而對應的類已經沒有了這個屬性名,所以會丟擲NoSuchFieldException.

注意,如果上面的filedName使用字面量或者字串常量,即使混淆也不會出現NoSuchFieldException異常。因為這兩種情況下,混淆可以感知外界對filed的引用,已經在呼叫出替換成了混淆後的名稱。

GSON的序列化與反序列化

GSON是一個很好的工具,使用它我們可以輕鬆的實現序列化和反序列化.但是當它一旦遇到混淆,就需要我們注意了.

一個簡單的類Item,用來處理序列化和反序列化

public class Item {
    public String name;
    public int id;
}

序列化的程式碼

Item toSerializeItem = new Item();
toSerializeItem.id = 2;
toSerializeItem.name = "Apple";
String serializedText = gson.toJson(toSerializeItem);
Log.i(LOGTAG, "testGson serializedText=" + serializedText);

開啟混淆之後的日誌輸出結果

I/MainActivity: testGson serializedText={"a":"Apple","b":2}

屬性名已經改變了,變成了沒有意思的名稱,對我們後續的某些處理是很麻煩的.

反序列化的程式碼

Gson gson = new Gson();
Item item = gson.fromJson("{\"id\":1, \"name\":\"Orange\"}", Item.class);
Log.i(LOGTAG, "testGson item.id=" + item.id + ";item.name=" + item.name);

對應的日誌結果是

I/MainActivity: testGson item.id=0;item.name=null

可見,混淆之後,反序列化的屬性值設定都失敗了.

為什麼呢?

  • 因為反序列化建立物件本質還是利用反射,會根據json字串的key作為屬性名稱,value則對應屬性值.

如何解決

  • 將序列化和反序列化的類排除混淆
  • 使用@SerializedName註解欄位

@SerializedName(parameter)通過註解屬性實現了

  • 序列化的結果中,指定該屬性key為parameter的值.
  • 反序列化生成的物件中,用來匹配key與parameter並賦予屬性值.

一個簡單的用法為

public class Item {
    @SerializedName("name")
    public String name;
    @SerializedName("id")
    public int id;

列舉也不要混淆

列舉是Java 5 中引入的一個很便利的特性,可以很好的替代之前的常量形式.

列舉使用起來很簡單,如下

public enum Day {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
}

這裡我們這樣使用列舉

Day day = Day.valueOf("monday");
Log.i(LOGTAG, "testEnum day=" + day);

執行上面的的程式碼,通常情況下是沒有問題的,是否說明列舉就可以混淆呢?

其實不是.

為什麼沒有問題呢,因為預設的Proguard配置已經處理了列舉相關的keep操作.

# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

如果我們手動去掉這條keep配置,再次執行,一個這樣的異常會從天而降.

E AndroidRuntime: Process: com.example.admin.proguardsample, PID: 17246
E AndroidRuntime: java.lang.AssertionError: impossible
E AndroidRuntime:  at java.lang.Enum$1.create(Enum.java:45)
E AndroidRuntime:  at java.lang.Enum$1.create(Enum.java:36)
E AndroidRuntime:  at libcore.util.BasicLruCache.get(BasicLruCache.java:54)
E AndroidRuntime:  at java.lang.Enum.getSharedConstants(Enum.java:211)
E AndroidRuntime:  at java.lang.Enum.valueOf(Enum.java:191)
E AndroidRuntime:  at com.example.admin.proguardsample.a.a(Unknown Source)
E AndroidRuntime:  at com.example.admin.proguardsample.MainActivity.j(Unknown Source)
E AndroidRuntime:  at com.example.admin.proguardsample.MainActivity.onCreate(Unknown Source)
E AndroidRuntime:  at android.app.Activity.performCreate(Activity.java:6237)
E AndroidRuntime:  at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1107)
E AndroidRuntime:  at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2369)
E AndroidRuntime:  at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2476)
E AndroidRuntime:  at android.app.ActivityThread.-wrap11(ActivityThread.java)
E AndroidRuntime:  at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1344)
E AndroidRuntime:  at android.os.Handler.dispatchMessage(Handler.java:102)
E AndroidRuntime:  at android.os.Looper.loop(Looper.java:148)
E AndroidRuntime:  at android.app.ActivityThread.main(ActivityThread.java:5417)
E AndroidRuntime:  at java.lang.reflect.Method.invoke(Native Method)
E AndroidRuntime:  at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
E AndroidRuntime:  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)
E AndroidRuntime: Caused by: java.lang.NoSuchMethodException: values []
E AndroidRuntime:  at java.lang.Class.getMethod(Class.java:624)
E AndroidRuntime:  at java.lang.Class.getDeclaredMethod(Class.java:586)
E AndroidRuntime:  at java.lang.Enum$1.create(Enum.java:41)
E AndroidRuntime:  ... 19 more

好玩的事情來了,我們看一看為什麼會丟擲這個異常

1.首先,一個列舉類會生成一個對應的類檔案,這裡是Day.class. 這裡類裡面包含什麼呢,看一下反編譯的結果

➜  proguardsample javap  Day
Warning: Binary file Day contains com.example.admin.proguardsample.Day
Compiled from "Day.java"
public final class com.example.admin.proguardsample.Day extends java.lang.Enum<com.example.admin.proguardsample.Day> {
  public static final com.example.admin.proguardsample.Day MONDAY;
  public static final com.example.admin.proguardsample.Day TUESDAY;
  public static final com.example.admin.proguardsample.Day WEDNESDAY;
  public static final com.example.admin.proguardsample.Day THURSDAY;
  public static final com.example.admin.proguardsample.Day FRIDAY;
  public static final com.example.admin.proguardsample.Day SATURDAY;
  public static final com.example.admin.proguardsample.Day SUNDAY;
  public static com.example.admin.proguardsample.Day[] values();
  public static com.example.admin.proguardsample.Day valueOf(java.lang.String);
  static {};
}
  • 列舉實際是建立了一個繼承自java.lang.Enum的類
  • java程式碼中的列舉型別最後轉換成類中的static final屬性
  • 多出了兩個方法,values()和valueOf().
  • values方法返回定義的列舉型別的陣列集合,即從MONDAY到SUNDAY這7個型別.

2.找尋崩潰軌跡 其中Day.valueOf(String)內部會呼叫Enum.valueOf(Class,String)方法

public static com.example.admin.proguardsample.Day valueOf(java.lang.String);
    Code:
       0: ldc           #4                  // class com/example/admin/proguardsample/Day
       2: aload_0
       3: invokestatic  #5                  // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
       6: checkcast     #4                  // class com/example/admin/proguardsample/Day
       9: areturn

而Enum的valueOf方法會間接呼叫Day.values()方法,具體步驟是

  • Enum.value呼叫Class.enumConstantDirectory方法獲取String到列舉的對映
  • Class.enumConstantDirectory方法呼叫Class.getEnumConstantsShared獲取當前的列舉型別
  • Class.getEnumConstantsShared方法使用反射呼叫values來獲取列舉型別的集合.

混淆之後,values被重新命名,所以會發生NoSuchMethodException.

關於呼叫軌跡,感興趣的可以自己研究一下原始碼,不難.

四大元件不建議混淆

Android中四大元件我們都很常用,這些元件不能被混淆的原因為

  • 四大元件宣告必須在manifest中註冊,如果混淆後類名更改,而混淆後的類名沒有在manifest註冊,是不符合Android元件序號產生器制的.
  • 外部程式可能使用元件的字串類名,如果類名混淆,可能導致出現異常

註解不能混淆

註解在Android平臺中使用的越來越多,常用的有ButterKnife和Otto.很多場景下註解被用作在執行時反射確定一些元素的特徵.

為了保證註解正常工作,我們不應該對註解進行混淆.Android工程預設的混淆配置已經包含了下面保留註解的配置

-keepattributes *Annotation*

關於註解,可以閱讀這篇文章瞭解.詳解Java中的註解

其他不該混淆的

  • jni呼叫的java方法
  • java的native方法
  • js呼叫java的方法
  • 第三方庫不建議混淆
  • 其他和反射相關的一些情況

stacktrace的恢復

Proguard混淆帶來了很多好處,但是也會導致我們收集到的崩潰的stacktrace變得更加難以讀懂,好在有補救的措施,這裡就介紹一個工具,retrace,用來將混淆後的stacktrace還原成混淆之前的資訊.

retrace指令碼

Android 開發環境預設帶著retrace指令碼,一般情況下路徑為./tools/proguard/bin/retrace.sh

mapping對映表

Proguard進行混淆之後,會生成一個對映表,檔名為mapping.txt,我們可以使用find工具在Project下查詢

find . -name mapping.txt
./app/build/outputs/mapping/release/mapping.txt

一個崩潰stacktrace資訊

一個原始的崩潰資訊是這樣的.

E/AndroidRuntime(24006): Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'int android.graphics.Bitmap.getWidth()' on a null object reference
E/AndroidRuntime(24006):    at com.example.admin.proguardsample.a.a(Utils.java:10)
E/AndroidRuntime(24006):    at com.example.admin.proguardsample.MainActivity.onCreate(MainActivity.java:22)
E/AndroidRuntime(24006):    at android.app.Activity.performCreate(Activity.java:6106)
E/AndroidRuntime(24006):    at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1123)
E/AndroidRuntime(24006):    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2566)
E/AndroidRuntime(24006):    ... 10 more

對上面的資訊處理,去掉E/AndroidRuntime(24006):這些字串retrace才能正常工作.得到的字串是

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'int android.graphics.Bitmap.getWidth()' on a null object reference
at com.example.admin.proguardsample.a.a(Utils.java:10)
at com.example.admin.proguardsample.MainActivity.onCreate(MainActivity.java:22)
at android.app.Activity.performCreate(Activity.java:6106)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1123)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2566)
... 10 more

將上面的stacktrace儲存成一個文字檔案,比如名稱為npe_stacktrace.txt.

開搞

./tools/proguard/bin/retrace.sh   /Users/admin/Downloads/ProguardSample/app/build/outputs/mapping/release/mapping.txt /tmp/npe_stacktrace.txt

得到的易讀的stacktrace是

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'int android.graphics.Bitmap.getWidth()' on a null object reference
at com.example.admin.proguardsample.Utils.int getBitmapWidth(android.graphics.Bitmap)(Utils.java:10)
at com.example.admin.proguardsample.MainActivity.void onCreate(android.os.Bundle)(MainActivity.java:22)
at android.app.Activity.performCreate(Activity.java:6106)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1123)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2566)
... 10 more

注意:為了更加容易和高效分析stacktrace,建議保留SourceFile和LineNumber屬性

-keepattributes SourceFile,LineNumberTable

關於混淆,我的一些個人經驗總結就是這些.希望可以對大家有所幫助.

相關文章