有赞移动隐私制约探索与实践
发布于 3 年前 作者 zhuxia 2644 次浏览 来自 分享

前言

近两年工信部连续颁布 App 违法违规收集个人信息法令,各大 App 厂商都开始跟进整治,但是在整治过程中自身主动发现合规问题的能力有限,每次新版本发布都需要配合三方检测平台或者应用市场自检测能力进行复检,检测出来的新问题需要第一时间进行修复,严重情况可能导致 App 无法正常上架。在问题的修复过程中,开发人员也比较被动,每次都是高优插入,对解决问题小伙伴的开发节奏也会造成干扰。

个人信息认定方法介绍:技术侧要化被动为主动,我们首先得清楚违法违规的行为认证方法,这里摘取《 App 违法违规收集使用个人信息行为认定方法》部分内容简要提炼核心场景如下。

1. 未公开收集使用规则

在 App 中没有隐私政策,或者隐私政策中没有收集使用个人信息规则,在 App 首次运行时未通过弹窗等明显方式提示用户阅读隐私政策等收集使用规则。

2. 未明示收集个人信息的目的、方式和范围

未逐一列出 App (包括委托的第三方或嵌入的第三方代码、插件)收集使用个人信息的目的、方式、范围等。

3. 未经同意收集使用个人信息

用户明确表示不同意后或者未经同意,仍收集个人信息或打开可收集个人信息的权限,或频繁征求用户同意、干扰用户正常使用。

4. 违背必要原则,收集与其提供的服务无关的个人信息

App新增业务功能申请收集的个人信息超出用户原有同意范围,若用户不同意,则拒绝提供原有业务功能。收集个人信息的频度等超出业务功能实际需要。

针对以上 4 个核心认定方法,App在隐私协议功能适配的过程中针对性拆解成三个不同的阶段,分为首次启动、登录成功、操作功能。

以上 3 个阶段在适配初期都是 case by case 由业务方根据问题逐步修复与规避。随着业务迭代,新增的隐私 API 调用场景或者权限申请流程很大概率违背上述个人信息认定方法,最终形成劣化。故此需要一套隐私制约工具,对业务方涉及到隐私 API 与权限获取场景进行强管控,最大可能避免 App 出现违法违规现象。

探索方案

对隐私 API 与危险权限进行 HOOK ,开发与线上运行过程中存在不合规的问题都会被捕获到。捕获到问题之后,会将问题存储在后台,结合 mPaaS 前台展示问题列表与详情,值班人可分配问题,问题处理人可更新问题状态。同时在 mPaaS 后台对危险权限、隐私 API 做强管控,业务方新增的相关需求需要在平台进行录入,未在平台录入的权限与隐私 API 在开发运行过程中会进行 crash 告警与阻塞,业务方需要及时修复,将隐患消灭到发布之前。同时平台还会采集 App 二三方依赖库、申请的权限列表等信息,以便安全合规同学方便查看相关信息,根据变更权限也能及时、准确调整隐私协议内容。

架构设计:

业务App在隐私制约平台( mPaaS )编辑应用信息,录入权限、隐私 API 规则。App 在运行过程中解析平台配置信息,校验合规情况,未命中匹配的场景会进行告警。同时结合打包平台,运行时采集 App 二三依赖库与权限信息并进行上传(关联上版本号),后台会计算前后版本权限变更情况,及时通知相关干系人,进行预警。

HOOK 技术选型:

Android HOOK 技术分类比较多,大体分为反射与动态代理、JNI HOOK 、Xposed 、inline HOOK 等技术,HOOK 的适用场景、实现原理、主要优缺点如下:

隐私 API 与权限都属于 java 层,所以只需要采用 java 层进行代理即可,而 Android 在运行时与编译时都可以进行代理,考虑到降低业务方感知与维护成本,最终决定在 App 编译时进行 HOOK 代理。

编译时 HOOK 流程:

在编译期间,将 App 中 class 、jar 、resource 作为输入,自定义 Plugin 注册自定义 Transform ,穷举需要 HOOK 的隐私 old API ,进行字节码代理替换,jump 到 new API 中,代理类最终内部调用 old API ,而在代理类中可以针对隐私 API 进行制约配置,从而达到隐私制约能力。

在隐私 API 穷举过程中还要考虑如何缩小检索范围,避免全局扫描,影响编译速度。同时也要避免字节码代理 jump 代码频繁适配修改,要争取核心代码稳定不变,遵循开闭原则。综合考虑下,最终决定在端上使用自定义注解标注需要被 HOOK 的 class 与 method ,在编译时解析自定义注解标注的隐私 API 与权限,确定原始调用与目标调用关系,进行傻瓜式代理调用,内部实现无需感知业务方,进而做到核心 HOOK 代码不因业务方逻辑而变更。

