前段时间由于项目需要给APP进行防破解工作,接触了对我来说相当陌生的安全技术,也趁此机会总结一下。

安全防护

混淆

混淆就是将变量,方法,类,包名这些全部打乱,比如以a,b,c的方式命名,这样不会影响代码正常运行。但对于人来说,要看懂代码就变得十分困难。

Android官方集成了Proguard以供我们进行代码混淆工作。通过修改proguard-rules.pro文件可以很方便进行混淆配置。

首先要在app文件夹下的build.gradle进行配置,我这里是将release编译版本进行了混淆,proguardFiles就是设置混淆文件:

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

直接上我的混淆文件供参考:

# 代码混淆压缩比,在0~7之间,默认为5,一般不下需要修改
-optimizationpasses 5

# 混淆时不使用大小写混合,混淆后的类名为小写
# windows下的同学还是加入这个选项吧(windows大小写不敏感)
-dontusemixedcaseclassnames

# 指定不去忽略非公共的库的类
# 默认跳过,有些情况下编写的代码与类库中的类在同一个包下,并且持有包中内容的引用,此时就需要加入此条声明
-dontskipnonpubliclibraryclasses

# 指定不去忽略非公共的库的类的成员
-dontskipnonpubliclibraryclassmembers

# 不做预检验,preverify是proguard的四个步骤之一
# Android不需要preverify,去掉这一步可以加快混淆速度
-dontpreverify

# 有了verbose这句话,混淆后就会生成映射文件
# 包含有类名->混淆后类名的映射关系
# 然后使用printmapping指定映射文件的名称
-verbose
-printmapping priguardMapping.txt

