Android 虚拟环境之虚拟环境检测,防止三方录屏等操作<二>

242 阅读6分钟

注意误杀,注意误杀,注意误杀!!!

重头戏来了

上篇分析了虚拟环境的大致分类,以及实现路径,接下来就剩下肝代码实现了.

牛马奋斗,开始.

1:虚拟机检测

虚拟机检测无非是判断常见的几种虚拟机对应他们的一些配置判断出他是虚拟机上运行

  • 系统配置
  • 手机架构(虚拟机大部分是x86架构)
  • 特定虚拟机的特定检测

1.1:对大部分默认配置的模拟器有效(Genymotion、Google Emulator、VirtualBox等)

   public static boolean isEmulator() {
        boolean checkProperty = Build.FINGERPRINT.startsWith("generic")
                || Build.FINGERPRINT.toLowerCase().contains("vbox")
                || Build.FINGERPRINT.toLowerCase().contains("test-keys")
                || Build.MODEL.contains("google_sdk")
                || Build.MODEL.contains("Emulator")
                || Build.MODEL.contains("Android SDK built for x86")
                || Build.MANUFACTURER.contains("Genymotion")
                || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
                || "google_sdk".equals(Build.PRODUCT);
        if (checkProperty) return true;

        String operatorName = "";
        TelephonyManager tm = (TelephonyManager) Utils.getApp().getSystemService(Context.TELEPHONY_SERVICE);
        if (tm != null) {
            String name = tm.getNetworkOperatorName();
            if (name != null) {
                operatorName = name;
            }
        }
        boolean checkOperatorName = operatorName.toLowerCase().equals("android");
        if (checkOperatorName) return true;

        String url = "tel:" + "123456";
        Intent intent = new Intent();
        intent.setData(Uri.parse(url));
        intent.setAction(Intent.ACTION_DIAL);
        boolean checkDial = intent.resolveActivity(Utils.getApp().getPackageManager()) == null;
        if (checkDial) return true;

//        boolean checkDebuggerConnected = Debug.isDebuggerConnected();
//        if (checkDebuggerConnected) return true;

        return false;
    }

1.2 万能的轮子 检测虚拟机场景的轮子

虚拟机检测的轮子:AntiFakerAndroidChecker

package com.snail.antifake.jni;

import android.content.Context;

import com.snail.antifake.deviceid.AndroidDeviceIMEIUtil;

/**
 * Author: snail
 * Data: 2017/7/20 下午4:46
 * Des:
 * version:
 */

public class EmulatorDetectUtil {

    static {
        System.loadLibrary("emulator_check");
    }

    public static native boolean detect();

    /**
     * 同时考虑特征值跟cache
     */
    public static boolean isEmulator(Context context) {
        return AndroidDeviceIMEIUtil.isRunOnEmulator(context) || detect();
    }
    /**
     * 只考虑cache,Android R之后,模拟器机制有变化,检测会有问题
     */
    public static boolean isEmulator() {
        return detect();
    }

    public void throwNativeCrash() {

    }
}

2.虚拟空间检测(重头戏)

接上文关机虚拟环境检测梳理,

虚拟空间,分为四种形式 我们重点处理宿主/沙箱和HOOK形式,HOOK 代码逻辑延时和接口配合可以处理.那么我们这里主要处理宿主/沙箱形式的虚拟空间问题

宿主/沙箱:

宿主(Host)就是设备的真实系统环境,沙箱(Sandbox)是虚拟隔离环境. 既,一个三方APP是一个宿主,打开你的APP运行在他的沙箱环境中,从而跨过你的一些配置.

回归正题,怎么防止这种方案的虚拟空间

2.1 检测 App 的 UID 是否异常(正常第三方App的UID一般>=10000)


/**
 * 检测 App 的 UID 是否异常(正常第三方App的UID一般>=10000)
 *
 * @return true 表示异常UID(可能多开环境)
 */
private fun isUidSuspicious(context: Context): Boolean {
    val uid = context.applicationInfo.uid
    return uid < 10000
}

2.2 检测硬件信息是否异常


/**
 * 检测硬件信息是否异常
 * 主要检测MAC地址是否为常见的虚拟空间默认值
 *
 * @return true 表示硬件信息异常
 */
