Android—混淆與打包

我們都希望自己的代碼足夠"安全",即使別人反編譯了我們的應用,他們也很難從反編譯的代碼中找出漏洞。這時候我們就依賴編譯器的混淆功能,混淆會將大部分(下面會解釋爲什麼是大部分)類和成員的名稱重命名爲沒有意義的短名,例如aaab這種,此時的代碼基本沒有可讀性,也就不容易找到漏洞。想要從代碼的角度分析混淆做了什麼,我們就得查看混淆後的代碼,本文通過反編譯來分析混淆前後的代碼有何不同。

一、混淆與反編譯

1.1 混淆、縮減與優化應用

混淆並不是單獨使用的,當你啓用混淆時,編譯器還會同時縮減和優化你的應用,以儘可能地減小應用的大小。當發佈應用的release版本時就需要開啓混淆,在build.gradle中添加以下代碼即可啓用。

    android {
        buildTypes {
            release { // 用於應用的release版本
                // 啓用 代碼縮減、混淆、代碼優化
                minifyEnabled true

                // 資源縮減
                shrinkResources true

                // 這裏引入了Android插件自帶的混淆規則
                proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                        'proguard-rules.pro'
            }
        }
        ...
    }
1.1.1 minifyEnabled

minifyEnabled爲true表示啓用代碼縮減、混淆處理和優化。

  1. 代碼縮減:也稱"搖樹優化",指從應用及其依賴庫中檢測並安全地移除未使用的類、字段、方法和屬性。如果應用僅使用某個依賴庫的少數幾個 API,縮減功能可以識別應用未使用的庫代碼並僅從應用中移除這部分代碼。搖樹優化

  2. 混淆處理:通過縮短類和成員變量的名稱,減小dex包的大小。寫代碼時,我們爲了代碼的可讀性,會爲類、方法和變量定義通俗的名稱。例如boolean isDataLoadFinished,一看就知道是判斷數據是否加載完畢的,但是混淆之後就會變爲類似boolean aa這樣的名稱。
    當然並不是所有的類和成員都能被混淆,上方配置的第3項中的proguard-rules.pro是用戶自定義的混淆規則,用戶可以自行決定哪些類不該被混淆。例如反射或自定義View這些需要用到原始類名或者方法名的類和成員就不該被混淆,之後會詳細介紹如何自定義混淆規則。

  3. 代碼優化:檢查並重寫代碼。例如,如果檢測到if/else語句中的else{...}代碼塊從未被執行,那麼編譯器會移除該部分代碼,以進一步縮減dex包的大小;或者檢測到某個方法只被調用一次,可能會將該方法移除並內嵌在調用的地方。

1.1.2 shrinkResources

資源縮減:從封裝應用中移除不使用的資源,包括依賴庫中不使用的資源。此功能可與代碼縮減功能結合使用,這樣一來,移除不使用的代碼後,也可以安全地移除不再引用的所有資源。

不過這並非萬無一失,我同事之前遇到過這樣一種情況:我們的應用分爲淺色模式與深色模式,淺色模式下的資源名爲xxx,深色模式下的資源名爲xxx_dark。當從淺色模式切換至深色模式時,代碼沒有直接引用深色模式下的資源圖片,而是在資源名xxx後面拼接_dark,修改資源名字達到替換圖片的效果。但是編譯器在資源縮減階段發現xxx_dark沒有被引用,就將所有深色模式的圖刪掉了。

