去哪儿 Android 客户端隐私安全处理方案
作者:江保贵 去哪儿网前端架构师
2011年4月加入去哪儿网,目前在基础研发大前端团队,专注于移动端质量效率提升,监控体系设计搭建等工作。先后多次参与客户端框架改版设计、制定开发规范,设计开发移动端差分升级系统、移动端交互日志&性能采集系统、渠道快速打包自动发布系统等,喜欢积极向上的团队氛围、不断学习新技术、追求技术创新带来的效率及性能提升。
1 背景
2019年央视3.15晚会曝光的个人隐私通过手机 app 泄露的案例令人触目惊心,作为一个应用开发者为了快速实现功能的快速开发,需要使用如广告、推送、统计、定位/地图、支付、社交等功能时都会引用相关的第三方 SDK,这些 SDK 很少以源码方式提供,也就是说这些 SDK 内执行逻辑对开发者来说是未知的,这些 SDK 自身滥用或者有安全漏洞,非法收集应用和用户隐私信息、远程下发执行恶意代码等造成的后果将不堪设想。
这里我分享一下去哪儿网如何通过技术手段,管控自身和三方 SDK 获取隐私信息的,因篇幅较长,先讲一下我们实现的功能特性及优势,后边详细介绍一下技术实现细节。
2 功能特性及优势
1、全局监控
应用自身和第三方 SDK 对敏感 API 调用,目前已经监控的如下:
-
网络请求
-
请求申请应用权限
-
读取设备应用列表
-
读取 Android SN(Serial)
-
读写联系人、通话记录、日历、本机号码
-
获取定位、基站信息
-
Mac 地址、IP 地址
-
读取 IMEI(DeviceId)、MEID、IMSI、ADID(AndroidID)
2、全面高效
不需要升级更新原有 SDK 版本依赖和更改业务逻辑代码,不用更改原有开发模式,只需要一次配置,无论之前或者以后新增 SDK 都可以监控。
3、开发简单
新增监控开发简单,一个自定义方法+一行注解,编译运行就能生效。
4、对工具库无版本依赖
工具库有哪些方法,就 hook 哪些 API,不需要复杂的版本依赖判断。
5、可扩展性强
工具方法自定义,可记录调用堆栈、返回空数据等可自主控制。
3 初步方案
我们首先考虑在 Android 项目编译器使用自定义的 Android Transform,全局 hook 相关 API 调用,替换为我们自定义的方法进行限制,这样无论是 javaClass 还是 jar 都可以控制。
AOP 操作字节码可用的技术很多,如 ASM、AspectJ、javassit 等,这里我们用 ASM 做一个简单替换原生获取 IMEI 的例子:
原始方法调用:
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY\_SERVICE);
telephonyManager.getDeviceId();
复制代码
要实现的效果:
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY\_SERVICE);
//调用我们的工具类QTelephonyManager
QTelephonyManager.getDeviceId(telephonyManager);
复制代码
我们跳过 Android Transform 部分介绍(不了解的可以看一下 Transform 详解:www.jianshu.com/p/37a5e0588… ASMPlugin 中 ASMified 查看 class 字节码,对比前后发生变化,修改原来的字节码:忽略相同代码,原始方法 ASMCode 如下:
//...
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "android/telephony/TelephonyManager", "getDeviceId", "()Ljava/lang/String;", false);
复制代码
**原始方法ASMCode解析:
**
-
methodVisitor 是方法解析器
-
visitMethodInsn 代表解析方法调用
-
INVOKEVIRTUAL 是方法修饰符,代表调用实体类的方法
-
android/telephony/TelephonyManager 代表的是调用该方法的 owner
-
getDeviceId 代表的是调用的方法名称
-
()Ljava/lang/String;() 代表的是方法的参数,后边是返回值
需要替换成我们的方法对应的 ASMCode 如下:
//...
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/mqunar/goldcicada/lib/QTelephonyManager", "getDeviceId", "(Landroid/telephony/TelephonyManager;)Ljava/lang/String;", false);
复制代码
调用自定义方法ASMCode解析:
-
INVOKESTATIC 替换实体类方法调用为静态方法
-
方法所在的类替换为我们自定义的工具类 com/mqunar/goldcicada/lib/QTelephonyManager
-
方法名称我们保持跟原生方法一致
-
方法的参数和返回值,我们修改为接收 android/telephony/TelephonyManager,返回值保持不变
自定义工具类 QTelephonyManager 代码实现:
public static String getDeviceId(TelephonyManager telephonyManager) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return "";
}
return telephonyManager.getDeviceId();
}
复制代码
Transform中对字节码处理代码实现:
public static boolean shouldInject = false
final static def QTelephonyManager = 'com/mqunar/goldcicada/lib/QTelephonyManager'
final static def TelephonyManager = "android/telephony/TelephonyManager"
static byte\[\] transform(byte\[\] bytes) {
def classNode = new ClassNode()
new ClassReader(bytes).accept(classNode, 0)//读取字节码,结果存放到classNode中
classNode = transform(classNode)//操作classNode处理class字节码
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE\_MAXS)
classNode.accept(cw)
return cw.toByteArray()
}
static ClassNode transform(ClassNode klass) {
if (!shouldInject) {//开关控制
return klass
}
// 检测到是自己工具类时不注入
if (klass.name.startsWith(QTelephonyManager)) {
return klass
}
klass.methods?.each { MethodNode method ->
method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
if (insnNode.opcode == INVOKEVIRTUAL
&& insnNode.owner == TelephonyManager
&& insnNode.name == "getDeviceId"
&& insnNode.desc == "()Ljava/lang/String;") {
QBuildLogger.log "QHOOK ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
insnNode.owner = QTelephonyManager
insnNode.opcode = INVOKESTATIC
insnNode.desc = "(L${TelephonyManager};)Ljava/lang/String;"
}
}
}
return klass
}
复制代码
这种方案实现了全局替换的功能,但是方案的缺点很明显:
-
Transform脚本代码硬编码判断;
-
对工具类库有依赖,如果工具类库新增了 hook 类或者方法,对老版本打包的时候就需要做版本判断,要不然运行时就报 ClassNotFoundException;
-
开发需要对 ASM 字节码编程有一定了解,每次新增 hook 都需要对比前后变化进行修改验证,工作量比较大。
有没有一种方法,同时解决以上三个问题?
4 进阶方案
要解决应上边遇到的问题,首先我们要考虑使用一个通用的配置,Transform 的字节码处理器不用关心具体要 hook 的类和方法,这里我们使用到自定义注解,在自定义方法上加上注解,告诉我要替换什么类和方法、该方法是静态、非静态,方法参数等。然后经过 Transform 先把加了自定义注解的所有配置读取出来,生成一份需要 hook 的方法配置列表。再次 Transform 根据配置进行 hook,就初步解决以上三个问题。流程图如下:
大家可能有一个疑问:自定义注解都是和注解处理器一起使用的,你这里为什么使用一个 Transform 进行注解的读取和解析?
其实大家用过注解处理器就知道:注解处理器处理最大的优点是可以在生成 class 字节码之前执行,处理的是 java 源码,此时可以结合 javapoet 动态生成 java 源代码;缺点是每个使用注解的项目都需要配置注解处理器,这样首先配置就比较麻烦,再一个结合我们这个项目,注解的作用是给 Transform 用的,这时候就适合在 App 打包时用 Android Transform 先生成一份配置,接着再交给下一个 Transform 进行 Hook。
4.1 创建一个自定义注解库 apt-annotation
声明自定义注解 AsmField,声明三个参数
-
@Retention 表示的是注解生效的生命周期,RetentionPolicy.CLASS 表示注解被保留到 class 文件,但 jvm 加载 class 文件时候被遗弃
-
oriClass 代表需要 hook 的类
-
oriMehod 代表被 hook 的方法名称,默认与工具类方法名一致
-
oriAccess 方法类型,默认是 static 方法
-
方法参数和返回值由原方法的类型和自定义方法决定,后边在注解处理器初详细描述
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface AsmField {
Class oriClass();
String oriMehod() default "";
MethodAccess oriAccess() default MethodAccess.INVOKESTATIC;
public enum MethodAccess {
/\*\* 静态方法 static \*/
INVOKESTATIC(Opcodes.INVOKESTATIC),
/\*\* 接口方法,如调用 onClickListener.onClick(view) \*/
INVOKEINTERFACE(Opcodes.INVOKEINTERFACE),
/\*\* 实体类方法 \*/
INVOKEVIRTUAL(Opcodes.INVOKEVIRTUAL),
/\*\* 调用super方法 \*/
INVOKESPECIAL(Opcodes.INVOKESPECIAL);
int value;
MethodAccess(int value) {
this.value = value;
}
public int value() {
return value;
}
}
}
复制代码
4.2 工具类库处理 toolsLibrary
-
让工具类库 toolsLibrary 依赖我们的 apt-annotation
-
在工具类 QTelephonyManager 的自定义方法上增加注解
@AsmField(oriClass = TelephonyManager.class, oriAccess = MethodAccess.INVOKEVIRTUAL)
public static String getDeviceId(TelephonyManager telephonyManager) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return "";
}
return telephonyManager.getDeviceId();
}
复制代码
4.3 主项目依赖工具库
- 在主项目中 build.gradle 的 dependencies 中配置上 toolsLibrary 项目
implementation project(":toolsLibrary")
复制代码
4.4 自定义 Android 插件 AnnotationParserTransform 读取注解,生成配置列表
- 解析注解 AnnotationParserTransform 核心代码如下:
//
static void parseAsmAnnotation(byte\[\] bytes) {
def klass = new ClassNode()
new ClassReader(bytes).accept(klass, 0)//读取字节码,结果存放到classNode中
klass.methods.each { method ->
method.invisibleAnnotations?.each { node ->
if (node.desc == 'Lcom/mqunar/qannotation/AsmField;') {
asmConfigs << new AsmItem(klass.name, method, node)
}
}
}
}
static class AsmItem {
public String oriClass
public String oriMethod
public String oriDesc
public int oriAccess = Opcodes.INVOKESTATIC
public String targetClass
public String targetMethod
public String targetDesc
public int targetAccess = Opcodes.INVOKESTATIC
public AsmItem(String targetClass, MethodNode methodNode, AnnotationNode node) {
this.targetClass = targetClass
this.targetMethod = methodNode.name
this.targetDesc = methodNode.desc
String sourceName
for (int i = 0; i < node.values.size() / 2; i++) {
def key = node.values.get(i \* 2)
def value = node.values.get(i \* 2 + 1)
if (key == 'oriClass') {
sourceName = value.toString()
oriClass = sourceName.substring(1, sourceName.length() - 1)
} else if (key == 'oriAccess') {
this.oriAccess = Opcodes."${value\[1\]}"
} else if (key == "oriMethod") {
this.oriMethod = value
}
}
if (this.oriMethod == null) {
this.oriMethod = targetMethod
}
if (this.oriAccess == Opcodes.INVOKESTATIC) {//静态方法,参数和返回值一致
this.oriDesc = targetDesc
} else {
String param = targetDesc.split("\\\\)")\[0\] + ")"
String returnValue = targetDesc.split("\\\\)")\[1\]
if (param.indexOf(sourceName) == 1) {
param = "(" + param.substring(param.indexOf(sourceName) + sourceName.length())
}
this.oriDesc = param + returnValue
}
}
}
复制代码
4.5 自定义 Android 插件 ASMTransform 根据配置替换字节码
- 根据配置列表,优雅地进行字节码替换
class AsmInjectProxy {
static byte\[\] transform(byte\[\] bytes) {
if (AsmAnnotationParser.asmConfigs.isEmpty()) {
return bytes
}
def classNode = new ClassNode()
new ClassReader(bytes).accept(classNode, 0)//读取字节码,结果存放到classNode中
classNode = transform(classNode)
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE\_MAXS)
classNode.accept(cw)
return cw.toByteArray()
}
/\*\*
\* 对ClassNode对象做处理
\* @param klass
\* @return
\*/
static ClassNode transform(ClassNode klass) {
def asmItems = AsmAnnotationParser.asmConfigs
for (def it : asmItems) {
if (klass.name.startsWith(it.targetClass)) {
return klass//目标类不注入
}
}
klass.methods?.each { MethodNode method ->
method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
if (insnNode instanceof MethodInsnNode) {
asmItems.each { asmItem ->
if (asmItem.oriDesc == insnNode.desc && asmItem.oriMethod == insnNode.name) //
&& insnNode.opcode == asmItem.oriAccess && insnNode.owner == asmItem.oriClass) {
insnNode.opcode = asmItem.targetAccess
insnNode.name = asmItem.targetMethod
insnNode.desc = asmItem.targetDesc
insnNode.owner = asmItem.targetClass
println "QHOOK ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
}
}
}
}
}
return klass
}
}
复制代码
至此我们使用通用配置、动态替换的目标已经实现,基本上能实现80%以上的需求,但是随着功能需求复杂度增加,如 hook 创建对象 new Instance,子类用 super 调用父类方法,子类用 this. 调用父类有的方法等,上边的功能无法实现,因此我们丰富完善支持更复杂的功能。
5 超级HOOK
5.1 子类使用 super 关键字调用父类的方法
如 Hook 自定义 MainActivity 中调用获取权限方法 super.requestPermissions:我们依然提供一个 public static 方法与 Activity 一样的 requestPermissions 方法,增加注解@AsmField(oriClass = Activity.class,oriAccess = MethodAccess.INVOKESPECIAL)。
@AsmField(oriClass = Activity.class, oriAccess = MethodAccess.INVOKESPECIAL)
public static void requestPermissions(Activity activity, String\[\] permissions, int requestCode) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return;
}
//避开自己方法调用,调用父类方法
ReflectUtils.invokeSuperMethod(activity, "requestPermissions", new Class\[\]{String\[\].class, Integer.TYPE}, new Object\[\]{permissions, requestCode});
}
复制代码
这样 super.requestPermissions(permissions, requestCode)编译后就被替换为QActivity.requestPermissions(permissions, requestCode)
大家注意,我们自定义方法中不能直接调用 activity.requestPermissions(permissions, requestCode)方法,因为这样调用是调用 MainActivity 自己的 requestPermissions 方法,如果 MainActivity 中重写了父类方法,并调用了 super.这样 hook 后就会死循环调用。我们这里的做法是,需要在工具类中反射调用父类的方法。
5.2 ReflectUtils 反射父类工具代码
public static <T> T invokeSuperMethod(final Object obj, final String name, final Class\[\] types, final Object\[\] args) {
try {
final Method method = getMethod(obj.getClass().getSuperclass(), name, types);
if (null != method) {
method.setAccessible(true);
return (T) method.invoke(obj, args);
}
} catch (Throwable t) {
t.printStackTrace();
}
return null;
}
private static Method getMethod(final Class<?> klass, final String name, final Class<?>\[\] types) {
try {
return klass.getDeclaredMethod(name, types);
} catch (final NoSuchMethodException e) {
final Class<?> parent = klass.getSuperclass();
if (null == parent) {
return null;
}
return getMethod(parent, name, types);
}
}
复制代码
5.3 子类调用父类方法,非super.
如 Hook 自定义 Activity 中调用获取权限方法 this.requestPermissions/requestPermissions:我们依然提供一个 public static 方法与 Activity 一样的 requestPermissions 方法,增加注解 @AsmField(oriClass = java.lang.Object.class,oriAccess = MethodAccess.INVOKEVIRTUAL)
@RequiresApi(api = Build.VERSION\_CODES.M)
@AsmField(oriClass = Object.class, oriAccess = MethodAccess.INVOKEVIRTUAL)
public static void requestPermissions(Object activity, String\[\] permissions, int requestCode) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return;
}
if (activity instanceof Activity) {
((Activity) activity).requestPermissions(permissions, requestCode);
} else {//非Activity hook,反射调用
ReflectUtils.invokeMethod(activity, "requestPermissions", new Class\[\]{String\[\].class, Integer.TYPE}, new Object\[\]{permissions, requestCode});
}
}
复制代码
这样 this.requestPermissions(permissions, requestCode)编译后就被替换为 QActivity.requestPermissions(permissions, requestCode)
大家注意,因为我们无法知道运行时的子类名称,因此我们也不知道被 hook 的类是谁,这里就用 Object 对象替代,并且参数也用 Object,在运行时判断传入的参数是否是 Activity 的子类,如果是就正常调用,不是的话就反射调用。
5.4 ReflectUtils 反射工具代码
public static <T> T invokeMethod(final Object obj, final String name, final Class\[\] types, final Object\[\] args) {
try {
final Method method = getMethod(obj.getClass(), name, types);
if (null != method) {
method.setAccessible(true);
return (T) method.invoke(obj, args);
}
} catch (Throwable t) {
t.printStackTrace();
}
return null;
}
复制代码
5.5 hook 创建对象方法
如我们想实现 okhttp 网络请求拦截,Hook 创建 new OkHttpClient.Builder()对象添加拦截器:
- hook 之前,我们先查看 new 对象的 ASM 字节码和需要替换代码对比:
【变换前】
methodVisitor.visitTypeInsn(NEW, "okhttp3/OkHttpClient$Builder");
methodVisitor.visitInsn(DUP);
methodVisitor.visitMethodInsn(INVOKESPECIAL, "okhttp3/OkHttpClient$Builder", "<init>", "()V", false);
//...
methodVisitor.visitMaxs(2, 1);
复制代码
【变换后】
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/mqunar/goldcicada/lib/QOkHttpClient", "getOkHttpClientBuilder", "()Lokhttp3/OkHttpClient/Builder;", false);
//...
methodVisitor.visitMaxs(1, 1);
复制代码
懵逼了,这里跟上边的所有的 hook 都不一样,这里三行代码 new 一个对象,后边还有一个数值变化了,这里我们就不仅仅替换,还需要移除修改代码。
我们先自定义一个 public static 方法,增加注解原始类和方法 @AsmField(oriClass = OkHttpClient.Builder.class, oriMehod = “”, oriAccess = MethodAccess.INVOKESPECIAL),这里大家注意 oriMehod 需要声明为<init>
,oriAccess 需要声明为 MethodAccess.INVOKESPECIAL。
@AsmField(oriClass = OkHttpClient.Builder.class, oriMehod = "<init>", oriAccess = MethodAccess.INVOKESPECIAL)
public static OkHttpClient.Builder getOkHttpClientBuilder() {
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.addInterceptor(chain -> {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return new Response.Builder().code(404).protocol(Protocol.HTTP\_2)
.message("Can\`t use network by GoldCicada")
.body(ResponseBody.create(MediaType.get("text/html; charset=utf-8"), ""))
.request(chain.request()).build();
}
return chain.proceed(chain.request());
});
return builder;
}
复制代码
首先,我们在 AnnotationParserTransform 注解解析中特殊处理方法的返回值为 “V”。
if (oriAccess == Opcodes.INVOKESPECIAL && oriMethod == "<init>") {
returnValue = "V"
}
复制代码
其次,我们在实现 hook 的 AsmTransform 中特殊处理。
klass.methods?.each { MethodNode method ->
Map<AbstractInsnNode, Object> needReplaceInitNode = \[:\] as Map
method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
if (insnNode instanceof MethodInsnNode) {
asmItems.each { asmItem ->
if (asmItem.oriDesc == insnNode.desc && asmItem.oriMethod == insnNode.name) {//方法名称和参数返回值一样
if (insnNode.opcode == asmItem.oriAccess && (asmItem.oriClass == "java/lang/Object" || insnNode.owner == asmItem.oriClass)) {
//处理init方法
if (asmItem.oriMethod == "<init>" && asmItem.oriAccess == Opcodes.INVOKESPECIAL) {
needReplaceInitNode.put(insnNode, asmItem)
} else {
insnNode.opcode = asmItem.targetAccess
insnNode.name = asmItem.targetMethod
insnNode.desc = asmItem.targetDesc
insnNode.owner = asmItem.targetClass
}
println "QHOOK ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
} else if (insnNode.opcode == Opcodes.INVOKESPECIAL && insnNode.owner != asmItem.oriClass && insnNode.name != "<init>") {
println "跳过相同方法名称和参数调用Hook,如需要hook请提供超级hook(oriClass = \\"java.lang.Object.class\\"),具体参考com.mqunar.goldcicada.lib.QActivity,\\n${insnNode.opcode} ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
}
}
}
}
}
needReplaceInitNode.each { replaceNode, asmItem ->
//替换
replaceNode.opcode = asmItem.targetAccess
replaceNode.name = asmItem.targetMethod
replaceNode.desc = asmItem.targetDesc
replaceNode.owner = asmItem.targetClass
//向前查找需要remove的 NEW TypeInsnNode 和 DUP InsnNode
//查找逻辑是,如果replaceNode之前有相同init 相同owner desc不一样的数,有就跳过
def newNode = findTypeInsn(replaceNode, asmItem, 0)
//一定要先移除后边的,要不然当前的移除之后就成无主node了
method.instructions.remove(newNode.next)
method.instructions.remove(newNode)
//改变栈空间变化
method.maxStack = method.maxStack - 1
}
}
...
//向前查找newTypeInsn节点:参考 methodVisitor.visitTypeInsn(NEW, "okhttp3/OkHttpClient$Builder");
private static AbstractInsnNode findTypeInsn(AbstractInsnNode initNode, def asmItem, int skipCount) {
def preNode = initNode.previous
if (preNode.opcode == Opcodes.NEW && preNode.desc == asmItem.oriClass) {
if (skipCount > 0) {
skipCount--
} else {
return preNode
}
}
if (preNode.opcode == asmItem.oriAccess
&& preNode.owner == asmItem.oriClass
&& preNode.name == asmItem.oriMethod
&& preNode.desc != asmItem.oriDesc) {//如果找到一个不替换的,跳过一次
skipCount++
}
return findTypeInsn(preNode, asmItem, skipCount)
}
复制代码
这样 new OkHttpClient.Builder() 编译后就被替换为 QOkHttpClient. getOkHttpClientBuilder() ,且不会造成方法内栈空间错乱。
细心的读者可能会发现 findTypeInsn 最后有一个参数 skipCount,这个什么作用呢??,卖个关子,大家可以动手 hook 一个复杂的 new 嵌套试试,如:new File(new File(“DirPath”),new File(“childPath”).getName());
6 写在最后
至此本项目已经实现对自身 app 及第三方 SDK 的调用监控,在需要 hook 一个方法调用时,用一个自定义方法、一行注解编译运行即可完成,而不需要修改源代码,升级第三方 SDK,脚本没有硬编码,也不需要做工具类版本判断。另外:如果想要 hook 第三方 SDK 反射调用,如运行时加载 dex 等功能,也可以用以上方法,hook 整个反射的调用过程,记录反射调用的类、方法、参数和返回值,从而保证整个客户端的安全。
END