QTA自动化测试 QT4A 重打包实现方案

匿名 · 2019年03月04日 · 1026 次阅读

前言

重打包是一种将非产品代码静态插入到安装包中,从而实现注入测试代码的能力。这种技术可以用于非 root 手机上无法利用ptrace动态注入被测进程的场景。

除此之外,还可以修改安装包的属性,例如将release包改为debug包等。

重打包需要解决的问题主要有:

  • 如何修改AndroidManifest.xml文件
  • 如何将自己的代码插入到dex
  • 如何让自己的代码逻辑优先执行
  • 如何绕过应用的签名校验逻辑

只有完美解决这几个问题,才能真正实现重打包。

如何修改AndroidManifest.xml文件

AndroidManifest.xml文件是安装包中一个非常重要的文件,它记录了应用实现的所有ActivityServiceContentProvider等组件,以及应用入口、应用属性、权限申明等信息。所以,要实现重打包,必然会需要修改这个文件。

事实上,AndroidManifest.xml并不是 xml 格式,而是Android binary XML(AXML)格式,这是一种二进制格式,可以使用androguard等工具进行解析,具体格式内容可以参考该文

不过,QT4A 是自己实现了一套解析和生成的逻辑,只要了解清楚每个字段的含义,实现起来并不是很复杂。

如何将release包变成debug

发布版本的安装包,一定是release包,这是为了避免安全风险。而将安装包转变为debug包,不仅可以对安装包进行调试,还可以获取到很多之前没法获取到的数据。

决定一个安装包是否是 debug 包,是根据AndroidManifest.xml文件中的application标签的android:debuggable属性值来判断的。

因此,只要将这个字段修改为true即可。

如何绕过应用的签名校验逻辑

为了避免应用被二次打包,现在很多应用都有签名校验逻辑,发现不是自己的签名,就直接退出。

网上也有这方面的对抗,例如https://bbs.pediy.com/thread-206742.htm这篇文章就是通过逆向,来破解掉应用的签名验证逻辑。

绕过原理分析

为了实现更简单的绕过逻辑,先来了解下应用是如何进行签名验证的,以下是一段最简单的 Java 层实现。

public static boolean verifySignature(Context context, int expectHash) {
    PackageManager pm = context.getPackageManager();
    PackageInfo pi;
    StringBuilder sb = new StringBuilder();

    try {
        pi = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
        Signature[] signatures = pi.signatures;
        for (Signature signature : signatures) {
            sb.append(signature.toCharsString());
        }
    } catch (PackageManager.NameNotFoundException e) {
        e.printStackTrace();
        return false;
    }
    return sb.toString().hashCode() == expectHash;
}

主要思路就是使用getPackageInfo接口获取应用的签名,然后和期望值进行对比。为了增加逆向的难度,很多应用会将这部分实现放到 native 层,但原理还是通过反射来调用这个函数。

那么,一个通用的绕过签名校验逻辑的方法,就是 Hook getPackageInfo函数,发现应用要获取签名的时候,把原始签名内容丢给应用即可。

常见的 Hook 方法一般都是在 native 层实现的,但是这种方法的兼容性不是很好。事实上,该函数还可以使用动态代理的方法来实现 Hook。

动态代理是一种在运行过程中动态生成代理类的方法,它可以使用很少量的代码,实现对被调用方法的拦截和处理。

但是,它有个缺点:只能针对接口创建代理。因此,只在部分场景中可以使用该方法。

来分析下为什么这里可以使用动态代理?

先来看Context.getPackageManager函数的实现:

@Override
public PackageManager getPackageManager() {
    if (mPackageManager != null) {
        return mPackageManager;
    }

    IPackageManager pm = ActivityThread.getPackageManager();
    if (pm != null) {
        // Doesn't matter if we make more than one instance.
        return (mPackageManager = new ApplicationPackageManager(this, pm));
    }

    return null;
}

这里实际上是调用了ActivityThread.getPackageManager()函数。

public static IPackageManager getPackageManager() {
    if (sPackageManager != null) {
        //Slog.v("PackageManager", "returning cur default = " + sPackageManager);
        return sPackageManager;
    }
    IBinder b = ServiceManager.getService("package");
    //Slog.v("PackageManager", "default service binder = " + b);
    sPackageManager = IPackageManager.Stub.asInterface(b);
    //Slog.v("PackageManager", "default service = " + sPackageManager);
    return sPackageManager;
}

