0x00 前言 重打包是一种将非产品代码静态插入到安装包中,从而实现注入测试代码的能力。这种技术可以用于非root手机上无法利用ptrace动态注入被测进程的场景。
除此之外,还可以修改安装包的属性,例如将release包改为debug包等。
重打包需要解决的问题主要有:
如何修改AndroidManifest.xml文件
如何将自己的代码插入到dex中
如何让自己的代码逻辑优先执行
如何绕过应用的签名校验逻辑
只有完美解决这几个问题,才能真正实现重打包。
0x01 如何修改AndroidManifest.xml文件 AndroidManifest.xml文件是安装包中一个非常重要的文件,它记录了应用实现的所有Activity、Service、ContentProvider等组件,以及应用入口、应用属性、权限申明等信息。所以,要实现重打包,必然会需要修改这个文件。
事实上,AndroidManifest.xml并不是xml格式,而是Android binary XML(AXML)格式,这是一种二进制格式,可以使用androguard等工具进行解析,具体格式内容可以参考该文 。
不过,QT4A是自己实现了一套解析和生成的逻辑,只要了解清楚每个字段的含义,实现起来并不是很复杂。
0x02 如何将release包变成debug包 发布版本的安装包,一定是release包,这是为了避免安全风险。而将安装包转变为debug包,不仅可以对安装包进行调试,还可以获取到很多之前没法获取到的数据。
决定一个安装包是否是debug包,是根据AndroidManifest.xml文件中的application标签的android:debuggable属性值来判断的。
因此,只要将这个字段修改为true即可。
0x03 如何绕过应用的签名校验逻辑 为了避免应用被二次打包,现在很多应用都有签名校验逻辑,发现不是自己的签名,就直接退出。
网上也有这方面的对抗,例如https://bbs.pediy.com/thread-206742.htm 这篇文章就是通过逆向,来破解掉应用的签名验证逻辑。
绕过原理分析 为了实现更简单的绕过逻辑,先来了解下应用是如何进行签名验证的,以下是一段最简单的Java层实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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函数的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override public PackageManager getPackageManager () { if (mPackageManager != null ) { return mPackageManager; } IPackageManager pm = ActivityThread.getPackageManager(); if (pm != null ) { return (mPackageManager = new ApplicationPackageManager (this , pm)); } return null ; }
这里实际上是调用了ActivityThread.getPackageManager()函数。
1 2 3 4 5 6 7 8 9 10 11 public static IPackageManager getPackageManager () { if (sPackageManager != null ) { return sPackageManager; } IBinder b = ServiceManager.getService("package" ); sPackageManager = IPackageManager.Stub.asInterface(b); return sPackageManager; }
由于该函数会在应用的Application类构造之前就被调用,因此,sPackageManager字段正常情况下都不为空。注意到该函数的返回值是IPackageManager类型,这正是一个可以使用动态代理的场景。
使用方法 实现InvocationHandler接口,在invoke中判断是否是目标调用,并修改返回值 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 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对象 1 2 3 4 5 6 7 8 9 10 11 12 13 Class<?> activityThreadClass = Class.forName("android.app.ActivityThread" ); Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread" ); Object currentActivityThread = currentActivityThreadMethod.invoke(null );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字段的值 1 sPackageManagerField.set(currentActivityThread, proxy);
为了避免在Hook前调用过getPackageManager,导致实例化过ApplicationPackageManager类,需要修改ApplicationPackageManager对象中保存的IPackageManager实例 1 2 3 4 5 ApplicationPackageManager(ContextImpl context, IPackageManager pm) { mContext = context; mPM = pm; }
根据以上代码可以看出,这是保存在mPM字段中的。
1 2 3 4 PackageManager pm = getContext().getPackageManager();Field mPmField = pm.getClass().getDeclaredField("mPM" );mPmField.setAccessible(true ); mPmField.set(pm, proxy);
至此,Hook逻辑已经实现,但问题是,如何在应用进行签名校验之前加载这段代码呢?
0x04 实现静态插桩逻辑 常见的静态插桩方案 目前,常见的静态插桩方案,基本上都是通过将dex文件反编译成Smali代码或class字节码,然后插入自己的逻辑,再重新编译成dex文件。这种方法成本相对来说较高,如果产品加入了反编译逻辑,可能会导致反编译失败,或者是插桩后的应用无法正常运行,不太适合自动化操作。
另外有一种方法是使用了应用加固的思想,通过替换应用的classes.dex文件,实现在运行时将原始的classes.dex解压出来并加载。这种方法需要实现一个Application子类,重写attachBaseContext函数,在该函数里实现解压和加载的逻辑,并将解压出来的dex加入到ClassLoader中,以保证系统可以正常获取应用中的类;同时,还要实例化应用原先定义的Application类,并替换所有持有Application类实例的地方。
这种方法有个问题,在应用首次运行的时候,需要进行dex解压和优化的操作,如果dex很大,该步操作会很耗时,导致启动黑屏,影响用户体验。而且,该方法在测试过程中,发现容易导致各种奇奇怪怪的异常,排查起来很花时间。
此时,还想到另外一种方法,先将我们的类插入到dex中,然后通过某种机制将其运行起来就可以了。
插入类到dex 在dex中添加类,不一定非要将dex进行反编译之类的操作,是否可以通过合并两个dex来实现呢?
经过Google后发现,Android源码 中已经提供了合并dex的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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进行合并,却发现报错了:
1 2 3 4 5 6 7 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,并进行合并,竟然还是会报错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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里配置:
1 2 3 4 5 android { dexOptions { jumboMode true } }
如果是使用Eclipse+Ant构建的,在project.properties文件中增加如下配置:
使用dx命令生成dex时,也可以通过加入--force-jumbo参数来开启jumbo模式。
再次执行合并就可以成功了。
反编译生成的dex,发现我们的类的确出现在了dex里面。
0x05 如何尽早执行插入的代码 通过dex合并方案插入的类,此时并没有任何调用时机。也就是说,它们现在就是段死代码,完全不会被执行。那么,如何可以让它们执行,并且是在非常早的时机运行呢(需要早于应用的签名校验逻辑)?
利用ContentProvider执行代码 在调试过程中,我偶然发现如果应用定义了ContentProvider组件,ActivityThread类会在handleBindApplication中自动安装这些组件,并调用onCreate方法,这个时机甚至是早于Application的onCreate调用。
1 2 3 4 5 6 7 8 9 10 11 if (!data.restrictedBackupMode) { List<ProviderInfo> providers = data.providers; if (providers != null ) { installContentProviders(app, providers); mH.sendEmptyMessageDelayed(H.ENABLE_JIT, 10 *1000 ); } }
由此可见,这倒是一个绝佳的插入时机。下面是调用到ReadInJoyDataProvider类的onCreate函数时的调用堆栈。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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中指定好类名,就可以在应用初始化时加载我们的代码。
1 <provider android:authorities ="test" android:name ="com.test.androidspy.inject.DexLoaderContentProvider" />
多进程支持 现在定义的ContentProvider只会在主进程里加载,要支持其它进程,需要每个进程创建一个对应的provider。
1 <provider android:authorities ="test1" android:name ="com.test.androidspy.inject.DexLoaderContentProvider$InnerClass1" android:process =":MSF" />
但是,需要注意的是,name和authorities都必须保证唯一性,因此,需要提供和进程总数一致的类的数量。
0x06 加载真正的dex 按照之前的介绍,实现的ContentProvider类中只能实现少量的功能。如果要执行更多逻辑,需要放在单独的dex中,然后动态加载进来。例如,加载QT4A的应用测试桩,可以使用如下方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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,从而无法注入的问题。
0x07 重签名 对安装包进行任何修改后,都需要进行重签名才能正常安装到Android系统中。因此,最后还需要使用自己的签名对安装包进行重签名。不过,由于这步操作比较简单,网上教程较多,这里就不细说了。
0x08 方案总结 对应用进行重打包的主要步骤如下:
修改AndroidManifest.xml,将android:debuggable设为true
为所有进程增加provider入口
合并classes.dex,加入ContentProvider子类
将原始签名信息和测试桩文件放到assets目录,在ContentProvider子类中会读取这些文件
重签名
经过测试,对于大部分常见应用都可以实现完美的重打包,重打包后的应用可以正常运行,并且绕过了应用的签名校验机制,安装包也成功地从release包变成了debug包,测试桩也会在进程启动时自动运行。
具体代码可以参考:https://github.com/Tencent/QT4A/blob/master/qt4a/apktool/repack.py