Android 应用安全加固与防破解措施分析
1、背景
在移动应用开发过程中,安全性往往是容易被忽视的环节。最近新开发了一款工具类型的软件,然而某天下午忽然群里来了一个不速之客说我的软件被破解了。虽然该软件无需付费并且没有广告,也进行了基础的安全加固,但是还是很轻易得被别人破解了。
现象是,启动页换成了别人的页面,需要用户点击页面上的按钮分享几次破解者的信息才能进入应用。并且,每次打开应用都是如此。这暴露了单纯依赖第三方免费加固服务的局限性。为了深入理解攻击原理并制定更有效的防御方案,我对该破解包进行了逆向分析。
2、深度分析与技术验证
2.1 检查破解应用签名
决定要进行分析之后,我首先想到的就是去检查下应用的签名。这里使用 keytool 即可,指令如下:
keytool -printcert -jarfile your_app.apk
获取的结果显示,应用的签名已经发生了变化,应用被别人二次打包了。实际上,我的应用是在某平台上面进行了软件加固(免费版),但是还是如此轻松地被别人完成了二次打包。这说明免费的加固方案往往只能提供基础的混淆和壳保护,无法抵御有经验的逆向人员。
2.2 签名校验机制实现
如果仅仅是签名发生了变化,那么解决方式倒也简单。在应用内部增加一个签名校验就可以了。不过签名校验也有需要注意的地方。
其一,签名校验可以放在 Java 层来完成,也可以放在 native 层通过 C++ 来完成。其二,在应用内部进行整个数字签名的校验还是部分校验。这是因为如果写入完整的数字签名很容易被别人发现,即便写入到 so 中,写入完整字符串比部分更容易被别人找到。
Java 层实现示例
在 Java 中,你可以按照下面这种方式获取应用的数字签名。我已经把相关的方法写成了工具类:
public static String getAppSignatureMD5(final String packageName) {
return getAppSignatureHash(packageName, "MD5");
}
private static String getAppSignatureHash(final String packageName, final String algorithm) {
if (StringUtils.isSpace(packageName)) return "";
Signature[] signature = getAppSignature(packageName);
if (signature == null || signature.length <= 0) return "";
return StringUtils.bytes2HexString(EncryptUtils.hashTemplate(signature[0].toByteArray(), algorithm))
.replaceAll("(?<=[0-9A-F]{2})[0-9A-F]{2}", ":$0");
}
Native 层实现示例
如果是使用 C++ 进行签名校验可以使用下面的方法,利用 JNI 调用 Android API:
jbyteArray getSignatureByteArray(JNIEnv *env, jobject context, jstring algorithm) {
jclass context_clazz = env->GetObjectClass(context);
jmethodID methodID_getPackageManager = env->GetMethodID(context_clazz,
"getPackageManager", "()Landroid/content/pm/PackageManager;");
jobject packageManager = env->CallObjectMethod(context, methodID_getPackageManager);
jclass packageManager_clazz = env->GetObjectClass(packageManager);
jmethodID methodID_getPackageInfo = env->GetMethodID(packageManager_clazz,
"getPackageInfo", "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;");
jmethodID methodID_getPackageName = env->GetMethodID(context_clazz,
"getPackageName", "()Ljava/lang/String;");
jobject application_package_obj = env->CallObjectMethod(context, methodID_getPackageName);
jstring application_package = static_cast<jstring>(application_package_obj);
const char* package_name = env->GetStringUTFChars(application_package, JNI_FALSE);
__android_log_print(ANDROID_LOG_DEBUG, "SecurityCheck", "Package Name : %s", package_name);
jobject packageInfo = env->CallObjectMethod(packageManager, methodID_getPackageInfo, application_package_obj, 64);
jclass packageinfo_clazz = env->GetObjectClass(packageInfo);
jfieldID fieldID_signatures = env->GetFieldID(packageinfo_clazz, "signatures", "[Landroid/content/pm/Signature;");
jobjectArray signature_arr = (jobjectArray)env->GetObjectField(packageInfo, fieldID_signatures);
jobject signature = env->(signature_arr, );
jclass signature_clazz = env->(signature);
jmethodID signature_toByteArray = env->(signature_clazz,, );
jbyteArray sig_bytes = (jbyteArray) env->(signature, signature_toByteArray);
jclass message_digest_clazz = env->();
jmethodID message_digest_getInstance = env->(message_digest_clazz,
,);
* algorithm_bytes = env->(algorithm, JNI_FALSE);
jstring algorithm_name = env->(algorithm_bytes);
jobject message_object = env->(message_digest_clazz, message_digest_getInstance, algorithm_name);
jthrowable exception = env->();
env->();
(exception) ;
jmethodID message_digest_update = env->(message_digest_clazz,,);
env->(message_object, message_digest_update, sig_bytes);
jmethodID digest = env->(message_digest_clazz, , );
jbyteArray sha1_bytes = (jbyteArray) env->(message_object, digest);
sha1_bytes;
}
这里使用的是 CMake 进行编译,使用 Android Studio 进行开发。上面的逻辑很简单,就是把之前的 java 层获取签名的方法照搬到了 native 方法里。借助于 Android Studio 进行开发,可以帮助我们减轻很多工作量,比如方法的映射关系就不需要你一个一个得进行对比,可以通过提示直接完成转换。
因为除了进行这些签名校验我还加了其他的安全措施,所以这部分代码不便开源。不过,我已经把一些基础功能的代码和环境配置打包成了压缩文件,你可以在相关的开源社区获取完整的代码。
拿到了签名之后当然是进行签名校验了,基本的字符串比较即可。当然,你可以进行部分匹配,这样增加了破解的难度。
2.3 签名校验就安全了吗?
增加了上述的签名校验只不过是第一步,也是比较常规的安全操作。但是这样操作未必就能防止其他用户破解。进一步进行反编译,我又发现一些新的东西。
相比于没有破解的包,在 lib 下面多了两个文件,这里的 libarm.so 的内容尚不清楚,这里的 libhook.apk 是一个 apk 文件,并且签名跟我的原始的包的签名一致,但是 apk 内容并不是我的完整包。所以,我猜测这里的 Apk 文件有其他用途,并且仅仅使用签名校验并不安全。因为别人可以读取你的应用的签名,然后通过某种方式进行伪造,让获取签名的方法返回的结果不是安装包真实的签名而是你的真实包的签名。这里的 apk 很有可能就是用来获取签名的。
于是,我进一步对应用进行资源反编译,这里使用 apktool 来完成:
apktool d hacked_app.apk
于是我发现启动应用是被篡改掉了的,这里的启动应用已经被修改为 arm.SignerPro,并且这里的启动类已经被修改为 SplashActivity。
然后,我们将 Apk 解压之后对 dex 文件进行反编译。这里使用的是 d2j-dex2jar 来完成。命令如下:
d2j-dex2jar classes.dex
然后,我们根据反编译的结果,查看启动 Application。这里的 Application 继承了 InvocationHandler。InvocationHandler 相比大家都不陌生,Retrofit 就是通过在 InvocationHandler 实现的代理来解析请求参数,并根据注解和入参来完成 OkHttp 请求的。我们找到该接口的实现方法 invoke()。从上图中很明显得可以看出,我们的 getPackageInfo() 方法有很大可能被别人 hook 了。这个方法就是上面说的用来获取应用签名的方法,这里会对 getPackageInfo() 方法进行处理,并返回 signs 作为签名的获取结果而不是应用真实的签名结果。
当然,上面都是猜着,因为他们也只破解了第一个版本的包,第二个版本的包我加了新的安全措施,并且悄悄地增加了几个隐藏的页面用来在应用内获取签名和其他的信息来验证我的想法。
不管怎么说,仅仅加上签名校验很可能是不安全的!
2.4 其他安全措施与对抗策略
其实即便他们 hook 了获取签名的 getPackageInfo() 方法我们仍然有许多措施来应对。他们这里的 hook 有几个地方可以入手。
首先,我们并没看到 signs 的赋值操作,我猜测是通过 native 方法来完成的赋值,他们如果每次都尝试返回 signs 的话并没有屡次对 signs 进行重新赋值。而从 PackageManager 中多次读取到的 Signature 对象应该是不同的。所以,如果我们多次读取签名的时候返回的同一对象,是不是可以认为该方法被别人 hook 了呢?
另外,这里 hook 了 getPackageInfo() 方法。只是我们无法判断他们 hook 的范围,我们可以尝试自定义一个 getPackageInfo() 方法,然后自定义返回的对象类型,如果返回的不是我们指定的类型,就可以得出结论,我们的 getPackageInfo() 方法被别人 hook 了。比如:
public class Fake {
public void getPackageInfo() { }
public FakePackageInfo getPackageInfo(@NonNull String packageName, int flags) throws PackageManager.NameNotFoundException {
return new FakePackageInfo();
}
public FakePackageInfo getPackageInfo(@NonNull VersionedPackage versionedPackage, int flags) throws PackageManager.NameNotFoundException {
return new FakePackageInfo();
}
public static class FakePackageInfo { }
}
当然,如果他们只 hook 了 PackageManager 的方法,这个就不适用了。
除了签名校验,我们还可以检查其他项。比如检查 Application 是否是我们的启动 Application,可以使用如下代码获取应用的 Application 并进行比较:
public static String getApplicationName(final String pkgName) {
if (StringUtils.isSpace(pkgName)) return null;
try {
PackageManager pm = UtilsApp.getApp().getPackageManager();
ApplicationInfo ai = pm.getApplicationInfo(pkgName, 0);
return ai == null ? null : ai.className;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return null;
}
}
此外,我们还可以对应用的启动 Activity 进行校验。因为他们对我们的应用的启动 Activity 进行了修改,于是我们启动 Activity 进行了修改,所以这也可以作为一个应对措施:
public static String getAppLauncher(final String pkgName) {
if (StringUtils.isSpace(pkgName)) return null;
try {
PackageManager pm = UtilsApp.getApp().getPackageManager();
PackageInfo pi = pm.getPackageInfo(pkgName, 0);
if (pi == null) return null;
Intent resolveIntent = new Intent(Intent.ACTION_MAIN, null);
resolveIntent.addCategory(Intent.CATEGORY_LAUNCHER);
resolveIntent.setPackage(pi.packageName);
List<ResolveInfo> resolveInfoList = pm.queryIntentActivities(resolveIntent, 0);
ResolveInfo resolveInfo = resolveInfoList.iterator().next();
if (resolveInfo != null) {
return resolveInfo.activityInfo.name;
}
return null;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return null;
}
}
2.5 综合防护体系构建
仅靠单一维度的校验难以构建坚固的安全防线,建议从以下几个维度构建综合防护体系:
2.5.1 代码混淆与加固
使用 ProGuard 或 R8 进行代码混淆,将类名、方法名、变量名替换为无意义的字符,增加反编译后的阅读难度。同时,结合商业加固服务时,应选择支持 VMP(虚拟机保护)的方案,将关键逻辑转换为字节码指令,避免直接暴露在本地环境中。
2.5.2 环境检测
检测运行环境是否为模拟器、Root 设备或调试器。常见的检测方法包括:
- 检查
/system/bin/su 是否存在。
- 检查
Build.TAGS 是否包含 test-keys。
- 检查是否有调试端口监听。
- 检查进程列表中是否存在 Frida、Xposed 等 Hook 框架特征。
一旦检测到异常环境,应限制核心功能的使用或直接退出应用。
2.5.3 完整性校验
定期对 APK 内的关键文件进行哈希校验。例如,计算 classes.dex 或关键 .so 文件的 MD5 值,并在运行时比对。如果发现文件被篡改,说明应用已被二次打包或注入,应立即终止运行。
2.5.4 敏感数据加密
对于存储在本地数据库或 SharedPreferences 中的敏感数据,务必进行加密存储。不要明文保存 Token、密钥等信息。推荐使用 Android Keystore 系统生成和管理密钥,确保密钥不出当前设备环境。
2.5.5 反调试技术
在关键业务逻辑执行前,加入反调试检测。例如,通过 ptrace 检测自身是否被附加调试器,或者通过检查寄存器状态判断是否处于断点。对于 Native 层代码,可以进行加壳处理,使得调试器难以附加。
3、总结
本文通过分析实际破解案例,探讨了 Android 应用面临的安全挑战及应对策略。从基础的签名校验到复杂的 Hook 对抗,再到综合性的防护体系建设,每一步都至关重要。
随着信息技术的快速发展,移动应用安全已成为软件开发中不可或缺的一环。单纯的免费加固往往无法满足企业级安全需求,开发者需要结合代码混淆、环境检测、完整性校验等多种手段,构建纵深防御体系。只有不断了解攻击者的手法,才能有效地提升应用的安全性,保护用户数据和商业利益。
在实际开发中,建议定期更新安全策略,关注最新的漏洞情报,并及时修复已知风险。安全是一个持续的过程,而非一次性的任务。