由于该函数会在应用的Application类构造之前就被调用,因此,sPackageManager字段正常情况下都不为空。注意到该函数的返回值是IPackageManager类型,这正是一个可以使用动态代理的场景。

使用方法

实现 InvocationHandler 接口,在invoke中判断是否是目标调用,并修改返回值

public class PmsHookBinderInvocationHandler implements InvocationHandler{
    private static String TAG = "PmsHookBinderInvocationHandler";

    private Object base;

    //应用正确的签名信息
    private String SIGN;
    private String appPkgName = "";

    public PmsHookBinderInvocationHandler(Object base, String sign, String appPkgName){
        this.base = base;
        this.SIGN = sign;
        this.appPkgName = appPkgName;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
        Log.i(TAG, "call " + method.getName());
        try{
            if("getPackageInfo".equals(method.getName())){
                String pkgName = (String)args[0];
                Integer flag = (Integer)args[1];
                if(flag == PackageManager.GET_SIGNATURES && appPkgName.equals(pkgName){
                    Log.i(TAG, "GET_SIGNATURES: " + SIGN);
                    Signature sign = new Signature(SIGN);
                    PackageInfo info = (PackageInfo) method.invoke(base, args);
                    info.signatures[0] = sign;
                    return info;
                }
            }
            return method.invoke(base, args);
        }catch(Exception e){
            e.printStackTrace();
            return null;
        }
    }
}

创建Proxy对象

Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = 
        activityThreadClass.getDeclaredMethod("currentActivityThread");
Object currentActivityThread = currentActivityThreadMethod.invoke(null);
// 获取ActivityThread里面原始的sPackageManager
Field sPackageManagerField = activityThreadClass.getDeclaredField("sPackageManager");
sPackageManagerField.setAccessible(true);
Object sPackageManager = sPackageManagerField.get(currentActivityThread);
Class<?> iPackageManagerInterface = Class.forName("android.content.pm.IPackageManager");
Object proxy = Proxy.newProxyInstance(
    iPackageManagerInterface.getClassLoader(),
    new Class<?>[] { iPackageManagerInterface },
    new PmsHookBinderInvocationHandler(sPackageManager, origSign, getContext().getPackageName()));

origSign为原始签名字符串

替换sPackageManager字段的值

sPackageManagerField.set(currentActivityThread, proxy);

为了避免在 Hook 前调用过getPackageManager,导致实例化过ApplicationPackageManager类,需要修改ApplicationPackageManager对象中保存的IPackageManager实例

ApplicationPackageManager(ContextImpl context,
                            IPackageManager pm) {
    mContext = context;
    mPM = pm;
}

根据以上代码可以看出,这是保存在mPM字段中的。

PackageManager pm = getContext().getPackageManager();
Field mPmField = pm.getClass().getDeclaredField("mPM");
mPmField.setAccessible(true);
mPmField.set(pm, proxy);

至此,Hook 逻辑已经实现,但问题是,如何在应用进行签名校验之前加载这段代码呢?

实现静态插桩逻辑

常见的静态插桩方案

目前,常见的静态插桩方案,基本上都是通过将dex文件反编译成Smali代码或class字节码,然后插入自己的逻辑,再重新编译成 dex 文件。这种方法成本相对来说较高,如果产品加入了反编译逻辑,可能会导致反编译失败,或者是插桩后的应用无法正常运行,不太适合自动化操作。

另外有一种方法是使用了应用加固的思想,通过替换应用的classes.dex文件,实现在运行时将原始的classes.dex解压出来并加载。这种方法需要实现一个Application子类,重写attachBaseContext函数,在该函数里实现解压和加载的逻辑,并将解压出来的 dex 加入到ClassLoader中,以保证系统可以正常获取应用中的类;同时,还要实例化应用原先定义的Application类,并替换所有持有Application类实例的地方。

这种方法有个问题,在应用首次运行的时候,需要进行 dex 解压和优化的操作,如果 dex 很大,该步操作会很耗时,导致启动黑屏,影响用户体验。而且,该方法在测试过程中,发现容易导致各种奇奇怪怪的异常,排查起来很花时间。

此时,还想到另外一种方法,先将我们的类插入到dex中,然后通过某种机制将其运行起来就可以了。

插入类到dex

在 dex 中添加类,不一定非要将 dex 进行反编译之类的操作,是否可以通过合并两个 dex 来实现呢?

经过 Google 后发现,Android 源码中已经提供了合并 dex 的功能。

public static void main(String[] args) throws IOException {
    if (args.length < 2) {
        printUsage();
        return;
    }

    Dex merged = new Dex(new File(args[1]));
    for (int i = 2; i < args.length; i++) {
        Dex toMerge = new Dex(new File(args[i]));
        merged = new DexMerger(merged, toMerge, CollisionPolicy.KEEP_FIRST).merge();
    }
    merged.writeTo(new File(args[0]));
}

private static void printUsage() {
    System.out.println("Usage: DexMerger <out.dex> <a.dex> <b.dex> ...");
    System.out.println();
    System.out.println(
        "If a class is defined in several dex, the class found in the first dex will be used.");
}

这部分代码已经集成在了 Android SDK 的dx.jar文件中,我没有找到命令行执行入口,但是可以通过将META-INF/MANIFEST.MF文件中的Main-Class: com.android.dx.command.Main替换为Main-Class: com.android.dx.merge.DexMerger,就可以使用命令行java -jar dx.jar <out.dex> <a.dex> <b.dex> ...来合并 dex。

尝试将手 Q 中的两个 dex 进行合并,却发现报错了:

Exception in thread "main" com.android.dex.DexIndexOverflowException: field ID not in [0, 0xffff]: 65536
    at com.android.dx.merge.DexMerger$5.updateIndex(DexMerger.java:479)
    at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:283)
    at com.android.dx.merge.DexMerger.mergeFieldIds(DexMerger.java:468)
    at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167)
    at com.android.dx.merge.DexMerger.merge(DexMerger.java:189)
    at com.android.dx.merge.DexMerger.main(DexMerger.java:1122)

