免越狱调试与分析黑盒iOS应用

上篇文章我们从开发者的角度介绍了如何建立iOS项目并且在真机上运行, 上上篇文章则介绍了Objective-C的基本概念和用法。而这一切,都是为了这次的铺垫。 今天,我们就要从攻击者的角度,尝试对黑盒iOS应用进行调试与动态跟踪(instrument)。

前言

为什么要执着于免越狱呢?因为随着iOS系统的不断更新,其安全性也越来越高, 挖掘越狱漏洞也越来越难,很多新版本都已经没有公开的越狱方案了。 近来苹果也会给提交越狱漏洞的白帽不菲的奖励, 加上越狱对于普通用户的好处也越来越少, 很多越狱工具的开发者也逐渐不再更新维护。因此,免越狱分析,很有可能是未来唯一的方法。

How

其实免越狱测试应用也不是什么新技术了,Android中也有类似免root测试的方法。 而这两个方法其实本质上都是一样的,那就是 —— 重打包

对于Android来说,就是将安装包解压后注入字节码,使得应用在启动前加载我们准备的.so动态库, 从而实现在应用的上下文执行任意代码的目的;对于iOS也是一样,区别是所注入的动态库为.dylib

获取目标

上篇文章中其实有说了,开发者在编译iOS或者MacOS项目时,最终生成的应该是.app文件, 但在iOS中还需要对app文件进行打包,生成.ipa文件。AppStore下载的应用实际上是加密ipa, 一般而言可以使用下面的工具来进行还原,这一步俗称砸壳。

砸壳操作本质上是在运行时从内存里将原始文件导出,因此都需要有在源程序上下文中执行代码的能力, 也就是说,需要越狱。对于我们的场景而言,可以在第三方应用市场去下载已经解密的ipa文件, 比如AppCake或者PP助手(以及各种xx助手)等。

Tips:

对于PP助手,可以用下面的地址搜索对应应用:
    https://www.25pp.com/ios/search_app_0/关键词
在应用详情界面中,通过查看网页源代码即可看到base64编码的ipa的下载地址。

以2048小游戏为例,我们把下载的越狱程序保存为pp2048.ipa,前面也说过,ipa本质上是zip格式, 将其解压后目录结构如下:

$ unzip pp2048.ipa -d pp2048
$ tree -d 1 pp2048
1 [error opening dir]
pp2048
├── META-INF
└── Payload
    └── 2048.app
        ├── 2048
        ├── Frameworks
        ├── _CodeSignature
        ├── en.lproj
        ├── es.lproj
        ├── fr.lproj
        ├── ...
        └── zh-Hans.lproj

otool命令可以输出app的load commands,可通过查看cryptid标志位来判断app加密状态。 其中1代表加密,0代表已解密:

$ otool -l pp2048/Payload/2048.app/2048  | egrep 'LC_ENCRYPTION_INFO|cryptid'
          cmd LC_ENCRYPTION_INFO
      cryptid 0
          cmd LC_ENCRYPTION_INFO_64
      cryptid 0

这一步对于我们后续的分析是必须的,因为如果app没有解密,那么后面即使重打包安装了, 也依然无法运行。

签名与重打包

有了解密的应用程序,接下来要做的就是对其进行重打包从而注入我们的代码了。 在重打包之前,可以先查看一下原APP的签名信息。

解压后可以看到Payloads下有2048.app文件夹,这便是我们需要的应用程序。 可以用苹果自带的codesign工具验证和查看签名信息:

验证签名:

$ codesign --verify pp2048/Payload/2048.app
pp2048/Payload/2048.app: invalid signature (code or signature have been modified)
In architecture: armv7

可以看到签名验证失败,一般越狱应用都会去除掉签名信息,如下:

$ codesign -d -vv pp2048/Payload/2048.app
Executable=/reverse/iOS/pp2048/Payload/2048.app/2048
Identifier=com.ketchapp.2048
Format=app bundle with Mach-O universal (armv7 arm64)
CodeDirectory v=20200 size=68369 flags=0x0(none) hashes=2129+5 location=embedded
Signature size=4297
Authority=(unavailable)
Info.plist=not bound
TeamIdentifier=BB5K34ZCVK
Sealed Resources version=2 rules=11 files=95
Internal requirements count=1 size=100

