绕过移动应用加固:检测止步之处,正是执行失效之时
检测与执行是两种不同的安全属性。在五款由四种商业加固产品保护的生产环境银行应用中,检测相当精密,执行却十分脆弱。
移动应用加固是编译进应用本身的保护机制。它以 RASP、防篡改、反 Hook、Root 检测、越狱检测或运行时保护等名义出售,而无论哪种名义,承诺都是一样的:如果应用发现自己运行在已 Root 的手机上、模拟器中、Frida 之下、被调试器附加,或者被重新打包,它就应当察觉并作出响应。
这一承诺包含两个部分,而且两者会各自独立地失效。
检测是判定出了问题。执行是对此采取行动。
一款产品即使能完美检测 Root,也可能什么都保护不了——只要应用忽略检测结果、把所有结果都汇入一个薄弱的分支,或者信任某个由攻击者控制的输入。
我们评估了 Android 和 iOS 上的五款生产环境银行应用,它们使用了四种不同的商业加固产品。检测始终做得很好,而出问题的地方始终在执行环节。
第一个症状:一次刻意不透露任何信息的崩溃
扫描在测试设备上安装了第一款应用并启动它。五秒后,进程消失了。没有对话框,没有错误,日志里也什么都没有。
崩溃报告如下:
signal 11 (SIGSEGV), fault addr 0x0
x0=0xdef040f0 x1=0x738416ae x4=0x2319d258 x5=0xbd7d47fb
x6..x30=0 sp=0 pc=0
tid: Thread-7x
信号 11 即 SIGSEGV,也就是段错误:进程以内核拒绝的方式访问了内存。这一部分很普通,寄存器转储却不普通。
在 ARM64 上,pc 是程序计数器,即正在执行的指令的地址。sp 是栈指针,它是调用栈的锚点。x0 到 x30 是通用寄存器,保存参数、返回值和局部变量。在真正的崩溃中,这些值会被保留下来,这也正是崩溃报告有用的原因:程序计数器指明出错的指令,栈指针则让调试器能够沿着调用者逐层回溯。
而这里,它们全部为零。没有可供查看的出错指令,也没有可供展开的调用栈。唯一的回溯帧显示为 #00 pc 0x0 <unknown>。
有某种东西在让进程终止之前,刻意清空了寄存器组。报告中提到的线程 Thread-7x 只是一个通用工作线程,与这一判定毫无关系。而 0xdef040f0 在该平台上并不是一个合理的指针;它的表现更像是保护代码留下的标记。
这是经过加固的应用中常见的模式。应用不会宣布判定结果,而是在退出时销毁取证证据,使分析人员既无法得知是哪项检查被触发,也无法得知它位于何处。

