概述APK完整性保护的逆向分析及防护策略
一. 概要
APK的完整性保护在Android逆向安全中是一个常见的话题,通常指为了防二次打包,防篡改,防独立调用so等,通过验证apk包名、签名,及apk包本身(如META-INF下签名文件,MD5等)来达到防范的目的。笔者曾分析过一些大型的APK,发现即使使用了包名、签名、apk完整性验证,仍然可以通过一定手段来绕过验证。即便多年前尼古拉斯四哥的apk签名爆破,到现在也非常好用,因此本文的案例中也集成了四哥的签名爆破。
本文主要对apk完整性进行逆向及分析相应的安全策略。
二. 准备
本文以Android8.0源码为例,涉及代理application的机制,会分析到Android中一个application的创建,以及如何通过代理Application hook掉目标application中有关apk包的属性(LoadedApk)
1 2 3 4 5 6 7 | 相关源码路径
frameworks\base\core\java\android\app\ActivityThread.java
frameworks\base\core\java\android\app\LoadedApk.java
frameworks\base\core\java\android\app\ContextImpl.java
frameworks\base\services\core\java\com\android\server\am\ActivityManagerService.java
frameworks\base\cmds\pm\src\com\android\commands\pm\Pm.java
frameworks\base\services\core\java\com\android\server\pm\PackageManagerService.java
|
三. 案例
本着以实用主义的原则,本文决定不以分析源码为开篇,而是以一个实际的案例(如tb无线保镖,wb等具有完整性保护的绕过原理相同),拆分其so库到自己的Android项目,绕过其包名签名检测,APK完整性校验以成功调用so。
3.1 集成项目

