0%

魔高还是道高?iOS 越狱检测与反检测的攻防大战

越狱的 iPhone 上,APP 进程中可能被插入任意代码。对于聊天软件来说,这最多意味着多出几个主题或手势。这属于功能上的定制,无伤大雅。但对于金融 APP 来讲,这意味着用户可见的界面和实际执行的逻辑之间,不一定再有可靠的对应关系。

例如说……

  • 注入代码可以读 App 沙盒里的任意文件,其中包括被错误地留在磁盘上的机密信息。
  • 可以读取键盘事件,把用户的每次输入上传到攻击者的服务器。
  • 可以在用户按下转账确认的那一瞬间改掉收款账号。
  • 可以制作一个和登录页一模一样的假界面,把密码骗到手。
  • 也可以绕过证书验证,让整个会话以明文暴露给中间人。

这些威胁在现实中都能找到对应的黑灰产,相关产业的规模比多数开发者估计得大。因此,监管对金融 APP 移动客户端的安全检测一直有明确要求。从这个角度看,检测越狱既正当,也必要。

下面按攻防的次序,把这两年处理过的十来款 App 归纳一遍。每个回合先看攻击方做了什么,再看检测方可以怎么补。

特定路径检测

早期检测几乎都在看文件或者看文件夹。

  • /var/jb 在不在?
  • 包管理器的应用目录在不在?
  • /usr/sbin/sshd 有没有安装?
  • fork() 能不能成功?
  • system("ls") 有没有返回内容?

这些检查写起来容易,理解起来也简单。然而,缺点在于它们依赖的是一份路径清单,而清单总是有限的。

攻击:把越狱目录藏起来

针对这类检测的攻击很简单。越狱的隐藏模式可以在 App 的进程里挂上一层伪造的文件系统视图,把越狱目录从可见路径里藏起来。App 再去看 /var/jb,读到的是「不存在」;再调用 fork(),拿到的是失败。几乎所有建立在文件存在性上的检查,在这层视图面前都会失效。

防守:读挂载表

文件可以藏,挂载藏不住。

statfs 返回的结构体里有一个字段叫 f_mntfromname,记录这个挂载点从哪里来。正常系统上,/usr/lib 属于系统卷,来源是 /dev/disk1s1 一类的块设备;隐藏模式下,它变成了一个用户态文件系统的挂载,来源指向 /private/preboot 下面一个以 .fakelib 结尾的目录。

1
2
3
4
5
6
7
8
struct statfs s;
if (statfs("/usr/lib", &s) == 0) {
// 正常设备上是 /dev/disk1s1 一类的卷名
// 隐藏模式下会读到 bindfs 和 .fakelib 目录
if (strstr(s.f_mntfromname, ".fakelib") != NULL) {
report(ThreatMountTable);
}
}

这段代码有意思的地方在于,它不依赖任何具体的越狱工具,也不需要预置路径黑名单。它只问系统一句「这块挂载从哪来」,然后比对来源是否符合已知的问题路径。

检测的判据应当选那些攻击者不改越狱本体就消不掉的状态。文件名可以被遮蔽,挂载来源不能。攻击者要改它,只能去改越狱工具本身的挂载实现。

异常代码检测

攻击者想要自己的代码在 APP 中发挥作用,就需要将自己的代码注入到 APP 的进程当中。既然注入就有痕迹,这就是所谓的异常代码检测。根据不同的注入方式,会有不同的检测方式。我们一个一个来看。

攻击:用最省事的方式注入

最简单的注入方式是往进程里 dlopen 一个动态库。

防守:动态库白名单

一经加载,_dyld_image_count() 就多一个,_dyld_get_image_name() 就能读到新库的路径。检测方把当前镜像清单和预期白名单比一比,多出来的就是可疑对象。

我们在一款股份制商业银行的 App 上验证过这条检测的灵敏度。当时我们做了一个探针,它只装载动态库而不包含任何 hook。这个探针照样触发了「检测到当前设备存在注入风险」的对话框。

攻击:绕过动态库白名单