所有通用寄存器均为零,回溯中只有一个未知帧。
这些产品实际在监视什么
第一项工作是盘点:这个东西到底检查什么?
静态分析发现了大约三十个原生检测函数,它们在加载时全部注册到同一个 Java 类中。
这个细节比看上去更重要。Android 应用通过 JNI(Java Native Interface,Java 本地接口)调用原生 C 或 C++ 代码。原生库可以通过两种方式向 Java 暴露函数。一种是导出带有修饰名的符号,例如 Java_com_example_Foo_bar,任何人对该库运行 nm 都能看到。另一种是在 JNI_OnLoad 期间调用 RegisterNatives,在运行时动态绑定方法。
该库采用的是第二种方式,因此所有检测函数的名称都不会以导出符号的形式出现。要找到它们,您必须读取注册表,或者在运行时观察进程:
JNI_OnLoad @ 0x424264
checkHooks @ 0x424e50 scans the process memory map
checkForFridaAgent @ 0x424c38
isSuExists @ 0x424940
isFoundMagisk @ 0x425a90
isFoundDangerousProps @ 0x424810
isPermissiveSelinux @ 0x4248e8
getNativeSignature @ 0x42ca00 hashes the signing certificate
这里涵盖了四个类别,每一类回答一个不同的问题。
Root 检测寻找设备授予了提升权限的证据:磁盘上的 su 二进制文件、Magisk 痕迹、可写的系统路径、宽容模式的 SELinux、Root 管理器软件包。Root 之所以重要,是因为它让攻击者能够读取应用的私有存储、附加到进程,并修改其运行时。
Hook 检测寻找在运行时改写函数行为的框架:Frida、Xposed、LSPosed、Zygisk、Substrate、SandHook、Taichi、VirtualXposed。Hook 可以在不触碰磁盘上 APK 的情况下改变函数的返回值,而客户端安全检查恰恰就是这样被攻破的。
checkHooks 会读取 /proc/self/maps。在 Linux 和 Android 上,该文件列出了映射到当前进程中的每一个内存区域,以及其权限和对应的后备文件。如果其中出现了外来的库或匿名的可执行区域,应用就能推断出自己正在被插桩。
签名校验即 getNativeSignature,它在运行时对应用的签名证书进行哈希。Android 要求每个 APK 都必须签名。修改并重新打包应用的攻击者必须重新签名,而且通常使用的是不同的密钥,因此将运行时证书与预期哈希值进行比较,可以发现简单的重新打包。
危险属性检测读取 Android 系统属性。系统属性是操作系统用来发布构建和设备配置的键值对:ro.build.tags、ro.debuggable、ro.secure、ro.hardware、ro.product.model。模拟器镜像和工程构建版本会在其中留下可识别的值。
在这款付费产品之下,还有来自其他无关厂商的三项控制措施:一项来自免费开源库的第二套 Root 检查,一项位于应用自身编译后的 Dart 代码中的第三套检查,以及来自第四方的遥测。清单文件中甚至声明了 <queries> 条目,使软件包管理器能够回答有关 su、Magisk 和 KingRoot 软件包的查询。
一个应用中有四项相互独立的控制措施,对同一台设备给出四种相互独立的判断。这一点在最后会变得很重要。
这并不是一个流于表面的实现。弱点不在于缺少检测,而在于应用信任什么,以及它如何处理检测结果。
为什么阅读检查代码行不通
在分析人员与这段逻辑之间隔着三层防护,而且三层都在尽职尽责。
Java 层是诱饵。应用在本应是方法体的位置放置了 4,129 个加密数据块,它们只在启动时才被解密并执行。真正重要的类,包括主 Activity,都不以可读的形式存在于文件中。反编译器只能展示结构和部分调用点,而无法展示行为。
原生库经过加壳。它在静态时处于加密状态,只有一个导出符号,真实内容只在启动后存在于内存中。其中一个库带有随机化的文件名和随机化的导出名:
libGHDSDFIUPOIFDLS8DSFN23LK.so
export: _3Wbwdz5QepMbJNn8CiW3HwFivKZsZoNvu
于是,扫描从运行中的进程里转储出解密后的库,再次进行查看。检查代码仍然不在那里。
检查代码在运行时生成。解密后的代码发出了 69 个系统调用。在 ARM64 上,系统调用是一条 svc 指令,调用号放在寄存器 x8 中,因此反汇编器通常能够读出所请求的是哪个内核函数。其中 64 处的调用号是常量。另外五处则由应用在运行时计算,这使得这五处对静态分析不可见。
这五处中,有一处解析为 mmap,申请一个带有 PROT_READ | PROT_WRITE | PROT_EXEC 权限的单页内存。它大约每 85 毫秒执行一次:把代码写入该页,执行,然后丢弃。
可写又可执行的内存在设计上就不常见。现代系统会把这两种权限分开,因为既能写入又能执行的内存正是代码注入得以实现的条件。加壳工具和保护工具却偏偏使用它,目的正是如此:生成从不以稳定代码形式存在于稳定地址的检查。
字符串是构造出来的,而不是存储的。在完全解密的库中搜索 qemu、goldfish 或 emulator,结果为零。应用在栈上逐个字符地拼装这些字符串,因此它们从不以连续文本的形式存在,无法用 grep 找到。
方法名是 Unicode 形近字符。另一款由不同产品保护的应用在 Java 层玩着同样的把戏。反编译器显示为 m13674,而运行时的真实名称是 ˎ,一个 Unicode 修饰字母。反编译器显示为 m13676,而真实名称是 Ι,即希腊大写字母 iota,其显示效果与大写字母 i 完全相同。
按反编译器给出的名称编写的 Hook 根本不会触发。没有错误,也没有失败,人们很容易把这种沉默误认为保护比实际更强。改为按类型签名而不是名称来解析方法,就能避免这个问题,这也是本文后面的 Hook 能够一次命中的原因。
从外部度量应用
当代码被构造得无法阅读时,就别再读它,转而观察它。
扫描附加了一个内核级跟踪。它从进程外部进行观察,不会在进程内部留下任何可被检测的东西。随后,扫描记录了应用在启动期间打开的每一个文件:
opens ~150 property files, three times each
reads /proc/self/maps three times
reads /proc/self/status, /proc/self/comm, /sys/fs/selinux/context
opens the virtual device files: 0 times
opens any su or root path: 0 times
对于不太熟悉 /proc 的读者:它是由内核按需生成的虚拟文件系统,/proc/self 是调用进程对自身的视图。maps 列出已映射的内存区域。status 包含进程元数据,其中包括 TracerPid,即当前正在跟踪该进程的进程 ID。comm 是进程名。/sys/fs/selinux/context 报告进程运行时所处的 SELinux 安全上下文。
这一结果改变了整个认识。几乎所有人都以为这类检查会去寻找磁盘上的 su 和模拟器设备节点。而这个检查两者都没有打开,一次都没有。启动时的判定依赖的是属性值、进程自身的内存映射以及它的跟踪状态。这三者中有两者可以被特权用户改写。
静态分析给出了一份可能重要的检查清单。跟踪则显示了哪些检查真正在运行。
一次文件写入即可攻破属性关卡
Android 通过一块共享内存区域发布系统属性,该区域以文件的形式暴露在 /dev/__properties__/ 下。库通过 __system_property_get 读取这些属性;属性值保存在内存中,在已 Root 的设备上,可以在应用启动之前就地改写。该关卡对其读取的内容不做任何完整性或真实性校验。
写入之前:
signal 11 (SIGSEGV), fault addr 0x0
x0=0xdef040f0, pc=0, sp=0
crash report: GENERATED
写入之后:
no crash report
process alive
UI drawn
native libraries loaded

