跳过内容

MASTG-TECH-0031: 调试

到目前为止,您一直在使用静态分析技术,而没有运行目标应用程序。在现实世界中,尤其是在逆向恶意软件或更复杂的应用程序时,纯静态分析非常困难。在运行时观察和操纵应用程序可以更容易地破译其行为。接下来,我们将了解动态分析方法,这些方法可以帮助您做到这一点。

Android 应用程序支持两种不同类型的调试:使用 Java 调试线协议 (JDWP) 在 Java 运行时级别进行调试,以及在原生层上进行基于 Linux/Unix 风格 ptrace 的调试,这两种方法对逆向工程师都很有价值。

调试发布应用

Dalvik 和 ART 支持 JDWP,JDWP 是一种用于调试器和它调试的 Java 虚拟机 (VM) 之间通信的协议。JDWP 是一种标准调试协议,所有命令行工具和 Java IDE(包括 jdb、IntelliJ 和 Eclipse)都支持它。Android 的 JDWP 实现还包括用于支持 Dalvik 调试监视器服务器 (DDMS) 实现的额外功能的钩子。

JDWP 调试器允许您单步执行 Java 代码、在 Java 方法上设置断点以及检查和修改局部变量和实例变量。在调试“正常”Android 应用程序(即,不经常调用原生库的应用程序)时,您将主要使用 JDWP 调试器。

在以下部分中,我们将展示如何仅使用 jdb 解决 Android UnCrackable L1。请注意,这不是解决此 crackme 的有效方法。实际上,您可以使用 Frida 和其他方法更快地完成它,我们将在本指南的后面介绍这些方法。但是,这可以作为 Java 调试器功能的介绍。

使用 jdb 调试

adb 命令行工具在“Android 基本安全测试”一章中介绍过。您可以使用它的 adb jdwp 命令列出连接设备上运行的所有可调试进程的进程 ID(即,托管 JDWP 传输的进程)。使用 adb forward 命令,您可以在主机上打开一个侦听套接字,并将此套接字的传入 TCP 连接转发到所选进程的 JDWP 传输。

$ adb jdwp
12167
$ adb forward tcp:7777 jdwp:12167

现在您可以附加 jdb 了。但是,附加调试器会导致应用程序恢复,这不是您想要的。您想保持它的挂起状态,以便您可以先进行探索。要防止进程恢复,请将 suspend 命令管道到 jdb 中

$ { echo "suspend"; cat; } | jdb -attach localhost:7777
Initializing jdb ...
> All threads suspended.
>

您现在已附加到挂起的进程,并且可以继续执行 jdb 命令。输入 ? 会打印完整的命令列表。不幸的是,Android VM 不支持所有可用的 JDWP 功能。例如,不支持 redefine 命令,该命令可让您重新定义类代码。另一个重要的限制是,行断点不起作用,因为发布字节码不包含行信息。但是,方法断点确实有效。有用的工作命令包括

  • classes:列出所有已加载的类
  • class/methods/fields class id:打印有关类的详细信息并列出其方法和字段
  • locals:打印当前堆栈帧中的局部变量
  • print/dump expr:打印有关对象的信息
  • stop in method:设置方法断点
  • clear method:删除方法断点
  • set lvalue = expr:将新值分配给字段/变量/数组元素

让我们回顾一下来自 Android UnCrackable L1的反编译代码,并考虑可能的解决方案。一种好的方法是在应用程序处于一种状态时将其挂起,其中密钥字符串以纯文本形式保存在一个变量中,以便您可以检索它。不幸的是,除非您先处理 root/篡改检测,否则您不会走那么远。

查看代码,您会看到方法 sg.vantagepoint.uncrackable1.MainActivity.a 显示“This in unacceptable...”消息框。此方法创建一个 AlertDialog 并为 onClick 事件设置一个侦听器类。此类(名为 b)有一个回调方法,一旦用户点击确定按钮,该方法将终止应用程序。为了防止用户简单地取消对话框,将调用 setCancelable 方法。

  private void a(final String title) {
        final AlertDialog create = new AlertDialog$Builder((Context)this).create();
        create.setTitle((CharSequence)title);
        create.setMessage((CharSequence)"This in unacceptable. The app is now going to exit.");
        create.setButton(-3, (CharSequence)"OK", (DialogInterface$OnClickListener)new b(this));
        create.setCancelable(false);
        create.show();
    }

您可以使用一点运行时篡改来绕过此问题。在应用程序仍处于挂起状态时,在 android.app.Dialog.setCancelable 上设置一个方法断点并恢复应用程序。

> stop in android.app.Dialog.setCancelable
Set breakpoint android.app.Dialog.setCancelable
> resume
All threads resumed.
>
Breakpoint hit: "thread=main", android.app.Dialog.setCancelable(), line=1,110 bci=0
main[1]

应用程序现在在 setCancelable 方法的第一条指令处挂起。您可以使用 locals 命令打印传递给 setCancelable 的参数(这些参数错误地显示在“局部变量”下)。