# 指定混淆时采用的算法,后面的参数是一个过滤器
# 这个过滤器是谷歌推荐的算法,一般不改变
-optimizations !code/simplification/artithmetic,!field/*,!class/merging/*

# 保护代码中的Annotation不被混淆
# 这在JSON实体映射时非常重要,比如fastJson
-keepattributes *Annotation*

# 避免混淆泛型
# 这在JSON实体映射时非常重要,比如fastJson
-keepattributes Signature

# 抛出异常时保留代码行号
-keepattributes SourceFile,LineNumberTable

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

# 保留了继承自Activity、Application这些类的子类
# 因为这些子类有可能被外部调用
# 比如第一行就保证了所有Activity的子类不要被混淆
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-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.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class * extends android.view.View
-keep public class com.android.vending.licensing.ILicensingService

# 如果有引用android-support-v4.jar包,可以添加下面这行
-keep public class com.null.test.ui.fragment.** {*;}

# 保留Activity中的方法参数是view的方法,
# 从而我们在layout里面编写onClick就不会影响
-keepclassmembers class * extends android.app.Activity {
    public void * (android.view.View);
}

# 枚举类不能被混淆
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# 保留自定义控件(继承自View)不能被混淆
-keep public class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
    public void set*(***);
    *** get* ();
}

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

# 保留Serializable 序列化的类不被混淆
-keepclassmembers class * implements java.io.Serializable {
   static final long serialVersionUID;
   private static final java.io.ObjectStreamField[] serialPersistentFields;
   !static !transient <fields>;
   private void writeObject(java.io.ObjectOutputStream);
   private void readObject(java.io.ObjectInputStream);
   java.lang.Object writeReplace();
   java.lang.Object readResolve();
}

# 对R文件下的所有类及其方法,都不能被混淆
-keepclassmembers class **.R$* {
    *;
}

# 对于带有回调函数onXXEvent的,不能混淆
-keepclassmembers class * {
    void *(**On*Event);
}

-keep class com.null.test.entities.** {
    #忽略get和set方法
    public void set*(***);
    public *** get*();
    public *** is*();
}

-keep class com.null.test.MainActivity$* {
    *;
}

默认情况下有两种东西不能被混淆:

  • 要在AndroidManifest.xml中注册的(四大组件)
  • 资源文件

AndroidManifest.xml中注册的类混淆后,由于名称改变,无法被manifest找到,就会出现问题。不过饿了么提供了一个开源项目可以用来混淆四大组件,叫Mess。简单来说,Mess弥补了Proguard不能检索XML文件的缺点,帮Proguard完成了ActivityView的改名及mapping

同样,资源文件也能暴露出很多信息,就像刚刚的去广告过程一样。所以对资源文件的混淆也是很重要的。微信团队提供了一个好用的资源混淆工具AndResGuard,它不仅能帮你全面混淆资源文件,还能帮你缩减资源文件的整体体积。

加固/加壳

加固就是通过给目标APK加一层保护程序,把需要保护的内容加密、隐藏起来,来防止反编译class.dex等文件的一种方法。

市面上已经有很多相当成熟的一键加固,像腾讯云应用安全爱加密360加固保。所以我也就比较少接触具体的技术细节了。但加固会影响程序的兼容性和效率,而且攻防对抗的技术一直在进步,没有绝对安全的方法。被破解只是时间问题,是无法阻止的。具体的利益权衡请自己斟酌。

NDK

Android NDK 是一套允许您使用原生代码语言(例如 C 和 C++)实现部分应用的工具集。在开发某些类型应用时,这有助于您重复使用以这些语言编写的代码库。

Java层的代码很容易被反编译,而C/C++就不一样了,反汇编难度很大。所以可以将重要的代码使用C/C++编写,而且由于C/C++的跨平台性和高性能,也可以将多平台复用代码和高性能计算用它们来编写。然后让NDK来调用。

可以用CMake来配置NDK开发环境,首先要在Android Studio中安装CMake插件。不会安装插件的话就百度吧,这里就略过了。

安装完CMake后,新建项目,选择Include C++ support。这时候项目就已经配置好NDK环境了。

可以发现在app模块中新增了cpp文件夹和CMakeLists.txt文件,cpp里面就是C/C++代码了,而CMakeLists.txt则定义了一些构建行为。app模块下的build.gradle也增加了点配置:

android {
    externalNativeBuild {
        cmake {
            cppFlags ""
        }
    }
}

externalNativeBuild {
    cmake {
        path "CMakeLists.txt"
    }
}

所以,如果想在原有的项目增加NDK环境的话,只需要把CMakeLists.txt复制过去,然后改一下相关的配置。再在build.gradle增加配置信息。

然后,要新建一个Java类,引入为我们编译生成的动态链接库,对C/C++的引用都会通过这个类实现。新建一个静态块,里面写上System.loadLibrary(动态链接库名称)

public class NativeHelper
{
    static
    {
        System.loadLibrary("native-lib");
    }
}

然后新建一个方法来调用C/C++

public class NativeHelper
{
    static
    {
        System.loadLibrary("native-lib");
    }
    
    public static native String helloWorld();
}

可以发现,多了native关键字,也没有代码块,而是直接以分号结尾。

接下来点击方法名,按下alt+Enter,选择Create function xxx,Android Studio就会自动帮我们在native-lib.cpp(就是你引入的动态链接库里)中创建一个方法,方法名的格式是Java_包名_类名_方法名,当我们调用Java中的helloWorld()时,实际上调用的是C/C++里的这个方法,就在这里编写C/C++代码:

extern "C"
JNIEXPORT jstring JNICALL
Java_包名_NativeHelper_helloWorld(JNIEnv *env, jclass type)
{
    // TODO

    return env->NewStringUTF(returnValue);
}

有关于NDK的内容我会单独写一篇文章详细介绍,这里就不再深入了。

Native层签名校验

mark

代码技巧

该部分参考Android 高级混淆和代码保护技术 - SegmentFault 思否

有了混淆,NDK还不够。还需要一些技巧和意识来保护代码。

很多时候对于已混淆的代码,字符串搜索是一个入手点。在代码里的字符串被反编译后会原样恢复,所以我们要避免这种情况。

注:在现代编译器中可能会被自动优化掉

一. 不要硬编码写入字符串值,即使你不得不这么做,也至少应该另起一个类,比如叫做HardStrings,用于静态存放这些硬编码的字符串。这样反编译者只能搜索到你这个常量类,而较难以搜索到这些字符串常量被哪里引用

二.release混淆过程中删除Log代码,使用-assumenosideeffects这个配置项可以帮我们在编译成 APK 之前把日志代码全部删掉,这么做不仅有助于提升性能,而且日志代码往往会保留很多我们的意图和许多可被反编译的字符串:

-assumenosideeffects class android.util.Log 
{

    public static boolean isLoggable(java.lang.String, int);

    public static int d(...);

    public static int w(...);

    public static int v(...);

    public static int i(...);

}

三. 对于你不得不留下的一些硬编码和日志内容,可以采用编码形式替换,如 你可以规定4001代表某种错误,而不是在你的代码里写入这个错误的具体描述字符串。这么做的话,你需要有个地方记下这些编码映射的内容,关于此有个技巧:你可以再创建一个常量类,其内容是一堆静态字符串对象,针对上面那个例子,你可以把真正的错误信息作为一个字符串变量的名字,而把它的值写成一个编码,如下:

public static final String SHOULD_REGISTER_FIRST_ERROR = "ssrrffe";

这样当你在看没混淆的代码引用这个静态变量,你能够一目了然它的意思。而反编译者看到的则是:

public static final String abc = "ssrrffe";

命名看不懂,值也看不懂。

四.AppKey之类特别敏感的字符串内容藏在native.so文件中。

关于字符串技巧的内容差不多就这样了,能做到这些就不错了,还有一些极端做法不多说,为了阻碍破解者阅读,自己也变得非常麻烦,双刃剑,这不是我们想要的结果。

不过一般情况我们无需所有内容都保护,只要把关键、核心内容保护好就可以了。

最后的最后,我们还需要做的就是防止反编译者重新打包,能做的就是在代码中加入签名验证,并做双向依赖。在native检查签名和加解密内容,这里暂且就不多说了。

总之,代码安全和混淆是一个意识加技巧的问题,但都不难,掌握以上内容就已经十分好了。