针对隐私制约 HOOK ,主要分为隐私 API 与权限两种场景,下面会逐一进行讲解分析。

隐私API HOOK:

隐私 API 主要涉及到 DeviceId 、IMEI 、Mac 地址、WIFI 、基站对位、GPS 等获取场景,在隐私协议同意之前调用任何一个 API 都算违规。下面会以 TelephonyManager 获取 IMEL 为例进行讲解。

在了解 HOOK 之前,需要了解下 TelephonyManager getImei 代理替换前后的效果(不会讲解字节码细节,只会呈现核心修改内容)。

代理前:

java调用:

TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
 telephonyManager.getImei();

字节码调用:

methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "android/telephony/TelephonyManager", "getImei", "()Ljava/lang/String;", false);

代理后:

java调用:telephonyManager 作为参数注入到 IMEIDelegate 类中,最终 IMEIDelegate 真正调用 TelephoneManager.getImei 方法 。

TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
IMEIDelegate.getImei(telephonyManager);

字节码调用:直接调用 telephonyManager.getImei 的字节码都会直接重定向到 IMEIDelegate.getImei 中。

methodVisitor.visitMethodInsn(INVOKESTATIC, "com.test/IMEIDelegate", "getImei", "(Landroid/telephony/TelephonyManager;)Ljava/lang/String;", false);

HOOK 处理流程如下:

先定义自定义注解,自定义注解中包含 class 、method 、originMethodOpcode 三个方法,需要 HOOK 的隐私 API 需要结合自定义注解编写代理代码

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public [@interface](/user/interface) ASMDelegate {
    /**
     * 原字节码
     */
    Class originClass();

    /**
     * 原方法
     */
    String originMethod() default "";

    /**
     * 原方法Opcode
     */
    MethodOpcodeEnum originMethodOpcode() default MethodOpcodeEnum.INVOKESTATIC;
}

编写 IMELDelegate 类,getImei 方法自定义注解上标注需要 HOOK 的原始方法。

public class IMELDelegate extends BaseDelegate {
    @ASMDelegate(originClass = TelephonyManager.class, originMethod = "getImei", originMethodOpcode = MethodOpcodeEnum.INVOKEVIRTUAL)
    public static String getDeviceId(TelephonyManager telephonyManager) {
        checkRestrict(PrivacyPersonalInfoEnum.DeviceId);
        return telephonyManager.getImei();
    }
}

编译期间解析 ASMDelegate 注解数据,获取需要 HOOK 的 class 、method 、opcode 信息,存储在asmDelegateInfoList 列表中。

static void handleClassNode(ClassNode classNode) {
        // 解析注解
        classNode.methods.each { methodNode ->
            methodNode.invisibleAnnotations?.each { annotationNode ->
                if (annotationNode.desc == "Lcom/youzan/privacypermission/restrict/annotation/ASMDelegate;") {
                    def asmDelegateInfo = new ASMDelegateInfo(annotationNode, methodNode, classNode.name)
                    asmDelegateInfoList.add(asmDelegateInfo)
                }
            }
        }
    }

解析 asmDelegateInfoList 列表数据,确定 API 代理原始方法与目标方式映射关系。

class ASMDelegateInfo {
    public ASMDelegateItem originDelegateItem
    public ASMDelegateItem targetDelegateItem

    ASMDelegateInfo(AnnotationNode annotationNode, MethodNode targetMethodNode, String targetClass) {
        this.originDelegateItem = new ASMDelegateItem()
        String originClass = ""
        for (int i = 0; i < annotationNode.values.size() / 2; i++) {
            def key = annotationNode.values.get(i * 2)
            def value = annotationNode.values.get(i * 2 + 1)
            if (key == "originClass") {
                originClass = value.toString()
                this.originDelegateItem.itemClass = originClass.substring(1, originClass.length() - 1)
            } else if (key == "originMethod") {
                this.originDelegateItem.itemMethod = value
            } else if (key == "originMethodOpcode") {
                this.originDelegateItem.itemMethodOpcode = Opcodes."${value[1]}"
            }
        }
        String targetMethodDesc = targetMethodNode.desc
        if (this.originDelegateItem.itemMethodOpcode == Opcodes.INVOKESTATIC) {
            // 静态方法,没有第一个隐含参数this
            this.originDelegateItem.itemDesc = targetMethodDesc
        } else {
            // (Landroid/accounts/AccountManager;)[Landroid/accounts/Account;
            String inputParam = targetMethodDesc.split("\\)")[0] + ")"
            String returnValue = targetMethodDesc.split("\\)")[1]
            if (inputParam.indexOf(originClass) == 1) {
                inputParam = "(" + inputParam.substring(inputParam.indexOf(originClass) + originClass.length())
            }
            this.originDelegateItem.itemDesc = inputParam + returnValue
        }
        this.targetDelegateItem = new ASMDelegateItem()
        this.targetDelegateItem.itemClass = targetClass
        this.targetDelegateItem.itemMethod = targetMethodNode.name
        this.targetDelegateItem.itemDesc = targetMethodDesc
        this.targetDelegateItem.itemMethodOpcode = Opcodes.INVOKESTATIC

    }
}

