近兩年工信部連續頒布 App 違法違規收集個人信息法令,各大 App 廠商都開始跟進整治,但是在整治過程中自身主動發現合規問題的能力有限,每次新版本發布都需要配合三方檢測平台或者應用市場自檢測能力進行復檢,檢測出來的新問題需要第一時間進行修復,嚴重情況可能導致 App 無法正常上架。在問題的修復過程中,開發人員也比較被動,每次都是高優插入,對解決問題小夥伴的開發節奏也會造成干擾。
個人信息認定方法介紹:技術側要化被動為主動,我們首先得清楚違法違規的行為認證方法,這裡摘取《 App 違法違規收集使用個人信息行為認定方法》部分內容簡要提煉核心場景如下。
在 App 中沒有隱私政策,或者隱私政策中沒有收集使用個人信息規則,在 App 首次運行時未通過彈窗等明顯方式提示用戶閱讀隱私政策等收集使用規則。
未逐一列出 App (包括委託的第三方或嵌入的第三方代碼、插件)收集使用個人信息的目的、方式、範圍等。
用戶明確表示不同意後或者未經同意,仍收集個人信息或打開可收集個人信息的權限,或頻繁徵求用戶同意、干擾用戶正常使用。
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);
先定義自定義註解,自定義註解中包含 class 、method 、originMethodOpcode 三個方法,需要 HOOK 的隱私 API 需要結合自定義註解編寫代理代碼。
@Target(ElementType.METHOD)@Retention(RetentionPolicy.CLASS)public @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 代理原始方法與目標方式映射關係。
classASMDelegateInfo{ 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 }涉及到個人信息的權限對應的其實就是 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運行之後,自動導出隱私合規報告,協助全局分析隱私合規問題。