白名单方案有一个天然的限制。它能有效证明「有额外的东西被加载」,但无法保证「没有额外的东西被加载」。例如,攻击者可以把代码插入到白名单的动态库中,或者可以先加载再卸载,从而绕过清单检测。即是说,它的精确率很高而召回率很低。

防守:内存页属性和代码签名

要防御这种攻击,有两个方案。

一是内存页属性:mach_vm_region 可以读出进程里每一段映射的保护标志。注入的代码往往落在一块同时可读、可写、可执行的页上,而正常的动态库代码段只有读和执行。通过这个差异,可以识别异常代码。

二是代码签名:csops 能向内核确认某段地址属于哪份签名。系统库和 App 自身都有对应的签名,注入进来的库没有。

攻击:劫持函数

hook 是越狱后修改 APP 行为的常见手段,也是绕过检测的常见手段。通过 hook 劫持原有函数的行为,可以用来绕过检测。

MSHookFunction 是一种劫持函数的手段。它在目标函数的开头写一条跳转,把函数的执行流引到自己的实现里。从效果上说,这一招几乎「无所不能」,但它必然修改目标函数开头的内容。

防守:检查关键函数的开头内容

对于 MSHookFunction,检测方只要知道函数头几个字节的原始值,就能确认函数是否被劫持篡改。此外,也可以检查代码段所在页的可写标志——要写跳转就得先把页改成可写,事后再改回来也会留下痕迹。

攻击:改方法表

对于 Objective-C 来说,方法调用最后会查到方法表里一条记录上;这条记录保存有函数的实际地址。修改这一地址指向攻击者需要的代码,即可劫持函数。修改方法表不会碰函数里的任何指令字节,上述比对就看不出变化了。

防守:检测方法表并把判定下沉到 C 甚至内联

对于 Objective-C 方法表替换,检测方可以枚举关心的 selector,读出它指向的实现地址,再看这个地址落在哪个镜像里。由于注入进来的异常代码肯定在 APP 镜像之外,检测方可以很清晰地知道相关函数被劫持。

不过这个做法有两个问题。

  • 一是成本,几百个方法各查一次,启动会明显变慢。
  • 二是位置,检测代码本身也是 Objective-C 方法,攻击者可以先找到它,把它的值也改掉。

所以真正要紧的判定不放在 Objective-C 的方法里,而是要下沉到 C 函数甚至内联汇编写。

完整性检查

除了对单个函数劫持的攻防之外,检测方还可以做完整性自检——把 text 段的内容过一遍哈希,与期望值比对。如果不符,则意味着代码段被篡改。我们在一款港资银行的 App 里读到过一次完整的实现:从代码段偏移 0x8000 处开始,长度 0x76c5fc,使用 xxHash64,种子是 0x9fca7ba5c41b2cec,比对发生在函数偏移 +0x43ccdc。

攻击:不碰检测,只改比较

要绕过完整性检查,攻击者不需要还原哈希算法,而只要找到比较的那一条指令,让两边永远相等即可。

我们当时把 cmp 换成 subs xzr, x21, x21,结果一定是零,于是绕过了完整性检查。

1
2
3
4
5
6
; 原始比较:相等则跳过失败分支
cmp x21, x20
b.eq pass

; 绕过:常置零标志位,后续跳转永远成立
subs xzr, x21, x21

防守:让结论参与计算

单点自检的问题就在这里。整段哈希算得再准,结果也只经过一次比较,而比较只是一条指令。防守方能做的,是把结论分散到多个位置,并让每个位置的形态不一样:有的比哈希,有的比长度,有的比一条内嵌常量。

最有效的做法是让自检结果参与业务计算。假设哈希值被用来派生一个会话密钥,攻击者改掉比较指令、让自检通过,拿到的是一个错误的哈希,派生出来的密钥和后续要解密的数据对不上,业务流程直接失败。这时候他要绕过,就得同时改比较、改派生、改使用点,任何一处漏掉都不行。

还有一个方向是把自检代码本身纳入被检查的范围。一段代码检查另一段,另一段再检查回来,攻击者想改任何一处都要先把这层关系解开。

防守:隐藏判定逻辑

还有另一种防守方案。