将需要 HOOK API 的原始 insnNode 中的 owner 、name 、opcode 、desc 等字段替换成代理目标类数据,重定向到目标代理方法中,整个流程配置完之后,老的隐私 API 会最终调用新的代理 API ,形成一个切片,隐私制约规则就可以在里面做拦截处理了。

static interruptClassNode(ClassNode classNode) {
        boolean needHook = false
        classNode.methods?.each { MethodNode method ->
            method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
                if (insnNode instanceof MethodInsnNode) {
                    asmDelegateInfoList.each { asmDelegateInfo ->
                        if (asmDelegateInfo != null) {
                            def originDelegateItem = asmDelegateInfo.originDelegateItem
                            if (originDelegateItem.itemClass == insnNode.owner
                                    && originDelegateItem.itemMethod == insnNode.name
                                    && originDelegateItem.itemDesc == insnNode.desc
                                    && originDelegateItem.itemMethodOpcode == insnNode.opcode
                            ) {
                                needHook = true
                                println "hook ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${classNode.name}.${method.name}${method.desc}"
                                insnNode.owner = asmDelegateInfo.targetDelegateItem.itemClass
                                insnNode.name = asmDelegateInfo.targetDelegateItem.itemMethod
                                insnNode.opcode = asmDelegateInfo.targetDelegateItem.itemMethodOpcode
                                insnNode.desc = asmDelegateInfo.targetDelegateItem.itemDesc
                            }
                        }
                    }
                }
            }
        }
        return needHook
    }

危险权限 HOOK :

涉及到个人信息的权限对应的其实就是 Android 6.0 之后的危险权限,包括日历(CALENDAR)、相机(CAMERA)、联系人(CONTACTS)、位置(LOCATION)、麦克风(MICROPHONE)、存储(STORAGE)等,下面会以存储权限为例,进行实现讲解。

其实在 App 中申请危险权限最终都会调用 Activity 与 Fragment 的 requestPermissions 方法,同理我们也可以在编译期间针对 requestPermissions 方法进行重定向代理操作。

大体流程与隐私 API HOOK 方法类似,首先编写 RequestPermissionsDelegate 代理类,代码如下。

public class RequestPermissionsDelegate extends BaseDelegate {

    @ASMDelegate(originClass = Activity.class, originMethod = "requestPermissions", originMethodOpcode = MethodOpcodeEnum.INVOKESPECIAL)
    public static void requestPermissionsBySuper(Activity activity, String[] permissions, int requestCode) {
            if (!isRequestAgain(activity)) {
                checkRestrict(activity.getClass().getName(), permissions);
            }
            invokeSuperMethod(activity, "requestPermissions", new Class[]{String[].class, Integer.TYPE}, new Object[]{permissions, requestCode});
        }

    @ASMDelegate(originClass = Fragment.class, originMethod = "requestPermissions", originMethodOpcode = MethodOpcodeEnum.INVOKESPECIAL)
    public static void requestPermissionsByFragmentSuper(Fragment fragment, String[] permissions, int requestCode) {
        handleFlag(fragment.getActivity());
        checkRestrict(fragment.getClass().getName(), permissions);
        invokeSuperMethod(fragment, "requestPermissions", new Class[]{String[].class, Integer.TYPE}, new Object[]{permissions, requestCode});
    }
}

自定义注解编译期间解析与字节码代理替换流程与隐私 API HOOK 技术复用一套,这里不再赘述。

特殊场景适配:

针对 requestPermissions 方法代理 HOOK 在运行过程中要考虑到一个死循环场景,在 Android 请求权限场景下主要有两种调用方式:一种是 super.requestPermissons(permissions, requestCode) ,还有一种是 this.requestPermissions(permissions, requestCode) 。我们代理类中不能直接调用activity.requestPermissions(permissions, requestCode) ,因为这样调用其实是调用业务 Activity 自己的 requestPermissions 方法,如果业务 Activity 中重写了父类方法,并调用了 super.requestPermissons ,这样 HOOK 后就会造成死循环调用,最终发生 stackoverflow 问题,具体出现问题流程如下。