fun checkHardwareInfo(context: Context): Boolean {
    val macAddress = getMacAddress()

    val virtualMacs = setOf(
        "00:00:00:00:00:00",
        "02:00:00:00:00:00",
        "aa:bb:cc:dd:ee:ff"
    )
    return virtualMacs.contains(macAddress.lowercase())
}

2.3 签名文件校验

这里是将签名文件存储到了SO库中相对安全一些,


object SignatureChecker {
    /**
    * 签名文件校验
     */
    fun isSignatureTampered(context: Context,shar265:String?,publicKey:String?,cn: String?): Boolean {
        return try {
            val packageInfo = getPackageInfo(context)
            val signatures = packageInfo.signatures ?: return true // 无签名,视为篡改

            // 校验每个签名(多签名场景)
            for (signature in signatures) {
                // 1. 校验签名哈希
                val signSha256 = getSignatureSha256(signature)
                if (signSha256 != shar265) {
                    return true
                }

                // 2. 校验证书公钥
                val key = getPublicKeyFromSignature(signature)
                if (key != publicKey) {
                    return true
                }

                // 3. 校验证书其他信息(如颁发者、有效期)
                val cert = getX509Certificate(signature)
                if (cert.issuerDN.name != cn || cert.notAfter.before(Date())) {
                    return true
                }
            }
            false // 所有校验通过,未篡改
        } catch (e: Exception) {
            true // 异常情况视为篡改(可能被Hook)
        }
    }


    private const val TAG = "PublicKeyExtractor"

    /**
     * 提取当前应用签名的公钥(Base64编码字符串)
     * @param context 上下文
     * @return 公钥字符串(可作为LEGAL_PUBLIC_KEY的值),失败返回null
     */
    fun extractPublicKey(context: Context): String? {
        return try {
            // 获取当前应用的签名信息
            val packageInfo: PackageInfo = context.packageManager.getPackageInfo(
                context.packageName,
                PackageManager.GET_SIGNATURES or PackageManager.GET_SIGNING_CERTIFICATES
            )

            // 获取签名(优先使用新API,兼容旧版本)
            val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                packageInfo.signingInfo.signingCertificateHistory
            } else {
                @Suppress("DEPRECATION")
                packageInfo.signatures
            }

            if (signatures.isNullOrEmpty()) {
                Log.e(TAG, "未找到应用签名")
                return null
            }

            // 解析第一个签名为X509证书(多签名场景需遍历)
            val signature: Signature = signatures[0]
            val certFactory = CertificateFactory.getInstance("X.509")
            val x509Certificate = certFactory.generateCertificate(
                signature.toByteArray().inputStream()
            ) as X509Certificate

            // 提取公钥并转为Base64字符串(去除换行和空格)
            val publicKeyBytes = x509Certificate.publicKey.encoded
            val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)

            Log.d(TAG, "提取到公钥:\n$publicKeyBase64")
            Log.d(TAG, "可直接用作LEGAL_PUBLIC_KEY的值")

            publicKeyBase64
        } catch (e: Exception) {
            Log.e(TAG, "提取公钥失败", e)
            null
        }
    }


    // 获取PackageInfo(避免直接用PackageManager,减少Hook风险)
    private fun getPackageInfo(context: Context): PackageInfo {
        val packageName = context.packageName
        return context.packageManager.getPackageInfo(
            packageName,
            PackageManager.GET_SIGNATURES or PackageManager.GET_SIGNING_CERTIFICATES
        )
    }

    // 计算签名的SHA256哈希
    private fun getSignatureSha256(signature: Signature): String {
        val md = MessageDigest.getInstance("SHA-256")
        md.update(signature.toByteArray())
        return bytesToHex(md.digest())
    }
    private fun getCertSha256(signature: Signature): String {
        val certFactory = CertificateFactory.getInstance("X.509")
        val cert = certFactory.generateCertificate(signature.toByteArray().inputStream()) as X509Certificate
        val md = MessageDigest.getInstance("SHA-256")
        val digest = md.digest(cert.encoded)
        return digest.joinToString(":") { "%02X".format(it) } // 带冒号的格式
    }

    /**
     * 从签名中提取公钥(正确方式)
     * @return 公钥的Base64编码字符串(与LEGAL_PUBLIC_KEY格式一致)
     */
    fun getPublicKeyFromSignature(signature: Signature): String {
        val cert = getX509Certificate(signature)
        // 获取公钥的原始字节数组,再转为Base64字符串(无换行)
        val publicKeyBytes = cert.publicKey.encoded
        return Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
    }

    /**
     * 解析签名为X509证书
     */
    private fun getX509Certificate(signature: Signature): X509Certificate {
        val certFactory = CertificateFactory.getInstance("X.509")
        return certFactory.generateCertificate(signature.toByteArray().inputStream()) as X509Certificate
    }


    // 字节数组转十六进制字符串
    private fun bytesToHex(bytes: ByteArray): String {
        val hexChars = CharArray(bytes.size * 2)
        for (i in bytes.indices) {
            val v = bytes[i].toInt() and 0xFF
            hexChars[i * 2] = "0123456789ABCDEF"[v ushr 4]
            hexChars[i * 2 + 1] = "0123456789ABCDEF"[v and 0x0F]
        }
        return String(hexChars)
    }
}