此時我們只能自定義資源保留的規則:修改res/raw/keep.xml,在 tools:keep 屬性中指定每個要保留的資源,在 tools:discard 屬性中指定每個要捨棄的資源。這兩個屬性都接受以逗號分隔的資源名稱列表,可以將星號字符用作通配符。如下所示,指定保留以_dark結尾的資源。

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/*_dark />

1.2 反編譯APK

1.2.1 反編譯步驟

查看反編譯後的代碼主要依賴dex2jar和jd-gui這2個工具,具體步驟也很簡單:
① 將APK後綴改爲rar,解壓得到dex文件。
② 通過dex2jar將dex文件轉爲jar文件。以Windows系統爲例,下載dex-tools後解壓,將dex文件複製到該目錄下,執行d2j-dex2jar.bat classes.dex命令即可得到classes-dex2jar.jar文件。不過網絡上的dex-tools不一定是最新版,最好在github下載源碼,編譯成功後執行gradlew assemble,執行完畢後可在dex2jar-2.x\dex-tools\build\distributions目錄下得到dex-tools。
③ 下載jd-gui,解壓後打開jd-gui.exe,選擇jar文件查看源碼即可。

1.2.2 反編譯實踐

新建一個測試混淆的項目,由於AndroidStudio打包默認是debug包,先啓用debug包的混淆。

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

項目中添加以下文件。
① MainActivity和SecondActivity
② 自定義視圖TestView(繼承自View)
③ WebView交互類CommonJSApi
④ 工具類SizeUtils

項目結構.png

此時項目使用的是默認的混淆規則,來看一下反編譯後的項目結構,我們發現MainActivity、SecondActivity和TestView還保留着原本的名字,而CommonJSApi和SizeUtils的類名已經被混淆成了a和b。

反編譯項目結構.png

下面來看每個類混淆前後的具體代碼。
① MainActivity
原始代碼如下,定義了2個沒有使用的變量mUnuesed和mUnUsedString,onCreate(...)中有一段if/else代碼,很明顯else{}代碼塊中的內容不會被執行。

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private int mUnuesed;
    private String mUnUsedString = "hahaha";
    private boolean mShouldShowDensity = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.btn_test).setOnClickListener(this);

        if (mShouldShowDensity) {
            Log.e("TAG", "density: " + SizeUtils.getDensity(this));
        } else {
            Log.e("TAG", "nothing to show");
        }
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.btn_test:
                startActivity(new Intent(this, SecondActivity.class));
                break;
        }
    }
}

混淆後代碼如下,沒有使用到的變量mUnuesed和mUnUsedString已經被刪除,但是不可能被執行到的else{}代碼塊卻並沒有被刪除。帶着疑惑,我改成了if (true) {...},再次打包反編譯,發現else{}代碼塊被刪除了…
希望有朋友交流一下這個優化規則的觸發條件,非要寫成if (true)這樣才進行代碼優化的話,這就顯得有點雞肋。

public class MainActivity extends d implements View.OnClickListener {
  public boolean s = true;
  
  public void onClick(View paramView) {
    if (paramView.getId() == 2131165250)
      startActivity(new Intent((Context)this, SecondActivity.class)); 
  }
  
  public void onCreate(Bundle paramBundle) {
    super.onCreate(paramBundle);
    setContentView(2131361820);
    findViewById(2131165250).setOnClickListener(this);
    if (this.s) {
      StringBuilder stringBuilder = new StringBuilder();
      stringBuilder.append("density: ");
      stringBuilder.append(b.a((Activity)this));
      Log.e("TAG", stringBuilder.toString());
    } else {
      Log.e("TAG", "nothing to show"); // 爲何沒有被刪除?
    } 
  }
}

② 自定義視圖TestView
這個自定義View比較簡單,原始代碼就不貼了,直接看混淆後的代碼,只是變量名和方法名被修改了。

public class TestView extends View {
  public Paint b;
  public int c; (原始: private int mWidth;)
  public int d; (原始: private int mHeight;)
  
  public TestView(Context paramContext) {
    this(paramContext, null);
  }
  
  public TestView(Context paramContext, AttributeSet paramAttributeSet) {
    this(paramContext, paramAttributeSet, 0);
  }
  
  public TestView(Context paramContext, AttributeSet paramAttributeSet, int paramInt) {
    super(paramContext, paramAttributeSet, paramInt);
    a();
  }
  
  public final void a() {
    this.b = new Paint(1);
    this.b.setStyle(Paint.Style.FILL_AND_STROKE);
    this.b.setColor(-16776961);
  }
  
  public void onDraw(Canvas paramCanvas) {
    super.onDraw(paramCanvas);
    paramCanvas.drawRect(0.0F, 0.0F, this.c, this.d, this.b);
  }
  
  public void onSizeChanged(int paramInt1, int paramInt2, int paramInt3, int paramInt4) {
    super.onSizeChanged(paramInt1, paramInt2, paramInt3, paramInt4);
    this.c = paramInt1;
    this.d = paramInt2;
  }
}

③ WebView交互類CommonJSApi
原始代碼如下,構造函數中雖然傳入了Context變量,但是並未被使用。
還有1個被@JavascriptInterface註解的getVersion()方法。

public class CommonJSApi {
    public CommonJSApi(Context context) {}

    @JavascriptInterface
    public String getVersion() {
        return "1";
    }
}

混淆後的代碼如下,由於構造函數中的Context沒有用到,因此我們自己添加的構造函數被刪除了,可以直接使用原本的無參構造函數。而getVersion()的方法名沒有被更改,因爲JS會通過方法名進行調用。

public class a {
  @JavascriptInterface
  public String getVersion() {
    return "1";
  }
}

④ 工具類SizeUtils
原始代碼如下,getDensity(Activity activity)在MainActivity中被使用過,而getString()沒有被用到過。

public class SizeUtils {
    public static float getDensity(Activity activity) {
        DisplayMetrics dm = new DisplayMetrics();
        activity.getWindowManager().getDefaultDisplay().getMetrics(dm);
        return dm.density;
    }

    public static String getString() {
        return "HAHA";
    }
}

混淆後的代碼如下,類名和方法名都被混淆了,而沒有用到的方法被刪除了。

public class b {
  public static float a(Activity paramActivity) {
    DisplayMetrics displayMetrics = new DisplayMetrics();
    paramActivity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
    return displayMetrics.density;
  }
}

二、混淆規則

2.1 默認混淆規則

雖然’proguard-rules.pro’文件中還沒有添加任何混淆規則,但是編譯器已經知道哪些類和變量一定不能被混淆,例如Activity、自定義View、JavascriptInterface等等,這些屬於默認的混淆規則。如果想告訴編譯器還有哪些類和變量也不該被混淆,就需要用戶自己添加規則,項目中的以下內容都不該被混淆。
① 枚舉
② 第三方庫
③ 運用了反射的類
④ 網絡數據解析的JavaBean實體類
⑤ Parcelable的子類和 Creator 靜態成員變量
⑥ 四大組件、自定義的Application
⑦ JNI中調用的類

2.2 自定義混淆規則

先來看混淆規則的通配符。

通配符 描述
匹配類中的所有字段
匹配類中所有的方法
匹配類中所有的構造函數
* 匹配任意長度字符,不包含包名分隔符(.)
** 匹配任意長度字符,包含包名分隔符(.)
*** 匹配任意參數類型

再來看制定混淆規則的關鍵字,這些關鍵字指定了混淆規則的粒度。
① keep: 保留類名或整個類不被混淆

// 直接將keep作用於類,只是保證類名不被混淆,如下所示,成員還是會被混淆
-keep public class com.lister.autopacktest.SizeUtils
// 如果不混淆整個類的話,規則如下所示
-keep public class com.lister.autopacktest.SizeUtils { *; }

// * 通配符表示保持該包下的類名,但是子包的類名還是會被混淆
-keep public class com.lister.autopacktest.utils.*
// ** 通配符表示保持該包及其子包下的類名
-keep public class com.lister.autopacktest.utils.**
// 如果想保留該包下的所有類名與方法,需要加上{ *; }
-keep public class com.lister.autopacktest.utils.** { *; }

② keepnames: 保留類和類中的成員的命名,成員沒有被引用會被移除
注意keepnames只是防止類和成員被重命名,沒有被引用的成員還是會被移除。

③ keepclassmembers: 保留類中的成員,防止被移除和重命名

// 類似之前示例中的CommonJSApi,雖然類名被混淆了,但是方法未被混淆。
// 如果想保留特定的方法,可以定義如下的規則。
// 1. 不混淆某個類的構造方法
-keepclassmembers class com.lister.autopacktest.SizeUtils { 
    public <init>(); 
}
// 2. 不混淆某個類的特定的方法
-keepclassmembers class com.lister.autopacktest.SizeUtils { 
    public void test(java.lang.String); 
}

④ keepclassmembernames: 保留類中成員的命名,成員沒有引用會被移除

⑤ keepclasseswithmembers: 保留指明的類和成員,防止被移除和重命名

⑥ keepclasseswithmembernames: 保留指明的類和成員,防止被重命名,成員沒有引用會被移除

上面提到,keep關鍵字可以保留單個類或者某個包下的類,如果不被混淆的類不在一個包下,就需要一個一個添加到混淆規則中。那麼有沒有什麼辦法能夠簡化這個流程呢?這裏介紹一種通過註解定義混淆規則的方法,在不被混淆的類和成員上加上註解即可,先定義 @Keep@KeepAll兩個註解。
@Keep註解可添加在類、變量、方法上,表示不混淆當前被註解的內容。

@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.CLASS)
@Documented
public @interface Keep {
   String value() default "";
}

@KeepAll註解添加在類上,表示不混淆當前類的所有內容。

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
@Documented
public @interface KeepAll {
    String value() default "";
}

在proguard-rules添加如下規則即可,可以發現@Keep註解對方法、變量和類名做了約束,而@KeepAll註解對整個類做了約束。

-keep @interface <packagename>.Keep
-keep @interface <packagename>.KeepAll

-keepclassmembers class * {
  @<packagename>.Keep <methods>;
  @<packagename>.Keep <fields>;
}
-keep @<packagename>.Keep class *
-keep @<packagename>.KeepAll class * { *; }

當需要對內部類操作時,通過$指明內部類;同時可以用private、public進一步指定需要保留的內容。例如當你需要保留某個內部類的public構造函數時:

-keep class Test$T {
    public <init>;
}

三、mapping文件

當應用崩潰或者發生錯誤時,我們會得到方法調用棧來分析問題,而混淆過的應用提供的是混淆後的調用棧,此時我們就需要解混淆。打開...build/outputs/mapping目錄下的mapping.txt文件,可以找到項目中所有類和變量混淆前後的名字與對應關係。

來看看測試項目的mapping文件,不僅有類和成員的對應關係,還很貼心地爲你標出了方法所在的行數。

com.lister.autopacktest.CommonJSApi -> b.a.a.a:
    9:10:void <init>(android.content.Context) -> <init>
    14:14:java.lang.String getVersion() -> getVersion
com.lister.autopacktest.MainActivity -> com.lister.autopacktest.MainActivity:
    boolean mShouldShowDensity -> s
    11:15:void <init>() -> <init>
    33:38:void onClick(android.view.View) -> onClick
    19:29:void onCreate(android.os.Bundle) -> onCreate
com.lister.autopacktest.SecondActivity -> com.lister.autopacktest.SecondActivity:
    android.webkit.WebView mWebView -> t
    android.widget.FrameLayout mWebViewContainer -> s
    11:11:void <init>() -> <init>
    18:42:void onCreate(android.os.Bundle) -> onCreate
    46:49:void onDestroy() -> onDestroy
com.lister.autopacktest.SizeUtils -> com.lister.autopacktest.SizeUtils:
    7:7:void <init>() -> <init>
    10:12:float getDensity(android.app.Activity) -> a
com.lister.autopacktest.TestView -> com.lister.autopacktest.TestView:
    android.graphics.Paint mPaint -> b
    int mHeight -> d
    int mWidth -> c
    20:21:void <init>(android.content.Context) -> <init>
    24:25:void <init>(android.content.Context,android.util.AttributeSet) -> <init>
    28:30:void <init>(android.content.Context,android.util.AttributeSet,int) -> <init>
    33:36:void init() -> a
    47:49:void onDraw(android.graphics.Canvas) -> onDraw
    40:43:void onSizeChanged(int,int,int,int) -> onSizeChanged

四、Gradle打包

4.1 實現debug與release包不同包名

商業化的應用都分爲debug版和release版,debug版用於快速調試錯誤,release版本用於外發。在AS中直接run app或者build apk生成的就是debug版本。爲了兩個版本的應用在手機上共存,可以修改debug版應用的包名,加一個.debug後綴,如下所示。

android {
    ......
    buildTypes {
        debug {
            applicationIdSuffix ".debug"
            ......
        }
    }
}

4.2 簽名

APK的打包都需要簽名,平時打debug包時如果沒有指定簽名,默認使用debug.keystore作爲debug包的簽名,可以通過keytool -list -v -keystore xxx命令查看某個簽名的信息。
也可以通過gradle的signingReport這個Task查看,在命令行運行gradlew signingReport即可在命令行看到debug.keystore的信息,如下所示。

> Task :app:signingReport
  Variant: debug
  Config: debug
  Store: C:\Users\win10\.android\debug.keystore
  Alias: AndroidDebugKey
  MD5: ......
  SHA1: ......
  SHA-256: ......
  Valid until: 2049年9月14日 星期二

而打release肯定不能用debug簽名,首先需要新建一個簽名文件。點擊AS的build->Generated signed Bundle/APK,選擇APK,點擊Create new…新建簽名文件,我這裏已經新建過了,存放在項目裏app目錄下。隨後點擊next,選擇release版本,在下方選擇v1、v2兩個簽名,點擊finish即可打包。

新建簽名.png

我們也可以在gradle.build中配置打包所使用的簽名,如下所示。在signingConfigs中配置release版本的簽名路徑、密碼等信息後,在buildTypes中通過signingConfig signingConfigs.release指定簽名配置爲signingConfigs中的信息。隨後在命令行運行gradlew assembleRelease即可。

    signingConfigs {
        release {
            storeFile file('../app/autoKey.jks')
            storePassword "123456"
            keyAlias "autoKey"
            keyPassword "123456"
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }

    buildTypes {
        debug {
            applicationIdSuffix ".debug"
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 
                'proguard-rules.pro'
        }
        release {
            minifyEnabled true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 
                'proguard-rules-release.pro'
        }
    }

上面的配置中直接聲明瞭簽名的密碼等信息,如果應用開源,這些信息很容易被獲取。因此官方更推薦通過properties文件的形式聲明簽名信息,新建keystore.properties文件。

storePassword=......
keyPassword=......
keyAlias=......
storeFile=......

之後在build.gradle中讀取keystore.properties文件即可,注意keystore.properties應該存儲於安全的地方,不應該隨着應用的代碼一起上傳上去,否則它就沒有意義了。

......
    def keystorePropertiesFile = rootProject.file("keystore.properties")
    def keystoreProperties = new Properties()
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))

    android {
        signingConfigs {
            release {
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
            }
        }
    }

如果要查看APK的簽名信息怎麼辦?
將APK解壓後進入META-INF目錄,其中的CERT.RSA文件中就存放着簽名信息。在該目錄下運行命令keytool -printcert -file CERT.RSA即可查看該APK的簽名信息。

五、Jenkins打包

在項目過程中,測試經常需要研發去打某個分支的包,研發人員需要進行保存代碼、切換分支、修改配置…等一系列操作,影響開發效率。而使用Jenkins進行遠程打包就沒有這個煩惱了,只要輸入對應的分支名即可打包。

Jenkins可以去官網下載,不過速度很慢,windows版本我上傳到了CSDN,有需要的同學自取:Jenkins
具體打包流程具體見參考5、6,本來想寫一下這塊的踩坑經歷,但是發現大神們都寫的很詳細了,就不畫蛇添足了。實踐中唯一的問題是插件下載太慢,可以在官網jenkins插件手動下載插件後安裝。

六、參考

  1. 縮減、混淆處理和優化您的應用
  2. Android混淆
  3. 深入理解 Android(一):Gradle 詳解
  4. Android Gradle學習(一):Gradle基礎入門
  5. Android Jenkins+Git+Gradle持續集成
  6. Android 使用 Jenkins 實現自動化打包
  7. Jenkins安裝插件方法
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章