Risk 设计初衷
随着我们项目的用户群体不断壮大,渐渐的我们会从 Bugly 日志等地方发现一些灰产使用 Hook 、自动化脚本等对我们应用进行数据的抓取、对正常用户进行骚扰与欺诈,我们希望能够有一款框架能够对这些“非法用户”进行识别,这就是我们 Risk 框架设计的初衷。
Risk 原理
Hook 检测
典型框架:Xpatch、Xposed、太极
对于 Hook 来说自然不得不提大名鼎鼎的 Xposed 由于他是免费、开源的,Xposed 已经有了种种可以绕过常规检测的方法,由于市面上的检测代码并不准确,我们通过分析 Xposed 源码,找出了以下这几个方案进行检测。
方案1:Class.forName()
我们通过 bugly 上报上来的异常 Class 路径,人工筛选过后通过“配置中心”下发到 Risk 框架中通过 Class.forName() 来进行检测,这个方法较为准确,但是非常依靠人力来做异常 Class 路径的识别与下发,并且没法第一时间发现,只能作为后手方案。方案2:com.android.internal.os.ZygoteInit
我们如果去阅读 Xposed 源码,可以发现他是抢在我们 App 的 ZygoteInit 初始化之前初始化的,那这样我们就可以通过检测 exception 堆栈来进行识别,但是需要注意,Hook 可以隐藏自身的信息,详情参见:利用Xposed躲过Xposed检测 所以我们这边利用了一个双重检测,经过线上的测试发现双重检测(指的是最底层的俩层堆是否都为 ZygoteInit ,一般的 Hook 框架只隐藏最后一层堆)还是较为有效的,大部份 Hook 框架使用者并未发现这个检测方案,代码如下:
// 应用都是从 zygoteInit 初始化出来的,所以我们判断最底层是否是 zygote 就可以判断是否被hook了
if (!sZygoteInit.equals(exception.getStackTrace()[(exception.getStackTrace().length - 1)].getClassName())) {
if(sZygoteInit.equals(exception.getStackTrace()[(exception.getStackTrace().length - 2)].getClassName())){
checkCredit(isTrusted);
isTrusted = false;
next();
return;
}
}
- 方案3:Application.class.getSuperclass()
由于 Hook 存在二次打包后入侵 Application 进行应用内 Hook 的情况,这种框架十分难检测,它的原理大致是这样:二次打包目标应用,替换目标应用的 Application 并在替换后的 Application 的 static 方法块写上初始化 Hook 的相关代码,这样就能在第一次时间初始化 Hook 框架,所以我们需要校验 Application 的完整性,这里已线上项目为例,被二次打包前的代码:
public class XjbApplication extends BaseApplication {
private static final String TAG = "XjbApplication";
private static XjbApplication instance;
private XjbApplicationHelper xjbAppHelper = XjbApplicationHelper.getInstance();
private Context mApplicationContext;
public XjbApplication() {
super();
instance = this;
Loger.init(BuildConfig.DEBUG);
Log.i(TAG, "APP instanced");
}
.........
二次打包后的代码:
public class XjbApplication extends HookApplication {
private static final String TAG = "XjbApplication";
private static XjbApplication instance;
private XjbApplicationHelper xjbAppHelper = XjbApplicationHelper.getInstance();
private Context mApplicationContext;
public XjbApplication() {
super();
instance = this;
Loger.init(BuildConfig.DEBUG);
Log.i(TAG, "APP instanced");
}
.........
public class HookApplication extends Application {
static {
Hook.init();
}
.........
所以,根据以上的情况我们先校验代码的完整性:
XjbApplication.class.getSuperclass();
BaseApplication.class.getSuperclass();
需要特别注意,有些入侵式 Hook 框架会更改 AndroidManifest.xml 中声明的 Application ,暂时还没找到什么比较好的检测方案。
多开检测
典型框架:virtualApp
关于多开检测网上的一些方案都十分有效,难点是由于多开框架众多,我们需要集成进大量的检测代码,下面分享俩个较为有效的方案
方案1:Context.getCacheDir()
VirtualApp、dkplugin 等框架在生成文件目录的时候,往往生成的目录很奇怪,例如
nativeLibraryDirectories=[/data/user/0/dkplugin.aix.ttr/virtual/data/user/0/com.xingjiabi.shengsheng/lib]
特别注意,检测 nativeLibraryDirectories 目录十分有效方案2:/proc/self/maps
/proc/self/maps 中出现包含 /vbox/data/ 、 /shadow/data/ 、 /virtual/data/ 的动态库,则运行在多开环境下。由于许多多开软件都是开源的,不排除某些大手子自己改名重新编译。
模拟器检测
模拟器检测并无太多技巧,主要检测 CPU 架构、ROM 名称、手机是否一直在充电中、电池电量等。
但是模拟器的系统应用都有一个特点,就是它们的 nativeLibraryDir 最终目录都是 x86,MuMu模拟器、逍遥模拟器、蓝叠模拟器、夜神模拟器、雷电模拟器 都经过验证,无一例外针对这个漏洞进行检测,准确率会比较高。
/**
* @author:杨浩
* 创建日期:2019-12-19
* 功能简介:用于检测虚拟机的工具类
* aosp:Android Open-Source Project 一般虚拟机都是基于这个开发的
* 目前能检测到的模拟器有:MuMu模拟器、逍遥模拟器、蓝叠模拟器、夜神模拟器、雷电模拟器、480 * 800 分辨率的脚本
*/
public class AntiAospUtils {
private static final String SCAN_DEVICE_TIME = "s_aosp_device_time";
/**
* 开始扫描设备信息
*
* @param accountId 账号,用于保存上一次扫描的时间,每隔 3 天才会扫描一次,如果扫描到模拟器就上报
* @param contex
*/
public static void startScanDeviceInfo(final String accountId, final Context contex) {
startScanDeviceInfo(accountId, contex, null);
}
/**
* 开始扫描设备信息
*
* @param accountId 账号,用于保存上一次扫描的时间,每隔 3 天才会扫描一次,如果扫描到模拟器就上报
* @param context
* @param scanDeviceListener 扫描完成回调
*/
public static void startScanDeviceInfo(final String accountId, final Context context, final ScanDeviceListener scanDeviceListener) {
ScanDevicePlanWrapper scanScreenInfo = new ScanDevicePlanWrapper(new ScanScreenInfo());
ScanDevicePlanWrapper scanAppInfo = new ScanDevicePlanWrapper(new ScanAppInfo());
ScanDevicePlanWrapper scanCpuInfo = new ScanDevicePlanWrapper(new ScanCpuInfo());
scanCpuInfo.setNextScanDevicePlanWrapper(scanAppInfo);
scanAppInfo.setNextScanDevicePlanWrapper(scanScreenInfo);
DeviceScanInfo deviceScanInfo = scanCpuInfo.scanDevice(context);
// 判断是否可疑设备 或者是否模拟器设备,都需要上报
if (deviceScanInfo.isFaker() || deviceScanInfo.isBadDevice()) {
LogUploadUtil.postAospDeviceLog(deviceScanInfo);
}
if (scanDeviceListener != null) {
scanDeviceListener.onComplete(deviceScanInfo);
}
}
private static class ScanDevicePlanWrapper implements ScanDevicePlanAble {
/**
* 下一个扫描器
*/
@Nullable
public ScanDevicePlanWrapper mNextScanDevicePlanWrapper;
@NotNull
public ScanDevicePlanAble mScanDevicePlan;
public ScanDevicePlanWrapper(ScanDevicePlanAble scanDevicePlan) {
mScanDevicePlan = scanDevicePlan;
}
public void setNextScanDevicePlanWrapper(ScanDevicePlanWrapper nextScanDevicePlanWrapper) {
mNextScanDevicePlanWrapper = nextScanDevicePlanWrapper;
}
@Override
public DeviceScanInfo scanDevice(Context context) throws Exception {
@Nullable
DeviceScanInfo nextDeviceScanInfo = null;
if (mNextScanDevicePlanWrapper != null) {
nextDeviceScanInfo = mNextScanDevicePlanWrapper.scanDevice(context);
// 判断是否需要扫描
if (!isAllScanInfo() && nextDeviceScanInfo.isBadDevice()) {
return nextDeviceScanInfo;
}
}
DeviceScanInfo currentDeviceScanInfo = mScanDevicePlan.scanDevice(context);
if (nextDeviceScanInfo != null) {
// 如果其他扫描器扫描出来有用的信息就保存下来
String scanInfoTemp = nextDeviceScanInfo.getScanInfo();
currentDeviceScanInfo.setScanInfo(scanInfoTemp + " || " + currentDeviceScanInfo.getScanInfo());
if (nextDeviceScanInfo.isBadDevice()) {
// 发现模拟器
currentDeviceScanInfo.setBadDevice(true);
} else if (nextDeviceScanInfo.isFaker()) {
// 发现疑似模拟器
currentDeviceScanInfo.setFaker(true);
}
}
return currentDeviceScanInfo;
}
@Override
public boolean isAllScanInfo() {
return mScanDevicePlan.isAllScanInfo();
}
}
private interface ScanDevicePlanAble {
/**
* 扫描设备
*
* @param context
* @return
* @throws Exception
*/
@NotNull
public DeviceScanInfo scanDevice(Context context) throws Exception;
/**
* 是否需要完整的扫描信息,因为这边的扫描器是链式的
* return true 的情况下,会将所有的链式扫描器跑一遍,为的是完整的模拟器信息
* return false 的情况下,只要有其中一个扫描器扫描到信息,本扫描器将不扫描信息
*
* @return
*/
public boolean isAllScanInfo();
}
/**
* 扫描 cpu 的架构信息
*/
private static class ScanCpuInfo implements ScanDevicePlanAble {
@Override
public DeviceScanInfo scanDevice(Context context) throws Exception {
if (checkDeviceForumX86()) {
return new DeviceScanInfo("scanCpuInfo:x86 == true", true);
} else {
return new DeviceScanInfo("scanCpuInfo:x86 == false", false);
}
}
/**
* cpu 架构信息不重要,如果之前其他扫描器已经扫描到了,这里就不需要工作
*
* @return
*/
@Override
public boolean isAllScanInfo() {
return false;
}
}
/**
* 针对 app 做扫描
* 模拟器的系统应用都有一个特点,就是它们的 nativeLibraryDir 最终目录都是 x86
* MuMu模拟器、逍遥模拟器、蓝叠模拟器、夜神模拟器、雷电模拟器 都经过验证,无一例外
* 针对这个漏洞进行检测,准确率会比较高
*/
private static class ScanAppInfo implements ScanDevicePlanAble {
/**
* 模拟器身上的标记
*/
private static final String BAD_TAG = "x86";
// --------------------- 需要扫描的包名 ---------------------
/**
* 拨打电话
*/
private final String CALL = "com.android.server.telecom";
/**
* 通讯录
*/
private final String CONTACTS = "com.android.contacts";
/**
* 网页渲染器
*/
private final String WEB_VIEW = "com.android.webview";
/**
* 系统设置
*/
private final String SYSTEM_SETTING = "com.android.settings";
/**
* Android 默认的浏览器
*/
private final String SYSTEM_BROWSER = "com.android.browser";
/**
* 需要扫描的应用包名
*/
private final String[] ALL_SCAN_PACKAGE_INFO = new String[]{CALL, CONTACTS, WEB_VIEW, SYSTEM_SETTING, SYSTEM_BROWSER};
@Override
public DeviceScanInfo scanDevice(Context context) throws Exception {
// 判断是否扫描成功过
// 正常的手机不太可能一个应用都没有找到
// 如果出现这种情况的话,一般只有俩种可能,1、系统没给权限(默认都是给的)2、被 Hook 了
// 这种情况下需要考虑一下这个设备是否是有问题的了
boolean isScanDeviceComplete = false;
PackageManager packageManager = context.getPackageManager();
if (packageManager == null) {
return new DeviceScanInfo("scanPackageInfo:packageManager == null", false, isScanDeviceComplete);
}
for (String scanPackageInfo : ALL_SCAN_PACKAGE_INFO) {
try {
PackageInfo packageInfo = packageManager.getPackageInfo(scanPackageInfo, PackageManager.GET_ACTIVITIES);
if (packageInfo != null) {
String nativeLibraryDir = packageInfo.applicationInfo.nativeLibraryDir;
// 如果 nativeLibraryDir 没有获取到的话,非常可疑
if (nativeLibraryDir != null) {
isScanDeviceComplete = true;
if (nativeLibraryDir.contains(BAD_TAG)) {
return new DeviceScanInfo("scanPackageInfo:" + scanPackageInfo + "." + BAD_TAG, true);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return new DeviceScanInfo("scanPackageInfo:scanPackageInfo == null", false, !isScanDeviceComplete);
}
@Override
public boolean isAllScanInfo() {
return true;
}
}
/**
* 扫描屏幕的宽高与物理尺寸来区分模拟器
* 目前发现针对的脚本,都需要限定屏幕的尺寸,就算他进行了 hook 也不太可能针对获取屏幕分辨率进行处理
* 所以这里检测屏幕分辨率
*/
private static class ScanScreenInfo implements ScanDevicePlanAble {
// --------------------- 可疑的屏幕分辨率 ---------------------
/**
* 貌似脚本会固定这个宽高,先检测看看
*/
private static final Integer SCREEN_WIDTH[] = new Integer[]{480, 540};
private static final Integer SCREEN_HEIGHT[] = new Integer[]{800, 960};
@Override
public DeviceScanInfo scanDevice(Context context) throws Exception {
DisplayMetrics dm = context.getResources().getDisplayMetrics();
int screenWidth = dm.widthPixels;
int screenHeight = dm.heightPixels;
// 判断是否是可疑宽高
if (Arrays.asList(SCREEN_HEIGHT).contains(screenHeight) && Arrays.asList(SCREEN_WIDTH).contains(screenWidth)) {
// 计算屏幕物理尺寸
double diagonalPixels = Math.sqrt(Math.pow(screenWidth, 2) + Math.pow(screenHeight, 2));
double size = new BigDecimal(diagonalPixels / (160 * dm.density)).setScale(1, BigDecimal.ROUND_HALF_UP).doubleValue();
return new DeviceScanInfo("ScanScreenInfo:badPixel width-" + screenWidth + "、height-" + screenHeight + "-size:" + size, true);
}
return new DeviceScanInfo("ScanScreenInfo:normal《 " + "width-" + screenWidth + "、height-" + screenHeight + " 》", false);
}
@Override
public boolean isAllScanInfo() {
return true;
}
}
/**
* 扫描设备回调
*/
public interface ScanDeviceListener {
public void onComplete(DeviceScanInfo info);
}
/**
* 扫描的结果信息
*/
public static class DeviceScanInfo {
/**
* 扫描的结果
*/
private String mScanInfo = "";
/**
* 是否模拟器
*/
private boolean isBadDevice = false;
/**
* 是否是可疑的设备
*/
private boolean isFaker = false;
public DeviceScanInfo(String scanInfo, boolean isBadDevice, boolean isFaker) {
mScanInfo = scanInfo;
this.isBadDevice = isBadDevice;
this.isFaker = isFaker;
}
public DeviceScanInfo(String scanInfo, boolean isBadDevice) {
mScanInfo = scanInfo;
this.isBadDevice = isBadDevice;
}
public String getScanInfo() {
return mScanInfo;
}
public void setScanInfo(String scanInfo) {
mScanInfo = scanInfo;
}
public boolean isBadDevice() {
return isBadDevice;
}
public void setBadDevice(boolean badDevice) {
isBadDevice = badDevice;
}
public boolean isFaker() {
return isFaker;
}
public void setFaker(boolean faker) {
isFaker = faker;
}
@NonNull
@Override
public String toString() {
if (isBadDevice) {
return "BadDevice: " + isBadDevice + "。" + mScanInfo;
}
return "Faker: " + isFaker + "," + mScanInfo;
}
}
}
/**
* @author:杨浩 项目:haibaobase
* 创建日期:2019-09-03
* 功能简介:
*/
class DeviceUtils {
public static final String ABI_X86 = "x86";
public static final String ABI_MIPS = "mips";
public static enum ARCH {
Unknown, ARM, X86, MIPS, ARM64,
}
private static ARCH sArch = ARCH.Unknown;
// see include/uapi/linux/elf-em.h
private static final int EM_ARM = 40;
private static final int EM_386 = 3;
private static final int EM_MIPS = 8;
private static final int EM_AARCH64 = 183;
// /system/lib/libc.so
// XXX: need a runtime check
public static synchronized ARCH getMyCpuArch() {
byte[] data = new byte[20];
File libc = new File(Environment.getRootDirectory(), "lib/libc.so");
if (libc.canRead()) {
RandomAccessFile fp = null;
try {
fp = new RandomAccessFile(libc, "r");
fp.readFully(data);
int machine = (data[19] << 8) | data[18];
switch (machine) {
case EM_ARM:
sArch = ARCH.ARM;
break;
case EM_386:
sArch = ARCH.X86;
break;
case EM_MIPS:
sArch = ARCH.MIPS;
break;
case EM_AARCH64:
sArch = ARCH.ARM64;
break;
default:
Log.e("NativeBitmapFactory", "libc.so is unknown arch: " + Integer.toHexString(machine));
break;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fp != null) {
try {
fp.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
return sArch;
}
public static String get_CPU_ABI() {
return Build.CPU_ABI;
}
public static String get_CPU_ABI2() {
try {
Field field = Build.class.getDeclaredField("CPU_ABI2");
if (field == null)
return null;
Object fieldValue = field.get(null);
if (!(fieldValue instanceof String)) {
return null;
}
return (String) fieldValue;
} catch (Exception e) {
}
return null;
}
public static boolean supportABI(String requestAbi) {
String abi = get_CPU_ABI();
if (!TextUtils.isEmpty(abi) && abi.equalsIgnoreCase(requestAbi))
return true;
String abi2 = get_CPU_ABI2();
return !TextUtils.isEmpty(abi2) && abi.equalsIgnoreCase(requestAbi);
}
public static boolean supportX86() {
return supportABI(ABI_X86);
}
public static boolean supportMips() {
return supportABI(ABI_MIPS);
}
public static boolean isARMSimulatedByX86() {
ARCH arch = getMyCpuArch();
return !supportX86() && ARCH.X86.equals(arch);
}
public static boolean isMiBox2Device() {
String manufacturer = Build.MANUFACTURER;
String productName = Build.PRODUCT;
return manufacturer.equalsIgnoreCase("Xiaomi")
&& productName.equalsIgnoreCase("dredd");
}
public static boolean isMagicBoxDevice() {
String manufacturer = Build.MANUFACTURER;
String productName = Build.PRODUCT;
return manufacturer.equalsIgnoreCase("MagicBox")
&& productName.equalsIgnoreCase("MagicBox");
}
public static boolean isProblemBoxDevice() {
return isMiBox2Device() || isMagicBoxDevice();
}
public static boolean isRealARMArch() {
ARCH arch = getMyCpuArch();
return (supportABI("armeabi-v7a") || supportABI("armeabi")) && ARCH.ARM.equals(arch);
}
public static boolean isRealX86Arch() {
ARCH arch = getMyCpuArch();
return supportABI(ABI_X86) || ARCH.X86.equals(arch);
}
/**
* 检测设备是否是 x86
*
* @return
*/
public static boolean checkDeviceForumX86() {
return isRealX86Arch() || isARMSimulatedByX86() || supportX86();
}
}
二次打包检测
由于所有二次打包的检测都会被 Hook 绕过,所以请先检测 Hook ,特别是二次打包后入侵 Application 进行应用内 Hook 的情况,所以不可信任任何 Java 层的代码,下列方案都是在 JNI 层执行。
- 方案1:通过读取 Apk 的 Zip包信息进行校验
如何开发 Risk 框架
因为 Risk 设计之初就是以代码被反编译的情况下,也能保证逻辑不被发现并且正确工作,所以需要有很多额外的设计,请后期维护 Risk 框架的同事请按照以下设计思路:
设计代码蜜罐
用最简单的名称例如 RiskManager 或者明显的字符串,让破解者在第一时能找到,这块代码不可信任,出现问题或者被移除都不影响真正的流程,一定要避免被混淆。保证逻辑分散
在保证逻辑连贯性的前提下,将代码分散开,用 extends 来将代码分布在各个子类、父类中。尽量混淆
用 JNI 代替原生代码
不要使用能够被阅读的包名
设计指南
对于 api 相关的设计推荐大量加入盐方法并且分散,以防止被发现核心逻辑。
· Hook 检测
// 初始化 hook 监听,必须在主线程中执行
new HookManager()
// 假方法,迷惑反编译者
.fakerMethods()
// 假方法,迷惑反编译者
.fakerInitHook()
// 真初始化方法
.initHookManager()
// 真方法 获取需要观测的对象,这里的数据推荐使用配置中心下载
.saveInfo("Xposed==com.bly.chaos0-0de.robv.android.xposed.XposedBridge0-0de.robv.android.xposed.installer0-0xposed0-0de.robv.android.xposed.XposedHelper")
// 假方法,迷惑反编译者
.fakerMethods2()
// 假方法,迷惑反编译者
.startHookObserver()
.fakerJniScanHook()
// 真正 jni 检测的方法
.jniScanHook()
// 假方法,迷惑反编译者
.fakerJniScanHook2();