读懂 Android 中的代码混淆

StanHigh 8年前
   <p>在Android开发工作中,我们都或多或少接触过代码混淆。比如我们想要集成某个SDK,往往需要做一些排除混淆的操作。</p>    <p>本文为本人的一些实践总结,介绍一些混淆的知识和注意事项。希望可以帮助大家更好的学习和使用代码混淆。</p>    <h2>什么是混淆</h2>    <p>关于混淆维基百科上该词条的解释为</p>    <p>代码混淆(Obfuscated code)亦称花指令,是将计算机程序的代码,转换成一种功能上等价,但是难于阅读和理解的形式的行为。</p>    <p>代码混淆影响到的元素有</p>    <ul>     <li>类名</li>     <li>变量名</li>     <li>方法名</li>     <li>包名</li>     <li>其他元素</li>    </ul>    <h2>混淆的目的</h2>    <p>混淆的目的是为了 <strong>加大反编译的成本</strong> ,但是并不能彻底防止反编译.</p>    <h2>如何开启混淆</h2>    <ul>     <li>通常我们需要找到项目路径下app目录下的build.gradle文件</li>     <li>找到minifyEnabled这个配置,然后设置为true即可.</li>    </ul>    <p>一个简单的示例如下</p>    <pre>  <code class="language-java">buildTypes {          release {              minifyEnabled true              proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'  </code></pre>    <h2>proguard是什么</h2>    <p>Java官网对Proguard的定义</p>    <p>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.</p>    <ul>     <li>Proguard是一个集文件压缩,优化,混淆和校验等功能的工具</li>     <li>它检测并删除无用的类,变量,方法和属性</li>     <li>它优化字节码并删除无用的指令.</li>     <li>它通过将类名,变量名和方法名重命名为无意义的名称实现混淆效果.</li>     <li>最后它还校验处理后的代码</li>    </ul>    <h2>混淆的常见配置</h2>    <h3>-keep</h3>    <p>Keep用来保留Java的元素不进行混淆. keep有很多变种,他们一般都是</p>    <ul>     <li>-keep</li>     <li>-keepclassmembers</li>     <li>-keepclasseswithmembers</li>    </ul>    <p>一些例子</p>    <p>保留某个包下面的类以及子包</p>    <pre>  <code class="language-java">-keep public class com.droidyue.com.widget.**  </code></pre>    <p>保留所有类中使用otto的public方法</p>    <pre>  <code class="language-java"># Otto  -keepclassmembers class ** {      @com.squareup.otto.Subscribe public *;      @com.squareup.otto.Produce public *;  }  </code></pre>    <p>保留Contants类的BOOK_NAME属性</p>    <pre>  <code class="language-java">-keepclassmembers class com.example.admin.proguardsample.Constants {       public static java.lang.String BOOK_NAME;  }  </code></pre>    <p>更多关于Proguard keep使用,可以参考 <a href="/misc/goto?guid=4958319853659344193" rel="nofollow,noindex">官方文档</a></p>    <h3>-dontwarn</h3>    <p>dontwarn是一个和keep可以说是形影不离,尤其是处理引入的library时.</p>    <p>引入的library可能存在一些无法找到的引用和其他问题,在build时可能会发出警告,如果我们不进行处理,通常会导致build中止.因此为了保证build继续,我们需要使用dontwarn处理这些我们无法解决的library的警告.</p>    <p>比如关闭推ter sdk的警告,我们可以这样做</p>    <pre>  <code class="language-java">-dontwarn com.推ter.sdk.**  </code></pre>    <p>其他混淆相关的介绍,都可以通过访问官方文档获取.</p>    <h2>哪些不应该混淆</h2>    <h3>反射中使用的元素</h3>    <p>如果一些被混淆使用的元素(属性,方法,类,包名等)进行了混淆,可能会出现问题,如NoSuchFiledException或者NoSuchMethodException等.</p>    <p>比如下面的示例源码</p>    <pre>  <code class="language-java">//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();  }  </code></pre>    <p>如果上面的Constants类进行了混淆,那么上面的语句就可能抛出 NoSuchFieldException .</p>    <p>想要验证,我们需要看一看混淆的映射文件,文件名为 mapping.txt ,该文件保存着混淆前后的映射关系.</p>    <pre>  <code class="language-java">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  </code></pre>    <p>从映射文件中,我们可以看到</p>    <ul>     <li>Constants 类被重命名为 a .</li>     <li>Constants类的 BOOK_NAME 重命名了 a</li>    </ul>    <p>然后,我们对APK文件进行反编译一探究竟.推荐一下这个在线反编译工具 <a href="/misc/goto?guid=4959675351860296259" rel="nofollow,noindex">http://www.javadecompilers.com/apk</a></p>    <p>注意,使用jadx decompiler后,会重新命名,正如下面注释 /* renamed from: com.example.admin.proguardsample.a */ 所示.</p>    <pre>  <code class="language-java">package com.example.admin.proguardsample;    /* renamed from: com.example.admin.proguardsample.a */  public class C0314a {      public static String f1712a;        static {          f1712a = "book_name";      }  }  </code></pre>    <p>而MainActivity的翻译后的对应的源码为</p>    <pre>  <code class="language-java">try {      Log.i("MainActivity", "bookNameField=" + C0314a.class.getField("BOOK_NAME"));  } catch (NoSuchFieldException e) {      e.printStackTrace();  }  </code></pre>    <p>MainActivity中反射获取的属性名称依然是 BOOK_NAME ,而对应的类已经没有了这个属性名,所以会抛出NoSuchFieldException.</p>    <p>注意,如果上面的filedName使用字面量或者字符串常量,即使混淆也不会出现NoSuchFieldException异常。因为这两种情况下,混淆可以感知外界对filed的引用,已经在调用出替换成了混淆后的名称。</p>    <h3>GSON的序列化与反序列化</h3>    <p>GSON是一个很好的工具,使用它我们可以轻松的实现序列化和反序列化.但是当它一旦遇到混淆,就需要我们注意了.</p>    <p>一个简单的类Item,用来处理序列化和反序列化</p>    <pre>  <code class="language-java">public class Item {      public String name;      public int id;  }  </code></pre>    <p>序列化的代码</p>    <pre>  <code class="language-java">Item toSerializeItem = new Item();  toSerializeItem.id = 2;  toSerializeItem.name = "Apple";  String serializedText = gson.toJson(toSerializeItem);  Log.i(LOGTAG, "testGson serializedText=" + serializedText);  </code></pre>    <p>开启混淆之后的日志输出结果</p>    <pre>  <code class="language-java">I/MainActivity: testGson serializedText={"a":"Apple","b":2}  </code></pre>    <p>属性名已经改变了,变成了没有意思的名称,对我们后续的某些处理是很麻烦的.</p>    <p>反序列化的代码</p>    <pre>  <code class="language-java">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);  </code></pre>    <p>对应的日志结果是</p>    <pre>  <code class="language-java">I/MainActivity: testGson item.id=0;item.name=null  </code></pre>    <p>可见,混淆之后,反序列化的属性值设置都失败了.</p>    <p>为什么呢?</p>    <ul>     <li>因为反序列化创建对象本质还是利用反射,会根据json字符串的key作为属性名称,value则对应属性值.</li>    </ul>    <p>如何解决</p>    <ul>     <li>将序列化和反序列化的类排除混淆</li>     <li>使用 @SerializedName 注解字段</li>    </ul>    <p>@SerializedName(parameter)通过注解属性实现了</p>    <ul>     <li>序列化的结果中,指定该属性key为parameter的值.</li>     <li>反序列化生成的对象中,用来匹配key与parameter并赋予属性值.</li>    </ul>    <p>一个简单的用法为</p>    <pre>  <code class="language-java">public class Item {      @SerializedName("name")      public String name;      @SerializedName("id")      public int id;  </code></pre>    <h3>枚举也不要混淆</h3>    <p>枚举是Java 5 中引入的一个很便利的特性,可以很好的替代之前的常量形式.</p>    <p>枚举使用起来很简单,如下</p>    <pre>  <code class="language-java">public enum Day {      MONDAY,      TUESDAY,      WEDNESDAY,      THURSDAY,      FRIDAY,      SATURDAY,      SUNDAY  }  </code></pre>    <p>这里我们这样使用枚举</p>    <pre>  <code class="language-java">Day day = Day.valueOf("monday");  Log.i(LOGTAG, "testEnum day=" + day);  </code></pre>    <p>运行上面的的代码,通常情况下是没有问题的,是否说明枚举就可以混淆呢?</p>    <p>其实不是.</p>    <p>为什么没有问题呢,因为默认的 <a href="/misc/goto?guid=4959675351949319406" rel="nofollow,noindex">Proguard配置</a> 已经处理了枚举相关的keep操作.</p>    <pre>  <code class="language-java"># For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations  -keepclassmembers enum * {      public static **[] values();      public static ** valueOf(java.lang.String);  }  </code></pre>    <p>如果我们手动去掉这条keep配置,再次运行,一个这样的异常会从天而降.</p>    <pre>  <code class="language-java">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  </code></pre>    <p>好玩的事情来了,我们看一看为什么会抛出这个异常</p>    <p>1.首先,一个枚举类会生成一个对应的类文件,这里是Day.class. 这里类里面包含什么呢,看一下反编译的结果</p>    <pre>  <code class="language-java">➜  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 {};  }  </code></pre>    <ul>     <li>枚举实际是创建了一个继承自java.lang.Enum的类</li>     <li>java代码中的枚举类型最后转换成类中的static final属性</li>     <li>多出了两个方法,values()和valueOf().</li>     <li>values方法返回定义的枚举类型的数组集合,即从MONDAY到SUNDAY这7个类型.</li>    </ul>    <p>2.找寻崩溃轨迹 其中Day.valueOf(String)内部会调用Enum.valueOf(Class,String)方法</p>    <pre>  <code class="language-java">  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  </code></pre>    <p>而Enum的valueOf方法会间接调用Day.values()方法,具体步骤是</p>    <ul>     <li>Enum.value调用Class.enumConstantDirectory方法获取String到枚举的映射</li>     <li>Class.enumConstantDirectory方法调用Class.getEnumConstantsShared获取当前的枚举类型</li>     <li>Class.getEnumConstantsShared方法使用反射调用values来获取枚举类型的集合.</li>    </ul>    <p>混淆之后,values被重新命名,所以会发生 NoSuchMethodException .</p>    <p>关于调用轨迹,感兴趣的可以自己研究一下源码,不难.</p>    <h3>四大组件不建议混淆</h3>    <p>Android中四大组件我们都很常用,这些组件不能被混淆的原因为</p>    <ul>     <li>四大组件声明必须在manifest中注册,如果混淆后类名更改,而混淆后的类名没有在manifest注册,是不符合Android组件注册机制的.</li>     <li>外部程序可能使用组件的字符串类名,如果类名混淆,可能导致出现异常</li>    </ul>    <h3>注解不能混淆</h3>    <p>注解在Android平台中使用的越来越多,常用的有ButterKnife和Otto.很多场景下注解被用作在运行时反射确定一些元素的特征.</p>    <p>为了保证注解正常工作,我们不应该对注解进行混淆.Android工程默认的混淆配置已经包含了下面保留注解的配置</p>    <pre>  <code class="language-java">-keepattributes *Annotation*  </code></pre>    <p>关于注解,可以阅读这篇文章了解.详解Java中的注解</p>    <h2>其他不该混淆的</h2>    <ul>     <li>jni调用的java方法</li>     <li>java的native方法</li>     <li>js调用java的方法</li>     <li>第三方库不建议混淆</li>     <li>其他和反射相关的一些情况</li>    </ul>    <h2>stacktrace的恢复</h2>    <p>Proguard混淆带来了很多好处,但是也会导致我们收集到的崩溃的stacktrace变得更加难以读懂,好在有补救的措施,这里就介绍一个工具,retrace,用来将混淆后的stacktrace还原成混淆之前的信息.</p>    <h3>retrace脚本</h3>    <p>Android 开发环境默认带着retrace脚本,一般情况下路径为 ./tools/proguard/bin/retrace.sh</p>    <h3>mapping映射表</h3>    <p>Proguard进行混淆之后,会生成一个映射表,文件名为mapping.txt,我们可以使用find工具在Project下查找</p>    <pre>  <code class="language-java">find . -name mapping.txt  ./app/build/outputs/mapping/release/mapping.txt  </code></pre>    <h3>一个崩溃stacktrace信息</h3>    <p>一个原始的崩溃信息是这样的.</p>    <pre>  <code class="language-java">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  </code></pre>    <p>对上面的信息处理,去掉 E/AndroidRuntime(24006): 这些字符串retrace才能正常工作.得到的字符串是</p>    <pre>  <code class="language-java">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  </code></pre>    <p>将上面的stacktrace保存成一个文本文件,比如名称为 npe_stacktrace.txt .</p>    <p>开搞</p>    <pre>  <code class="language-java">./tools/proguard/bin/retrace.sh   /Users/admin/Downloads/ProguardSample/app/build/outputs/mapping/release/mapping.txt /tmp/npe_stacktrace.txt  </code></pre>    <p>得到的易读的stacktrace是</p>    <pre>  <code class="language-java">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  </code></pre>    <p>注意:为了更加容易和高效分析stacktrace,建议保留SourceFile和LineNumber属性</p>    <pre>  <code class="language-java">-keepattributes SourceFile,LineNumberTable  </code></pre>    <p>关于混淆,我的一些个人经验总结就是这些.希望可以对大家有所帮助.</p>    <p> </p>    <p><a href="/misc/goto?guid=4959675352036982580">阅读原文</a></p>    <p> </p>