[iOS逆向] 某相机 Frida反检测绕过 + VIP解锁 + Theos插件制作 全流程
前言
目标App: 某相机 com.ydgn.dokacamera v1.6.5
测试环境: iPhone 13 Pro Max iOS 16.1 (Dopamine越狱) + macOS + Frida 17.6.2
难度等级: (主要难在反Frida检测的绕过)
本帖记录从 Frida 注入闪退 → 绕过检测 → 追踪请求参数 → VIP解锁 → 最终制作成 Theos 插件的完整过程。
声明:本文仅供技术学习交流,请勿用于商业用途。
1 初步分析
通过抓包发现,App 使用 Alamofire 进行网络请求,请求头中包含一个自定义的 User-Agent-Follow 字段,这个字段中的 deviceUUID 用于标识设备,也是服务器的实际校验依据,并且是完全单一校验,随便改值都能过。但是在抓包工具中每次都改值还是太麻烦了,所以先直接 frida 看他怎么生成的:
{
"os_type": "ios",
"Device-ID": "iPhone14,3",
"deviceUUID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", #uuid 格式
"doka_version": "1.6.5",
"device_model": "iPhone 13 Pro Max",
"os_version": "16.1"
}
目标:搞清楚 deviceUUID 怎么生成的,以及解锁 VIP。
2 Frida 注入 — 遭遇反检测
直接用 Frida spawn 模式启动,并且写了简单的 bypass.js:
frida -U -f com.ydgn.dokacamera -l bypass.js
结果:App 闪退,闪退,闪退。。。。。。。,后面加入异常捕获处理,控制台刷出大量 access-violation 异常。
检测原理分析
通过多轮测试发现:
- 即使脚本为空也闪退 — 说明不是 Hook 触发的,而是检测到 Frida 本身
- 崩溃地址固定在
0x???fbec — 位于 App 主二进制的 __mod_init_func 段
- 该构造函数在
main() 之前由 dyld 自动执行
- 它位于 Execute-Only Memory(只可执行不可读写),无法 patch
App 的检测逻辑大致为:
构造函数() → 检测 Frida 特征 → 触发 access-violation → 进程崩溃
失败的尝试
| 方案 |
结果 |
原因 |
Hook strstr/strcmp 过滤 Frida 字符串 |
卡死 |
系统调用太频繁,性能崩溃 |
Memory.patchCode() 直接 NOP 检测代码 |
失败 |
Execute-Only 内存,无读权限 |
Memory.protect("rwx") 修改权限 |
SIGKILL |
触发内核 W^X 保护 |
3 绕过方案:异常处理器 + 帧指针链回溯
既然不能阻止检测代码运行,那就让它崩,但是我们接住。
核心思路
检测代码触发 access-violation
↓
Process.setExceptionHandler 捕获异常
↓
沿 ARM64 帧指针链 (FP/x29) 向上回溯
↓
找到 dyld 中的返回地址
↓
修改 PC 跳转到 dyld → 跳过整个检测函数
↓
App 正常启动 ✓
关键代码
Process.setExceptionHandler(function(details) {
// 只处理 access-violation
if (details.type !== "access-violation") return false;
var ctx = details.context;
var fp = ctx.x29; // ARM64 帧指针
var mods = Process.enumerateModules();
// 沿帧指针链向上回溯,找 dyld 的返回地址
for (var i = 0; i < 20; i++) {
if (fp.isNull() || fp.compare(ptr(0x1000)) < 0) break;
try {
var saved_fp = fp.readPointer();
var saved_lr = fp.add(8).readPointer();
// 检查返回地址是否在某个已知模块内
for (var j = 0; j < mods.length; j++) {
var base = mods[j].base;
var end = base.add(mods[j].size);
if (saved_lr.compare(base) >= 0 && saved_lr.compare(end) < 0) {
// 跳转到 dyld 的返回地址,跳过检测
ctx.pc = saved_lr;
ctx.x29 = saved_fp;
ctx.x0 = ptr(0);
return true; // 已处理异常
}
}
fp = saved_fp;
} catch (_) { break; }
}
return false;
});
为什么这样做?
ARM64 的栈帧结构:
高地址
┌──────────────┐
│ 返回地址 (LR) │ ← FP + 8
├──────────────┤
│ 上一级 FP │ ← FP (x29)
├──────────────┤
│ 局部变量 │
└──────────────┘
低地址
沿 FP 链一直往上追,找到第一个属于 dyld 模块的返回地址,然后让 PC 跳过去——相当于告诉 CPU:这个函数已经执行完了,回去吧。
4 补充防护:Anti-Debug + 隐藏 Frida 痕迹
绕过异常后,还需要防止 App 的其他检测手段:
Anti-Debug(反调试)
// 1. ptrace — 阻止 PT_DENY_ATTACH
Interceptor.attach(Module.findExportByName(null, "ptrace"), {
onEnter: function(args) {
if (args[0].toInt32() === 31) args[0] = ptr(0); // PT_DENY_ATTACH = 31
}
});
// 2. sysctl — 清除 P_TRACED 标志位
Interceptor.attach(Module.findExportByName(null, "sysctl"), {
onLeave: function(retval) {
// 在 kinfo_proc 结构体中清除调试标志
}
});
// 3. getppid — 返回 1 (launchd),伪装非调试状态
Interceptor.attach(Module.findExportByName(null, "getppid"), {
onLeave: function(retval) { retval.replace(ptr(1)); }
});
隐藏 Frida 痕迹
// 重命名 frida-agent.dylib
Interceptor.attach(Module.findExportByName(null, "_dyld_get_image_name"), {
onLeave: function(retval) {
var name = retval.readUtf8String();
if (name && name.indexOf("frida") !== -1) {
retval.replace(Memory.allocUtf8String("/usr/lib/libSystem.B.dylib"));
}
}
});
阻止进程自杀
Hook exit、abort、kill 等函数,阻止 App 自我终止。
5 追踪请求参数生成
成功进入 App 后,用 Frida Hook 追踪 User-Agent-Follow 的生成过程。
核心 Hook 点
// 监控 HTTP 请求头设置
Interceptor.attach(
ObjC.classes.NSMutableURLRequest["- setAllHTTPHeaderFields:"].implementation, {
onEnter: function(args) {
var headers = new ObjC.Object(args[2]);
if (headers.toString().indexOf("User-Agent-Follow") !== -1) {
console.log("捕获到请求头:", headers.toString());
}
}
});
发现
| 字段 |
来源 |
device_model |
Device-ID 硬编码映射表(iPhone14,3 → iPhone 13 Pro Max) |
os_version |
UIDevice.systemVersion |
Device-ID |
utsname().machine |
doka_version |
Info.plist 的 CFBundleShortVersionString |
deviceUUID |
KeyChain 钥匙串存储(见下文) |
deviceUUID 来源分析
通过 Hook SecItemCopyMatching 发现:
SecItemCopyMatching({
acct = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // 账户名 (MD5 哈希)
class = "genp", // 通用密码
svce = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // 服务名
pdmn = "ck", // 保护域
})
关键发现:UIDevice.identifierForVendor 从未被调用!UUID 是 App 首次安装时随机生成并存入 KeyChain 的。由于 KeyChain 数据在卸载后仍保留,即使重装 App UUID 也不变——实现了设备级持久标识。
6 VIP 解锁
只是为了看着舒服,谁不想装一波呢?实际每次构图请求修改--deviceUUID--即可。
当然也是为了防止每次启动只有五次 ai 构图次数,用完又得重启 app,有了 vip 标志后,我们除开服务器的校验, app 本身的使用限制就小了很多。
方法1:修改 JSON 响应
Hook NSJSONSerialization 修改服务器返回的 VIP 状态:
Interceptor.attach(
ObjC.classes.NSJSONSerialization["+ JSONObjectWithData:options:error:"].implementation, {
onLeave: function(retval) {
var result = new ObjC.Object(retval);
if (result.toString().indexOf("is_vip") === -1) return;
var mutableData = new ObjC.Object(result.objectForKey_("data")).mutableCopy();
var mutableRoot = result.mutableCopy();
mutableData.setObject_forKey_(ObjC.classes.NSNumber.numberWithBool_(true), "is_vip");
mutableData.setObject_forKey_(
ObjC.classes.NSString.stringWithString_("2099-12-31 23:59:59"), "expire_time");
mutableData.setObject_forKey_(ObjC.classes.NSNumber.numberWithInt_(9999), "remaining_count");
mutableRoot.setObject_forKey_(mutableData, "data");
retval.replace(mutableRoot);
}
});
结果:只有次数修改成功了,vip 和时间都没有成功显示。
方法2:覆写本地缓存(关键!)
只改 JSON 不够!App 还在 NSUserDefaults 中缓存 VIP 状态。
通过诊断监控发现了精确的 key:
[Defaults] objectForKey("VipManager.expiryDate") = 0001-01-01 00:00:00 +0000 ← 已过期的时间
[Defaults] objectForKey("VipManager.originalTransactionId") = nil ← 无购买记录凭证
[Defaults] integerForKey("VipManager.freeUseCount") = 0 ← 无次数
精准覆写这三个 key:
Interceptor.attach(ObjC.classes.NSUserDefaults["- objectForKey:"].implementation, {
onEnter: function(args) { this.key = new ObjC.Object(args[2]).toString(); },
onLeave: function(retval) {
if (this.key === "VipManager.expiryDate") {
retval.replace(ObjC.classes.NSDate.dateWithTimeIntervalSince1970_(4102444799.0));
}
if (this.key === "VipManager.originalTransactionId") {
retval.replace(ObjC.classes.NSString.stringWithString_("530000123456789"));
}
}
});
7 UUID 随机化
每次请求都随机生成全新设备身份,确保每次 AI 构图使用不同的 UUID,无限次数:
function newRandomDevice() {
var device = pickRandom(DEVICES); // 15 款 iPhone 随机选
return {
deviceUUID: randomUUID(), // 全新 UUID
deviceID: device.id, // 如 iPhone15,2
deviceModel: device.model, // 如 iPhone 14 Pro
osVersion: pickRandom(device.versions) // 匹配的 iOS 版本
};
}
在 setAllHTTPHeaderFields: 中每次都调用 newRandomDevice() 替换 JSON 字段。每个请求都是一台"新设备",服务器永远不会限制次数。
在 setAllHTTPHeaderFields: 中替换 JSON 字段,每次启动身份不同但同一次运行内保持一致。
8 制作 Theos 插件
最终将所有功能转换为 Theos Tweak,安装后无需 Frida。
项目结构
dokavip/
├── Makefile # 构建配置
├── control # Debian 包信息
├── DokaVip.plist # 注入过滤(只对目标App生效)
└── Tweak.x # 核心代码(Logos 语法)
Tweak.x 核心片段
VIP 解锁(Logos 语法比 Frida 简洁很多):
%hook NSJSONSerialization
+ (id)JSONObjectWithData:(NSData *)data options:(NSJSONReadingOptions)opt error:(NSError **)error {
id result = %orig;
if (![result isKindOfClass:[NSDictionary class]]) return result;
NSDictionary *dataDict = result[@"data"];
if (!dataDict[@"is_vip"]) return result;
NSMutableDictionary *mData = [dataDict mutableCopy];
NSMutableDictionary *mRoot = [result mutableCopy];
mData[@"is_vip"] = @YES;
mData[@"expire_time"] = @"2099-12-31 23:59:59";
mData[@"remaining_count"] = @9999;
mRoot[@"data"] = mData;
return mRoot;
}
%end
Anti-Debug(用 MSHookFunction Hook C 函数):
%ctor {
MSHookFunction(dlsym(RTLD_DEFAULT, "ptrace"), (void *)hook_ptrace, (void **)&orig_ptrace);
MSHookFunction(dlsym(RTLD_DEFAULT, "sysctl"), (void *)hook_sysctl, (void **)&orig_sysctl);
MSHookFunction(dlsym(RTLD_DEFAULT, "getppid"), (void *)hook_getppid, (void **)&orig_getppid);
}
UUID 随机化(每次请求新身份):
%hook NSMutableURLRequest
- (void)setAllHTTPHeaderFields:(NSDictionary *)fields {
if (!fields[@"User-Agent-Follow"]) { %orig; return; }
// 每次请求生成全新设备身份
NSDictionary *dev = newRandomDevice();
// 解析 JSON → 替换 → 序列化回去
NSMutableDictionary *parsed = [...];
parsed[@"deviceUUID"] = dev[@"deviceUUID"]; // 全新随机 UUID
parsed[@"Device-ID"] = dev[@"Device-ID"]; // 随机机型标识
parsed[@"device_model"] = dev[@"device_model"]; // 对应机型名
parsed[@"os_version"] = dev[@"os_version"]; // 匹配的 iOS 版本
%orig(mutableFields);
}
%end
构建与安装
# 构建
cd dokavip && make package
# 安装直接通过巨魔注入对应app即可
9 总结
技术要点
- 反 Frida 检测绕过:异常处理器 + ARM64 帧指针链回溯,跳过 Execute-Only Memory 中的检测构造函数
- 请求参数追踪:Hook
SecItemCopyMatching 发现 UUID 存储在 KeyChain(非 identifierForVendor)
- VIP 解锁:JSON 响应修改 + NSUserDefaults
VipManager.* 本地缓存覆写(两层都要改)
- UUID 随机化:在 HTTP Header 设置阶段替换 JSON 字段
- 插件化:Frida JS → Theos Logos 转换,注意异常处理器是 Frida 独有 API,在 Substrate 注入模式下不需要
踩坑记录
| 坑 |
教训 |
Hook strstr/strcmp 导致卡死 |
高频系统函数不能无差别 Hook |
Memory.protect("rwx") 被 SIGKILL |
iOS 内核 W^X 保护不可绕过 |
| 只改 JSON 没改 UserDefaults |
App 有本地缓存,两层都要改 |
setTimeout(1000) 第一个请求没拦到 |
用 setImmediate 保证 Hook 在首请求前安装 |
NSDateFormatter 在 setImmediate 中崩溃 |
用 NSDate.dateWithTimeIntervalSince1970: 替代 |
Hook 全部 boolForKey: 导致 App 崩溃 |
NSUserDefaults 调用极其频繁,只能精准匹配 key |
工具链:Frida 17.6.2 + Theos + Dopamine 越狱
如有问题欢迎交流讨论 🙏
本文仅供技术学习交流,请勿用于商业用途。
结果展示
