分享流程:
暂时无法在飞书文档外展示此内容
一. 目的与意义
1. 遇到的问题
应用市场规定,在获取设备ID等敏感信息前,需用户同意隐私许可协议,否则视为非合规调用,存在非合规调用将导致APP无法过审并且上架。以下是常见的隐私协议API:
//常见的隐私Api
android.telephony.TelephonyManager.getNetworkOperator() # 获取运营商信息
android.telephony.TelephonyManager.getDeviceId() # 获取设备信息
android.telephony.TelephonyManager.getPhoneType() # 获取手机信息
android.telephony.TelephonyManager.getSubscriberId() # 获取设备信息
android.telephony.TelephonyManager.getLine1Number() # 获取手机号
android.telephony.TelephonyManager.getCellLocation() # 获取位置信息
android.telephony.TelephonyManager.listen() # 电话监听
android.telephony.TelephonyManager.getSimOperator() # 获取SIM卡信息
android.app.ActivityManager.getRunningAppProcesses() # 获取运行APP
android.app.ActivityManager.getRunningTasks() # 获取正运行的task
android.content.pm.PackageManager.getInstalledPackages() # 获取安装APP
-
需解决的问题
- 找到APP内所有可能非合规调用的地方,在出现可以进行定位并提示开发人员;
- 对代码中发生非合规调用处进行拦截,不执行方法。
二. 技术选型
1. 如何实现?
暴力的解法,就是对每个涉及隐私协议的方法进行封装,在调用前进行判断。有如下问题:
- 开发人员在调用隐私协议时,需要提前知晓,存在沟通成本,使用不方便,无法做到无痕代码插入;
- 另外对于引入的依赖(例如Tinker),由于是aar文件和jar文件格式的文件,我们无法直接进行修改。
由于我们无法在打包前对源码,能否对APK包,或者运行时的代码动态进行修改呢?
暂时无法在飞书文档外展示此内容
- 我们可以将APK的包进行反编译,然后全局进行检索,找到非合规调用处;=> 代码静态检查
- 但是APK的包生成我们就无法修改代码了,所以我们可以在打包过程中,使用字节码插桩技术,对隐私Api进行查找,并且对代码进行修改。 => 字节码插桩
- 另外我们也可以在运行过程中对方法进行动态修改 => 方法动态重写(dynamic callee-side rewriting)
- 字节码插桩和运行时的方法动态重写。利用的都是 AOP 思想,那么什么是AOP?
- 面向方面编程:aspect oriented programmingaop为AOP缩写,意为:面向方法编程,即通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的方式。把很多类对象中的横切问题点,从业务逻辑中分离出来,减少代码的冗余和降低模块间的耦合度,提高开发效率。即纵向关系 OOP,横向角度 AOP。
比如:设计一个日志打印模块。按 OOP 思想,我们会设计一个打印日志 LogUtils 类,然后在需要打印的地方引用即可。
public class ClassA {
private void initView() {
LogUtils.d(TAG, "onInitView");
}
}
public class ClassB {
private void onDataComplete(Bean bean) {
LogUtils.d(TAG, bean.attribute);
}
}
public class ClassC {
private void onError() {
LogUtils.e(TAG, "onError");
}
}
问题是:
- 代码冗余较多,使用不方便。
- 耦合较多,需要对现有的对象动态增加某种行为或责任时非常困难。
public class ClassA {
@Log(msg = "onInitView")
private void initView() {
}
}
public class ClassB {
@Log(msg = "bean.attribute")
private void onDataComplete(Bean bean) {
}
}
public class ClassC {
@Log(msg = "onError")
private void onError() {
}
}
AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。利用 AOP 思想,这样对业务逻辑的各个部分进行了隔离,从而降低业务逻辑各部分之间的耦合,提高程序的可重用性,提高开发效率**。
2. 对比选型
2.1. 合规静态检查
解压 apk 取出所有 dex,将 dex 反汇编成java文件或者smail 文件(Dalvik编译器的语法规范),根据规则扫描 smail 文件中的方法是否有调用隐私相关的 API。
-
常见的三方合规检测工具
优势:
- APK 包是最终产物,扫描内容比较完整;
- 不在编译期进行扫描,不会降低开发效率。
劣势:
- 不能精确定位到隐私函数调用处理,难以归因;
- 比较难以获取完整的函数调用链;
- 只可以进行检测,无法进行实时对代码进行拦截。
2.2. 运行时插桩AOP
在ART中,每一个Java方法在虚拟机内部都由一个ArtMethod对象表示(native层,实际上是一个C++对象),这个native 的 ArtMethod对象包含了此Java方法的所有信息,比如名字,参数类型,方法本身代码的入口地址(entrypoint)等:1. 想办法拿到这个Java方法所代表的ArtMethod对象
- 取出其entrypoint,然后跳转到此处开始执行
- Dexposed(饿了么开源)
基于在Dalvik(Android5.0之前的主Android编译器)编译器上
- epic(个人作者weishu开源)
- 基于在ART(Android5.0之后的主Android编译器)编译器上 | 优点:- 可以精确定位到隐私函数调用处,便于归因;
- 可以打印堆栈信息,以获取完整的函数调用链;
- 可以实时对代码进行拦截。* * *缺点:- 不稳定,偶发NDK Crash,无法定位和修改;
- 无法对注册前的方法进行Hook,三方工具的非合规调用可能早于onCreate();
- 对编译环境影响性较强。
2.3. 字节码插桩 (APK打包时)
字节码插桩就是在.class文件转为.dex之前,修改.class文件从而达到修改代码的目的。class 文件就是字节码文件。一个App的所有代码都在一个Dex文件里面。Dex是一个类似Jar的存储了多有Java编译字节码的归档文件。
-
Gradle的transform方法
- 骑士APP对Gilde库的方法进行替换
-
Lancet (饿了么开源)
- 使用注解的方式对transform进行封装
- 使用者可以通过配置注解进行代码Hook
优点:
- 解决了以上的缺点。
- Lancet 框架较稳定,未出现crash,由于直接修改的源码,不存在适配问题;
- 可以对任何时候的非合规调进行Hook。
缺点:
- 由于是编译器插入代码,可能导致打包变慢
- 可能导致异常上报的行数不对 |
最佳方案:使用第三方开源库lancet,在打包过程中进行方法的修改,将指定的方法替换成自己的方法。
3. Epic库
3.1 方案一:修改ArtMethod中的entrypoint
当我们想要调用一个方法时,先拿到ArtMethod,再去取entrypoint然后跳转实现调用。所以我们可以通过直接替换entrypoint。进而直接跳转到目标方法的code段;从而达到Hook的目的。
class ArtMethod {
…………
protect:
HeapReference declaring_class_;
HeapReference> dex_cache_resolved_methods_;
HeapReference> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;
struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_;
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_; //entrypoint
#if defined(ART_USE_PORTABLE_COMPILER)
void* entry_point_from_portable_compiled_code_;
#endif
} ptr_sized_fields_;
static GcRoot java_lang_reflect_ArtMethod_;
}
但是很多情况下,第一步是不必要的;对于一些系统API,系统知道你要调用的这个方法的entrypoint是什么,直接写死在汇编代码里。这样方法调用的时候就不会有取ArtMethod这个动作,从而不会去拿被替换的entrypoint,导致Hook失效。
框架代表:AndFix(阿里巴巴)基于此原理来做热修复的,Sophix 在对此做了一些优化。
3.2 方案二:修改entrypoint指向的节点
ArtMethod中的entrypoint指向的地址不变,去修改所在地址的代码。
但是Hook函数和原函数的代码长度基本上是不一样的,而且为了实现AOP,Hook函数通常比原函数长很多。如果直接把Hook函数的代码段copy到原函数entrypoint所指向的代码段,很可能没地儿放。因此,通常的做法是写一段trampoline。也就是把原函数entrypoint所指向代码的开始几个字节修改为一小段固定的代码,这段代码的唯一作用就是跳转到新的位置开始执行,如果这个「新的位置」就是Hook函数,那么基本上就实现了Hook;这种跳板代码我们一般称之为trampoline/stub,比如Android源码中的 art_quick_invoke_stub/art_quick_resolution_trampoline等。
框架代表:xposed/dexposed(阿里巴巴) 基于此方式的Hook框架,可在Dalvik上运行。epic也是使用这种方式实现的,它可运行在ART编译器上。
3.3 拓展:ART中遇到的问题
- bridge函数分发以及堆栈平衡
- 入口重合的问题
- 指针与对象转换
- Android N无法dlsym
- Android N 解释执行
- Android N JIT编译
4. Lancet库
4.1 APK打包过程
- 通过aapt打包res资源文件,生成R.java、resources.arsc和res文件(二进制&非二进制如res/raw和pic保持原样)。
- 处理.aidl文件, 生成对应的Java接口文件;
- 通过Java Compiler编译R.java、Java接口文件、Java源文件,生成.class文件;
- 通过dex工具,将.class文件和第三方库中的.class文件处理生成classes.dex;
- 通过apkbuilder工具,将aapt生成的resources.arsc和res文件、assets文件、动态库
.so
文件、AndroidManifest.xml
清单文件和classes.dex一起打包生成apk; - 通过jarsigner对上面的apk进行debug或者release签名;
- 通过zipalign工具, 将签名后的apk进行对齐处理。
4.2 字节码插桩以及Transform API
字节码插桩就是在.class文件转为.dex之前,修改.class文件从而达到修改代码的目的。
Transform API
public abstract class Transform {
public abstract String getName(); //执行这个Transform的task时,会以这个名字为基础生成task名称
public abstract Set<ContentType> getInputTypes(); //表示要处理的数据类型是什么,CLASSES 表示要处理编译后的字节码(可能是 jar 包也可能是目录),RESOURCES 表示要处理的是标准的 java 资源
public abstract Set<? super Scope> getScopes(); //表示Transform 的作用域,这里设置的SCOPE_FULL_PROJECT代表作用域是全工程
public abstract boolean isIncremental(); //表示是否支持增量编译,false不支持
fun transform(transformInvocation:TransformInvocation) //就是遍历每一个class文件,然后使用ASM进行字节码操作。
}
public abstract class MethodVisitor {
public void visitCode();
public void visitInsn(final int opcode);
public void visitIntInsn(final int opcode, final int operand);
public void visitVarInsn(final int opcode, final int var);
public void visitTypeInsn(final int opcode, final String type);
public void visitFieldInsn(final int opcode, final String owner, final String name, final String descriptor);
public void visitMethodInsn(final int opcode, final String owner, final String name, final String descriptor,
final boolean isInterface); //访问方法的指令。
public void visitInvokeDynamicInsn(final String name, final String descriptor, final Handle bootstrapMethodHandle,
final Object... bootstrapMethodArguments);
public void visitJumpInsn(final int opcode, final Label label);
public void visitLabel(final Label label);
public void visitLdcInsn(final Object value);
public void visitIincInsn(final int var, final int increment);
public void visitTableSwitchInsn(final int min, final int max, final Label dflt, final Label... labels);
public void visitLookupSwitchInsn(final Label dflt, final int[] keys, final Label[] labels);
public void visitMultiANewArrayInsn(final String descriptor, final int numDimensions);
public void visitTryCatchBlock(final Label start, final Label end, final Label handler, final String type);
public void visitMaxs(final int maxStack, final int maxLocals);
public void visitEnd();
// ......
}
继承MethodVisitor时,可以对类使用ASM API进行修改,一句简单的日志打印:
Log.d(“tag”, " onCreate");
转换成ASM API将会是下面这样:
mv.visitLdcInsn(“tag”); //加载常量"tag"入栈
mv.visitLdcInsn(“onCreate”); //加载常量"onCreate"入栈
//执行Log的静态方法d
mv.visitMethodInsn(INVOKESTATIC, “android/util/Log”, “d”, “(Ljava/lang/String;Ljava/lang/String;)I”, false);
//方法调用出栈
mv.visitInsn(POP);
如果稍懂JVM汇编指令的话,可以看出大致意思。
4.3 拓展: Lancet实现方式
- 通过transform进行字节码插桩
class LancetTransform extends Transform {
@Override
//getName方法:执行这个Transform的task时,会以这个名字为基础生成task名称( 比如这里的任务是Task :app:transformClassesWithPageTransformForDebug)
public String getName() {
return "lancet";
}
@Override
//getInputTypes方法:表示要处理的数据类型是什么,CLASSES 表示要处理编译后的字节码(可能是 jar 包也可能是目录),RESOURCES 表示要处理的是标准的 java 资源
public Set < QualifiedContent . ContentType > getInputTypes () {
return TransformManager.CONTENT_CLASS;
}
@Override
//getScopes方法:表示Transform 的作用域,这里设置的SCOPE_FULL_PROJECT代表作用域是全工程
public Set <? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
//isIncremental方法:表示是否支持增量编译,false不支持
public boolean isIncremental() {
return true ;
}
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
initLog();
//收集所有 jar 信息和预分析结果的一个数据集。
TransformContext context = new TransformContext(transformInvocation, global);
//是否是增量编译
boolean incremental = lancetExtension.getIncremental();
//PreClassAnalysis主要记录所有类的依赖图,记录hook类到判断下一次增量编译是否可用。
PreClassAnalysis preClassAnalysis = new PreClassAnalysis(cache, project);
//开始预分析,此方法将阻塞,直到预分析完成。
incremental = preClassAnalysis.execute(incremental, context);
//注解解析器
MetaParser parser = createParser(context);
if (incremental && !context.getGraph().checkFlow()) {
incremental = false;
context.clear();
}
context.getGraph().flow().clear();
//一个数据集存储所有用于转换操作的数据。
TransformInfo transformInfo = parser.parse(context.getHookClasses(), context.getGraph());
//使用指定规则转换输入类。输入一个类可能会返回两个类,因为 weaver 可能会创建多个内部类。此方法将在多线程和多进程中调用
Weaver weaver = AsmWeaver.newInstance(transformInfo, context.getGraph());
//TransformProcessor 是主要处理进行transfrom的函数
new ContextReader(context, project, false).accept(incremental, new TransformProcessor(context, weaver));
cache.saveToLocal();
}
}
- 在内部类中添加相关类和方法,并且替换原来方法。
public class ProxyMethodVisitor extends MethodVisitor {
private final Map<String, MethodChain.Invoker> invokerMap;
private final Map<String, List<ProxyInfo>> matchMap;
private final String className;
private final String name;
private final ClassCollector classCollector;
private final MethodChain chain;
public ProxyMethodVisitor(MethodChain chain, MethodVisitor mv, Map<String, MethodChain.Invoker> invokerMap, Map<String, List<ProxyInfo>> matchMap, String className, String name, ClassCollector classCollector) {
super(Opcodes.ASM5, mv);
this.chain = chain;
this.invokerMap = invokerMap;
this.matchMap = matchMap;
this.className = className;
this.name = name;
this.classCollector = classCollector;
}
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
String key = owner + " " + name + " " + desc;
List<ProxyInfo> infos = matchMap.get(key);
MethodChain.Invoker invoker = invokerMap.get(key);
if (invoker != null) {
invoker.invoke(mv);
} else if (infos != null && infos.size() > 0) {
String staticDesc = TypeUtil.descToStatic(opcode == Opcodes.INVOKESTATIC ? Opcodes.ACC_STATIC : 0, desc, owner);
// begin hook this code.
chain.headFromProxy(opcode, owner, name, desc);
String artificialClassname = classCollector.getCanonicalName(ClassTransform.AID_INNER_CLASS_NAME);
//1. 生成内部类
ClassVisitor cv = classCollector.getInnerClassVisitor(ClassTransform.AID_INNER_CLASS_NAME);
Log.tag("transform").i("start weave Call method " + " for " + owner + "." + name + desc +
" in " + className + "." + this.name);
infos.forEach(c -> {
if (TypeUtil.isStatic(c.sourceMethod.access) != (opcode == Opcodes.INVOKESTATIC)) {
throw new IllegalStateException(c.sourceClass + "." + c.sourceMethod.name + " should have the same " +
"static flag with " + owner + "." + name);
}
Log.tag("transform").i(
" from " + c.sourceClass + "." + c.sourceMethod.name);
String methodName = c.sourceClass.replace("/", "_") + "_" + c.sourceMethod.name;
//2. 生成内部类中的Hook方法
chain.next(artificialClassname, Opcodes.ACC_STATIC, methodName, staticDesc, c.threadLocalNode(), cv);
});
invokerMap.put(key, chain.getHead());
chain.getHead().invoke(mv);
} else {
super.visitMethodInsn(opcode, owner, name, desc, itf);
}
}
}
三. 基于Lancet代码实现
-
项目目录
2 代码实现
- 我们想对getSSID进行Hook,在代码调用前判断是否合规。
public class HookTest {
private void TestWifiInfoManager() {
WifiManager wm = ((WifiManager) application.getSystemService(Context.WIFI_SERVICE));
WifiInfo wifiInfo = wm.getConnectionInfo();
wifiInfo.getSSID();
}
}
- 使用时,直接可以通过注解进行配置。
public class WifiInfoHookManager {
@Proxy("getSSID")
@TargetClass("android.net.wifi.WifiInfo")
public String getSSID() {
try {
Boolean hasPrivacy = PrivacyHookManager.privacy.hasPrivacy() != null ? PrivacyHookManager.privacy.hasPrivacy() : false;
PrivacyHookManager.dealCallBack(hasPrivacy, BuildConfig.PRIVACY_CLASS_WIFI_INFO, BuildConfig.PRIVACY_METHOD_WIFI_INFO_SSID);
if (hasPrivacy) {
return (String) Origin. call ();
} else {
return "" ;
}
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
}
- 在编译时,则会插桩相应的Hook代码
原代码:
public class HookTest {
private void TestWifiInfoManager() {
WifiManager wm = ((WifiManager) application.getSystemService(Context.WIFI_SERVICE));
WifiInfo wifiInfo = wm.getConnectionInfo();
wifiInfo.getSSID();
}
}
插桩后的代码:
public class HookTest {
public class _lancet {
private _lancet () {}
@Proxy("getSSID")
@TargetClass("android.net.wifi.WifiInfo")
static String com_lib_supportx_privacy_manager_WifiInfoHookManager_getSSID (WifiInfo wifiInfo) {
try {
Boolean valueOf = Boolean.valueOf(PrivacyHookManager.privacy.hasPrivacy() != null ? PrivacyHookManager.privacy.hasPrivacy().booleanValue() : false );
PrivacyHookManager.dealCallBack(valueOf, BuildConfig.PRIVACY_CLASS_WIFI_INFO, BuildConfig.PRIVACY_METHOD_WIFI_INFO_SSID);
return valueOf.booleanValue() ? wifiInfo.getSSID() : "" ;
} catch (Exception e) {
e.printStackTrace();
return "" ;
}
}
}
}
private void TestWifiInfoManager() {
WifiManager wm = (WifiManager) this.application.getSystemService("wifi");
WifiInfo wifiInfo = wm.getConnectionInfo();
_lancet.com_lib_supportx_privacy_manager_WifiInfoHookManager_getSSID(wifiInfo);
}
}
- 本次所配置的隐私协议相关方法。
package com.lib.supportx.privacy.config;
/**
* @author xumingxiao
* @description :
* @date 2022/7/22 11:53 上午
*/
public class BuildConfig {
public static String TAG = "PrivacyHookManager";
public final static int SUCCESS = 0;
public final static int FAIL = -1;
//android.telephony.TelephonyManager
public final static String PRIVACY_CLASS_TELEPHONY_MANAGER = "android.telephony.TelephonyManager";
public final static String PRIVACY_METHOD_TELEPHONY_MANAGER_DEVICE_ID = "getDeviceId";
public final static String PRIVACY_METHOD_TELEPHONY_MANAGER_IMEI = "getImei";
public final static String PRIVACY_METHOD_TELEPHONY_MANAGER_NAI = "getNai";
public final static String PRIVACY_METHOD_TELEPHONY_MANAGER_SUBSCRIBER = "getSubscriberId";
public final static String PRIVACY_METHOD_TELEPHONY_MANAGER_NUMBER = "getLine1Number";
public final static String PRIVACY_METHOD_TELEPHONY_MANAGER_NETWORK = "getNetworkType";
//android.location.LocationManager
public final static String PRIVACY_CLASS_LOCATION_MANAGER = "android.location.LocationManager";
public final static String PRIVACY_METHOD_LOCATION_MANAGER_NEIGHBORING = "getNeighboringCellInfo";
public final static String PRIVACY_METHOD_LOCATION_MANAGER_LOCATION = "requestLocationUpdates";
public final static String PRIVACY_METHOD_LOCATION_MANAGER_ALL_CELL = "getAllCellInfo";
public final static String PRIVACY_METHOD_LOCATION_MANAGER_CELL_LOCATION = "getCellLocation";
//android.net.ConnectivityManager
public final static String PRIVACY_CLASS_CONNECTIVITY_MANAGER = "android.net.ConnectivityManager";
public final static String PRIVACY_METHOD_CONNECTIVITY_MANAGER_NETWORK_INFO = "getActiveNetworkInfo";
public final static String PRIVACY_METHOD_CONNECTIVITY_MANAGER_NETWORK_CALLBACK = "registerNetworkCallback";
//android.net.wifi.WifiInfo
public final static String PRIVACY_CLASS_WIFI_INFO = "android.net.wifi.WifiInfo";
public final static String PRIVACY_METHOD_WIFI_INFO_SSID = "getSSID";
public final static String PRIVACY_METHOD_WIFI_INFO_BSSID = "getBSSID";
//android.app.ActivityManager
public final static String PRIVACY_CLASS_ACTIVITY_MANAGER = "android.app.ActivityManager";
public final static String PRIVACY_METHOD_ACTIVITY_MANAGER_APP_PROCESSES = "getRunningAppProcesses";
public final static String PRIVACY_METHOD_ACTIVITY_MANAGER_INSTALLED_PACKAGES = "getInstalledPackages";
public final static String PRIVACY_METHOD_ACTIVITY_MANAGER_INSTALLED_APPLICATION = "getInstalledApplications";
public final static String PRIVACY_METHOD_ACTIVITY_MANAGER_RUNNING_TASKS = "getRunningTasks";
//android.provider.Settings$Secure
public final static String PRIVACY_CLASS_SETTINGS_SECURE = "android.provider.Settings$Secure";
public final static String PRIVACY_METHOD_SETTINGS_SECURE_GET_STRING = "getString";
//android.content.pm.PackageManager
public final static String PRIVACY_CLASS_PACKAGE_MANAGER = "android.content.pm.PackageManager";
public final static String PRIVACY_METHOD_PACKAGE_MANAGER_PACKAGES = "getInstalledPackages";
public final static String PRIVACY_METHOD_PACKAGE_MANAGER_APPLICATIONS = "getInstalledApplications";
public final static String PRIVACY_METHOD_PACKAGE_MANAGER_SERVICE_INFO = "getServiceInfo";
}
四. 拓展:AOP还可以做什么?
暂时无法在飞书文档外展示此内容
-
日志记录
@GetMapping("/getUserByName")
public String name(@RequestParam("name") String name) {
return "Hello:" + name;
}
-
性能统计
如果我们想要统计所有页面的绘制时长,可以通过AOP的方式,来获取所有页面的绘制时长。
@Override
protected void onResume() {
super.onResume();
final long start = System.currentTimeMillis();
getWindow().getDecorView().post( new Runnable () {
@Override
public void run () {
new Hanlder ().post( new Runnable () {
@Override
public void run () {
Log.d(TAG, “onPause cost:”+(System.currentTimeMillis() - start));
}
});
}
});
}
-
无痕埋点
比如对于页面的曝光,可以在Activity的oncreate()中,View的show()方法中做相关的代码插入;
对于view的点击,可以在setOnClick()中进行做相关的代码插入;