这是因为 dex 中的字段数不能超过65536的限制,方法数也会受该限制的影响。正因为如此,很多大型应用都需要进行 dex 分包,将初始化时需要用到的类放到classes.dex中,其它类放到次 dex 中,并在运行的时候动态加载进来。

绕过方法数限制

一般来说,分包逻辑并不会正好占用到字段数和方法数的上限,而是留有一定的空间。因此,只要合并的 dex 非常小,是不会超过上限的。

实现一个最简单的 ContentProvider 类后,编译为 dex,并进行合并,竟然还是会报错。

Exception in thread "main" com.android.dex.DexIndexOverflowException: Cannot merge new index 92177 into a non-jumbo instruction!
    at com.android.dx.merge.InstructionTransformer.jumboCheck(InstructionTransformer.java:109)
    at com.android.dx.merge.InstructionTransformer.access$800(InstructionTransformer.java:26)
    at com.android.dx.merge.InstructionTransformer$StringVisitor.visit(InstructionTransformer.java:72)
    at com.android.dx.io.CodeReader.callVisit(CodeReader.java:114)
    at com.android.dx.io.CodeReader.visitAll(CodeReader.java:89)
    at com.android.dx.merge.InstructionTransformer.transform(InstructionTransformer.java:49)
    at com.android.dx.merge.DexMerger.transformCode(DexMerger.java:842)
    at com.android.dx.merge.DexMerger.transformMethods(DexMerger.java:813)
    at com.android.dx.merge.DexMerger.transformClassData(DexMerger.java:785)

    at com.android.dx.merge.DexMerger.transformClassDef(DexMerger.java:682)
    at com.android.dx.merge.DexMerger.mergeClassDefs(DexMerger.java:542)
    at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:171)
    at com.android.dx.merge.DexMerger.merge(DexMerger.java:189)
    at com.android.dx.merge.DexMerger.main(DexMerger.java:1122)

网上的解决方法一般如下:

使用 Gradle 构建的,在模块的 build.gradle 里配置:

android {  
  dexOptions {  
      jumboMode true  
  }  
}  

如果是使用 Eclipse+Ant 构建的,在 project.properties 文件中增加如下配置:

dex.force.jumbo=true

使用dx命令生成 dex 时,也可以通过加入--force-jumbo参数来开启 jumbo 模式。

再次执行合并就可以成功了。

反编译生成的 dex,发现我们的类的确出现在了 dex 里面。

如何尽早执行插入的代码

通过 dex 合并方案插入的类,此时并没有任何调用时机。也就是说,它们现在就是段死代码,完全不会被执行。那么,如何可以让它们执行,并且是在非常早的时机运行呢(需要早于应用的签名校验逻辑)?

利用ContentProvider执行代码

在调试过程中,我偶然发现如果应用定义了ContentProvider组件,ActivityThread类会在handleBindApplication中自动安装这些组件,并调用onCreate方法,这个时机甚至是早于ApplicationonCreate调用。

