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 自定义混淆规则

先来看混淆规则的通配符。

通配符 描述
<field> 匹配类中的所有字段
<method> 匹配类中所有的方法
<init> 匹配类中所有的构造函数
* 匹配任意长度字符,不包含包名分隔符(.)
** 匹配任意长度字符,包含包名分隔符(.)
*** 匹配任意参数类型

再来看制定混淆规则的关键字,这些关键字指定了混淆规则的粒度。
① 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安装插件方法