终止不再发生。没有修补 APK,没有 Hook,对应用本身没有做任何修改。
这并不意味着属性检查毫无用处,而是意味着它们不具备权威性。如果已获得 Root 权限的攻击者能够改写该值,应用就需要在多个独立信号之间相互印证,或者采用不会因某一个信号翻转而整体崩塌的执行机制。
为什么索引文件很重要
这比看上去要微妙,而且其失败方式值得了解。
该目录包含两种不同的结构:存储每个属性的 prop_info 记录,以及一个独立的序列化索引,用于把属性名称解析到对应的记录。每一次按名称查找都要经过这个索引。在这些文件中盲目地进行搜索替换会破坏它。
而且这种破坏会自我隐藏。枚举属性时会直接遍历记录,因此仍然正常工作,返回大约 460 个条目;但按名称查询任何单个属性都会一无所获。每一个在启动时读取属性的库都会出错,而且出错的方式看起来与您刚刚做的操作毫不相关。
因此,扫描在写入之前会根据结构特征识别出真正的记录,写入之后再验证自己的工作,而不是想当然地信任它:
properties : 465 (was 465)
sdk : '31'
giveaways : 0
属性数量未变,一个已知的查找仍能正常解析,暴露特征已消失。只有到这时,扫描才会启动应用。靠破坏环境来奏效的绕过,并不是真正的绕过。
Frida 检测本质上是时序检测
Frida 是这类工作的标准动态插桩工具包。它把一个 Agent 注入到正在运行的进程中,让您能够 Hook Java 方法、原生函数和系统调用。
面对这些应用,它会立即被终止。而调试器——可以说侵入性更强——却想运行多久就运行多久。
debugger attaches 1.9s after launch -> ran 120 seconds, no complaint
Frida attaches 1.0s after launch -> killed
Frida launches together with the app -> killed
应用会在早期读取一次 /proc/self/status。该文件中有一行是 TracerPid,当没有任何东西跟踪该进程时它为 0,否则保存跟踪者的进程 ID。调试器使用 ptrace,因此会设置这个值。Frida 的注入也会短暂地使用 ptrace,用来劫持一个线程并让它加载 Agent。
这项检查只运行一次,大约在启动后一秒左右。我们用最朴素的方法确认了这一点:附加调试器,恢复运行,然后在 100 秒内什么都不做。没有判定,应用存活。被观察本身并不是触发条件。
Frida 的问题出在它的进入方式上。注入会在那段启动窗口内留下两个痕迹:TracerPid 被设置几百毫秒,以及 /proc/self/maps 中出现一个外来的可执行映射——Agent 是从一个匿名文件加载的。判定在 Agent 脚本打印出第一行之前就已触发:
{"c":7,"v":1,"p":{"id":107,"name":"Hooking Detected"}}
人们会尝试重命名 Frida 的线程和端口来躲避检测。这毫无作用,因为被抓住的从来都不是名称。
所以,应用提出的问题并不是“这是 Frida 吗?”,而更接近于“在这段窗口内,是否有任何东西在跟踪或修改我?”。这是一种不同的控制措施,后果也不同:一旦某项检查只在已知时刻运行一次,时序就成了攻击面。您可以在它之前到达,也可以在它之后到达,或者以挂起状态启动进程,在应用执行第一条指令之前就已就位。扫描选择了最后一种方式,这也是它能触及常规附加永远看不到的检查的原因。
第二款应用通过另一条路径得出了相同的结论。从它的第一条指令开始跟踪,整个判定就是它所做的最后四件事:
openat(".../split_config.arm64_v8a.apk")
readlinkat("/proc/self/fd/60", ".../split_config.arm64_v8a.apk")
openat("/proc/self/status")
--- SIGBUS {si_code=BUS_ADRALN, si_addr=0x8f19c0bf} ---
+++ killed by SIGBUS +++
带有 BUS_ADRALN 的 SIGBUS 是对齐错误:对某个地址进行的加载或存储,在该访问宽度下不被 CPU 接受。在 ARM64 上,这并不是常见的意外崩溃,而且每次运行时的出错地址都完全相同。应用读取自己的跟踪状态,然后故意执行一次未对齐的访问来终止自身。
一个整数决定八个位置
第二款 Android 应用比这组应用中的任何其他例子都更好地说明了核心观点。
它的保护是认真的。检测字符串加密存放在原生库中,类名在 Java 层被加密,方法名是 Unicode 形近字符,系统调用以内联 svc 指令发出而不是经由 libc,因此 Hook 标准的 syscall() 包装函数什么也抓不到。
所有这些最终都汇入一个 Java 方法。您向它传入一个随机整数。在干净的设备上,它会原样返回同一个整数。
应用中的每一个执行点都检查这同一个值:
j/ma.java:480 if (rd.m13674(ctx, nextInt) != nextInt)
j/ma.java:551 if (rd.m13674(ctx, nextInt2) != nextInt2)
bk/a.java:844 m9379 = rd.m13674(ctx, nextInt) == nextInt ? -91 : 808;
bk/a.java:911 if (rd.m13674(ctx, nextInt2) == nextInt2)
ca/ma.java:2298 if (rd.m13674(ctx, r0) == r0)
ca/b.java:1331 if (rd.m13674(ctx, r0) == r0)
ca/a.java:816 if (rd.m13674(ctx, r0) == r0)
y/b.java:192,228 rd.m13674(ctx, SecureRandom.nextInt())

