21、移动端反外挂(iOS/Android):越狱/root检测、注入检测、模拟器识别
移动端反外挂,说白了就是一场猫鼠游戏。我做了这么多年安全,最深的体会就是:没有绝对的安全,只有不断升级的对抗。今天咱们聊聊iOS和Android上的三个核心战场——越狱/root检测、注入检测、模拟器识别。
嗯,先别急着看代码。你得先理解一个道理:外挂作者也是人,他们也在研究你的检测逻辑。所以我们的策略是——多层防御,动态变化。
一、越狱/root检测:你手机越狱了吗?
越狱和root,说白了就是获取了系统的最高权限。外挂有了这个权限,就能干很多坏事——修改游戏内存、注入代码、绕过签名校验。所以第一步,就是判断设备是否被“开刀”了。
1.1 iOS越狱检测
iOS越狱检测,我习惯分三步走:文件检测、沙盒检测、动态检测。
文件检测:越狱设备通常会留下一些标志性文件。比如:
// 常见越狱文件路径
NSArray *jailbreakPaths = @[
@"/Applications/Cydia.app",
@"/Library/MobileSubstrate/MobileSubstrate.dylib",
@"/bin/bash",
@"/usr/sbin/sshd",
@"/etc/apt"
];
for (NSString *path in jailbreakPaths) {
if ([[NSFileManager defaultManager] fileExistsAtPath:path]) {
// 检测到越狱
return YES;
}
}
我在项目中遇到过一个问题:有些用户只是越狱了,但没装Cydia。所以光靠文件检测不够,还得配合沙盒检测。
沙盒检测:越狱设备能访问系统沙盒外的文件。试试能不能在/private目录下创建文件:
// 尝试写入系统目录
NSError *error;
NSString *testPath = @"/private/jailbreak_test.txt";
[@"test" writeToFile:testPath atomically:YES encoding:NSUTF8StringEncoding error:&error];
if (!error) {
// 能写入系统目录,说明越狱了
return YES;
}
动态检测:调用fork()或system()函数。越狱设备上这些函数通常不会被沙盒限制。
1.2 Android root检测
Android的root检测,思路类似,但更复杂。因为Android碎片化严重,不同厂商的root方式不一样。
我常用的检测方法:
- 检测su文件:
/system/bin/su、/system/xbin/su、/sbin/su等 - 检测root管理应用:SuperSU、Magisk Manager等
- 检测系统属性:
ro.debuggable、ro.secure等 - 检测挂载点:
/system分区是否可写
// Android root检测示例
public boolean isRooted() {
// 检测su文件
String[] paths = {
"/system/bin/su",
"/system/xbin/su",
"/sbin/su",
"/system/sd/xbin/su"
};
for (String path : paths) {
if (new File(path).exists()) {
return true;
}
}
// 检测Magisk
try {
Process process = Runtime.getRuntime().exec("magisk -v");
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()));
String line = reader.readLine();
if (line != null && !line.isEmpty()) {
return true;
}
} catch (IOException e) {
// 没有Magisk
}
return false;
}
我曾经遇到一个坑:有些手机厂商自己就预置了su文件,但用户并没有root。所以单纯检测su文件会误报。我的解决办法是——组合检测,多个条件同时满足才判定为root。
二、注入检测:谁在往游戏里塞东西?
注入,是外挂最常用的手段。iOS上常见的是Cycript、Substrate注入;Android上则是Xposed、Frida、LSPosed。
2.1 iOS注入检测
iOS的注入检测,我主要看两点:动态库加载和函数Hook。
动态库检测:遍历当前进程加载的所有动态库,看看有没有可疑的:
// 检测可疑动态库
uint32_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
const char *name = _dyld_get_image_name(i);
NSString *libName = [NSString stringWithUTF8String:name];
// 黑名单
if ([libName containsString:@"Substrate"] ||
[libName containsString:@"Cycript"] ||
[libName containsString:@"frida"]) {
// 发现注入
return YES;
}
}
但外挂作者也不傻。他们会把动态库改名,或者用dlopen手动加载。所以光靠名字检测不够。
函数Hook检测:检查关键函数是否被Hook。比如objc_msgSend、malloc、free等。方法是对比函数地址是否在预期范围内:
// 检查objc_msgSend是否被Hook
IMP original = class_getMethodImplementation(objc_getMetaClass("NSObject"),
@selector(alloc));
// 获取libobjc.A.dylib中的原始地址
Dl_info info;
dladdr((void*)original, &info);
// 如果地址不在libobjc范围内,说明被Hook了
2.2 Android注入检测
Android的注入检测,核心是检测Xposed框架和Frida。
Xposed检测:
- 检测
/data/data/de.robv.android.xposed.installer/目录 - 检测
XposedBridge.jar是否在ClassLoader中 - 检测
android.app.ActivityThread的currentActivityThread方法是否被Hook
// Xposed检测
public boolean isXposedInstalled() {
try {
// 尝试加载Xposed类
ClassLoader classLoader = getClassLoader();
classLoader.loadClass("de.robv.android.xposed.XposedBridge");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
Frida检测:Frida的检测比较麻烦,因为它可以运行在多种模式下。我常用的方法:
- 检测
/data/local/tmp/frida-server进程 - 检测
frida相关端口(27042默认端口) - 检测
linenoise、gum-js-loop等Frida特征字符串
说实话,Frida的检测很难做到100%。我见过最狠的做法是——在native层定时扫描/proc/self/maps,看看有没有可疑的内存映射。
三、模拟器识别:你用的是真机还是模拟器?
模拟器识别,这个我太有发言权了。很多外挂作者喜欢在模拟器上跑脚本,因为方便调试。所以识别模拟器,能挡住一大批低端外挂。
3.1 常见模拟器特征
| 特征类型 | 检测方法 | 说明 |
|---|---|---|
| 硬件特征 | CPU型号、GPU渲染器 | 模拟器通常使用特定的虚拟硬件 |
| 系统属性 | ro.product.model、ro.build.fingerprint | 模拟器有固定的属性值 |
| 传感器 | 光线传感器、加速度计 | 模拟器通常没有真实传感器 |
| 网络特征 | IP地址、MAC地址 | 模拟器网络环境与真机不同 |
| 文件系统 | /system/build.prop、/proc/cpuinfo | 模拟器文件内容有特定模式 |
3.2 Android模拟器检测代码
// 模拟器检测
public boolean isEmulator() {
// 检测硬件
String cpuInfo = readFile("/proc/cpuinfo");
if (cpuInfo.contains("intel") || cpuInfo.contains("amd")) {
// 真机ARM,模拟器通常是x86
return true;
}
// 检测系统属性
String model = Build.MODEL;
if (model.contains("sdk") || model.contains("emulator")) {
return true;
}
// 检测传感器
SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
if (sensorManager.getSensorList(Sensor.TYPE_ALL).size() < 5) {
// 真机通常有10+个传感器
return true;
}
// 检测电话功能
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
if (tm.getDeviceId() == null || tm.getDeviceId().equals("000000000000000")) {
return true;
}
return false;
}
3.3 iOS模拟器检测
iOS模拟器检测相对简单。因为iOS模拟器运行在x86架构上,而真机是ARM架构:
// iOS模拟器检测
#if TARGET_IPHONE_SIMULATOR
// 模拟器环境
return YES;
#else
// 真机环境
return NO;
#endif
但要注意,这个宏只在编译时有效。如果外挂作者直接修改了编译配置,那就没用了。所以我建议在运行时也做检测:
// 运行时检测
#include <sys/utsname.h>
struct utsname systemInfo;
uname(&systemInfo);
NSString *machine = [NSString stringWithCString:systemInfo.machine
encoding:NSUTF8StringEncoding];
// 模拟器的machine值通常是x86_64或i386
if ([machine isEqualToString:@"x86_64"] || [machine isEqualToString:@"i386"]) {
return YES;
}
四、知识体系总览
说了这么多,我画了一张图帮你理清思路。移动端反外挂的三个核心方向,以及它们之间的关联:
五、实战建议
说了这么多理论,最后给点实在的。我这些年踩过的坑,总结成几条:
1. 检测代码要分散
别把所有检测逻辑写在一个函数里。外挂作者一旦Hook了那个函数,你的检测就全废了。我习惯把检测点分散到游戏的不同模块,甚至不同线程里。
2. 检测结果要上报
客户端检测到异常,别直接弹窗说“检测到外挂”。那样等于告诉外挂作者“你的方法有效”。我建议静默上报到服务器,让服务器做最终判定。
3. 定期更新检测库
外挂技术在进步,你的检测库也得跟着更新。我建议每个月至少更新一次检测特征库,包括新的越狱文件路径、新的模拟器特征等。
嗯,移动端反外挂这块,说白了就是一场持续的攻防战。没有一劳永逸的方案,只有不断迭代的策略。你想想看,外挂作者也是要吃饭的,他们也在研究你的检测逻辑。所以我们的目标不是100%防住,而是提高门槛,让外挂作者觉得“搞这个游戏不划算”。
好了,这一章就聊到这儿。记住:检测是基础,响应才是关键。下一章咱们聊聊检测到外挂之后,该怎么处理。