首页
社区
课程
招聘
[原创]概述APK完整性保护的逆向分析及防护策略
2021-7-16 00:44 20137

[原创]概述APK完整性保护的逆向分析及防护策略

2021-7-16 00:44
20137

目录

概述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命令。

1
adb shell pm path <包名>

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


[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法

收藏
点赞10
打赏
分享
最新回复 (6)
雪    币: 62
活跃值: (545)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
万里星河 2021-7-18 10:56
2
0
支持一下
雪    币: 26
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
柒晨 2021-7-18 14:02
3
0
支持支持
雪    币: 312
活跃值: (687)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Yuan! 2021-7-20 13:23
4
0
支持,mark一下
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
mb_rkmcfkbf 2023-4-24 17:26
5
0
mark一下
雪    币: 62
活跃值: (545)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
万里星河 2023-4-24 20:25
6
0
再次支持 十分精彩
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
mb_anbthlub 2024-4-17 18:16
7
0
为什么非要替换application?我测试过了发现直接在原有的application的基础上替换地址也是可以的。
游客
登录 | 注册 方可回帖
返回