这个随机整数并不是装饰。它是一个 nonce,用来阻止最偷懒的攻击。如果该方法在干净设备上只是返回 true 或 0,攻击者就会 Hook 它,让它永远返回这个常量。每次调用都传入一个新的随机值并要求原样返回,意味着返回常量会失败,因为预期的答案每次都在变化。
但在这里这并没有帮助。只要攻击者能够 Hook 这个方法,就可以把传给它的参数原样返回,于是每个调用点的比较都会通过。
三个 Hook,由于方法名是 Unicode 字符,因此按类型签名而不是名称进行解析:
{"type":"hooked","method":"m13674","sig":"int(android.content.Context,int)"}
{"type":"hooked","method":"m13676","sig":"boolean(java.lang.Exception)"}
{"type":"hooked","method":"m13677","sig":"int(android.content.Context,int,int)"}
{"type":"alive","pid":6285,"ping":1} ... {"type":"alive","pid":6285,"ping":18}

运行两分钟,进程稳定,没有崩溃,篡改处理路径从未触发。
这一失败是架构层面的,而不是密码学层面的。原生层计算出了正确的判定。随后,应用却把这个判定当作一个可变整数来信任,而这个整数恰恰位于程序中保护最薄弱的部分。
如何真正证明证书锁定已被绕过
证书锁定意味着应用不只依赖操作系统的信任存储。它还会检查服务器的证书,或其中的公钥,是否与应用内置的值相匹配。如果实现得当,即使攻击者在设备上安装了自己的根 CA,它也依然有效。
但证书锁定是应用代码,因此 Hook 或修补其中的比较逻辑就能攻破它。
大多数报告对此的证明都很薄弱:Hook 已加载,代理显示了流量,应用没有崩溃。这些现象与绕过成功相符,但也与其他几种解释相符。
扫描转而针对应用自身的证书检查器做了一项对照实验:该检查器通过应用自己的构造函数创建,并加载了应用自己的锁定列表。两次使用同一张证书、同一个对象、同样的锁定值。唯一的变量是 Hook 是否处于激活状态。
| 同一证书、同一检查器、同样的锁定值 | 未绕过 | 已绕过 |
|---|---|---|
| 锁定值比较,第一个锁定值 | false |
true |
| 锁定值比较,第二个锁定值 | false |
true |
| 证书校验 | 拒绝,抛出异常 | 接受,无错误 |
一次拒绝,一次接受,其他一切保持不变。这才是值得追求的标准:不是“工具说它成功了”,而是“受保护的决策在受控条件下发生了改变”。而且它不需要代理,也不需要捕获流量,因为测试直接驱动证书锁定代码,而不是等待应用发起连接。
在 iOS 上,没有任何东西检查“收据”
这款 iOS 应用带有一个越狱关卡、用两种不同语言编写的两套独立证书锁定实现、一个 35 MB 的保护框架,以及若干第三方 SDK。
它从不检查自己的代码是否被修改过。
在 iOS 上进行自我校验,意味着通过 SecStaticCodeCheckValidity 等 API 或 csops 系统调用向系统查询自身的代码签名,或者对自己的 __TEXT 页进行哈希并与预期值比较。扫描在全部七个与保护相关的二进制文件中搜索了所有这些标准形式:
main binary 36.8 MB self-check: NONE
Flutter framework 19.6 MB self-check: NONE
protection framework 35.0 MB self-check: NONE
attribution SDK 0.6 MB self-check: NONE
jailbreak detection 0.07 MB self-check: NONE
fingerprinting SDK 0.9 MB self-check: NONE
monitoring agent 5.9 MB self-check: NONE
在经过混淆的二进制文件中,缺少某个符号说明不了什么,因为符号可以被剥离,调用也可以直接通过原始系统调用号发出。所以扫描并没有就此止步。它反汇编了保护框架,并审查了该框架直接发出系统调用的全部 1,761 个位置。其中没有一处查询代码签名。
这个框架能够发现正在运行的调试器和正在进行的注入,却无法察觉自身的字节已被改变。
于是,字节被改变了。三处补丁,每处只有几条指令:
Jailbreak check
sub sp, sp, #0x40 -> mov w0, #0 ; ret
Flutter TLS pinning
ldrb w0, [sp, #8] -> mov w0, #1
bic w8, w0, w0, asr#31 -> mov w8, #1
Payment SDK pinning, four places
mov w1, #2 -> mov w1, #1
在 ARM64 调用约定中,w0 是 x0 的低 32 位,后者是函数存放返回值的寄存器。mov w0, #0 之后紧跟 ret,就构成一个返回零的完整函数,在 Objective-C 中即 NO。因此,替换越狱检查函数序言的这八个字节,把整个函数变成了“未越狱”,其后的代码永远不会执行。
证书锁定补丁的原理相同,只是深入了一层:强制让报告信任决策的回调报告成功。
第三处补丁最值得玩味。它并没有强制应用接受一张无效证书,而是修改了应用拒绝证书的四个位置。这些位置原本向质询处理程序传递 2,意为取消连接。现在它们传递 1,意为回退到系统的常规评估。真正接受有效证书的那唯一一个位置则保持不变。
应用不再拒绝,却从未被告知要接受,这使它的行为看起来依然合理,而不是明显出了故障。
三处补丁在重新打包和重新签名之后都依然有效,应用包中没有任何组件作出反应。这正是一款从不检查自己“收据”的应用所应有的表现。
加固不等于授权
这组应用中有一款不存在值得报告的加固缺陷,却仍然存在一个严重漏洞。
Activity 是 Android 应用中的一个入口点,大致相当于一个界面。默认情况下,它对应用是私有的。将其标记为 exported="true" 可以让其他应用启动它。在其 Intent 过滤器中添加 BROWSABLE 类别,则可以让 Web 浏览器通过跟随带有该应用自定义 scheme 的链接来启动它。
这个 Activity 是导出的、可由浏览器启动的,并且在写入交易存储库之前没有进行任何身份验证、会话或调用方检查。
在清除了所有应用数据、因而没有任何人登录的情况下,一行 JavaScript 就足够了:
<script>
window.location.href = "app://quickpay?payee=BrowserAttacker&amount=200.00";
</script>
浏览器启动了该界面。随后,交易列表中有六个条目,而基线时只有五个,新增的条目带有攻击者指定的收款人和金额。
任何加固产品都不可能发现这一点,因为设备本身没有任何可疑之处。加固提高的是攻击一台已被攻陷手机的成本,对一台干净手机上缺失的授权却无能为力,它也不能替代服务器端对交易本身的校验。
每款应用中都出现的模式
所有应用中的检测技术都不简单。加壳的原生代码、每秒数次生成到新内存中的代码、在 libc 之下发出的系统调用、从不以文本形式存在的字符串、动态 JNI 注册、内存映射扫描、签名哈希、伪装成拉丁字母的希腊字母方法名,以及刻意设计得让您一无所获的崩溃。
失败之处都在更深一层:
- 信任特权攻击者可以改写的属性值
- 由单一的启动窗口决定一切
- 八个执行点读取同一个可变整数
- iOS 代码被修补,却没有任何自我校验能够察觉
- 一款应用正确检测出了 Root,却没有执行任何措施
而最尖锐的发现根本不是绕过。其中一款应用被安装在一台普通的已 Root 手机上,没有任何补丁、Hook 或属性修改。它直接进入登录界面并停留在那里。加固机制察觉到了 Root,并正确地报告了它。应用却照常运行,因为没有任何东西在倾听。
检测器回答的是:我们看到了什么?控制措施回答的是:我们做什么,以及攻击者能否改变这一决策?许多部署在第一点上很强,在第二点上却很弱。
值得在您自己的应用上运行的三项测试
确认响应,而不是检测。不要问应用能否检测 Root。把它放到一台已 Root 的手机上观察。它是终止运行、限制敏感流程、阻止身份验证,还是只记录日志后继续运行?如果它继续运行,您买到的只是遥测,而不是控制措施。
统计执行点的数量。追踪判定是如何转化为行为的。是否有一个方法决定一切?是否由一个布尔值或整数承载全部答案?Hook 能否改变它?执行是在敏感操作旁边进行,还是只在启动时进行一次?
校验您自己的代码完整性,尤其是在 iOS 上。应用是否查询自身的代码签名,或对自己的代码页进行哈希?它能否检测到重新打包和重新签名?它是否会校验保护框架本身?它是否在失败时默认拒绝?如果没有这些,其他所有保护都只是一条等待被翻转的指令,而应用包中没有任何东西会察觉。
评估是如何进行的
移动应用加固很容易被误判。工具加载成功不是证据。崩溃消失也并不总是证据。代理显示了流量,并不能证明证书锁定已被绕过。方法与结果同样重要。
先建立基线,再尝试绕过。每款应用都首先在一台干净、未 Root、没有附加任何东西的设备上启动。它能运行吗?它会终止吗?如果终止,最先触发的是什么?这个答案指明了最外层的防护,并成为此后每一项结论的参照。没有它,您就无法区分保护机制和程序缺陷。
同时摸清两个层面。软件包检查、反编译和反汇编产出了上文中的检测函数清单及其偏移量、八个执行点及其背后的方法、iOS 补丁位置,以及证书校验逻辑。
让设备状态与要回答的问题相匹配。干净设备用于基线,已 Root 设备用于 Root 和属性相关工作,流量拦截设备用于证书锁定,挂起启动用于那些在常规附加看到之前就已触发的检查,修补并重新签名的构建版本则用于 iOS 篡改测试。没有一种设备状态能回答所有问题。
一次只改变一个变量。同一款应用在属性写入前后的对比。同一张证书在证书锁定 Hook 前后的对比。同一个进程在早期附加与晚期附加之间的对比。同一个 iOS 二进制文件在修补前后的对比。
要求可观察的安全效果。只有当某项与安全相关的结果发生改变时,才算作一项发现:原本终止的应用保持存活、UI 进入了受保护状态、证书决策发生翻转、存储库接受了未经身份验证的输入,或者修补后的构建版本在未被检测到的情况下运行。经过的时间从来不是成功标准,因为“它坚持了三十秒”只是一种期望,而不是一项观察结果。

评估为自己设定的每一项标准,以及它实际返回的结果。
常见问题
什么是移动应用加固?
嵌入到应用中的保护机制,用于增加逆向工程、篡改、调试、Hook 以及在恶意环境中运行的难度。它通常包含 Root 与越狱检测、模拟器与调试器检测、Hook 检测、代码混淆、加壳、证书锁定以及防篡改逻辑。
什么是 RASP?
运行时应用自我保护(Runtime Application Self-Protection)。在移动端,它指应用监视自身所处的环境,并在发现异常时作出反应。关键词是“反应”。没有响应的检测只是遥测。
检测与执行有什么区别?
检测判定环境是恶意的。执行决定对此采取什么行动。一款应用可以检测得完美无缺却什么都保护不了,原因要么是忽略了检测结果,要么是把结果暴露为一个攻击者可以改变的单一值。
为什么 Frida 会被发现,而调试器不会?
原因在于它的进入方式,而不在于它做了什么。注入会短暂设置 TracerPid,并在进程内存映射中留下一个外来的可执行映射。在启动期间对这两项进行采样的应用,会在您的脚本运行之前就发现这次注入。在该检查之后才附加的调试器则不会留下这两种痕迹。
为什么一个 Hook 能同时攻破八项保护?
因为全部八个执行点读取的都是同一个方法的返回值。集中判定便于集成,也便于审计,但这也意味着一个 Hook 就能让一切失效。
Root 检测就足够了吗?
不够。Root 检测是一项输入,而不是一项控制措施。应用仍然必须决定如何处理它,而且必须考虑到攻击者可能隐藏 Root 迹象、改写属性、Hook 检测器或修补应用。
证书锁定就足够了吗?
不够。证书锁定是在应用代码中实现的,因此 Hook 或修补这段代码就能攻破它。它值得部署,但不应成为敏感交易的唯一保护。
加固能防止业务逻辑漏洞吗?
不能。它也许能检测到恶意的运行时环境,但它无法取代身份验证、授权、服务器端校验或交易完整性。如果一个未经身份验证的深度链接就能创建交易,那么加固并不是对口的防御手段。
移动团队应该首先检查什么?
把应用放到一台已 Root 的手机上,看它是否会停止运行。然后统计有多少处读取了判定结果。接着,在 iOS 上确认二进制文件会校验自身的签名。这三个答案决定了其余的投入是否真正发挥了作用。