main[1] locals
Method arguments:
Local variables:
flag = true

调用了 setCancelable(true),因此这不可能是我们正在寻找的调用。使用 resume 命令恢复进程。

main[1] resume
Breakpoint hit: "thread=main", android.app.Dialog.setCancelable(), line=1,110 bci=0
main[1] locals
flag = false

您现在已经到达了一个使用参数 false 调用 setCancelable 的位置。使用 set 命令将变量设置为 true 并恢复。

main[1] set flag = true
 flag = true = true
main[1] resume

重复此过程,每次到达断点时都将 flag 设置为 true,直到最终显示警报框(断点将被到达五到六次)。警报框现在应该是可取消的!点击框旁边的屏幕,它将关闭而不会终止应用程序。

现在反篡改已经完成,您可以提取密钥字符串了!在“静态分析”部分中,您看到该字符串使用 AES 解密,然后与输入到消息框的字符串进行比较。java.lang.String 类的 equals 方法将字符串输入与密钥字符串进行比较。在 java.lang.String.equals 上设置一个方法断点,在编辑字段中输入任意文本字符串,然后点击“验证”按钮。到达断点后,您可以使用 locals 命令读取方法参数。

> stop in java.lang.String.equals
Set breakpoint java.lang.String.equals
>
Breakpoint hit: "thread=main", java.lang.String.equals(), line=639 bci=2

main[1] locals
Method arguments:
Local variables:
other = "radiusGravity"
main[1] cont

Breakpoint hit: "thread=main", java.lang.String.equals(), line=639 bci=2

main[1] locals
Method arguments:
Local variables:
other = "I want to believe"
main[1] cont

这就是您正在寻找的纯文本字符串!

使用 IDE 调试

在 IDE 中使用反编译的源代码设置项目是一个巧妙的技巧,可让您直接在源代码中设置方法断点。在大多数情况下,您应该能够单步执行应用程序并使用 GUI 检查变量的状态。体验不会很完美,毕竟它不是原始源代码,因此您将无法设置行断点,并且有时事情根本无法正常工作。但是,反转代码从来都不容易,并且有效地导航和调试普通的 Java 代码是一种非常方便的方式。在 NetSPI blog 中描述了一种类似的方法。

要设置 IDE 调试,首先在 IntelliJ 中创建您的 Android 项目,并将反编译的 Java 源代码复制到源代码文件夹中,如上面 查看反编译的 Java 代码中所述。在设备上,在“开发者选项”中选择应用程序作为调试应用程序(本教程中的 Android UnCrackable L1),并确保您已打开“等待调试器”功能。

一旦您从启动器点击应用程序图标,它将在“等待调试器”模式下挂起。

现在,您可以使用“附加调试器”工具栏按钮设置断点并附加到应用程序进程。

请注意,从反编译的源代码调试应用程序时,只有方法断点有效。到达方法断点后,您将有机会在方法执行期间单步执行。

从列表中选择应用程序后,调试器将附加到应用程序进程,您将到达在 onCreate 方法上设置的断点。此应用程序在 onCreate 方法中触发反调试和反篡改控件。这就是为什么在执行反篡改和反调试检查之前,在 onCreate 方法上设置断点是一个好主意。

接下来,通过在调试器视图中点击“强制步入”来单步执行 onCreate 方法。“强制步入”选项允许您调试通常被调试器忽略的 Android 框架函数和核心 Java 类。

一旦您“强制步入”,调试器将停止在下一个方法的开头,该方法是类 sg.vantagepoint.a.ca 方法。

此方法在目录列表(/system/xbin 和其他目录)中搜索“su”二进制文件。由于您是在已 root 的设备/模拟器上运行该应用程序,因此您需要通过操纵变量和/或函数返回值来绕过此检查。

您可以通过在调试器视图中点击“步过”来进入和遍历 a 方法,从而在“变量”窗口中查看目录名称。

使用“强制步入”功能步入 System.getenv 方法。

在您获取以冒号分隔的目录名称后,调试器光标将返回到 a 方法的开头,而不是返回到下一个可执行行。发生这种情况是因为您正在处理反编译的代码而不是源代码。此跳过使跟踪代码流对于调试反编译的应用程序至关重要。否则,识别要执行的下一行将变得复杂。

如果您不想调试核心 Java 和 Android 类,您可以通过点击调试器视图中的“步出”来退出该函数。一旦您到达反编译的源代码并“步出”核心 Java 和 Android 类,使用“强制步入”可能是一个好主意。这将有助于加快调试速度,同时您会密切关注核心类函数的返回值。

a 方法获取目录名称后,它将在这些目录中搜索 su 二进制文件。要绕过此检查,请单步执行检测方法并检查变量内容。一旦执行到达将检测到 su 二进制文件的位置,请通过按 F2 或右键单击并选择“设置值”来修改保存文件名或目录名的变量之一。

一旦您修改了二进制文件名或目录名,File.exists 应该返回 false

