在我发完第二篇关于 Frida 的博文之后, @muellerberndt 立即就决定公布另一个 OWASP Android crackme 。我想试试看我是否依然可以用 Frida 来解决这个问题。如果你也想跟着我一起,那么你需要:
OWASP Uncrackable Level2 APK
Android SDK 和 模拟器 (我用的是 Android 7.1 x64 镜像 )
Frida 的安装包(加上 frid 服务器 )
字节码查看器
radare2 (或者你自己选择用其他的反编译器)
apktool
如果你需要 Frida 的安装教程,请查看 Frida 的 官方文档 . 至于Frida的使用, 请查看这个教程的 第一部分 。现在我就当你已经准备好所有东西,在继续往前走之前,你还需要稍微熟悉 Frida 的使用,还有,确保 Frida 可以连接你的设备 / 模拟器。(比如,通过使用 frida-ps -U 命令)。
说在前面的话:这不仅仅是一篇解决那个crackme 的攻略。相反的,我打算向你展示几种不同的方法来解决这个具体的问题。如果你只是想看解决方法,可以直接翻到本教程的最后面,那里有Frida 脚本。
注意:如果你在使用Frida 时收到下面的错误
或者其他类似的错误,它可能会清除模拟器上所有的用户数据,所以你要重启然后重新安装apk 。
做好得多试好几次的思想准备,程序会崩溃,模拟器会需要重启,一切都可能变得很乱七八糟的,但最终,我们会成功。
和在 UnCrackable 1 做的一样,我们第一步是运行那个 app 。也和它 UnCrackable 1 一样,当我们在模拟器上运行这个 app 时,它会被检测到说是在已经 root 过的设备上运行的。
我们会像在 UnCrackable 1 那里一样,钩住 OnClickListener 函数。但在这之前,我们得看看我们是否已经连上 Frida 以便我们修改。
这是啥?有两个同名进程。我们可以 frida-ps -U
来验证:
好奇怪,让我们把Frida 注入到父进程:
没用。因为我们是在以 root 运行 Frida 时得到这样的结果的,所以这个方案没什么效果。这是怎么了?我们得好好研究这个 app 。解压 apk ,用字节码查看器(例如 CFR-Decompiler )反编译 classes.dex
我们注意到 static 块里调用了 System.load
来加载 foo 库(参看【 1 】)。这个 app 还在 OnCreate 函数里的第一行就调用了 this.init () ,而这个函数被声明为 native 函数(参看【 2 】),所以它应该是 foo 的一部分。
让我们来看看这个 foo 库。在 radare2 中打开这个库(你会在 lib 文件夹里看到几个不同的架构,我这里用的是 lib/x86_64 ),分析它并列出它的输出。
我们看到这个库导出了两个很有意思的函数: Java_sg_vantagepoint_uncrackable2_MainActivity_init
和 Java_sg_vantagepoint_uncrackable2_CodeCheck_bar
(关于这些函数的命名,请查看 Java nativ interface JNI ),我们要看的是:
Java_sg_vantagepoint_uncrackable2_MainActivity_init
这是一个蛮短的函数:
它调用了另一个函数 sub.fork_820
,这个要做的事就比较多了:
我们看到调用了 fork , pthread_create
,
getppid
, ptrace
和 waitpid
. 无需 花太多时间来反编译我们就可以猜到,当调试器用 ptrace 的时候,主进程会 fork 一个子进程来关联它。这是很简单的反调试技术,你可以从 这里 了解到更多细节。
因为 Frida 用 ptrace 来初始化注入,所以这就解释了为什么我们不能连接到父进程:因为已经连接了一个进程来作为调试器,再来一个进程关联调试将被阻塞。
Frida 救援。相比于注入 Frida 到一个正在运行的进程,我们可以让它自己 spawn 出一个进程来给我们。用 -f 选项,我们告诉 Frida 注入 Zygote 然后启动该应用程序。在我们启动 Frida 后关掉这个应用程序,看看发生什么:
我们得到:
呼啦啦! Frida 注入到 Zygote 了, spaw n 我们的进程并等待输入。(我承认,有很多教程都告诉你们要在 Frida 加 -f 选项,但你也被警告过……)
我们现在已经做好准备了。但是在继续往下走之前,我们再来看另一种针对这个crackme 的反反调试方案。
除去让Frida 来spawn ,我们也可以通过修改这个app 来解决这个问题。这意味着,我们要反编译这个app ,重新打包和签名修改过的apk 。然而,在这个crackme 中,这样做会在后面给我们带来麻烦。就算这样,我还是决定告诉你怎样做,后面的问题后面解决。
我们可以用apktool 来修改:
(我用-r 来跳过了提取资源,因为这会在重新编译apk 时出错。而且,在这里我们也不需要这些资源。)
来看看在 smali/sg/vantagepoint/uncrackable2/MainActivity.smali
的 smali 代码。你可以看到调用 inti 的操作是在 82 行附近,你可以把它注释掉。
重新打包(忽略叼那个fatal error…… ):
align (优化):
签名(注意:在这一步你要有一个 key 和 keystore ,你可以在 OWASP 手机安全测试指南 看到更多介绍。):
卸载原来的apk ,安装这个修改过的apk :
启动这个 app ,运行 frida-ps 我们会看到只有一个进程了:
然后连接 Frida 也没有问题:
相比于只是在 Frida 里加 -r 选项是比较麻烦啦,但这也更普遍。
就像前面提过的,如果我们使用这个修改过的版本,那后面要提取那个 Secret String 就不会那么容易(尽管如此,我还是会告诉你怎样解决的,所以不要放弃哦)。但是现在我们要用的是原来的版本来进行后面的操作。确保你下面安装的是原来的版本。
在我们找到摆脱反调试的可能性后,我们来看看要怎么处理。这个app 会做一个root 检测,当我们在模拟器上运行的时候,只要我们按下OK 按钮,就会退出。我们已经从UnCrackable1 看到过这样的情况。同样,我们可以修改这个行为,删掉对System.exit 的调用。但这次我们打算用Frida 来解决。查看反编译后的代码,我们可以看到并没有OnClickListener 类,只有一个匿名的内部类。因为OnClickListener 实现System.exit 的调用,我们可以简单的hook 这个函数,然后让它失效。
这是做这些操作的Frida 脚本:
再次关掉UnCrackable 2 ,然后用Frida 来打开它:
等,直到 App 启动并且Frida 在控制台显示Hooking calls…… 信息。然后按下“OK” 。你会得到类似下面的信息:
这样,这个app 就不会被退出来了。我们可以输入一个secret string :
但我们在这输入了什么?来看MainActivity 里的Android 代码是如何检查正确的输入的:
用到了 CodeCheck 类:
我们可以看到我们在文本框输入的信息—我们的“secret string” 会被传送到一个名为 bar 的native 函数中。我们在 libfoo. so 库中再次找到这个函数。查找这个函数的地址(像我们之前找 init 函数那样),然后用radare2 来反编译它:
仔细观察这些汇编代码,我们可以看到有一些字符串的比较操作,还看到一个很有意思的明文字符串 Thanks for all t. 我们在文本框里输入这个字符串发现并不是这个crackme 的答案,所以我们还得继续。
查看在 0x000010d8
的汇编代码,我们可以看到:
所以,这里比较了 eax 和 0x17 ,也就是十进制的23 。如果比较不成功,就不会调用 strncmp 。我们也注意到在0x00010e1 处,0x17 作为 strncmp 的一个参数:
要知道,按照 64 位 linux 的调用惯例,函数参数是放在——至少参数 1 到 6—— 寄存器中的。尤其是前三个参数是按序放在 RDI,RSI 和 RDX 中(具体的可以看这里 [PDF] , p. 20 )。 strncmp 的头部是这样的:
所以我们的 strncmp 函数会比较 0x17=23 个字符。我们可以推断出我们的 secret string 长度应该是 23.
最后让我们尝试这去 hook 这个 strncmp 函数,输出它的参数。我们期望这样能给出解密后的字符串。我们要做的是:
找到 strncmp 在 libfoo.so 中的内存地址。
用 Interceptor.attach 来 hook libfoo.so 中的 strncmp 函数,并 dump 它的参数。
如果你这么做了,你会发现很多地方都有调用strncmp ,所以我们要进一步限制输出。这是一段Frida 代码:
在这段代码中有几点需要注意的:
这段代码调用了 Module.enumerateImportsSync
来检索对象数组,这些对象中包含了从 libfoo.so 导入的信息(具体请看 文档 )。我们迭代这个数组直至我们找到 strncmp 和它的地址。然后我们给它关联一个拦截器( Interceptor )。
Java 中的字符串不是以 null 来终止的。当我们用 Frida 的 Memory.readUtf8String 方法且不提供长度来读取 strncmp 内存中的字符串时, Frida 会以为有 \0 来终止,不然就一直返回一些内存垃圾,因为它不知道字符串的终点在哪里。如果我们在第二个参数中明确给出要读取的字符串长度,我们就不会遇到这个问题。
如果我们不在判断条件那里作限制,限制我们要 dump 的 strncmp 参数,我们会看到很多输出。所以我们只在 strncmp 的第三个参数 size_t 是 23 ,且第一个参数指向我们的输入框的时候输出。在输入框中我们会输入 01234567890123456789012 (这个字符串有 23 个字符)。
我是怎么知道 args[0] 指向我们的输入, args[1] 指向那个 secret string 的?事实上,我并不知道。我只是测试,然后在满屏的输出中找到我的输入。如果你不想跳过这部分,你可以把上面代码中的 if 语句删掉,然后使用 Frida 的 hexdump 输出。
这样每次调用 strncmp 都会输出很多 hexdump ,要小心哦。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课