首先将目标so拷贝出来,新应用的包名需与原来的一致,并配置好so所需环境,在application中调用四哥的PMS签名爆破。对于一般的项目来说,绝大多数的so是能调用成功的。
这里简单提供一下目标签名的获取:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | frida:
function call_signature(){
Java.perform(function(){
var context = Java.use( "android.app.ActivityThread" ).currentApplication().getApplicationContext();
var packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 64 );
/ / .signatures[ 0 ]
var Signatures = packageInfo.signatures.value;
console.log( "Signatures: " ,Signatures.length,Signatures[ 0 ].toCharsString())
})
}
Xposed:
大多数情况下xposed hook提示 class not find的原因是classloader不对
XposedHelpers.findAndHookMethod( "com.kwai.hotfix.loader.app.TinkerApplication" ,loadPackageParam.classLoader , "attachBaseContext" , Context. class , new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
Context context = (Context) param.args[ 0 ];
String signature = context.getPackageManager().getPackageInfo(context.getPackageName(),PackageManager.GET_SIGNATURES).signatures[ 0 ].toCharsString();
LogUtil.d(TAG, "PackageName" + context.getPackageName() + " signature: " + signature);
super .beforeHookedMethod(param);
}
}
|
但是这里我们调用返回的却是null。这说明了一个问题,我们的环境相对于so原本的环境,必然有某些地方不一致,so仍能校验出来。且不同于某些0335之类的图片签名,也不同于某些在assets目录中读取文件的so,我们的demo并未发生崩溃,也没有其他异常信息。我们已经加好了签名爆破,那么就有可能是apk的完整性问题了。
3.2 分析问题
上文猜测有可能是apk的完整性保护问题。那么我们应该如何绕过呢?
我们知道一个release版本的apk是需要签名才能打包的,并且会在META-INF文件下生成三个签名文件,
MANIFEST.MF、CERT.SF、CERT.RSA。其中CERT.RSA文件以签名的方式“包含”了前两个文件,并且也会将公钥证书一同写进去。(名称解释:RSA算法为非对称加密,通常来说,以公钥加密、私钥解密称为加解密过程;以私钥加密、公钥解密称为签名、验证的过程)
而里面包含的公钥证书,我们可以通过openssl命令提取
1 | openssl pkcs7 - inform DER - in CERT.RSA - print_certs | openssl x509 - outform DER - out CERT.cer
|
提取出来的CERT.cer证书文件,在010中打开,可以发现其hex值正是在Android中获取的签名值(toCharsString())。
当然,我们并不确定so中是否真的去校验这个签名值,更简单的方式可能直接校验CERT.RSA
的MD5,而不用去提取证书。但是有一点我们可以确定,so中一定会通过某种方式获取到原始apk文件。
3.3 getPackageCodePath
一个apk在安装成功后,会在/data/app/包名 下存有原始apk的信息,且这个路径是动态变化的。一般获取apk的完整路径,都会使用context.getPackageCodePath()这个方法来获取。因此,我们的目标就变成了如何hook掉getPackageCodePath,并转移其指向到原本的apk。
当然,比较直接的方式就是通过frida或者xposed进行hook,但是使用这两者都需要额外的成本,尤其是如果搭建模拟器服务时,会更加复杂。不过getPackageCodePath方法既然是在Java层,我们便可以利用Java厉害的“武器”反射,来手动hook掉他。
四. 源码分析
Android中context的实现类是ContextImpl,因此context通过ContextWrapper调用到ContextImpl的getPackageCodePath()方法
1 2 3 4 5 6 7 8 | / / ContextImpl.java
@Override
public String getPackageCodePath() {
if (mPackageInfo ! = null) {
return mPackageInfo.getAppDir();
}
throw new RuntimeException( "Not supported in system context" );
}
|
这里的mPackageInfo的类型是LoadedApk,getAppDir()返回的是其字段mAppDir。也就是说,如果我们能在运行时,动态替换掉LoadedApk,并把mAppDir字段设置为虚假的apk路径,那么是否就能够绕过apk的完整性验证?
这里我们可以参考插件化的思想,应用启动时先加载代理application,在创建代理application的过程中,替换为目标application,并修改其值。因此我们需要了解一个application是如何创建的。
4.1 Application的创建过程
首先,这张图很好的概括了,当一个应用启动后,Launcher进程首先会通过binder IPC的方式将请求转发到远端服务AMS中(system_server进程),接着又会通过socket,在Zygote进程中fork出我们的应用进程,也就是所谓所有的应用程序都是由zygote孵化而来,最终会通过反射调用到ActivityThread.main()方法。
(图来源)
因此,我们从ActivityThread.main()方法开始,作为分析application创建的入口点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | / / android.app.ActivityThread
public static void main(String[] args) {
......
thread.attach(false);
}
private void attach(boolean system) {
......
if (!system) {
final IActivityManager mgr = ActivityManager.getService();
try {
mgr.attachApplication(mAppThread);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
}
|
在attach方法中,getService()最终会通过binder驱动在server_manager中获取已注册好的远端服务AMS的对象,并调用其(AMS)attachApplication方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | / / ActivityManagerService.java
@Override
public final void attachApplication(IApplicationThread thread) {
synchronized (this) {
int callingPid = Binder.getCallingPid();
final long origId = Binder.clearCallingIdentity();
attachApplicationLocked(thread, callingPid);
Binder.restoreCallingIdentity(origId);
}
}
private final boolean attachApplicationLocked(IApplicationThread thread,
int pid) {
......
if (app.instr ! = null) {
thread.bindApplication(processName, appInfo, providers,
app.instr.mClass,
profilerInfo, app.instr.mArguments,
app.instr.mWatcher,
app.instr.mUiAutomationConnection, testMode,
mBinderTransactionTrackingEnabled, enableTrackAllocation,
isRestrictedBackupMode || !normalMode, app.persistent,
new Configuration(getGlobalConfiguration()), app.compat,
getCommonServicesLocked(app.isolated),
mCoreSettingsObserver.getCoreSettingsLocked(),
buildSerial);
}
}
|
调用到bindApplication方法中

这个方法会创建一个AppBindData对象,并通过handler的方式发送出去,这个消息将会在主线程的handleMessage里面接收,此时swich的分支是BIND_APPLICATION,接着就走到handleBindApplication并把新创建的AppBindData对象传进去

在handleBindApplication中做了最关键的事情就是makeApplication方法,这个方法是在data.info也就是LoadedApk中
1 2 3 4 5 6 7 8 9 10 11 | private void handleBindApplication(AppBindData data) {
......
data.info = getPackageInfoNoCheck(data.appInfo, data.compatInfo);
......
Application app;
try {
app = data.info.makeApplication(data.restrictedBackupMode, null);
mInitialApplication = app;
}
......
}
|

makeApplication这个方法非常关键,他代表了一个application是如何创建的。
1部分首先判断当前进程的application是否为空,也就是判断当前进程的application是否已经被加载过,如果加载过,则直接返回。然后会获取此application的全限定类名,获取classloader,通过2处,创建出application,最后把创建出来的application添加进集合,并返回创建出的application,也就是3处。
来看一下2处的newApplication中做了什么?


发现最终会调用到attachBaseContext方法,也就是通常在应用层面,继承Application复写的那个方法,所以attachBaseContext被调用的时机,其实还是很早的,此时2、3处的代码还没有执行到。因此我们可以在代理application中复写attachBaseContext,在此方法内通过反射,重新调用一次makeApplication,并把新的application替换掉原有的application,并完成相应属性的替换工作。
4.2 hook application
这里我们会通过大量的反射获取成员对象、调用方法,因此下边的表格表示了我们需要反射获取的全部属性\方法,如有忘记可以上下查看。
重要字段/方法 |
所属类 |
描述 |
currentActivityThread() |
android.app.ActivityThread |
该方法返回一个全局的ActivityThread对象 |
mBoundApplication |
android.app.ActivityThread |
类型为ActivityThread的静态内部类AppBindData,包含着存有apk类的信息,LoadedApk,ApplicationInfo等 |
info |
ActivityThread$AppBindData |
类型为LoadedApk,表示一个apk在当前内存的信息 |
mApplication |
android.app.LoadedApk |
类型为Application,表示当前进程的Application,在makeApplication()过程中,最先判断此值是否为空 |
mInitialApplication |
android.app.ActivityThread |
Application,makeApplication()的返回结果会赋给此值 |
mAllApplications |
android.app.ActivityThread |
List<Application>,makeApplication()过程中,将新创建的application保存到集合中 |
mApplicationInfo |
android.app.LoadedApk |
ApplicationInfo,封装了一个应用的基本信息 |
appInfo |
ActivityThread$AppBindData |
ApplicationInfo,同上 |
上节提到,我们需要做的就是重新调用makeApplication创建出一个新的application,来看核心代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | public class ProxyApplication extends Application {
/ / 新的application
private App app;
/ / 当前application的attachBaseContext被调用时机很早
@Override
protected void attachBaseContext(Context base) {
super .attachBaseContext(base);
/ / 创建虚假apk路径,将真实的apk复制进去
boolean b = initFakeFiles(base);
if (b){
/ / 传入类的全限定名
app = (App) makeApplication(App. class .getName());}
}
public Application makeApplication(String appClassName){
try {
/ / 反射获取ActivityThread全局对象
Object currentActivityThread = getCurrentActivityThread();
/ / hook LoadedApk
Object loadedApkInfo = getLoadedApk(appClassName, currentActivityThread);
/ / 通过反射调用makeApplication,重新加载 application
return makeApplication(loadedApkInfo);
} ......
}
public Object getLoadedApk(String appClassName, Object currentActivityThread){
/ / AppBindData
Object mBoundApplication = getFieldValue(currentActivityThread, "mBoundApplication" );
/ / LoadedApk
Object loadedApkInfo = getFieldValue(mBoundApplication, "info" );
/ / 把当前进程的mApplication设置成null,否则调用makeApplication会直接返回mApplication
setFieldValue(loadedApkInfo, "mApplication" ,null);
/ / hook mAppDir 创建虚假路径
String fakePath = new File (getFilesDir(), Utils.fakeAPK).getAbsolutePath();
setFieldValue(loadedApkInfo, "mAppDir" ,fakePath);
/ / handleBindApplication的时候,调用LoadedApk中的makeApplication返回赋值给mInitialApplication
Object oldApplicaiton = getFieldValue(currentActivityThread, "mInitialApplication" );
ArrayList<Application> mAllApplications = getFieldValue(currentActivityThread, "mAllApplications" );
/ / 删除oldApplicaiton
mAllApplications.remove(oldApplicaiton);
ApplicationInfo lodaedApk = getFieldValue(loadedApkInfo, "mApplicationInfo" );
ApplicationInfo appBindData = getFieldValue(mBoundApplication, "appInfo" );
/ / 用于makeApplication中读取到的appClass名字
lodaedApk.className = appClassName;
appBindData.className = appClassName;
lodaedApk.sourceDir = fakePath;
appBindData.sourceDir = fakePath;
return loadedApkInfo;
}
}
|
4.3 成功调用
完成hook工作后,我们可以验证一下,发现已经能调用成功,apk的完整性校验已经绕过!

虽然绕过检测的方式不是很复杂,简而言之其实就一句话,hook掉so中获取的apk路径,但本案例仍然是相对安全的典范,这种方式大约是笔者在2年前发现的,不过当时笔者在其他完整性保护的项目中,是hook libc中fopen或open来转移指向,通过inline hook的方式集成在单独应用中,但在此案例中并未hook成功,如果排除笔者的操作失误,那么大概率此案例对libc中的系统库有保护操作,或者以系统调用来完成读取文件路径。
其次,本案例的完整性检查是针对apk全文件的,不同于其他案例只保留apk中meta-inf文件夹下的签名文件就可以调用成功,因此需要将大概70M的apk拷贝至指定的位置,才能调用成功。
五. 防护策略
通过上文分析,无论是通过open/fopen,还是getPackageCodePath,都是需要获得apk的路径,而我们绕过检测的方式也正是基于此。那么有没有其他的方式可以动态获取apk的路径,并且不会被上述的hook绕过呢?答案是有的,就是使用pm命令。
adb shell的命令基本上都会通过binder调用到远端服务,如pm相关的命令最终会调用到PMS系统服务。我们简单看一下使用pm path之后做了哪些操作
1 2 3 4 5 6 7 8 9 10 11 12 | / / Pm.java
public final class Pm {
public static void main(String[] args) {
......
exitCode = new Pm().run(args);
}
public int run(String[] args){
if ( "path" .equals(op)) {
return runPath();
}
}
}
|

首先获取userId,可以看到通过pm命令获取到的userId是系统用户的Id,0。
1 2 3 4 5 6 7 8 9 10 | private int displayPackageFilePath(String pckg, int userId) {
try {
PackageInfo info = mPm.getPackageInfo(pckg, 0 , userId);
if (info ! = null && info.applicationInfo ! = null) {
System.out. print ( "package:" );
System.out.println(info.applicationInfo.sourceDir);
......
}
} ......
}
|
其次会调用到pms的getPackageInfo方法,在这个方法里面,最终会通过generatePackageInfo方法重新生成apk相关信息,因此我们在application中替换的值也就没有用了。
这里以Java为例,执行pm命令,可以看到pm打印的apk原始路径是没有被hook掉的。

因此,针对apk的完整性保护,可以采取双重验证,一方面通过context读取路径,另一方面通过pms服务获取路径。
六. 总结
本文概述了apk完整性检测的绕过方式,并增加了一种新的检测方式,不过攻防总是相对的,即便是本文提出的保护方案,可能也会有其他的绕过方式,有兴趣的读者可以继续研究。
最后总结一下独立调用so的好处,最简单的就是搭建Android服务,且不依赖于第三方hook工具,效率很高。其次调用成功可以完全了解so所处环境,作为unicorn模拟调用的环境补全。最后,独立调用可以很好的控制so加载的时机,避免了在原始环境下分析so的困境。
本文案例已上传https://github.com/SliverBullet5563/ApkIntegrityCheck(如侵删!),谢谢!
参考:
https://github.com/fourbrother/HookPmsSignature
http://gityuan.com/2017/04/02/android-application/
https://blog.csdn.net/AndrExpert/article/details/103535201
http://gityuan.com/2016/03/26/app-process-create/
https://www.jianshu.com/p/3ef82ac4bf4f
https://bbs.pediy.com/thread-250990.htm
[2023春季班]《安卓高级研修班(网课)》月薪两万班招生中~