1.如何检测/复现 android_id/Mac地址等权限被超前获取
最近公司启动了隐私合规检测,报告告知我们在用户未同意隐私协议前,我们APP提前获取了WIIF/SSID/Mac地址等信息,不合规需要处理 (就是在同意前以及拒绝后, 不进行某些SDK的初始化…)
APP启动时,在用户授权同意隐私政策前,APP及SDK不可以提前收集和使用IMEI、OAID、IMSI、MAC、应用列表等信息
为了处理上面问题,或者说, 为了自己能够检测改版后的APP 是否符合要求, 我们就需要自己进行对APP进行检测和验证, 确保修改后达到效果 (第三方检测机构出的报告, 我们也要能自己给自己出 ╭(╯^╰)╮)
一通了解折腾以后, 我遇到了这么些框架/工具/demo, 这里逐个介绍一下 Xposed -> Dexposed -> Epic -> VirtualXposed ->HookLoginDemo
Xposed框架不用多说 rovo90 - Xposed , Xpose中文站 但是直接使用这个,需要走root,搞机等操作,较为麻烦
Dexposed 是阿里开源的 alibaba/dexposed,能在非root情况下掌控自己进程空间内的任意Java方法调用, 主要的就是用框架进行hook, 来抓取系统函数调用, 追踪三方SDK的启动情况/函数调用等
Epic - github 是weishu大神基于ART重新实现的一套 Dexposed( ART取代Dalvik成为Android的运行时,Dexposed无法支持更高版本的安卓系统) 这边一篇大神的Epic介绍
Epic可以在你自己的android工程中添加依赖,
dependencies {
compile 'com.github.tiann:epic:0.11.2'
}
然后可以通过
class ThreadMethodHook extends XC_MethodHook{
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Thread t = (Thread) param.thisObject;
Log.i(TAG, "thread:" + t + ", started..");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Thread t = (Thread) param.thisObject;
Log.i(TAG, "thread:" + t + ", exit..");
}
}
DexposedBridge.hookAllConstructors(Thread.class, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Thread thread = (Thread) param.thisObject;
Class<?> clazz = thread.getClass();
if (clazz != Thread.class) {
Log.d(TAG, "found class extend Thread:" + clazz);
DexposedBridge.findAndHookMethod(clazz, "run", new ThreadMethodHook());
}
Log.d(TAG, "Thread: " + thread.getName() + " class:" + thread.getClass() + " is created.");
}
});
DexposedBridge.findAndHookMethod(Thread.class, "run", new ThreadMethodHook());
提供的函数进行 hook操作, 但是只支持 inhook,就是你自己有android工程, 自己在项目里面添加epic依赖后, 自己写代码自己抓日志, 其实, 如果仅仅是为了验证 用户隐私确认前合规的改版后APP . Epic其实足够使用了. 借助Epic,我们可以通过移除SDK来比对监听偷跑的WIFI/MAC地址等行为是否还存在
贴一下我项目里面的一些调用 在MainActivity中, oncreat() 中调用就可以
implementation 'com.github.tiann:epic:0.11.2'
private String TAG ="ddddd";
private void startHook() {
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Object hookObj = param.thisObject;
String clsName = "unknownClass";
if (hookObj != null) {
clsName = hookObj.getClass().getName();
}
String mdName = "unknownMethod";
if (param.method != null) {
mdName = param.method.getName();
}
Log.d(TAG, "beforeHookedMethod: " + clsName + "-" + mdName);
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Object hookObj = param.thisObject;
String clsName = "unknownClass";
if (hookObj != null) {
clsName = hookObj.getClass().getName();
}
String mdName = "unknownMethod";
if (param.method != null) {
mdName = param.method.getName();
}
Log.d(TAG, "afterHookedMethod: " + clsName + "-" + mdName);
}
};
try {
DexposedBridge.hookAllMethods(BluetoothLeScanner.class, "startScan", hook);
DexposedBridge.hookAllMethods(TelephonyManager.class,"getDeviceId", hook);
DexposedBridge.hookAllMethods(WifiManager.class,"", hook);
DexposedBridge.hookAllConstructors(WifiManager.class, hook);
DexposedBridge.hookAllMethods(WifiManager.class,"getMacAddress", hook);
} catch (Throwable t) {
t.printStackTrace();
}
}
踩坑1: Epic依赖的minSdkVersion 是21 , 记得把项目的minSdkVersion改为一样否则跑不了程序
但是,检测机构是如何检测我们的APP?
猜测也是借助于 VirtualXposed 等工具,自行编写hook插件 , 来扫描 提交审核的 APP
VirtualXposed - 官方介绍及使用视频 是一个apk应用程序, 目的就是 过简单的 APP 使用 Xposed,无需 root、解锁引导加载程序或刷写系统映像。 具体使用开官网/或者视频,
VirtualXposed 是基于VirtualApp 和 epic 在非ROOT环境下运行Xposed模块的实现(支持5.0~10.0)。
我最后使用的是 0.20.3 版本, 其他版本都没成功, 要么唤起不了自己的APP, 要么安装不了 Xposed install 失败
这是目前我测试确认可以使用的环境:
设备: 小米 Mix2s
Androdi版本:10
VirtualXposed版本: 0.20.3
踩坑1: VirtualXposed 仅支持到 Android 5~10 , 在2022的今天, 也好在我的Mix2s还能使用, 一开始我用公司测试机 都是Android11, 不支持…
踩坑2: 确定自己项目的APP 是 32位 还是 64位 , 32位的仅仅能使用 0.18版本
HookLoginDemo 是一个插件(也是APP) , 基于这个插件 , 可以让我们方便的对我们的APP进行hook, 通过查看日志来方便的检测 在用户同意隐私前 我们的APP或第三方SDK, 有没有偷跑一些系统API, (就像检测机构对我们APP做的检测一样 )
当然, 也可以在 HookLoginDemo 中添加跟多的检测 比如SSID / WIFI列表. 只要稍微修改代码编译成apk 安装到手机, 接着就是 VirtualXposed 的时间了
贴一下 HookLoginDemo 中 HookLogin 代码的修改 (有错误的代码… 后面再完善)
package com.example.hooklogin;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.location.LocationManager;
import android.util.Log;
import android.widget.AutoCompleteTextView;
import android.widget.EditText;
import android.widget.Toast;
import java.lang.reflect.Field;
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
import static de.robv.android.xposed.XposedHelpers.findField;
public class HookLogin implements IXposedHookLoadPackage {
private static final String TAG = "HookLogin";
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) {
if (lpparam == null) {
return;
}
Log.e(TAG, "Load app packageName:" + lpparam.packageName);
XposedHelpers.findAndHookMethod(
android.telephony.TelephonyManager.class.getName(),
lpparam.classLoader,
"getDeviceId",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getDeviceId()获取了imei");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.telephony.TelephonyManager.class.getName(),
lpparam.classLoader,
"getDeviceId",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getDeviceId(int)获取了imei");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.telephony.TelephonyManager.class.getName(),
lpparam.classLoader,
"getSubscriberId",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getSubscriberId获取了imsi");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.net.wifi.WifiInfo.class.getName(),
lpparam.classLoader,
"getMacAddress",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getMacAddress()获取了mac地址");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.net.wifi.WifiInfo.class.getName(),
lpparam.classLoader,
"getWifiName",
new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getWifiName()获取了wifi地址");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.net.wifi.WifiInfo.class.getName(),
lpparam.classLoader,
"getSSID",
new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getSSID()获取了wifi地址");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.net.wifi.WifiInfo.class.getName(),
lpparam.classLoader,
"getBSSID",
new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getBSSID()获取了wifi地址");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
java.net.NetworkInterface.class.getName(),
lpparam.classLoader,
"getHardwareAddress",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getHardwareAddress()获取了mac地址");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
android.provider.Settings.Secure.class.getName(),
lpparam.classLoader,
"getString",
ContentResolver.class,
String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用Settings.Secure.getstring获取了" + param.args[1]);
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
XposedHelpers.findAndHookMethod(
LocationManager.class.getName(),
lpparam.classLoader,
"getLastKnownLocation",
String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("调用getLastKnownLocation获取了GPS地址");
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log(getMethodStack());
super.afterHookedMethod(param);
}
}
);
}
private String getMethodStack() {
StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
StringBuilder stringBuilder = new StringBuilder();
for (StackTraceElement temp : stackTraceElements) {
stringBuilder.append(temp.toString() + "\n");
}
return stringBuilder.toString();
}
}
抓到的一些日志
关于hooklogin插件
后续自己研究一下 , 计划写一个插件, 方便以后使用,
太极
太极 是VirtualXposed的兄弟版本, 具体怎么用还没尝试, 后续把使用计划回来补全
---- 以上基础工作完成----
2.RN端对安卓/iOS 隐私权限的处理方案
相关资料参考 如何在android原生中加载RN页面 RN打包生产jsbundle文件 RN加载Bundle的方式
简要介绍下项目当前情况, 纯RN项目一枚 在尽量不改动原版功能的基础上,做好隐私协议的前置(在各种RN组件/三方组件加载前),不同意就多次确认后,退出APP, 基本上就是这种思路
基于以上思路, 有两个 处理方案
1.方案一: RN工程中, 仍然使用一个bundle资源, 通过配置index.js / idnea.js 两个入口
在原生APP启动的时候(这里用安卓举例), 用户拒绝隐私->退出app 用户同意隐私-> 动态修改 getJSMainModuleName() 入口->重刷新MainActivity生命周期
@Override
protected String getJSMainModuleName() {
return "indea";
return "indeX";
}
在MaincActivity.java中添加重刷代码
public void destoryAndRecreate() {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
runOnUiThread(new Runnable() {
@Override
public void run() {
recreate();
}
});
}
}, 1000);
}
2.方案二 : 方案1的变种 ,区别是, 在Assets中放入一个极简的bundle包, 这意味着,我们打包的apk,将携带两个bundle 区别就在于: 方案一 加载的是一个包括indea.js /index.js 文件的, 名为index.android.bundle 的budle 方案二 分别加载的是 一个仅包含indea.js文件的名为indea.android.bundle 的bundle和 一个仅包含index.js文件的名为index.android.bundle 的bundle
同样的通过如下逻辑 用户拒绝隐私->退出app 用户同意隐私-> ->动态加载getBundleAssetName() 两个bundle->动态修改 getJSMainModuleName() 入口->重刷新MainActivity生命周期
重写的 getBundleAssetName() 代码举例
@Nullable
@org.jetbrains.annotations.Nullable
@Override
protected String getBundleAssetName() {
return "indea.android.bundle";
return "index.android.bundle";
}
采用方案二的考虑
1.双bundle,代码隔离/逻辑隔离 首先加载极简隐私bundle, 无任何三方SDK引用/无RN相关特殊组件影响,仅RN最基础UI组件逻辑, 如果是方案一, 混在一起不讨本人喜欢 2.方案一已经实现了, 方案二没实现过, 一定要搞! 不会就要搞!
Android + RN
依旧拿Android举例 Android端的原生加载RN源码,网上挺多, 这里记录一下个人的理解,方便后期自己查阅和修改 这里稍作记录
Android端的RN加载bundle过程
1.MainApplication中
public class MainApplication extends Application implements ReactApplication {
2.ReactApplication中发现
public interface ReactApplication {
ReactNativeHost getReactNativeHost();
}
3.ReactNativeHost中发现
String jsBundleFile = this.getJSBundleFile();
if (jsBundleFile != null) {
builder.setJSBundleFile(jsBundleFile);
} else {
builder.setBundleAssetName((String)Assertions.assertNotNull(this.getBundleAssetName()));
}
ReactInstanceManager reactInstanceManager = builder.build();
ReactMarker.logMarker(ReactMarkerConstants.BUILD_REACT_INSTANCE_MANAGER_END);
return reactInstanceManager;
源码基本写的较为明白, 优先从 this.getJSBundleFile() 加载bundleFile, 但是仔细看
@Nullable
protected String getJSBundleFile() {
return null;
}
getJSBundleFile() 默认是返回null的
这里也插一个小实践 ,如果是直接 react-native run-android 运行RN命令, RN会默认打包出debug模式的apk文件,帮我们install到手机/模拟器上去
注意: 在打包apk之前, react-native run-android 先是会打包出bundle文件:index.android.bundle , 这里打包出的index.android.bundle 是作为Assets资源在后面打包apk的过程中,存入了Assets资源目录 并不会帮我们存放到./android/app/src/main/assets/ 文件路径下, 所以在 ./android/app/src/main/assets/ 中找不到 index.android.bundle , 因为已经被打包到apk包的asset资源目录了 这个debug的apk, 就是自带了index.android.bundle的一个apk包
也因此无论是直接react-native run-android 还是gradlew直接打包apk, 我们如果不通过打包bundle的特殊命令,是无法直接获取bundle资源的, 想要打bundle包,得自己单独打包
继续说回getJSBundleFile() 源码这里很明确,RN就是这样设计的, jsBundleFile 默认是null,
if (jsBundleFile != null) {
builder.setJSBundleFile(jsBundleFile);
} else {
builder.setBundleAssetName((String)Assertions.assertNotNull(this.getBundleAssetName()));
}
andorid Bundle的加载逻辑:
1.优先从 getJSBundleFile()加载资源
2.如果没有,就从apk打包的Asset资源目录里面加载bundle(即 index.android.bundle这个默认的bundle)
这里就很明确了, 如果不做任何处理, 一定走入else逻辑分支, 从 getBundleAssetName() 中获取BundleAssetName , 继续看
@Nullable
protected String getBundleAssetName() {
return "index.android.bundle";
}
getBundleAssetName() 还真的就是默认返回一个 "index.android.bundle" 资源路径程序会在Assets中加载该名称的bundle
结合上面提过的小实践, 也印证了直接 react-native run-android , RN会默认打包出 index.android.bundle 存在Assets资源目录里面
我们再看看运行日志
这里要注意一点: 1.如果 getBundleAssetName() 在开发/调试模式, 并不会真的走asset资源下的bundle, 而是会走 pakcager server, 就是从我们的终端服务器中获取, 毕竟是为了要热刷新 2.如果是在打包出apk以后, getBundleAssetName() 会去asset资源中加载真正的 index.android.bundle , 当然如果加载不到, 表现就是启动后闪退啦
那么, 如果:
1.手动打包一个名为: indea.android.bundle 的bundle作为极简bundle, 存放于项目工程./android/app/scr/main/assets/ 中 (亦或者 ./android/app/scr/main/assets/bundleA/ , 自己添加一个bundleA的文件目录也是可以的)
- 重写 getJSMainModuleName(), 修改主入口为 :
indea
@Override
protected String getJSMainModuleName() {
return "indea";
}
3.重写 getBundleAssetName() ,指定加载 asset资源下的indea.android.bundle
@Nullable
@org.jetbrains.annotations.Nullable
@Override
protected String getBundleAssetName() {
return "indea.android.bundle";
or
return "bundleA/indea.android.bundle";
}
这三步处理好, 这不就是 方案二 么
Android几个函数的理解/记录
1.getJSMainModuleName() 的用法
用于在RN工程中, 寻找MainModule的文件入口, 讲白了就是在Android工程中,安卓原生启动后,会从这个接口指定RN层代码的入口
举个例子 RN端 我有一个index.js 入口 代码如下
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
AppRegistry.registerComponent(appName, () => App);
我还有个indea.js 入口 代码如下
import {AppRegistry} from 'react-native';
import {name as appName} from './app.json';
import TestApp from "./test/TestApp";
AppRegistry.registerComponent(appName, () => TestApp);
在Android端 如果不重写 ReactNativeHost 下的 getJSMainModuleName() 我们看到源码里面是这样的
protected String getJSMainModuleName() {
return "index.android";
}
安卓默认会去找一个叫做 index.android 的MainModule主入口js文件, 这里 可以重写 getJSMainModuleName() 改为寻找 我们上面两个例子的入口文件名
例子1
@Override
protected String getJSMainModuleName() {
return "index";
}
例子2
@Override
protected String getJSMainModuleName() {
return "indea";
}
分别运行安卓, 进入的效果也是分别进入了 App.js 和 TestApp.js
getJSMainModuleName() 就是一个用来指定 RN工程中, 主入口js文件的函数, 是getJSMainModuleName 选择了RN的主入口js文件, 当然如果不匹配就报错了
2.getBundleAssetName() 的用法
记得只要把打包的bundle文件 放在 ./android/app/scr/main/assets/ 文件夹下, 在打包apk的过程中会存放在apk包内,通过getBundleAssetName() 加载使用, (当然,采用方案二 , 我们会有俩bundle , indea.android.bundle 和默认的index.android.bundle ,apk包也会变大咯, 2022年了,大公司的apk,当然是越大越好啦 :XD )
记录下indea.android.bundle 打包/使用
1.bundle包-直接打包到./android/app/src/maic/assets/
react-native bundle --entry-file indea.js --bundle-output ./android/app/src/main/assets/indea.android.bundle --platform android --assets-dest ./android/app/src/main/assets/ --dev false --verbose
- 修改
MainApplication 中代码
@Override
protected String getJSMainModuleName() {
return "indea";
}
@Nullable
@org.jetbrains.annotations.Nullable
@Override
protected String getBundleAssetName() {
return "indea.android.bundle";
}
3.打包+安装 apk, 运行看效果
如果加一层文件夹路径呢? 也是可以的
1.bundle包-直接打包到./android/app/src/maic/assets/bundleA
react-native bundle --entry-file indea.js --bundle-output ./android/app/src/main/assets/bundleA/indea.android.bundle --platform android --assets-dest ./android/app/src/main/assets/bundleA/ --dev false --verbose
2.修改 MainApplication中代码
@Override
protected String getJSMainModuleName() {
return "indea";
}
@Nullable
@org.jetbrains.annotations.Nullable
@Override
protected String getBundleAssetName() {
return "bundleA/indea.android.bundle";
}
3.打包+安装 apk, 运行看效果
3.验证该极简bundle :indea.android.bundle
4. iOS 端… 略
|