2.4 反射检测包名是否异常(是否被Hook或篡改)

/**
 * 反射检测包名是否异常(是否被Hook或篡改)
 *
 * @return true 表示异常
 */
private fun checkPackageName(context: Context): Boolean {
    try {
        val clazz = Class.forName("android.content.Context")
        val method = clazz.getMethod("getPackageName")
        val result = method.invoke(context) as String
        if (result != context.packageName) {
            return true
        }
    } catch (e: Exception) {
        // 反射异常可能是被Hook的迹象
        return true
    }
    return false
}

2.5 读取 /proc/self/maps 文件判断内存映射是否异常 (生效)

/**
 * 读取 /proc/self/maps 文件判断内存映射是否异常
 *
 * @return true 表示异常
 */
private fun readMapsByFile(context: Context): Boolean {
    val mapsFile = File("/proc/self/maps")
    if (!mapsFile.exists() || !mapsFile.canRead()) {
        Log.e("VirtualCheck", "/proc/self/maps 不存在或不可读")
        return false
    }
    try {
        BufferedReader(FileReader(mapsFile)).use { reader ->
            var line: String?
            while (reader.readLine().also { line = it } != null) {
                val currentLine = line!!.lowercase()
                Log.e("VirtualCheck数据:", currentLine)
                if (isAbnormal(currentLine, context.packageName)) {
                    return true
                }
            }
        }
    } catch (e: IOException) {
        Log.e("VirtualCheck", "读取 /proc/self/maps 失败: ${e.message}")
        e.printStackTrace()
    }
    return false
}

2.6 通过本地so读取 /proc/self/maps 内容判断异常(高版本android 读取不到 用c方式读取) (生效)

这里贴出来对比代码 c读取代码 自己写就行了

/**
* 判断路径是否异常(检测是否有非正常的映射路径)
*
* @param path 进程内存映射路径字符串
* @param packageName 应用包名
* @return true 表示异常
*/
private fun isAbnormal(path: String, packageName: String): Boolean {
   val tag = "/data/data/"
   if (!path.contains(packageName)) return false

   val result = path.split(packageName)
   result.forEach {
       if (it.startsWith(tag) && it.length > tag.length) {
           return true
       } else if (it.endsWith(tag)) {
           return false
       } else {
           val data = it.split(tag)
           if (data.size > 1) {
               return true
           }
       }
   }
   return false
}

2.7 通过类加载器路径检测是否包含虚拟空间关键词

/**
 * 通过类加载器路径检测是否包含虚拟空间关键词
 *
 * @return true 表示可疑路径
 */
private fun isClassLoaderPathSuspicious(): Boolean {
    return try {
        val classPath = this.javaClass.getClassLoader().toString().lowercase(Locale.getDefault())
        classPath.contains("virtual") || classPath.contains("xspace") ||
                classPath.contains("dual") || classPath.contains("multi") ||
                classPath.contains("parallel") || classPath.contains("cloner") ||
                classPath.contains("xposed")
    } catch (e: Exception) {
        false
    }
}

2.8 检测父进程是否属于虚拟空间多开进程(生效)



/**
 * 已知虚拟空间多开包名列表,可扩展
 */