这一步不是必须的,但是有助于我们了解目标应用程序。

在注入代码之前,我们可以先尝试修改应用内的资源文件来重新打包看是否能正常运行。 步步为营,这样排除由于注入代码而导致的错误。

要做的很简单,向app中添加一个文本文件:

echo "Leave Me" > pp2048/Payload/2048.app/evilpan.txt

然后重新打包:

cd pp2048 && zip -qry ../pp2048-repack.ipa Payload

这样就生成了新的pp2048-repack.ipa文件。这时候的ipa还不能用,需要对其进行签名。

苹果要求ipa需要经过签名才能部署,Xcode7后可以使用个人Apple ID来进行provision签名, 用于在自己的设备上对应用进行测试。值得一提的是,ipa的签名和app签名还不太一样, 后者是用codesign工具进行签名,而ipa则需要第三方工具的帮助。

可以用于给ipa(重)签名的工具有一大把,比如:

还有很多没列举到的,这里使用node-appsign来签名:

applesign -i $SIGNID -m /path/to/embedded.mobileprovision pp2048-repack.ipa -o pp2048-resign.ipa

其中SIGNID指定用于签名的私钥,可用security命令来查看当前可用于给代码签名的SIGNID:

$ security find-identity -v -p codesigning
  1) CDC7************************************ "iPhone Developer: evilpan (V2KHK9DD9)"
  2) A3BB************************************ "org.radare.radare2"
     2 valid identities found

实际测试中发现似乎只能用第一个也就是Apple ID生成的私钥来进行签名。

在签名的命令中,还有一个需要提供的文件就是embedded.mobileprovision, 即provision profiles,这个文件可以在我们自己的iOS项目生成文件中找到,比如:

/Users/Peter/Library/Developer/Xcode/DerivedData/HelloWorld-dnyjqrgxcjjobvfzytzhtzpmjlmx/Build/Products/Debug-iphoneos/HelloWorld.app/embedded.mobileprovision
# 或者
~/Library/MobileDevice/Provisioning Profiles/

provision profiles是将签名,授权和沙盒联系起来文件,每个iOS开发者应该都不会陌生。 关于iOS/OSX代码签名和授权机制的介绍,强烈推荐这篇文章,这里就不展开了。

签名完成后的ipa就可以部署到真机上了,比如用我们上一篇文章说到的ios-deploy

ios-deploy -b pp2048-resign.ipa

安装成功!如果是第一次启动,会提示不受信任的开发者,安装提示允许即可正常运行。

注入代码灵魂

现在我们已经验证了重打包应用的可行性,接下来就要开始做点有用的事情了。 为了在应用中执行我们自己的代码,我们可以直接修改二进制的2048.app/2048文件, 毕竟,它只是一个运行于ARM平台的普通Mach-O文件而已:

$ file pp2048/Payload/2048.app/2048
pp2048/Payload/2048.app/2048: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64]
pp2048/Payload/2048.app/2048 (for architecture armv7):	Mach-O executable arm_v7
pp2048/Payload/2048.app/2048 (for architecture arm64):	Mach-O 64-bit executable arm64

可以使用十六进制编辑器如radare2对其进行patch,不过这种方式太原始,做点简单修改还行, 不方便做太复杂的操作。所以,一般还是直接在Mach-O中注入少量指令,并使其在运行时加载我们的动态库, 这样就可以在动态库中实现复杂的操作了。

在给iOS注入动态库之前,我们先在Mac下面测试一下注入动态库的可行性,二者平台是比较类似的。 首先编译一个简单的Mach-O可执行文件,如下:

$ cat hello.m
#import <Foundation/Foundation.h>

int main (int argc, const char * argv[])
{
    @autoreleasepool {
        NSLog (@"Hello, World!");
    }
    return 0;
}