// don't bring up providers in restricted mode; they may depend on the
// app's custom Application class
if (!data.restrictedBackupMode) {
    List<ProviderInfo> providers = data.providers;
    if (providers != null) {
        installContentProviders(app, providers);
        // For process that contains content providers, we want to
        // ensure that the JIT is enabled "at some point".
        mH.sendEmptyMessageDelayed(H.ENABLE_JIT, 10*1000);
    }
}

由此可见,这倒是一个绝佳的插入时机。下面是调用到ReadInJoyDataProvider类的onCreate函数时的调用堆栈。

01-12 09:59:28.236: D/DexloaderApplication(14615):  at cooperation.readinjoy.content.ReadInJoyDataProvider.onCreate(ReadInJoyDataProvider.java:106)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.content.ContentProvider.attachInfo(ContentProvider.java:1686)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.content.ContentProvider.attachInfo(ContentProvider.java:1655)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.app.ActivityThread.installProvider(ActivityThread.java:4964)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.app.ActivityThread.installContentProviders(ActivityThread.java:4559)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4499)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.app.ActivityThread.access$1500(ActivityThread.java:144)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1339)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.os.Handler.dispatchMessage(Handler.java:102)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.os.Looper.loop(Looper.java:135)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at android.app.ActivityThread.main(ActivityThread.java:5221)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at java.lang.reflect.Method.invoke(Native Method)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at java.lang.reflect.Method.invoke(Method.java:372)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:899)
01-12 09:59:28.236: D/DexloaderApplication(14615):  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:694)

因此,只要在AndroidManifest.xml文件中的application节点下插入一个provider节点,在android:name中指定好类名,就可以在应用初始化时加载我们的代码。

<provider android:authorities="test" android:name="com.test.androidspy.inject.DexLoaderContentProvider" />
多进程支持

现在定义的 ContentProvider 只会在主进程里加载,要支持其它进程,需要每个进程创建一个对应的provider

<provider android:authorities="test1" android:name="com.test.androidspy.inject.DexLoaderContentProvider$InnerClass1" android:process=":MSF"/>

但是,需要注意的是,nameauthorities都必须保证唯一性,因此,需要提供和进程总数一致的类的数量。

加载真实的 dex

按照之前的介绍,实现的 ContentProvider 类中只能实现少量的功能。如果要执行更多逻辑,需要放在单独的 dex 中,然后动态加载进来。例如,加载 QT4A 的应用测试桩,可以使用如下方法:

/*
    * 加载QT4A测试桩
    */
private void loadQT4ADriver(String dexPath){
    int pid = android.os.Process.myPid();
    String processName = "";
    ActivityManager manager = (ActivityManager) getContext().getSystemService(Context.ACTIVITY_SERVICE);
    for (ActivityManager.RunningAppProcessInfo process: manager.getRunningAppProcesses()) {
        if(process.pid == pid){
            processName = process.processName;
        }
    }
    DexClassLoader cl = new DexClassLoader(dexPath, getContext().getCacheDir().getAbsolutePath(), null, ClassLoader.getSystemClassLoader());   
    try{
        Class<?> entryClass = Class.forName("com.test.androidspy.ActivityInspect", true, cl);
        Method run = entryClass.getDeclaredMethod("run", String.class);
        run.invoke(entryClass, processName);
    }catch(Exception e){
        e.printStackTrace();
    }
}

这种方法可以解决像三星等手机中遇到的无法使用run-as命令切换到 debug 应用的 uid,从而无法注入的问题。

重签名

对安装包进行任何修改后,都需要进行重签名才能正常安装到 Android 系统中。因此,最后还需要使用自己的签名对安装包进行重签名。不过,由于这步操作比较简单,网上教程较多,这里就不细说了。

方案总结

对应用进行重打包的主要步骤如下:

  1. 修改AndroidManifest.xml,将android:debuggable设为true
  2. 为所有进程增加provider入口
  3. 合并classes.dex,加入ContentProvider子类
  4. 将原始签名信息和测试桩文件放到assets目录,在ContentProvider子类中会读取这些文件
  5. 重签名

经过测试,对于大部分常见应用都可以实现完美的重打包,重打包后的应用可以正常运行,并且绕过了应用的签名校验机制,安装包也成功地从 release 包变成了 debug 包,测试桩也会在进程启动时自动运行。

感兴趣的同学可以加入 QQ 群和公众号交流


如果你想要了解更多资讯,欢迎关注我们的微信公众号😀 我们会定时向大家推送团队同学分享的经验文章哦。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册