0. 引言:一次真实 iOS 混合 App 的 AI 逆向实践
本文记录一次 iOS 逆向实践:目标 App 启动后被第三方安全 SDK 拦截,直接出现 Jailbroken Device Detected 阻断弹窗。
这个 App 不是简单 Demo,而是 Native Swift/ObjC 主体 + Flutter 模块 + 第三方安全 SDK + 混淆字符串 + 动态 selector 的混合型 App。正常人工分析难点在于:检测链路长、关键字符串不完全明文、Swift/ObjC/Flutter/ZDefend 多栈交织,而且全量 hook 还可能触发额外 tamper 风险。

这次尝试把分析过程尽量交给 AI:人工只给初始提示词和方向,AI 负责读上下文、写 Frida probe、跑 UI 自动化、整理日志、定位调用链、生成证据矩阵,最终把链路收敛为:
ZDefend rule -> 系统检测函数 -> threat/status -> TargetApp 消费点 -> UIKit alert builder
本文重点记录这个过程:先用截图确认 App 确实存在 jailbreak 检测,再展示 AI 如何一步步还原链路,最后说明本地绕过为什么选择“结果消费层 sanitize”,而不是盲目 hook 所有底层检测函数。
0.1 实战结果:绕过前 vs 绕过后
绕过前,目标 App 启动后直接被 jailbreak 检测拦截,只能看到阻断弹窗:

完成分析并在结果消费层做 sanitize patch 后,App 可以正常进入主界面,不再被启动期 jailbreak 弹窗阻断:

这两个截图对应本文的核心目标:不是简单隐藏一个弹窗,而是先还原 ZDefend rule -> threat/status -> App UI 消费点 的完整链路,再选择副作用更小的本地绕过位置。
0.2 开始分析前:真实提示词片段
这次不是先人工完整分析完再让 AI 总结,而是先给 AI 一个总体方向,再不断让它沿着证据继续深挖。下面是本次推动 Jailbreak 检测链路分析的真实提示词片段;为了不偏离本文主题,只保留和安全检测链路相关的部分:
# 最终通用 iOS 登录/验证码接口逆向提示词
你扮演一名资深 iOS 逆向工程师。当前任务只在本地授权 CTF/测试环境中进行。目标是通过 **已有上下文读取 + 静态分析 + 运行时验证 + Python replay 复现**,还原目标 iOS App 的登录、验证码、注册、重置密码、token/bootstrap/refresh 等认证相关接口,并输出可脱离原 App 独立运行的纯 Python 复现脚本。
最终结果必须能够清楚区分:
- 网络不可达 / timeout
- HTTP 空响应
- 参数错误
- 签名 / requestId / 加密错误
- 账号、密码、验证码等业务错误
- 服务端异常
- 成功请求
重要原则:**不要预设目标一定有自研签名。** 如果实际使用 Supabase、Firebase、Auth0、Cognito、Clerk、OAuth、GraphQL、WebView/H5 或其它第三方认证体系,应以静态和运行时证据为准,按真实 wire protocol 复现。
---
## 0. 工作目录与产物约定
当前工程目录可能包含:
```text
.ipa / .app / Payload/
主二进制 / Frameworks/
Info.plist / ProjectConfig.plist / buildInfo.conf
strings / nm / otool / class-dump / IDA 输出
Frida 脚本与日志
iOS MCP 调用脚本、UI 截图、元素树
历史 Python replay 脚本、state、请求响应 capture
```
所有新产物继续写入:
```text
analysis/
analysis/frida_scripts/
analysis/captures/
docs/
output/
```
不要把长期有效 access token、refresh token、session、Keychain secret 明文写入最终文档或最终回复。文档中使用:
```text
<server_issued_access_token>
<server_issued_refresh_token>
<device_id>
<session_id>
```
state 文件可以用于本地验证,但文档和总结必须脱敏。
---
## 1. 先读取已有上下文,避免重复从零开始
如果存在以下文件,必须先读取并延续已有结论:
```text
docs/login_reverse_summary.md
docs/next_prompt.md
analysis/header_bootstrap_logic.md
analysis/captures/runtime_key_excerpts.md
analysis/captures/python_*_*.json
analysis/captures/frida_*_login*.log
output/*_login_replay.py
output/*_runtime_state.json
```
先判断每条已有结论的证据类型:
```text
运行时事实 > Python replay 验证 > 静态反编译推断 > strings/grep 猜测
```
如果已有结论和运行时证据冲突,以运行时最终请求为准。
---
## 2. 先做基础状态与网络确认
确认 App、设备、Frida、iOS MCP、网络是否可用:
```bash
frida-ps -Uai | grep -i '<app_or_bundle_keyword>'
python3 ios_mcp_call.py call get_frontmost_app '{}'
python3 ios_mcp_call.py call get_screen_info '{}'
python3 ios_mcp_call.py call get_ui_elements '{"max_depth":25,"max_elements":3000}'
```
如果存在本地 CTF 服务地址,例如 `http://192.168.2.1`,先测:
```bash
curl -i -sS --max-time 5 http://<local-ip>/
```
如果 timeout / connection refused / no route to host,要记录为:
```text
Mac 到本地服务不可达
```
不要误判为 Python 脚本、参数或签名失败。
---
## 3. 先判断认证体系类型
静态和运行时都要判断目标属于哪类认证体系:
```text
1. Supabase / Firebase / Auth0 / Cognito / Clerk 等 BaaS/Auth SDK
2. OAuth / Apple / Google / Facebook 等第三方 token exchange
3. 自研 REST API
4. GraphQL
5. WebView / H5 登录
6. React Native / Flutter / Hybrid 自定义桥接网络层
```
识别依据包括:
```text
base URL / host
SDK 字符串
anon key / api key / client id / project id
Authorization 格式
token 缓存 key
接口 path
运行时最终 JSON body/query
请求 headers
响应 body
```
如果是 BaaS/Auth SDK:
- 不要强行假设存在自研 `sign` / `requestId`。
- 公开 client key / anon key 可以作为客户端配置使用。
- 服务端签发 token 不要伪造,应通过 login / otp / refresh / bootstrap 正常获得。
- Python replay 应按 SDK 的真实 HTTP 协议复现。
如果是自研 API:
- 再重点追踪 `sign`、`signature`、`requestId`、`nonce`、`timestamp`、HMAC、AES、RSA、URL encoding 等逻辑。
---
## 4. 静态分析任务
### 4.1 App 基础信息
提取并记录:
```text
Bundle ID
App 名称
版本号 / build
主二进制名称
Frameworks
最低系统版本
URL Scheme / Associated Domains
ATS 配置
```
### 4.2 环境与 base URL
定位:
```text
生产 / 测试 / staging / debug 环境切换逻辑
API base URL
CDN / gateway / auth host
GraphQL endpoint
WebView/H5 URL
本地 CTF 域名重定向关系
```
### 4.3 网络层定位
重点查找:
```text
API Manager / Service / Router / TargetType / Endpoint / RequestBuilder
Moya / Alamofire / AFNetworking / NSURLSession
Supabase / Firebase / Auth0 / Cognito / GraphQL Client
React Native RCTNetworking
Flutter MethodChannel / Dio / http client
WebView bridge
```
要还原:
```text
method
path
query
body
headers
timeout
content-type
公共 interceptor / middleware
```
### 4.4 登录相关接口范围
至少覆盖:
```text
密码登录
获取验证码 / 重发验证码
验证码登录
注册 / 注册验证码
忘记密码 / 重置密码验证码
token refresh
bootstrap / init / anonymous session
第三方登录 token exchange,如果存在
登出,如果会影响 session/token
```
### 4.5 签名 / 加密 / 编码定位
只在有证据时深入,不要先入为主。
搜索关键词:
```text
sign / signature / requestId / nonce / timestamp / ts
hmac / sha1 / sha256 / md5
AES / RSA / CCCrypt / CCHmac / CC_SHA / CC_MD5
encrypt / decrypt / encode / urlEncode / base64
buildHeaders / buildRequest / appendPublicParams
```
如果存在自研签名,必须还原:
```text
canonical string 组成
参与签名的 query/body/header 字段
参数排序规则
空值/null/数组/嵌套对象处理方式
URL encode 在签名前还是签名后
timestamp 来源、单位、精度
nonce/requestId 生成方式
secret/key 来源:硬编码、配置、服务端下发、本地派生
hash/HMAC/RSA/AES 算法细节
输出大小写、hex/base64 格式
GET query 与 POST body 是否统一参与签名
```
如果没有自研签名,要明确写:
```text
N/A: runtime evidence indicates no custom sign/requestId
```
---
## 5. Runtime token / device / session 来源追踪
不要硬编码 Frida 抓到的 token、session、ST、UT、device id、install id、push token。
必须追踪来源:
```text
本地生成:UUID / install id / device id / random nonce
本地缓存:NSUserDefaults / Keychain / SQLite / plist / AsyncStorage
服务端下发:bootstrap / anonymous session / login response / refresh response
第三方下发:Apple / Google / Firebase / APNS / FCM
```
如果某个 header 是服务端签发 token/JWT:
- 不要伪造服务端签名。
- 应在 Python 中复现 App 的 bootstrap / login / refresh 请求来获取。
- 如果无法获取,必须说明阻塞点和证据。
建议文档化 state schema:
```json
{
"app": "<app_name>",
"bundle_id": "<bundle_id>",
"base_url": "https://<host>",
"device_id": "<device_id>",
"install_id": "<install_id>",
"access_token": "<server_issued_access_token>",
"refresh_token": "<server_issued_refresh_token>",
"session_id": "<session_id>",
"expires_at": 0,
"updated_at": 0
}
```
---
## 6. Frida hook 策略
Frida hook 至少覆盖 request-side:
```text
NSURLSession dataTask/uploadTask
NSURLSessionTask resume
NSMutableURLRequest setURL:
NSMutableURLRequest setHTTPMethod:
NSMutableURLRequest setHTTPBody:
NSMutableURLRequest setValue:forHTTPHeaderField:
NSMutableURLRequest addValue:forHTTPHeaderField:
NSMutableURLRequest setAllHTTPHeaderFields:
```
同时根据 App 类型覆盖:
```text
业务入口:login / auth / otp / verify / register / reset / refresh
公共参数:appendHttpPublic / buildRequest / buildHeaders / interceptor
存储:NSUserDefaults / Keychain / SQLite / AsyncStorage / plist
Crypto:CC_MD5 / CC_SHA* / CCHmac / CCCrypt / SecKey / RSA
React Native:RCTNetworking / fetch / XMLHttpRequest bridge
WebView:WKWebView loadRequest / evaluateJavaScript / messageHandler
Flutter:MethodChannel / URLSession / Dart 层网络桥接,如能定位
```
启动示例:
```bash
frida -U -f <bundle_id> \
-l analysis/frida_scripts/<app>_login_hooks.js \
-o analysis/captures/frida_<app>_login.log
```
如果当前 Frida 版本不支持 `--no-pause`,不要加该参数。
如果 wrapping completion block 导致 App 或 FridaAgent 崩溃,则降级为稳定 request-side hook;响应由 Python replay 捕获。
---
## 7. 用 iOS MCP 真实触发 UI 流程
你可以通过 iOS MCP 服务操作一台 iPhone 设备。
MCP 地址: http://192.168.1.20:8090/mcp
支持的操作:
\- 触控:点击、滑动、长按、双击、拖拽
\- 文字输入:快速粘贴输入、逐字键盘模拟、特殊键(回车、删除等)
\- 硬件按键:Home、电源、音量、静音
\- 截图(screenshot 返回 MCP image content,不是 text;图片 base64 在 result.content[0].data,mimeType 通常是 image/jpeg)
\- App 管理:启动、关闭、列表、安装 IPA(无需签名)、卸载
\- UI 无障碍:获取当前页面节点树、坐标查询元素
\- 剪贴板:读写剪贴板内容
\- 设备控制:锁屏/解锁、亮度、音量
\- 打开 URL 或 URL Scheme
\- Shell 命令执行
\- 设备信息:型号、iOS 版本、电池、存储
操作规则:
1. 开始前先获取当前前台 App、UI 节点和必要截图。
2. 交互时优先根据 UI 节点执行点击和输入,不要盲点。
3. 页面变化后重新读取 UI 节点,再继续下一步。
4. 如果目标元素不明显,先截图再判断。
5. 处理 screenshot 结果时,按 image content 解析,不要读取 result.content[0].text。
必须尽量通过 UI 真实触发,而不是只看静态代码。
触发范围:
```text
密码登录
获取验证码 / 重发验证码
验证码登录
忘记密码 / 重置密码验证码
注册 / 注册验证码
第三方登录 token exchange,如果有入口
```
使用 UI tree / 截图定位,不要盲点:
```bash
python3 ios_mcp_call.py call get_ui_elements '{"max_depth":25,"max_elements":3000}'
python3 ios_mcp_call.py call tap_screen '{"x":<x>,"y":<y>}'
python3 ios_mcp_call.py call input_text '{"text":"<test@example.com>"}'
python3 ios_mcp_call.py save_image screenshot analysis/captures/ios_screenshot_<step>.jpg '{}'
```
保存关键 UI 状态:
```text
analysis/captures/ios_ui_<step>.json
analysis/captures/ios_screenshot_<step>.jpg
```
如果 MCP UI tree 失败,要保存错误、截图和替代证据;不要因此停止静态分析或 Python replay。
---
## 8. 从 Frida 日志提取接口证据
使用类似命令提取关键线索:
```bash
grep -nE 'REQ_SET_URL|REQ_SET_METHOD|REQ_SET_BODY|REQUEST|RCT_NETWORKING|CRYPTO_IN|CRYPTO_OUT|Authorization|token|refresh|otp|verify|login|password|register|reset|apikey|client-info|device|install|push|sign|requestId|signature' \
analysis/captures/frida_<app>_login.log | tail -300
```
每个接口必须记录:
```text
场景:密码登录 / 验证码发送 / 验证码登录 / 重置密码 / 注册 / refresh / bootstrap
认证体系类型:自研 / Supabase / Firebase / Auth0 / GraphQL / WebView / 其它
method:
path:
base_url:
headers:
运行时最终 body/query:
公共参数:
token/session/device/push 来源:
requestId/sign preimage/value(如存在):
是否 AES/HMAC/RSA/编码:
服务端响应:
错误分类:
证据来源:Frida 日志行号 / IDA 函数 / Python capture 文件
```
关键原则:**运行时最终 key 优先**。不要只相信 Swift/ObjC/JS 方法入参标签。
例如静态看到:
```text
account / userPsd / isEmail
```
运行时可能实际发送:
```text
email / password / loginType / msgToken
```
最终 replay 以运行时请求为准。
---
## 9. Python replay 脚本要求
输出:
```text
output/<app>_login_replay.py
output/<app>_runtime_state.json
```
脚本必须可脱离原 App 独立运行。
### 9.1 CLI 参数
至少支持:
```text
--dry-run
--send
--api <name>
--base-url
--sign-base-url
--timestamp
--request-id-preimage
--expect-sign
--param key=value
--header key=value
--state-file output/<app>_runtime_state.json
--refresh-auth
--no-auto-auth
--no-runtime-headers
--timeout
```
建议支持:
```text
--method
--path
--body-json
--proxy
--insecure
--verbose
--save-capture
```
### 9.2 参数和 header 处理
要求:
1. CLI 可接受静态字段别名,但最终发送运行时真实 key。
2. 显式 `--header` 优先级最高。
3. `--base-url` 与 `--sign-base-url` 分离,便于本地 IP 直连但按生产 host 构造 canonical/sign。
4. 公共参数结构必须和运行时一致。
5. Content-Type、User-Agent、locale、timezone、app version 等 headers 要按运行时证据复现。
### 9.3 dry-run 输出
`--dry-run` 必须输出:
```text
api name
method
final URL
final headers
final body/query
sorted params/body
canonical string
requestId/sign preimage
requestId/sign value 或 N/A: no custom signing
expect-sign check 结果
```
如果有 `--expect-sign`:
```text
-- expect-sign check --
OK: <sign/requestId>
```
或:
```text
FAIL:
expected=<value>
actual=<value>
```
### 9.4 send 输出
`--send` 必须输出:
```text
status
response headers
response body,JSON 自动格式化
error/timeout 摘要
classification
capture 文件路径
```
请求和响应保存到:
```text
analysis/captures/python_<app>_<api>_<timestamp>.json
```
capture 建议包含:
```json
{
"request": {
"method": "POST",
"url": "https://<host>/<path>",
"headers": {},
"body": {}
},
"response": {
"status": 200,
"headers": {},
"body": {}
},
"classification": "ok",
"error": null
}
```
### 9.5 自动 runtime bootstrap
如果缺少 token、session、ST、UT、device id、install id、anon key 等运行时字段:
1. 先查 `--header` / `--param` 显式输入。
2. 再查 state 文件。
3. 如允许 auto auth,则调用 bootstrap / anonymous / login / refresh 获取。
4. 获取后写回 state。
5. 如果 `--no-auto-auth`,不得自动联网 bootstrap。
6. 如果 `--refresh-auth`,强制重新获取并覆盖 state。
7. 如果 `--no-runtime-headers`,完全关闭自动运行时 headers。
---
## 10. 错误分类规则
必须按以下优先级分类:
```text
network_timeout: timeout / 连接超时
network_unreachable: connection refused / DNS / no route / unreachable
server_empty_body: HTTP 有响应但 body 空
ok: HTTP 2xx 且请求语义正常,尤其先于文本关键字扫描
business_auth_failed: invalid credentials / user not found / password error / session_not_found / refresh_token_not_found
business_otp_failed: invalid OTP / expired OTP / invalid verification token
parameter_error: missing parameter / invalid parameter / validation error
signature_or_requestid_error: 明确 invalid sign / invalid signature / invalid requestId / timestamp invalid
server_error: HTTP 5xx
unknown_error: 无法归类
```
重要修正:
- HTTP 2xx 要优先考虑 `ok`,不要仅因 body 包含 `sign` 字段就误判签名错误。
- 不要因为响应字段包含 `last_sign_in_at`、`sign_in_provider` 等词就误判为 sign 错误。
- 账号不存在、密码错误、验证码错误通常说明请求已到业务层,不等于签名失败。
- timeout/connection refused 是网络问题,不是签名问题。
---
## 11. 验证命令模板
### 11.1 dry-run 复现 Frida 抓到的 sign/requestId
仅在存在自研签名时使用:
```bash
python3 output/<app>_login_replay.py \
--dry-run \
--api <login_password|login_code|send_code|reset_code> \
--request-id-preimage '<Frida_CRYPTO_IN_preimage>' \
--expect-sign '<Frida_body_requestId_or_sign>' \
--param account='<test@example.com>' \
--param pwd='<password>' \
--param isEmail=true
```
预期:
```text
-- expect-sign check --
OK: <sign/requestId>
```
如果无自研签名,应输出:
```text
N/A: runtime evidence indicates no custom sign/requestId
```
### 11.2 send 到生产/测试环境
```bash
python3 output/<app>_login_replay.py \
--send \
--api login_password \
--param account='<test@example.com>' \
--param pwd='<password>' \
--param isEmail=true \
--timeout 15
```
### 11.3 send 到本地 CTF IP
```bash
python3 output/<app>_login_replay.py \
--send \
--api login_password \
--base-url http://<local-ip> \
--sign-base-url https://<production-host> \
--param account='<test@example.com>' \
--param pwd='<password>' \
--param isEmail=true \
--timeout 15
```
如果 timeout,要明确打印:
```text
classification=network_timeout
status=None
body=<empty>
```
如果 HTTP 有响应但 body 为空,要明确打印:
```text
classification=server_empty_body
status=<status_code>
body=<empty>
```
---
## 12. 证据矩阵要求
最终文档中每个关键结论都要尽量包含证据来源。
建议表格:
```text
结论 | 类型 | 证据来源 | 文件/函数/日志行 | 可信度 | 备注
```
类型包括:
```text
runtime_request
runtime_crypto
python_replay
static_decompile
strings_guess
```
可信度建议:
```text
high: 运行时最终请求或 replay 验证通过
medium: 静态反编译和日志部分吻合
low: 仅 strings/grep 推测
```
---
## 13. 文档输出要求
每轮结束必须更新:
```text
docs/login_reverse_summary.md
docs/next_prompt.md
analysis/header_bootstrap_logic.md
analysis/captures/runtime_key_excerpts.md
```
`docs/login_reverse_summary.md` 至少包含:
```text
1. App 基础信息
2. 认证体系类型与判断依据
3. base URL / env 选择逻辑
4. 登录/验证码/重置/注册/refresh/bootstrap 接口列表
5. 每个接口的 method/path/headers/body/query
6. 运行时最终参数字段,不只写静态方法标签
7. 公共 headers/params
8. token/device/install/session/push 来源
9. sign/requestId/encryption 结论:存在则写算法,不存在则明确 N/A
10. Python replay 使用方法
11. dry-run 验证结果
12. send 验证结果与错误分类
13. 本地 CTF IP 是否可达
14. 未解决问题与下一步建议
15. 敏感 token 脱敏说明
```
`analysis/header_bootstrap_logic.md` 如存在复杂 runtime header/token,必须写:
```text
字段名
来源
生成/获取步骤
是否缓存
过期/刷新逻辑
Python replay 中如何处理
```
`docs/next_prompt.md` 必须面向下一轮工作,包含:
```text
当前已确认结论
当前阻塞点
下一步最小验证路径
建议优先执行的命令
不要重复做的事情
```
---
## 14. 最小闭环优先级
不要一开始就试图还原所有接口。优先完成一个最小闭环:
```text
1. 选一个最关键接口,例如 send_code 或 login_password
2. 通过 Frida 获取运行时最终请求
3. 用 Python dry-run 复现最终请求/sign
4. 用 Python send 得到服务端响应
5. 正确分类响应
6. 保存 capture
7. 再扩展其它接口
```
如果项目很复杂,优先顺序:
```text
认证体系判断 > runtime 最终请求 > token/bootstrap 来源 > Python replay > 其它接口补全 > 文档整理
```
---
## 15. 验收标准
本轮任务完成时必须满足:
```text
1. Python replay 可独立运行
2. --dry-run 能展示最终请求和 canonical/sign 信息
3. 如果存在自研 sign/requestId,--dry-run --expect-sign 能复现 Frida 抓到的值
4. 如果不存在自研签名,明确输出 N/A: no custom signing
5. --send 能打印完整响应或明确 timeout/empty body
6. 不依赖硬编码 Frida 抓到的服务端 token
7. state/bootstrap/refresh 逻辑清楚
8. 登录失败能区分业务失败、参数失败、签名失败、网络不可达
9. 每个关键结论有证据来源
10. 文档和最终回复中的敏感 token 已脱敏
```
最终回复必须使用中文,列出:
```text
关键文件路径
已验证命令
验证结果
错误分类
仍未解决的问题
下一步建议
```
后续 AI 的行为基本都围绕这几句话展开:先找原始检测,再下钻到 rule,再追系统调用,最后把 threat/status 到 App 弹窗的链路闭环验证。
0.3 本次 AI 辅助逆向使用到的工具与技术清单
本次分析不是单靠某一个工具完成,而是由 AI 编排多类静态、动态和自动化验证手段,持续把“猜测”收敛成“可复现证据”。下表是本次实际用到的主要工具/技术:
| 类别 | 工具 / 技术 | 本次用途 |
|---|---|---|
| 静态反汇编 / 反编译 | IDA Pro / Hex-Rays | 分析 TargetAppProd 与 ZDefend.framework/ZDefend,定位 ZDeviceStatus、ZDefendThreat、0x100902864 alert builder、native primitive |
| IDA 自动化 | IDA Python / MCP 查询 | 批量导出函数、xrefs、字符串解密点、stub/import map、Swift field metadata,减少人工翻函数时间 |
| Mach-O 分析 | otool / Mach-O segment/stub/indirect symbol 解析 |
确认 _objc_msgSend、_sel_registerName、_objc_getClass、Swift bridge 等 stub 归属 |
| 字符串与配置搜索 | strings / grep / JSON 汇总 |
搜索 Framework、SDK、ZDefend asset 和策略相关字符串;确认很多关键 rule/path 并非明文硬编码 |
| Swift metadata 分析 | __swift5_fieldmd / ObjC ivar metadata |
确认 AppDelegate.jailBreakAlert: UIAlertController?、TargetAppTabBarController.deviceStatusRegistration 等字段 |
| 运行时插桩 | Frida Interceptor.attach |
只读观察 lstat/sysctlbyname/fcntl、activeThreats、UIKit alert/present |
| 运行时方法替换 | Frida method_setImplementation / ObjC method implementation assignment |
在本地 sanitize build 中做参数级验证:固定 internalThreatID=39/severity=3/isMitigated=0 并触发真实 UI |
| C API 调用链追踪 | Frida backtrace + system call hook | 串联 JB Dopamine-Roothide -> sub_25297C/sub_252848 -> lstat/lstat64(paths) |
| ObjC/Swift selector 追踪 | objc_msgSend callsite hook / selector decode |
确认 activeThreats、internalThreatID、UIAlertController、presentViewController:animated:completion: 等动态 selector |
| iOS UI 自动化 | iOS MCP | 启动 App、读取 UI tree、截图、点击弹窗、验证 App UI 状态和 jailbreak alert 文案 |
| Runtime 状态解析 | base64 JSON decode / threat/status 结构化 | 解析 device_status_base64,脱敏保存 threats、ruleName、legacyThreatId、severity、forensic 摘要 |
| Python 自动化 | Python 脚本 / JSON evidence generator | 生成结构化证据文件、整理 runtime 参数、维护 capture/state、辅助整理 runtime 证据 |
| 本地 patch | Mach-O 指令级 patch / IPA 重打包安装 | 对 ZDefend 结果 getter 做 sanitize patch,绕过启动期阻断 UI,进入正常 App 流程 |
| 证据管理 | Markdown 报告 + evidence matrix | 按 runtime 事实、静态反编译、strings 猜测分级记录结论 |
| AI 协作方式 | 上下文读取、计划拆解、日志归并、代码生成、文档同步 | 自动串联静态/动态证据,生成 Frida probe、Python parser、最终文章和下一步提示词 |
其中最关键的协作点是:AI 不直接把某个字符串命中当结论,而是持续要求每条判断都有证据来源。例如 internalThreatID=39 -> Jailbroken Device Detected 这条链路,先由静态字符串解密得到映射,再由 Frida runtime 参数级验证,最后用 iOS MCP UI tree 复核弹窗文本,才写入 high-confidence 结论。
0.4 这是一份由 AI 主导完成的逆向分析结果
本次分析比较特别的一点是:我主要在开始阶段给出总体提示词,明确目标、证据优先级和输出要求;后续的大部分分析流程,基本由 AI 自行推进。
AI 自动完成了上下文读取、Frida probe 编写、运行时日志整理、IDA/Swift metadata 交叉定位、UI tree 复核、调用链归纳和文档更新。我更多是在过程中确认方向,并不断要求它继续下钻到 rule、系统调用和 runtime 参数验证。
所以这篇文章记录的不只是一次 jailbreak 检测绕过,也是一种 AI 辅助逆向的实践方式:给出清晰目标后,让 AI 持续生成实验、验证证据,并把零散结果整理成完整链路。
0.5 本文范围
本文只聚焦 Jailbreak 检测链路分析与本地绕过实践:包括 ZDefend rule、系统检测函数、threat/status 结构、App 侧消费点、UIKit 阻断弹窗和 sanitize patch 设计。其它业务流程、账号体系、服务端交互等内容不在本文展开,以保证文章主线集中在本地安全检测与 UI 阻断链路。
1. 现象截图:目标应用确实存在 Jailbreak 检测
先看最直观的现象:在测试设备上启动目标 App,主界面没有正常进入,而是直接弹出 Jailbroken Device Detected 阻断框。