$ clang -framework Foundation hello.m -o hello
$ ./hello
2019-04-07 12:38:10.031 hello[17348:3859846] Hello, World!

然后准备一个动态库文件libtest.m

#import <Foundation/Foundation.h>

__attribute__((constructor)) void dylibMain()
{
    @autoreleasepool {
        NSLog(@"libtest LOADED!!!!!!!");
    }

}

__attribute__((constructor))宏描述dylibMain为动态库的constructor,即入口。 编译动态库的方法为:

$ clang -framework Foundation libtest.m -shared -o libtest.dylib
$ file libtest.dylib
libtest.dylib: Mach-O 64-bit dynamically linked shared library x86_64

在Linux中注入动态库最简单的方法就是使用LD_LIBRARY环境变量, Mac下也有类似的环境变量,为DYLD_INSERT_LIBRARIES,如下:

$ DYLD_INSERT_LIBRARIES=libtest.dylib ./hello
2019-04-07 12:44:08.810 hello[17497:3861923] libtest LOADED!!!!!!!
2019-04-07 12:44:08.812 hello[17497:3861923] Hello, World!

可以看到动态库中的代码已经被调用了,而且是在main函数之前。

在没越狱的iOS中,我们无法轻易给目标进程添加环境变量。不过既然能修改文件, 我们也可以将其改成在启动是自己加载指定动态库。有很多自动化工具可以帮助我们完成这个操作:

这些工具的作用都是在Mach-O的Load Commands段中添加一条LC_LOAD_DYLIB。 关于Mach-O的详细文件格式可以参考PARSING MACH-O FILES

insert_dylib为例,注入dylib只需一条命令:

$ ./hello
2019-04-07 12:57:42.274 hello[17728:3865999] Hello, World!

$ insert_dylib libtest.dylib hello
Added LC_LOAD_DYLIB to hello_patched

$ ./hello_patched
2019-04-07 12:57:58.425 hello_patched[17752:3866077] libtest LOADED!!!!!!!
2019-04-07 12:57:58.425 hello_patched[17752:3866077] Hello, World!

现在我们已经(在Mac上)验证了向二进制文件中注入动态库的可行性,接下来就是iOS了。 和Mac不同的是,iOS应用一般是ARM/ARM64版本,因此在x86的笔记本上,就需要交叉编译。 通过clang和Xcode提供的SDK,我们可以很方便在Mac上交叉编译iOS的应用或动态库:

clang -framework Foundation libtest.m -shared -o libtest.dylib -arch armv7 -arch arm64 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.1.sdk -mios-simulator-version-min=6

和ELF不同的是,Mach-O支持多架构,也就是说我们可以指定多个arch,生成的文件如下:

$ file libtest.dylib
libtest.dylib: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O dynamically linked shared library arm_v7] [arm64:Mach-O 64-bit dynamically linked shared library arm64]
libtest.dylib (for architecture armv7):	Mach-O dynamically linked shared library arm_v7
libtest.dylib (for architecture arm64):	Mach-O 64-bit dynamically linked shared library arm64

但是一般只需要支持armv7即可,因为iOS新的CPU会兼容老的架构,详见iOS Support Matrix

新生成的libtest.dylib此时还不能直接添加到ipa中,要时刻记住在iOS中, 所有的代码都需要有合法的签名。

$ codesign -f -v -s $SIGNID libtest.dylib
libtest.dylib: signed Mach-O universal (armv7 arm64) [libtest]

将libtest.dylib拷贝到ipa,并注入到可执行文件中,参考上面简单重打包的方法, 对最后的ipa进行重新打包签名。这里为了看到标准输出,直接使用lldb调试启动:

$ unzip pp2048-resign.ipa -d pp2048-resign
$ ios-deploy -b pp2048-resign/Payload/2048.app --debug -W
(lldb)     connect
(lldb)     run
success
libtest LOADED!!!!!!!
2019-04-07 13:49:49.731657+0800 2048[14630:3523860] [Accessibility] ****************** Loading GAX Client Bundle ****************
2019-04-07 13:49:50.085734+0800 2048[14630:3523860] [aurioc] 1029: failed: '!pla' (enable 2, outf< 2 ch,  44100 Hz, Float32, non-inter> inf< 2 ch,      0 Hz, Float32, non-inter>)
2019-04-07 13:49:50.127830+0800 2048[14630:3523860] error loading sound: a004
Process 14630 exited with status = 1 (0x00000001)
(lldb)