检测框架可以将判定逻辑编译成自定义字节码,交给一个小解释器执行。如此在机器码里就看不到对应的分支。攻击者想要搞清楚具体的判定逻辑,就得梳理清楚这个小解释器,把字节码格式和含义还原出来。成本比改一条比较指令高出几个数量级。

我们在一款寿险 App 的防护组件里见过这套做法的完整形态。函数边界被拆得看不出调用关系,判定过程只能从行为上观察。

其它防御手段:时序与退出机制

防守:提早检测时机

攻击的另一个重要思路是抢在检测实际发生之前就劫持检测代码。而这是防守方的天然主场。攻击者的代码得以执行的最早时间是注入框架将它装载进内存后的构造函数;而注入库什么时候被加载、构造函数什么时候执行,都由越狱工具的注入基座决定,要改这个顺序,等于去改越狱本体。

在我们研究过的几款金融类 APP 中,我们分别探测过首次越狱检测的时间点。上述寿险 App 的判定时间最早,分别在 7 毫秒和 12 毫秒就已完成。那时我们用于测试的注入库甚至连构造函数都还没来得及跑。

防守:退出要有多种机制

退出不应是一个函数调用,而应是一组不同的机制。在我们的研究过程中,我们见过四类退出机制。

第一,内联系统调用。一款视频平台 App 在二进制里内联了 svc #0x80,直接发 SYS_exit,完全不走 C 库的 exit,对 exit 做 hook 拦不到。另一款房产中介 App 的二进制里有二十九处这样的调用。

第二,毒地址。把栈指针写成一个含 DEAD 常量的哨兵值,再跳进 __PAGEZERO 里的地址。我们见过的几个毒地址是 0xb5a98000、0xb5a06000、0xb5a01000,它们都落在 0 到 0x100000000 之间,内核不会给这段区间映射内存。

第三,冻结。另一款国有大行的 App 不结束进程,而是用一个信号量把主线程挂起来,每秒检查两百次。App 看起来还在前台,实际完全不动,用户只能强制关闭。

第四,延迟退出。接到检测结果之后,并不立即退出,而是等十六秒再调用 exit(0),中间还挂了一个栈保护失败的分支。攻击者拦下 exit,App 转而从栈保护那条分支退出。

单独看,每种都能被拦住。内联系统调用可以在二进制里全部找出来逐个改写,毒地址可以让栈写入落空,冻结可以恢复信号量。四种同时存在,攻击者要全部找到并全部拦下,漏掉任何一种就会失败。这属于成本问题,不属于原理问题。

两条约束下的设计

回到开头那个问题。如果把条件收紧成两条(不动越狱本体,也不修改并重签 App 本体),一个理论上足够完整的检测框架应当由六部分组成。

第一,判据选择。检测的对象必须是攻击者不改越狱本体就消不掉的状态。挂载表来源、已加载镜像的签名,以及可读可写的匿名映射,这三类都属于此列;文件路径、URL scheme、fork() 返回值则不在此列。

第二,完整性自检。检测代码自身必须在被检测的范围内,自检结果要参与后续计算,不能只产出一个布尔值。比较点尽可能分散在不同的函数里,形态各不相同。

第三,时序。首次判定要在第一个业务请求之前完成。由于构造顺序由越狱本体和注入基座决定,App 自身的代码无法抢在注入库之前运行,所以「早」只能由判定结果的落点来保证。判定结果要变成内存里只计算一次、后续所有请求都要用的某个数据来源。

第四,退出机制。至少要有四种互相独立的终止方式,触发条件各不相同,不共用同一个开关。

第五,关键判定放进字节码解释器里执行。检测逻辑以自定义字节码存放,从而使机器码里看不到对应的分支。攻击者要理解判定过程,得先把字节码的格式和含义还原出来。

第六,周期性重检。启动时判定一次之后,运行期间按随机间隔重新判定,间隔不写死。攻击者没法通过「先通过检测、再注入代码」来规避。

这套组合逼迫攻击者同时完成两件在约束之外的事,于是变得几乎不可攻破。要消掉挂载表和镜像签名的证据,必须改越狱本体的挂载与注入方式;要消掉完整性自检,必须改 App 的二进制。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。