对应 UI tree 中能读到完整文案:
title = Jailbroken Device Detected
message = Your device appears to be jailbroken. For your security, TargetApp cannot be used. Contact your device service centre to fix your device.
button = 了解
这一步很重要:它先证明问题不是“猜测 App 有越狱检测”,而是运行时 UI 已经高可信确认存在 jailbreak blocking alert。
如果按传统方式硬翻,第一反应可能是搜索 Jailbroken Device Detected、isJailbroken、jailBreakAlert 等字符串。但这次实际情况并不简单:弹窗文案不是普通明文路径;App 侧也不是一个简单 if 判断;真正的触发链路跨过 ZDefend SDK、Swift/ObjC 状态机、动态 selector 和 UIKit alert builder。
后续静态与运行时证据显示,真实链路更复杂:
ZDefend native rule / classifier
-> device_status_base64
-> ZDeviceStatus / ZDefendThreat
-> TargetAppProd activeThreats 消费点
-> TargetAppProd alert builder
-> UIKit blocking alert
换句话说,阻断 UI 不是单个路径检查直接弹窗,而是 ZDefend SDK 先生成 threat/status,再由 目标 App 主工程消费 activeThreats 并展示安全弹窗。
2. 运行时威胁事实
在未 sanitize 的原始状态下,ZDefend 运行时生成了多个 active threat。脱敏后的核心结果如下:
| threat | ruleName | internalThreatID | severity | 说明 |
|---|---|---|---|---|
DEVICE_ROOTED |
JB Dopamine-Roothide |
39 | 3 | RootHide/Dopamine/Sileo 相关越狱痕迹命中 |
DEVICE_ROOTED |
ios_combined_classifier |
39 | 3 | iOS 综合越狱/环境分类器命中 |
APP_TAMPERING |
ios_combined_classifier |
75 | 3 | App tamper / 注入 / 完整性类风险 |
一个关键经验是:不要只依赖 strings 查找。DEVICE_ROOTED、JB Dopamine-Roothide、ios_combined_classifier 等并不是主二进制明文硬编码出来的普通字符串,而是运行时 status/policy 产物。
3. ZDeviceStatus:从 status JSON 到 activeThreats
ZDefend 的关键入口是:
+[ZDeviceStatus sendDeviceStatusToListeners:] @ 0x102c0
它接收 JSON 字符串,解析外层 status,再把 device_status_base64 解成内层 status JSON,最终构造 ZDeviceStatus。
关键初始化函数:
-[ZDeviceStatus initWithStatusDict:priorStatus:] @ 0xe548
其中 threats 数组会逐个被包装成 ZDefendThreat:
for each item in status["threats"]:
threat = [[ZDefendThreat alloc] initWithThreat:item]
[self.allThreats addObject:threat]
if threat.isMitigated:
[self.mitigatedThreats addObject:threat]
else:
[self.activeThreats addObject:threat]
相关 getter 的 ivar offset 如下:
| Getter | 地址 | 原始 ivar offset | 含义 |
|---|---|---|---|
-[ZDeviceStatus allThreats] |
0x10d90 |
0x70 |
所有 threat |
-[ZDeviceStatus activeThreats] |
0x10dd4 |
0x78 |
未 mitigated 的 active threat |
-[ZDeviceStatus mitigatedThreats] |
0x10e18 |
0x80 |
已 mitigated threat |
-[ZDeviceStatus activeNewThreats] |
0x10e5c |
0x88 |
与 priorStatus 比较后的新增 active threat |
这为后续“结果消费层绕过”提供了很好的切入点。
4. ZDefendThreat:威胁对象字段映射
-[ZDefendThreat initWithThreat:] @ 0xc23c 会把 threat dict 中的字段保存到 ObjC 对象。关键字段映射:
| getter / 含义 | 来源字段 | 说明 |
|---|---|---|
internalThreatID |
legacyThreatId |
39 表示 DEVICE_ROOTED;75 表示 APP_TAMPERING |
severity / threatSeverity |
severity |
CRITICAL 映射为数值 3 |
mitigated / isMitigated |
mitigated |
决定是否进入 activeThreats |
internalName |
lang_description.internalLocalizationName |
运行时为 DEVICE_ROOTED / APP_TAMPERING |
ruleName |
顶层 internalName |
运行时为 JB Dopamine-Roothide / ios_combined_classifier |
因此,App 层真正关心的不是底层检测函数本身,而是最终可见的 threat getter 返回值。
5. rule 级分析:最原始检测方式
为了确认最原始的检测方式,使用只读 Frida probe 调用:
+[ZDDHelper runRuleBlocking:Details:]
重点验证两个 rule:
JB Dopamine-Roothide
ios_combined_classifier
5.1 JB Dopamine-Roothide
该 rule 的完整链路为:
ZDDHelper.runRuleBlocking("JB Dopamine-Roothide")
-> com.zimperium.dd.runRule
-> ZDefend rule VM / evaluator
-> sub_25297C / sub_252848
-> lstat / lstat64(path)
-> ZDDRuleResult(triggered=true)
-> ZDefendThreat(ruleName="JB Dopamine-Roothide", legacyThreatId=39)
运行时 lstat/lstat64 命中的典型路径包括:
/var/mobile/Library/Caches/com.opa334.Dopamine
/var/mobile/Library/Caches/org.coolstar.SileoStore
/var/mobile/Library/HTTPStorages/com.opa334.Dopamine
/var/mobile/Library/HTTPStorages/org.coolstar.SileoStore
/var/mobile/Library/SplashBoard/Snapshots/org.coolstar.SileoStore
对应 rule result:
triggered = true
triggerValue = 02 00 00 0e 01
forensic = Reasons=Dopamine detected
其中事件 ID 0x0e 在 ZDefend taxonomy 中对应 com.zimperium.fs.mounted_all 类文件系统信号。
5.2 ios_combined_classifier
ios_combined_classifier 是综合分类器,观测到的底层特征包括:
sub_F14A4 -> sysctlbyname(security.mac.*_enforce)
sub_F9A94 -> sub_D84F0 -> sub_D8568 -> open + fcntl(fd, 61)
injected library forensic
运行时 forensic 摘要:
Reasons = ML: App tampering detected
features_vector_len = 27
injected_libs = /usr/lib/systemhook-...dylib
这解释了为什么同一个综合 classifier 既可能映射到 DEVICE_ROOTED,也可能映射到 APP_TAMPERING:底层输出是风险特征,策略层再映射为业务 threat。
5.3 应用/SDK 中用到的系统检测函数列表
下表只列本轮已通过 imports、静态反编译或 runtime call-chain probe 确认的函数。它们主要位于 ZDefend.framework/ZDefend,宿主 App 通过 ZDeviceStatus / ZDefendThreat 消费最终结果。
| 类别 | 系统函数 / runtime API | 本轮证据 | 作用判断 |
|---|---|---|---|
| 文件存在性 / 越狱痕迹 | access |
imports / sub_F68B8 / primitive registry |
检查策略中配置的路径是否存在 |
| 文件状态 | stat |
imports / sub_25297C 静态反编译 |
读取文件 metadata;iOS 上 runtime 观测与 lstat/lstat64 包装有关 |
| 符号链接 / 路径状态 | lstat, lstat64 |
live call-chain probe | JB Dopamine-Roothide 直接命中 Dopamine/Sileo/SplashBoard 路径 |
| 文件系统 / 挂载状态 | statfs |
sub_F022C |
读取 f_flags/f_fstypename/f_mntfromname/f_mntonname 等,用于挂载/文件系统异常判断 |
| 文件打开 | open |
sub_D8568 / ios_combined_classifier |
为后续完整性或签名校验打开目标文件 |
| 文件关闭 | close |
sub_D8568 调用链 |
与 open/fcntl 配套 |
| 目录枚举 | opendir, readdir |
imports / runtime probe 范围 | 枚举目录项,辅助发现越狱或注入相关文件 |
| 路径解析 | readlink |
imports / runtime probe 范围 | 检查 symlink / 重定向路径 |
| 标准 IO | fopen |
imports | 读取策略或本地文件内容的辅助路径 |
| 内核/系统状态 | sysctl |
imports / sub_F19CC |
按 MIB 读取内核/系统状态 |
| 命名 sysctl | sysctlbyname |
sub_F16F4, sub_F11A4, live probe |
ios_combined_classifier 读取 security.mac.*_enforce 特征 |
| 文件控制 / 完整性 | fcntl(fd, 61, ...) |
sub_D8568 |
代码签名/文件完整性相关信号,和 APP_TAMPERING 语义吻合 |
| 环境变量 | getenv |
sub_247F58 |
检查注入/调试/运行时环境变量 |
| ObjC 方法检查 | class_getInstanceMethod |
sub_EBB70 |
ZDefend 自己的 touch events hook 管理;不是本轮 jailbreak 主因 |
| ObjC IMP 读取 | method_getImplementation |
sub_EBB70 |
同上,也可作为 hook/tamper 检查基础 |
| ObjC IMP 替换 | method_setImplementation |
sub_EBB70 |
ZDefend 内部 instrumentation;full hook 场景可能引入 tamper 信号 |
| symlink 辅助 | +[OsFunctions fileIsSymlink:] |
0xed028 |
对路径是否为符号链接做封装判断 |
补充说明:
- • live
JB Dopamine-Roothide规则的关键系统调用是lstat/lstat64(path);这一路已经精确到具体 Dopamine/Sileo/SplashBoard artifact。 - • live
ios_combined_classifier的关键系统调用包括sysctlbyname(security.mac.*_enforce)与open + fcntl(fd,61) + close。 - • 本轮未在原始 imports 中确认直接导入
ptrace、fork、dladdr、csops等传统反调试/签名 API;ZDefend 的 taxonomy 中存在com.zimperium.proc.*事件名,但具体 iOS 实现可能被内联或由其它策略模块触发,本文不把它们列为“已确认调用”。
6. App 侧消费链路:activeThreats 到弹窗
ZDefend 生成 status 后,会通过 listener 回调宿主 App。最终定位到 目标 App 主工程中的链路:
TargetAppTabBarController.deviceStatusRegistration
-> 0x10090f14c
-> 0x1008f344c register ZDeviceStatus callback
-> block invoke 0x1003d9abc
-> Swift closure 0x1008f4864
-> active handler 0x100904c98
-> objc_msgSend(selector=activeThreats) @ 0x100905c28
activeThreats 非空后,0x100904c98 会选择 threat 并调用真正的 alert builder:
0x100904c98
-> 0x1009055cc: bl 0x100902864
0x100902864
-> objc_msgSend(selector=internalThreatID)
-> switch internalThreatID
-> 解密 title/message 与 UIKit selector
-> UIAlertController.alertControllerWithTitle:message:preferredStyle:
-> UIAlertAction.actionWithTitle:style:handler:
-> addAction:
-> presentViewController:animated:completion:
静态解密后得到的部分 threat ID 映射:
| internalThreatID | title | 说明 |
|---|---|---|
| 14 | SSL Strip Warning |
通信被拦截风险 |
| 37 | System Tampering Detected |
系统篡改 |
| 38 | Rogue Access Point |
Rogue AP |
| 39 | Jailbroken Device Detected |
越狱设备 |
| 75 | App Tampering Detected |
App 篡改 |
| 125 | Compromised Network Detected |
网络被攻陷 |
| 130 | Pegasus Spyware Detected |
间谍软件风险 |
| default | Device is compromised |
默认风险提示 |
7. runtime 参数级验证
为了确认 internalThreatID=39 是否确实对应原始 jailbreak 弹窗,使用本地 sanitize build 做参数级闭环:构造真实 ZDefendThreat 对象,临时固定关键 getter 返回值,再调用 TargetAppProd +0x902864。
验证脚本:
analysis/frida_scripts/targetapp_zdefend_alert_builder_runtime_verify_methodset.js
关键 runtime 参数:
ZDefendThreat.internalThreatID = 39
ZDefendThreat.severity = 3
ZDefendThreat.isMitigated = 0
调用后 UI tree 显示:
title = Jailbroken Device Detected
message = Your device appears to be jailbroken. For your security, TargetApp cannot be used. Contact your device service centre to fix your device.
button = 了解
这证明 0x100902864 的 internalThreatID=39 分支就是原始阻断弹窗的 App 侧构造路径。
下图是验证结果截图,可以看到弹窗内容和绕过前一致:

一个有价值的反例是:使用自定义 fake ObjC class 虽然实现了 internalThreatID selector,但进入 builder 后会因对象布局不兼容触发 access violation accessing 0x1c。这说明 builder 不只是“鸭子类型”发消息,还期望真实 ZDefendThreat / Swift-ObjC 兼容对象布局。
8. 绕过策略:选择结果消费层而不是底层 detector

这次绕过没有删除底层检测函数,也没有试图把所有 lstat/sysctl/fcntl 都 hook 成良性值。原因有三点:
- 1. ZDefend rule VM 和 policy 复杂,底层检测点很多;
- 2. 全量 Frida hook 容易引入额外
APP_TAMPERING / apptampering_hooked; - 3. 宿主 App 最终依赖的是
activeThreats/allThreats/internalThreatID/severity/mitigated这些结果字段。
因此采用“结果消费层 sanitize”策略:让 SDK 底层检测继续运行,但让宿主 App 看到的 threat 结果变成良性或不可阻断。
当前 patch 产物:
analysis/patched/targetapp_zdefend_sanitize.ipa
核心修改点:
| 函数 | 原始逻辑 | patch 后 | 目的 |
|---|---|---|---|
-[ZDeviceStatus allThreats] @ 0x10d90 |
读取 offset 0x70 |
改读 offset 0x88 |
让 allThreats 指向通常为空的 activeNewThreats |
-[ZDeviceStatus activeThreats] @ 0x10dd4 |
读取 offset 0x78 |
改读 offset 0x88 |
避免 App 看到 active threat |
-[ZDefendThreat internalThreatID] @ 0xd75c |
返回真实 threat ID | mov w0,#0; ret |
避免命中 39/75 等阻断分支 |
-[ZDefendThreat isMitigated] @ 0xd7a0 |
读取真实 mitigated | mov w0,#1; ret |
将 threat 视为已缓解 |
-[ZDefendThreat mitigated] @ 0xdf54 |
读取真实 mitigated | mov w0,#1; ret |
同上 |
-[ZDefendThreat severity] @ 0xdbe0 |
返回真实 severity | mov w0,#0; ret |
降低风险等级 |
-[ZDefendThreat threatSeverity] @ 0xe020 |
返回真实 severity | mov w0,#0; ret |
同上 |
绕过后,App 可以进入正常主界面;后续分析只保留只读或窄口 hook,避免继续触发 ZDefend 的 tamper classifier。
9. 为什么不建议 full hook
在早期尝试中,替换大量 ZDefend 方法虽然能短暂绕过某些 getter,但也会引入新的风险信号:
APP_TAMPERING / apptampering_hooked
这类信号会污染样本,导致后续很难区分:
- • 原始设备越狱风险;
- • 重签/注入/Frida 引入的 tamper 风险;
- • App 自身对 ZDefend 结果的消费逻辑。
因此后续采集运行时证据时,应优先使用只读/窄口 hook,只观察必要对象、selector 和系统调用,不再替换 ZDefend 检测函数。
10. 最终调用链总结
最终可以把链路串成:
[1] rule 执行层
ZDDHelper.runRuleBlocking / com.zimperium.dd.runRule
[2] primitive / 系统调用层
JB Dopamine-Roothide:
sub_25297C/sub_252848 -> lstat/lstat64(paths)
ios_combined_classifier:
sub_F14A4 -> sysctlbyname(security.mac.*)
sub_F9A94/sub_D84F0/sub_D8568 -> fcntl(fd,61)
injected library forensic
[3] 结果归一化层
ZDDRuleResult.setFromJson
-> triggered / triggerValue / forensicJson
[4] ZDefend status 层
ZDeviceStatus.sendDeviceStatusToListeners
-> ZDeviceStatus.initWithStatusDict
-> ZDefendThreat.initWithThreat
-> activeThreats / allThreats
[5] TargetApp UI 层
TargetAppTabBarController.deviceStatusRegistration
-> callback 0x1003d9abc / closure 0x1008f4864
-> active handler 0x100904c98
-> alert builder 0x100902864
-> internalThreatID=39
-> Jailbroken Device Detected
11. 原理小结:它到底是怎么做检测和阻断的
从原理上看,这套 jailbreak 检测不是 App 自己直接调用一个 isJailbroken() 函数后弹窗,而是分成三段:
- 1. 检测层:ZDefend rule VM 调用系统 primitive,例如
lstat/lstat64检查 Dopamine/Sileo 痕迹,sysctlbyname读取系统安全特征,fcntl(fd,61)做完整性/签名相关检查。 - 2. 归一化层:检测结果被整理成
ZDDRuleResult、device_status_base64、ZDeviceStatus和ZDefendThreat,核心字段包括internalThreatID、severity、mitigated。 - 3. App 消费层:TargetApp 读取
activeThreats,发现internalThreatID=39且风险未缓解后,进入0x100902864alert builder,动态解密文案和 selector,最终展示 UIKit 阻断弹窗。
因此本地绕过的关键不是“让所有系统调用都返回正常”,而是让 App 在消费结果时看不到 active threat,或者看到的是低风险/已缓解 threat。这就是为什么本文选择 sanitize activeThreats/allThreats/internalThreatID/severity/mitigated 这些结果字段,而不是粗暴 patch 所有 detector。
12. 经验总结
这次分析中最重要的经验有三点:
- 1. 先拆清安全门禁的触发层级,再做最小化绕过。 目标不是“隐藏检测”,而是在测试环境中确认 detector、status 和 App UI 消费层分别在哪里。
- 2. 不要把底层 detector 和 App 消费层混为一谈。 ZDefend 仍然能检测到 Dopamine/Sileo 痕迹;绕过只是让 App 不再消费这些 active threat 去阻断 UI。
- 3. runtime 证据优先。 静态能解出
internalThreatID=39的文案映射,但最终仍通过真实ZDefendThreat入参与 UI tree 完成了闭环验证。

我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。
***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...