为了避免死循环问题,可以在代理类中通过反射方式直接调用 super.requestPermissions 方法来进行规避,实现代码如下。

private 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);
        }

隐私制约平台开发

上面讲解的主要还是隐私与权限 API 切片的基础能力,还不涉及到规则校验流程,要让整个隐私制约流程运作起来,还需要一套制约平台管理能力,通过规则下发,切片解析校验规则,捕获异常并告警,这样才能有效的捕获问题。

功能列表:

目前的设计主要分为配置与成分检测两个分类功能,配置中可以新增 App ,并且可对 App 权限、隐私 API 生效场景进行编辑,比如隐私 API 需要在隐私协议同意之后才能调用,电话权限只能在首页申请等,通过规则管理,达到强管控能力。而成分检测部分主要是为了配合安全合规同学方便收集 App 使用的权限与依赖库列表信息,及时补充到隐私协议文档中,隐私制约平台主要功能如下。

配置管理:

APP管理:

业务方可在后台编辑应用基本信息,包括应用名称、平台类型、版本号等。

隐私API管控:

业务方可在平台上编辑隐私 API(默认在隐私协议同意之后生效),并且可以配置调用频率。

权限管控:

权限管理相对会复杂些,需要增加场景(比方需要在那个页面调用某个权限),且可以配置权限申请间隔时间(如果用户拒绝首次权限申请,默认 24 小时之后才能再次申请,具体间隔时间可调整)。

场景管理:

需要描述清楚权限使用场景、使用目的,以及页面标识(在那个页面上申请权限),所有的危险权限的使用场景都需要在隐私协议文档中描述出来。

隐私协议文案内容如下:

成分检测:

权限管理:

采集 App 申请的所有权限,包括危险权限、正常权限、自定义权限等,实现方式主要在编译期解析 AndroidManifest.xml 文件。

核心代码如下:

final Path mergedManifestDir = Paths.get(project.getBuildDir().getAbsolutePath(), MERGED_MANIFEST_DIR_NAME, variant.getName());
final Collection<File> manifestFiles = Files.walk(mergedManifestDir)
        .filter(p -> p.toFile().getName().equals("AndroidManifest.xml"))
        .map(p -> p.toAbsolutePath().toFile())
        .collect(Collectors.toSet());

final Set<String> permissions = manifestFiles.stream().flatMap(file -> {
NodeList nodes = null;
try {
    nodes = DocumentBuilderFactory.newInstance().newDocumentBuilder()
            .parse(file)
            .getElementsByTagName("uses-permission");
} catch (Exception e) {
    e.printStackTrace();
}
ArrayList<String> uses = new ArrayList<>();
for (int i = 0; nodes != null && i < nodes.getLength(); i++) {
    uses.add(nodes.item(i).getAttributes().getNamedItem("android:name").getNodeValue());
}
return uses.stream();
}).collect(Collectors.toSet());

依赖库管理:将 App 依赖的所有二三方依赖库采集下来,上传到后台,以便进行统一管理,实现方式主要在编译之后拿到 compileClasspath ,解析出所有的依赖配置。

核心代码如下:

final Set<String> dependencies = project.getConfigurations().getByName(variant.getName() + "CompileClasspath")
                    .getIncoming().getResolutionResult().getAllDependencies().stream()
                    .filter(dep -> dep.getRequested() instanceof ModuleComponentSelector)
                    .map(dep -> (ModuleComponentSelector) dep.getRequested())
                    .map(dep -> String.format("%s:%s", dep.getGroup(), dep.getModule()))
                    .collect(Collectors.toSet());

APP成分前后版本对比:

结合打包流程,将每个版本的权限与依赖库信息上传到后台,后台进行前后台信息对比,输出差异性内容,进行告警通知。

实践成果

隐私制约这套能力目前在应用中已经捕获了不少违规的问题,比如:push sdk 在隐私协议同意之前调用了 AndroidId ,bugly crashreport 调用 deviceId 频次超出了限制,设置中新增加的读写权限未在平台中录入运行环境中直接告警等等,通过平台制约能力对权限、隐私 API 进行强管控,提升了 App 的安全性与稳定性。

未来规划

1、 隐私制约后台平台完善,方便问题追溯与管理。

2、新增权限变更自动同步隐私协议,减少开发适配成本。

3、隐私制约CI运行之后,自动导出隐私合规报告,协助全局分析隐私合规问题。

回到顶部