QTA自动化测试 QT4A 重打包实现方案
前言
重打包是一种将非产品代码静态插入到安装包中,从而实现注入测试代码的能力。这种技术可以用于非 root 手机上无法利用ptrace
动态注入被测进程的场景。
除此之外,还可以修改安装包的属性,例如将release
包改为debug
包等。
重打包需要解决的问题主要有:
- 如何修改
AndroidManifest.xml
文件 - 如何将自己的代码插入到
dex
中 - 如何让自己的代码逻辑优先执行
- 如何绕过应用的签名校验逻辑
只有完美解决这几个问题,才能真正实现重打包。
如何修改AndroidManifest.xml
文件
AndroidManifest.xml
文件是安装包中一个非常重要的文件,它记录了应用实现的所有Activity
、Service
、ContentProvider
等组件,以及应用入口、应用属性、权限申明等信息。所以,要实现重打包,必然会需要修改这个文件。
事实上,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
方法,这个时机甚至是早于Application
的onCreate
调用。
// 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"/>
但是,需要注意的是,name
和authorities
都必须保证唯一性,因此,需要提供和进程总数一致的类的数量。
加载真实的 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 系统中。因此,最后还需要使用自己的签名对安装包进行重签名。不过,由于这步操作比较简单,网上教程较多,这里就不细说了。
方案总结
对应用进行重打包的主要步骤如下:
- 修改
AndroidManifest.xml
,将android:debuggable
设为true
- 为所有进程增加
provider
入口 - 合并
classes.dex
,加入ContentProvider
子类 - 将原始签名信息和测试桩文件放到
assets
目录,在ContentProvider
子类中会读取这些文件 - 重签名
经过测试,对于大部分常见应用都可以实现完美的重打包,重打包后的应用可以正常运行,并且绕过了应用的签名校验机制,安装包也成功地从 release 包变成了 debug 包,测试桩也会在进程启动时自动运行。
感兴趣的同学可以加入 QQ 群和公众号交流
如果你想要了解更多资讯,欢迎关注我们的微信公众号 我们会定时向大家推送团队同学分享的经验文章哦。