这可以绕过应用程序的第一个 root 检测控件。其余的反篡改和反调试控件可以通过类似的方式绕过,以便您最终可以到达密钥字符串验证功能。

密钥代码由类 sg.vantagepoint.uncrackable1.a 的方法 a 验证。在方法 a 上设置一个断点,并在到达断点时“强制步入”。然后,单步执行直到您到达对 String.equals 的调用。这是用户输入与密钥字符串进行比较的地方。

当您到达 String.equals 方法调用时,您可以在“变量”视图中看到密钥字符串。

调试原生代码

Android 上的原生代码被打包到 ELF 共享库中,并且像任何其他原生 Linux 程序一样运行。因此,只要它们支持设备的处理器架构(大多数设备都基于 ARM 芯片组,因此通常不是问题),您就可以使用标准工具(包括 GDB 和内置 IDE 调试器(例如 IDA Pro))对其进行调试。

您现在将设置您的 JNI 演示应用程序 HelloWorld-JNI.apk 进行调试。它与您在“静态分析原生代码”中下载的 APK 相同。使用 adb install 将其安装到您的设备或模拟器上。

adb install HelloWorld-JNI.apk

如果您按照本章开头的说明进行操作,您应该已经拥有 Android NDK。它包含各种架构的 gdbserver 的预构建版本。将 gdbserver 二进制文件复制到您的设备

adb push $NDK/prebuilt/android-arm/gdbserver/gdbserver /data/local/tmp

gdbserver --attach 命令导致 gdbserver 附加到正在运行的进程并绑定到 comm 中指定的 IP 地址和端口,在这种情况下,它是一个 HOST:PORT 描述符。在设备上启动 HelloWorldJNI,然后连接到设备并确定 HelloWorldJNI 进程的 PID (sg.vantagepoint.helloworldjni)。然后切换到 root 用户并附加 gdbserver

$ adb shell
$ ps | grep helloworld
u0_a164   12690 201   1533400 51692 ffffffff 00000000 S sg.vantagepoint.helloworldjni
$ su
# /data/local/tmp/gdbserver --attach localhost:1234 12690
Attached; pid = 12690
Listening on port 1234

进程现在已挂起,并且 gdbserver 正在端口 1234 上侦听调试客户端。通过 USB 连接设备后,您可以使用 adb forward 命令将此端口转发到主机上的本地端口

adb forward tcp:1234 tcp:1234

您现在将使用 NDK 工具链中包含的 gdb 预构建版本。

$ $TOOLCHAIN/bin/gdb libnative-lib.so
GNU gdb (GDB) 7.11
(...)
Reading symbols from libnative-lib.so...(no debugging symbols found)...done.
(gdb) target remote :1234
Remote debugging using :1234
0xb6e0f124 in ?? ()

您已成功附加到进程!唯一的问题是您调试 JNI 函数 StringFromJNI 已经太晚了;它只运行一次,在启动时。您可以通过激活“等待调试器”选项来解决此问题。转到开发者选项 -> 选择调试应用程序并选择 HelloWorldJNI,然后激活等待调试器开关。然后终止并重新启动应用程序。它应该会自动挂起。

我们的目标是在恢复应用程序之前,在原生函数 Java_sg_vantagepoint_helloworldjni_MainActivity_stringFromJNI 的第一条指令处设置一个断点。不幸的是,在执行的这一点上这是不可能的,因为 libnative-lib.so 尚未映射到进程内存中,它是在运行时动态加载的。为了使此工作正常进行,您将首先使用 jdb 巧妙地将进程更改为所需的状态。

首先,通过附加 jdb 恢复 Java VM 的执行。您不希望进程立即恢复,因此将 suspend 命令管道到 jdb 中

$ adb jdwp
14342
$ adb forward tcp:7777 jdwp:14342
$ { echo "suspend"; cat; } | jdb -attach localhost:7777

接下来,挂起 Java 运行时加载 libnative-lib.so 的进程。在 jdb 中,在 java.lang.System.loadLibrary 方法上设置一个断点并恢复进程。到达断点后,执行 step up 命令,该命令将恢复进程,直到 loadLibrary 返回。此时,libnative-lib.so 已加载。

> stop in java.lang.System.loadLibrary
> resume
All threads resumed.
Breakpoint hit: "thread=main", java.lang.System.loadLibrary(), line=988 bci=0
> step up
main[1] step up
>
Step completed: "thread=main", sg.vantagepoint.helloworldjni.MainActivity.<clinit>(), line=12 bci=5

main[1]

执行 gdbserver 以附加到挂起的应用程序。这将导致该应用程序被 Java VM 和 Linux 内核同时挂起(创建一个“双重挂起”状态)。

$ adb forward tcp:1234 tcp:1234
$ $TOOLCHAIN/arm-linux-androideabi-gdb libnative-lib.so
GNU gdb (GDB) 7.7
Copyright (C) 2014 Free Software Foundation, Inc.
(...)
(gdb) target remote :1234
Remote debugging using :1234
0xb6de83b8 in ?? ()