可以看到,libtest已经成功加载并执行了!这里需要一提的是我把libtest.m中的NSLog改成了printf, 因为测试中发现前者在运行时根本没有打印,这问题也令我测试了好久。

动态调试

终于,我们成功在目标iOS应用中注入了我们自己的动态库!由于动态库的代码是我们自己写的, 因此可以编写复杂的插件,在应用上下文执行任意代码。

刚刚启动时我们使用了ios-deploy--debug参数来启动lldbserver/client 并自动attach,lldb是LLVM中功能强大的调试器,功能与gdb类似,如下:

(lldb) process interrupt
Process 14675 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
    frame #0: 0x00000002230efea4 libsystem_kernel.dylib`mach_msg_trap + 8
libsystem_kernel.dylib`mach_msg_trap:
->  0x2230efea4 <+8>: ret

libsystem_kernel.dylib`mach_msg_overwrite_trap:
    0x2230efea8 <+0>: mov    x16, #-0x20
    0x2230efeac <+4>: svc    #0x80
    0x2230efeb0 <+8>: ret
Target 0: (2048) stopped.
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
  * frame #0: 0x00000002230efea4 libsystem_kernel.dylib`mach_msg_trap + 8
    frame #1: 0x00000002230ef37c libsystem_kernel.dylib`mach_msg + 72
    frame #2: 0x00000002234f5ad8 CoreFoundation`__CFRunLoopServiceMachPort + 236
    frame #3: 0x00000002234f0974 CoreFoundation`__CFRunLoopRun + 1396
    frame #4: 0x00000002234f00e0 CoreFoundation`CFRunLoopRunSpecific + 436
    frame #5: 0x0000000225769584 GraphicsServices`GSEventRunModal + 100
    frame #6: 0x0000000250740c00 UIKitCore`UIApplicationMain + 212
    frame #7: 0x000000010298ca20 2048`___lldb_unnamed_symbol49$$2048 + 88
    frame #8: 0x0000000222faebb4 libdyld.dylib`start + 4
(lldb)

例如,使用lldb来对抗ptrace反调试:

# 设置断点
b ptrace

# 继续执行
continue

# 触发断点后直接跳转到返回地址
register write pc `$lr`

# 绕开ptrace继续运行
continue

lldb还支持python扩展,从而实现更丰富的自定义功能,详见LLDB-Python

除了使用调试器,我们还可以使用frida来进行动态的hook, 其ObjC 接口提供了许多针对ObjectiveC的封装,对于Objective-C项目, 可以使用frida很轻松地修改应用程序逻辑。

在非越狱场景使用frida主要是通过frida-gadget,即通过重打包注入 frida-gadget.dylib动态库,流程和注入我们自己编译的libtest.dylib一模一样。 关于frida的详细使用方法可以多看看其官方文档

使用insert_dylib注入frida-gadget.dylib例子如下:

cp frida-gadget.dylib pp2048/Payload/2048.app/
insert_dylib --strip-codesig --inplace '@executable_path/frida-gadget.dylib' pp2048/Payload/2048.app/2048

小结

至此,免越狱对应用进行动态分析的部分已经介绍完毕,这对于真正分析一个iOS程序还不完整, 但却是最重要的部分。分析一个iOS应用和其他应用一样,无非就是逆向分析与动态调试, 对于静态分析来说,一般也是使用Reveal定位关键点, 然后把Mach-O拖进逆向分析工具如IDA/radare2/Ghidra进行分析,复杂点的需要面对混淆和对抗, 因此动态分析也能帮助理解静态代码。而动态分析除了用于分析还能用于持久化, 这对于日常工作生活而言,或许是更为实用的。

参考链接