我的 Android 重构之旅:Hook与模拟器检测

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();

你可能感兴趣的:(我的 Android 重构之旅:Hook与模拟器检测)