private val VIRTUAL_SPACE_PACKAGES = setOf(
    "com.cmaster.cloner",        // Cloner 多开器
    "com.lbe.parallel",          // Parallel Space
    "com.vmos",                  // VMOS 虚拟系统
    "org.app.virtual",           // Virtual App
    "de.robv.android.xposed.installer", // Xposed 安装器
    "com.excelliance.dualaid",  // 双开助手
    "com.bly.dkplat",           // 多开分身
    "com.qihoo.magic",          // 360分身大师
    "com.oem.oemclone",         // 部分国产ROM自带多开
    "com.island",               // Island 多账户
    "com.shelter"               // Shelter
)



/**
 * 检测父进程是否属于虚拟空间多开进程
 *
 * @return true 表示父进程属于已知虚拟空间
 */
private fun isParentVirtualSpace(): Boolean {
    return try {
        val currentPid = Process.myPid()
        val statFile = File("/proc/$currentPid/stat")
        if (!statFile.exists()) return false

        // 读取stat文件,格式参考proc文档
        val statContent = statFile.readText().split(" ")
        if (statContent.size < 4) return false
        val ppid = statContent[3].toIntOrNull() ?: return false

        val parentCmdlineFile = File("/proc/$ppid/cmdline")
        if (!parentCmdlineFile.exists()) return false

        val parentProcessName = parentCmdlineFile.readText().trim()
        // 取包名或最后路径段
        val parentPackage = parentProcessName.substringAfterLast("/", parentProcessName)

        // 判断是否在虚拟空间已知包名列表中
        VIRTUAL_SPACE_PACKAGES.any { parentPackage == it }
    } catch (e: Exception) {
        false
    }
}

2.9 检测设备中是否安装了已知虚拟空间多开App(生效)


/**
 * 已知虚拟空间多开包名列表,可扩展
 */
private val VIRTUAL_SPACE_PACKAGES = setOf(
    "com.cmaster.cloner",        // Cloner 多开器
    "com.lbe.parallel",          // Parallel Space
    "com.vmos",                  // VMOS 虚拟系统
    "org.app.virtual",           // Virtual App
    "de.robv.android.xposed.installer", // Xposed 安装器
    "com.excelliance.dualaid",  // 双开助手
    "com.bly.dkplat",           // 多开分身
    "com.qihoo.magic",          // 360分身大师
    "com.oem.oemclone",         // 部分国产ROM自带多开
    "com.island",               // Island 多账户
    "com.shelter"               // Shelter
)


/**
 * 检测设备中是否安装了已知虚拟空间多开App
 *
 * @return true 表示安装了已知多开App
 */
private fun detectKnownVirtualApps(context: Context): Boolean {
    val pm = context.packageManager
    val packages = pm.getInstalledPackages(0)
    return packages.any { it.packageName in VIRTUAL_SPACE_PACKAGES }
}

2.10 检测挂载点是否含有虚拟环境标志字符串(未深入处理 类似self/maps方式)

/**
 * 检测挂载点是否含有虚拟环境标志字符串
 * 例如包含 vmos、virtual、parallel 等关键字
 *
 * @return true 表示存在虚拟挂载点
 */
private fun checkSuspiciousMounts(): Boolean {
    return try {
        BufferedReader(FileReader("/proc/self/cgroup")).use { br ->
            var line: String?
            while (br.readLine().also { line = it } != null) {
                Log.e("挂载点:", line ?: "")
                line?.let {
                    if (it.contains("vmos") || it.contains("virtual") || it.contains("parallel")) {
                        return true
                    }
                }
            }
            false
        }
    } catch (e: Exception) {
        e.printStackTrace()
        false
    }
}

3. 注意点

android 10 11 13 版本生效(某为,某O,某米测试

生效方案:

  • /proc/self/maps (读取数据匹配是否出现/data/data/xxxxxx/包名)
  • detectKnownVirtualApps(检测是否安装有虚拟空间的软件,需要根据基类增加)

注意:

  • /proc/self/maps文件读取有厂商和版本限制,读取不了可以尝试用c方式读取数据试试
  • 误杀,误杀,误杀!!!,切记检测留后门或者后台动态控制

总结

切记,版本,厂商,切记切记切记!!!

虚拟空间实现方式不同,需要用到多种方案尝试判断,我这里只是想到了这么多方式,有知道的大佬欢迎增加.