从零基础到零日漏洞——逆向工程中的混合分析

0 阅读29分钟

逆向工程中没有单一完美的方法。虽然静态分析理论上可以暴露程序的所有指令,但解读复杂应用尤其是在涉及对象和类等抽象概念时,往往十分困难。动态分析则能揭示程序的实际行为,但其效果高度依赖于能否触发那些代码路径。

随着你在逆向工程中的经验积累,你会开始识别汇编代码和库函数调用中的常见模式,比如加密例程或网络通信。你可以在任一方法上深入钻研。然而,记住逆向工程只是为了高效发现漏洞的一种手段,何不将两者结合起来?

混合分析即是在动态插桩的基础上,融合静态的数据来源,从源代码到汇编指令,实现对二进制执行过程的详尽且精准的分析。

本章中,你将通过一个简单示例,使用 DynamoRIO 练习代码覆盖率测量。接着,使用 Qiling 框架模拟 FreshTomato Web 服务器,并借助 Lighthouse 可视化其覆盖率,提升动态分析能力。最后,你将练习用 angr 进行符号分析,感受大规模自动化逆向工程的可能性。

代码覆盖率

在软件开发中,代码覆盖率指的是测试用例执行的代码行数占总代码行数的百分比。然而在逆向工程中,代码覆盖率有不同的含义:它指的是程序运行时实际执行的指令或基本代码块。收集代码覆盖率可以帮助你更好地进行静态和动态分析,明确聚焦于二进制中的关键部分。

进行静态分析时,你通常试图将分散的汇编指令块和伪代码映射回目标程序的高级函数。例如,你可能重点关注调用 HTTP 相关函数的基本块,以便识别 Web 服务器中不同路由的请求处理器。这种做法依赖上下文线索,如响应字符串或错误日志,但也容易让你陷入死胡同,比如分析那些实际使用中永远不会被执行的代码。通过动态分析获得的代码覆盖率可以帮助你避免这些问题。

另一方面,在动态分析中,追踪攻击者控制输入在程序中的流动路径往往困难且耗时,除非它出现在被截获的调用或硬件断点中。相比之下,先动态收集代码覆盖率,再切换到静态分析快速解析处理输入的指令,是更高效的办法。

编译二进制分析中的代码覆盖率应用

现代代码覆盖率工具多采用插桩技术,而非传统的触发昂贵系统调用的硬件或软件断点。DynamoRIO 是一款动态二进制插桩框架,能让你在程序运行时快速插桩。

DynamoRIO 的原理是将应用程序的指令复制到独立的代码缓存区,在那里对指令进行操作,例如记录代码覆盖率。你可以把 DynamoRIO 想象成一个翻译层,连接被操作的应用程序和执行被修改指令的操作系统及硬件。

另外两款流行的动态二进制插桩工具是 Intel Pin 和 Frida。Intel Pin 支持即时编译(JIT)模式(类似 DynamoRIO 的代码缓存机制)和探针(Probe)模式,后者在指定例程开头插入跳转指令(类似传统断点)。Frida 的 Stalker 跟踪引擎则在内存中按块写入并执行插桩后的程序指令。在这三者中,Intel Pin 和 DynamoRIO 被认为更成熟,其中 DynamoRIO 的优势包括开源许可宽松和性能更快。

无论是 Frida Stalker、Intel Pin 还是 DynamoRIO,都能让你在运行时操纵指令。研究人员可以利用这些能力构建辅助分析工具和工作流程,实现代码覆盖率收集等自动化逆向工程任务。DynamoRIO 自带代码覆盖率工具 drcov,无需你自己开发。因此,本章将使用 DynamoRIO 演示如何在编译二进制分析中应用代码覆盖率。

示例程序

以下是对第156页示例 5-3 的稍作修改版本(同样可在书中代码仓库找到):

// hello-coverage.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char command[100];

    ➊ if (argc < 2) {
        return 1;
    }

    ➋ if (strcmp(argv[1], "hello") == 0) {
        if (argc != 3) {
            return 1;
        }
        snprintf(command, sizeof(command), "echo Hello, %s", argv[2]);
        system(command);
    ➌ } else if (strcmp(argv[1], "bye") == 0) {
        printf("bye bye\n");
    } else {
        printf("Invalid option\n");
    }

    return 0;
}

该程序根据输入参数表现不同。它验证命令行参数数量 ➊,并检查第一个参数是否为 "hello" ➋ 或 "bye" ➌。

你可以用以下命令快速测试:

$ gcc -o hello-coverage hello-coverage.c
$ ./hello-coverage hello world
Hello, world
$ ./hello-coverage bye
bye bye
$ ./hello-coverage hola
Invalid option

第一次执行满足 hello 条件,回显 world 参数;第二次满足 bye 条件,输出 bye bye;第三次不满足任何条件,输出 Invalid option。假设程序选项更多且代码混淆更复杂,用静态分析手工完全逆向会很困难,且动态分析若不给出正确选项参数,也难以深入。通过代码覆盖率,你可以定位因选项检查失败而分支的具体位置,从而推测预期值。

使用 DynamoRIO 和 drcov 收集代码覆盖率

访问 github.com/DynamoRIO/d… 下载 Linux 版 DynamoRIO 8.0.0(版本对后续可视化工具兼容性很重要),并解压。

drcov 支持输出二进制和文本格式的覆盖信息。你可以用文本格式查看实际收集的信息。在 hello-coverage 上运行:

$ wget https://github.com/DynamoRIO/dynamorio/releases/download/release_8.0.0-1/DynamoRIO-Linux-8.0.0-1.tar.gz
$ tar -xzf DynamoRIO-Linux-8.0.0-1.tar.gz
$ cd DynamoRIO-Linux-8.0.0-1
$ ./bin64/drrun -t drcov -dump_text -- /home/kali/Desktop/from-day-zero-to-zero-day/chapter-06/hello-coverage/hello-coverage
$ head -n 30 drcov.hello-coverage.21791.0000.proc.log

输出示例开头部分:

DRCOV VERSION: 2 ➊
DRCOV FLAVOR: drcov-64
Module Table: version 4, count 18--省略--
BB Table: 2368 bbs ➌
module id, start, size:
module[  9]: 0x000000000001a9c0,   8
module[  9]: 0x000000000001b5c0, 119
module[  9]: 0x000000000001b637,  33
module[  9]: 0x000000000001b67a,   6
module[  9]: 0x000000000001b669,  17

日志格式以元数据开始 ➊,接着是列出进程加载模块及其地址的表 ➋,最后是基本块执行表,显示运行命令时执行的基本块数量 ➌。

使用 Lighthouse 可视化代码覆盖率

由于代码覆盖率日志长度较长且语法复杂,手动阅读并不现实。你可以使用代码覆盖率可视化工具,在反汇编器中高亮显示被记录的指令。最流行的可视化工具之一是 Lighthouse(github.com/gaasedelen/…),但它仅支持 IDA Pro 和 Binary Ninja。对于 Ghidra,可以使用较早的 Dragon Dance 插件(github.com/0ffffffffh/…)或 Light Keeper(github.com/WorksButNot…),后者是 Lighthouse 在 Ghidra 上的移植版本。

从 GitHub 下载 Light Keeper 的最新版本(本例使用 1.1.1 版),并按照以下步骤在 Ghidra 中安装:

  1. 使用命令 ./bin64/drrun -t drcov -- /tmp/hello-coverage 收集覆盖率数据。
  2. 启动 Ghidra,选择菜单 File ► Install Extensions。
  3. 点击绿色加号图标,打开你下载的 Light Keeper ZIP 文件。
  4. 若出现版本警告,点击“Install Anyway”。
  5. 重启 Ghidra。
  6. 新建项目并导入 hello-coverage 二进制文件。
  7. 在 Code-Browser 中打开该二进制。
  8. 系统询问是否配置新插件时,勾选 LightKeeperPlugin 并点击“OK”。
  9. 对二进制执行默认分析。
  10. 在 CodeBrowser 中通过菜单 Window ► Light Keeper 打开 Light Keeper 窗口。
  11. 点击绿色加号图标,打开你生成的非文本格式 drcov 日志文件。

此时,Light Keeper 窗口应显示程序各函数的覆盖率数据,如图 6-1 所示。

image.png

该表格按行列出了每个函数的覆盖率数据。第一列显示该函数的代码覆盖率百分比,并通过颜色渐变进行标示,颜色由绿色(在本书截图中为浅灰色)代表低覆盖率,逐渐过渡到红色(深灰色)代表高覆盖率。第三列和第四列分别显示被执行的基本块数量和指令数量,帮助你了解程序运行时哪些函数及代码段被执行。这使你能快速发现覆盖率的盲区,从而调整命令行参数或输入,以触达程序的不同部分,收集更多代码覆盖率。

返回主界面 CodeBrowser,你还会看到 DynamoRIO 捕获的已执行指令被高亮显示,如图 6-2 所示。

image.png

虽然伪代码中匹配的代码行被高亮显示,但实际源代码与伪代码之间存在差异。例如,在第一个条件判断参数数量时,伪代码并没有立即返回1,而是将变量 uVar2 设为1,最后在函数末尾才返回 uVar2。虽然功能上效果相同,但这说明过度依赖伪代码存在风险,可能导致你误判程序的实际执行流程。

像这样可视化代码覆盖率,能帮助你直观地看到代码中的哪些分支被执行了,哪些未被执行。此外,这些分支通常也反映了输入检查的位置。hello-coverage 程序先检查参数数量,再将第一个参数与固定字符串比较,这一点在代码覆盖率可视化中有所体现。在 CodeBrowser 窗口中,选择菜单 Window ► Function Graph,即可查看基本块的图形化表示。图 6-3 展示了一个缩小视图,标明了高亮区域的大致位置。

image.png

如果你放大图表顶部的高亮代码块,就会看到这些指令是在将第一个函数参数与 0x1 进行比较。如果参数大于 0x1,汇编指令会跳转到另一个基本块,这对应了源代码中的参数数量比较。

由于潜在的跳转目标基本块没有被高亮,说明在代码覆盖率收集时该基本块没有被执行。通过分析条件跳转指令,你可以推断出命令行参数数量不足。

通过这种反复尝试的方法,你可以逐步确定程序中其他基本块的必需输入。虽然这种“摸石头过河”的方法可能比较繁琐,但远胜于盲目猜测所需输入。你还可以将动态测试与程序中条件分支的静态分析相结合,提高效率。

仿真

仿真是将为不同系统或硬件构建的软件转换并执行的过程。有时,由于缺少库文件或处理器架构不兼容,你无法在本地执行目标二进制文件。一个常见的例子是针对不同硬件和操作系统构建的固件二进制文件,这会阻止你进行诸如代码覆盖率收集或调试的动态分析。

在很多情况下,仅仅能够仿真特定的机器码指令是不够的。复杂软件依赖其他软件库、配置文件以及操作系统 API,这些也需要被重建或仿真以确保软件按预期工作。此时,仿真框架通过自动化许多繁琐的仿真任务发挥了作用。

使用 Qiling 仿真固件

Qiling 是基于 Unicorn 仿真框架(该框架又基于 QEMU 仿真器)构建的二进制仿真框架。(趣闻:Qiling 来源于中国神话中的麒麟形象!)

从高层来看,仿真器将一种 CPU 架构的指令集翻译为另一种架构的指令,你可以通过执行这些翻译后的指令来“运行”二进制文件。然而,仅执行二进制往往不够。固件中的二进制可能会导入库文件、执行文件操作等。Qiling 支持处理系统调用、I/O 和动态链接等关键操作。没有这些附加功能,几乎不可能成功仿真复杂二进制。

Qiling 支持动态插桩(类似于 Frida),还能在运行时修补内存、映射文件,甚至修改寄存器值,从而访问其他执行路径。更重要的是,你可以在本地环境对各种二进制进行混合分析,无需专用硬件。

不过,仿真也有限制。例如,试图用 Qiling 仿真 FreshTomato 的 ARM 版本 httpd 二进制失败,因为它加载了 libcrypto.so.1.1 库,该库执行了底层 Unicorn 仿真器不支持的非标准 CPU 指令。

注意
参见 github.com/qilingframe…github.com/unicorn-eng… 了解更多讨论。有趣的是两者问题编号相同!

你可以用 Qiling 练习仿真针对 MIPS 架构编译的 FreshTomato 版本。但部分新版 Unicorn 也不再支持 MIPS,因此请务必安装指定版本:

$ pip install qiling===1.4.6
$ pip install unicorn==2.0.1

安装指定版本后,下载并解压 FreshTomato 固件:

$ wget https://freshtomato.org/downloads/freshtomato-mips/2022/2022.5/K26RT-AC/freshtomato-RT-N66U_RT-AC6x-2022.5-AIO-64K.zip
$ unzip freshtomato-RT-N66U_RT-AC6x-2022.5-AIO-64K.zip
$ binwalk -eM freshtomato-RT-N66U_RT-AC6x-2022.5-AIO-64K.trx
$ mv _freshtomato-RT-N66U_RT-AC6x-2022.5-AIO-64K.trx.extracted freshtomato

和 Frida 一样,Qiling 以方便的 Python 包形式提供,你既可以在自定义 Python 脚本中导入使用,也可以直接命令行调用。

下面脚本演示如何仿真 httpd 二进制并捕获代码覆盖率,请根据你固件文件的路径修改 PROJECT_ROOTBINARY_PATH。本书代码仓库中也提供了该脚本及后续示例:

# qlrun_1.py
from qiling import Qiling
from qiling.extensions.coverage import utils as cov_utils
from qiling.const import QL_VERBOSE

PROJECT_ROOT = "/home/kali/Desktop/freshtomato/squashfs-root/"
BINARY_PATH = "usr/sbin/httpd"ql = Qiling(
    [PROJECT_ROOT + BINARY_PATH],
    PROJECT_ROOT,
    console=True,
    verbose=QL_VERBOSE.DEBUG
)

➋ with cov_utils.collect_coverage(ql, 'drcov', 'output.cov'):
    ql.run()

Qiling 类用目标二进制路径和仿真根目录实例化 ➊,后者帮助定位固件文件系统中被仿真二进制加载的库。Qiling 对仿真指令动态插桩,可进行额外操作,如收集代码覆盖率 ➋。覆盖率数据以 drcov 格式输出,可用 Lighthouse 和 Light Keeper 可视化。

显然,脚本开箱即用并不完美。运行应返回如下日志:

$ python qlrun_1.py
[+]     Profile: default
[+]     Mapped 0x400000-0x425000
[+]     Mapped 0x434000-0x43e000
[+]     mem_start : 0x400000
[+]     mem_end   : 0x43e000
[+]     Interpreter path: /home/kali/Desktop/freshtomato/squashfs-root/lib/ld-uClibc.so.0
[+]     Interpreter addr: 0x47ba000
[+]     Mapped 0x47ba000-0x47c0000
[+]     Mapped 0x47cf000-0x47d1000
[+]     mmap_address is : 0x90000000
[+]     dynsym name b'tree' ➊
[+]     dynsym name b'hsearch_r'
[+]     dynsym name b'get_ipv6_service'
--省略--
[+]     Connecting to "/dev/log"
[+]     0x90353fb4: connect(sockfd = 0x3, addr = 0x903639b0, addrlen = 0x10) = -0x1 (EPERM)
[+]     Received interrupt: 0x11
[+]     0x90328578: close(fd = 0x3) = 0x0 ➋
[+]     Received interrupt: 0x11
[+]     0x90329b04: time() = 0x64b441aa
[+]     Received interrupt: 0x11
[+]     open(/etc/TZ, 0o0) = -2
[+]     File not found /home/kali/Desktop/freshtomato/squashfs-root/tmp/etc/TZ ➌
[+]     0x9032a570: open(filename = 0x90363b44, flags = 0x0, mode = 0x0) = -0x2 (ENOENT)
[+]     Received interrupt: 0x11
[+]     0x903277cc: getpid() = 0x512
[+]     Received interrupt: 0x11
[+]     0x903276cc: rt_sigaction(signum = 0xd, act = 0x7ff3c528, oldact = 0x0) = 0x0
[+]     Received interrupt: 0x11
[+]     0x90329ac0: exit(code = 0x1) = ?
[+]
[+]     syscalls called ➍
[+]     ------------------------
[+]     ql_syscall_mmap:
[+]        {"params": {"addr": 0, "length": 4096, "prot": 3, "flags": 2050, "fd": 4294967295,
"pgoffset": 0}, "retval": 2415919104, "address": 75219828, "retaddr": null, "position": 0}

Qiling 会记录动态加载的库、其映射地址和符号信息 ➊,还会记录系统调用及参数 ➋。你或许能注意到尝试打开 /tmp/etc/TZ 文件,暗示 FreshTomato 如何获取时区配置 ➌。日志末尾输出了执行期间所有系统调用的映射 ➍。

遗憾的是,程序执行似乎提前终止。要了解原因,可以用代码覆盖率可视化流程分析。在 Ghidra 新建项目导入 httpd,接着用 Light Keeper 打开 Qiling 脚本生成的 output.cov 文件,切换到 View 标签页,即可看到被捕获的函数覆盖情况,包括约 12% 的 main 函数,如图 6-4 所示。

image.png

对于像 main 这样的重要函数来说,覆盖率偏低。正如 hello-coverage 示例所示,这表明提供的命令行选项不正确或不完整。要准确定位问题发生的位置,请打开 Function Graph 窗口。

从图 6-5 中高亮区域的大致位置可以看出,图表上方的大部分区域被标记为已覆盖。点击该区域中最低的一个仍被高亮的基本块。这些指令是在退出 main 函数前不久执行的。

image.png

回到主 CodeBrowser 窗口,Listing 和反编译视图应自动跳转到对应位置。如果伪代码没有高亮,可以点击 Light Keeper 窗口中的刷新图标。视图同步后,你会发现以下伪代码对应你选中的基本块:

if (DAT_00439f78 == 0) {
    syslog(3,"can't bind to any address");
    uVar3 = 1;
}

变量 uVar3 后续作为 main 函数的返回值。显然,httpd 提前退出是因为无法绑定任何地址,正如错误信息所述。如果检查此前高亮的基本块,伪代码显示:

if (param_1 != 0) {
    while (iVar2 = getopt(param_1, param_2,"Np:s:"), pcVar7 = _optarg, iVar2 != -1) { ➊
        if (iVar2 == 0x4e) { ➋
            disable_maxage = 1;
        } else {
            if ((iVar2 == 0x70) || (iVar2 == 0x73)) {
                pcVar1 = strrchr(_optarg,0x3a); ➌
                --省略--
                http_port = atoi(pcVar7);

看起来 while 条件 ➊ 从未为真,因为其余指令 ➋ 未被高亮,也就未执行。

根据 C 标准库文档,getopt 用于解析程序命令行参数,如果没有有效选项则返回 -1。结合目前信息,这说明 httpd 期待关于绑定地址的命令行参数。

文档进一步说明,getopt 的第一个和第二个参数分别对应 argc(参数个数)和 argv(参数数组),第三个参数是定义有效选项字符的字符串。

此处的 "Np:s:" ➊ 表示接受 -N、-p 和 -s 作为命令行选项,后两个选项需要附加参数。结合 while 代码块伪代码,可以推断 -p 选项接收以冒号(十六进制 0x3a)分隔的地址和端口 ➌。建议花时间在 Ghidra 中阅读伪代码以确认这一点。

尝试第二次运行,修改 qlrun_1.py,添加 -p 命令行参数,并将覆盖率保存到不同文件:

# qlrun_2.py
from qiling import Qiling
from qiling.extensions.coverage import utils as cov_utils
from qiling.const import QL_VERBOSE

PROJECT_ROOT = "/home/kali/Desktop/freshtomato/squashfs-root/"
BINARY_PATH = "usr/sbin/httpd"

ql = Qiling(
    [PROJECT_ROOT + BINARY_PATH, "-p", "127.0.0.1:8080"],
    PROJECT_ROOT,
    console=True,
    verbose=QL_VERBOSE.DEBUG
)

with cov_utils.collect_coverage(ql, 'drcov', 'output2.cov'):
    ql.run()

运行修改后的脚本,程序仍然在启动服务器前退出。但如果在 Light Keeper 中加载新的覆盖率文件,你可能会注意到一些变化。按图 6-6 所示,在 Select 标签页中取消选中旧覆盖率文件左侧的复选框。

image.png

这一次,main 函数的覆盖率约为 24%(如图 6-7 所示),是上一次运行的两倍。如果查看图表并比较高亮的代码块,你会发现之前对应地址绑定错误消息的指令不再被高亮,而选项解析相关的代码块则开始被执行。

image.png

此外,如果你查看 main 函数图中间部分与伪代码第 209 行对应的高亮代码块,会发现执行流程现在跳转到了以下伪代码:

pcVar7 = (char *)FUN_004032ac("http_id");
iVar2 = strncmp(pcVar7, "TID", 3);
if (iVar2 != 0) {
    f_read("/dev/urandom", &local_2a0, 8);
    memset(acStack_bc, 0, 0x80);
    snprintf(acStack_bc, 0x80, "TID%llx");
    nvram_set("http_id", acStack_bc);
}
nvram_unset("http_id_warn");
➊ iVar2 = daemon(1, 1);

看起来函数覆盖在执行 daemon 之后停止了 ➊。该标准库函数通过将 httpd 进程从控制终端分离并以子进程形式在后台运行,实现守护进程化,因此程序表面上看似退出,实际上仍在运行。

劫持 API 调用

程序经常会表现出让分析变得困难的行为,比如创建子进程或调用 fork。在 FreshTomato 中,daemon 调用会干扰代码覆盖率的捕获,因为 fork 出来的子进程没有被插桩。这种情况下,需要用动态插桩工具修改程序行为。

你可以确认 fork 出的 httpd 进程确实在后台运行,因为如果访问 http://127.0.0.1:8080,Qiling]会在主终端重新开始输出日志。别忘了杀掉这个进程:

$ python qlrun_2.py
[+]     Profile: default
[+]     Mapped 0x400000-0x425000
[+]     Mapped 0x434000-0x43e000
[+]     mem_start : 0x400000
[+]     mem_end   : 0x43e000
--省略--
$ curl -m 1 http://127.0.0.1:8080
[+]     Received interrupt: 0x11
[+]     0x90353f0c: accept(sockfd = 0x3, addr = 0x7ff3cc14, addrlenptr = 0x7ff3cb28) = 0x4
[+]     Received interrupt: 0x11
[+]     open("/var/lock/action", 0x0, 00) = -1
[+]     0x9032a570: open(filename = 0x900276c0, flags = 0x0, mode = 0x0) = -0x1 (EPERM)
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
--省略--
$ ps | grep python
104521 pts/0    00:00:00 python
$ kill 104521

为了避免进程变成守护进程,你可以用 Qiling 的 hijack API 钩住并修改 daemon 函数:

# qlrun_3.py
from qiling import Qiling
from qiling.extensions.coverage import utils as cov_utils
from qiling.const import QL_VERBOSE, QL_INTERCEPT

PROJECT_ROOT = "/home/kali/Desktop/freshtomato/squashfs-root/"
BINARY_PATH = "usr/sbin/httpd"
ql = Qiling(
    [PROJECT_ROOT + BINARY_PATH, "-p", "127.0.0.1:8080"],
    PROJECT_ROOT,
    console=True,
    verbose=QL_VERBOSE.DEBUG
)

def my_daemon(ql: Qiling):
    ql.log.info(f'hijacking daemon')
    return 0  # ➊

with cov_utils.collect_coverage(ql, 'drcov', 'output3.cov'):
    ql.os.set_api('daemon', my_daemon, QL_INTERCEPT.CALL)  # ➋
    ql.run()

修改后的脚本拦截标准库函数 daemon,用你自己的实现替换它 ➋,该实现直接返回 0 ➊。

运行脚本后,程序不再提前退出。但如果尝试访问路由器的 Web 请求,会反复尝试打开缺失的 /var/lock/action 文件:

[+]     Received interrupt: 0x11
[+]     open("/var/lock/action", 0x0, 00) = -1

查看新的 output3.cov 覆盖率文件,会发现覆盖率停在 main 中的这段代码:

for (; puVar18 = &DAT_00439f7c, p_Var17 = local_23c, -1 < iVar2; iVar2 = iVar2 + -1) {
    uVar10 = *puVar21;
    if ((-1 < (int)uVar10) && ((local_23c[uVar10 >> 5] >> (uVar10 & 0x1f) & 1U) != 0)) {
        do_ssl = 0;
        local_2a8[0] = 0x80;
        connfd = accept(uVar10,local_30,local_2a8); ➊
        if (-1 < connfd) {
            iVar19 = wait_action_idle(10); ➋
            if (iVar19 == 0) {
                syslog(4,"router is busy");
            }

好消息是,仿真二进制似乎已运行到接收连接的位置 ➊,但调用了可能导致无限循环的等待函数 ➋。

为解决此问题,你可以继续劫持该函数:

# qlrun_4.py
from qiling import Qiling
from qiling.extensions.coverage import utils as cov_utils
from qiling.const import QL_VERBOSE, QL_INTERCEPT

PROJECT_ROOT = "/home/kali/Desktop/freshtomato/squashfs-root/"
BINARY_PATH = "usr/sbin/httpd"
ql = Qiling(
    [PROJECT_ROOT + BINARY_PATH, "-p", "127.0.0.1:8080"],
    PROJECT_ROOT,
    console=True,
    verbose=QL_VERBOSE.DEBUG
)

def my_daemon(ql: Qiling):
    ql.log.info(f'hijacking daemon')
    return 0def my_wait_action_idle(ql: Qiling):
    ql.log.info(f'hijacking wait_action_idle')
    return 0

with cov_utils.collect_coverage(ql, 'drcov', 'output4.cov'):
    ql.os.set_api('daemon', my_daemon, QL_INTERCEPT.CALL)
    ➋ ql.os.set_api('wait_action_idle', my_wait_action_idle, QL_INTERCEPT.CALL)
    ql.run()

与劫持 daemon 类似,使用 set_api 函数钩住 wait_action_idle ➋,用你自定义的函数替代它,该函数直接返回,不执行等待操作 ➊。

现在,运行脚本后,在浏览器访问 http://127.0.0.1:8080 ,应该会弹出身份验证提示,表明 HTTP 请求已获得正确响应!

绑定虚拟路径

即使成功仿真了二进制文件,并通过函数劫持绕过了麻烦行为,你可能仍会遇到其他依赖问题,比如缺少文件。

这种情况可以在认证后尝试与 httpd 服务器交互时观察到。大多数路由器的使用手册或在线文档会告诉你默认的用户名和密码,此处用户名为 root,密码为 admin。但输入这些凭据后,服务器会返回“500 Unknown Read error”错误。如果尝试访问其他路径,如 http://127.0.0.1:8080/test.html,会得到如下日志:

[+]     open(/test.html, 0o0) = -2
[+]     File not found /home/kali/Desktop/freshtomato/squashfs-root/test.html
[+]     0x9032a570: open(filename = 0x7ff3a3c1, flags = 0x0, mode = 0x0) = -0x2 (ENOENT)
[+]     Received interrupt: 0x11
[+]     write() CONTENT: ...

服务器正从根目录(/)查找文件,而非预期的 /www 目录。这可能是因为直接执行二进制时,当前工作目录配置不正确。幸好,解决办法简单:URL 中的路径直接映射到根目录下的文件路径,所以你只需访问 http://127.0.0.1:8080/www/about.asp 即可访问 /www 下的文件。

这样一来,你应当拥有一个较为完整的 httpd 仿真环境。所有辛苦工作将极大加快你的分析速度。你可以精准追踪与 Web 请求和路由相关的函数及指令,不再需要依赖诸如日志字符串这类不够精确的方法。更好的是,你可以在二进制分析的每一步调试潜在漏洞。

Qiling 的 API 还有更多强大功能,帮助你使仿真二进制运行更顺畅。下面的脚本包含几个示例:

# qlrun_5.py
from qiling import Qiling
from qiling.extensions.coverage import utils as cov_utils
from qiling.const import QL_VERBOSE, QL_INTERCEPT

PROJECT_ROOT = "/home/kali/Desktop/freshtomato/squashfs-root/"
BINARY_PATH = "usr/sbin/httpd"
ql = Qiling(
    [PROJECT_ROOT + BINARY_PATH, "-p", "127.0.0.1:8080"],
    PROJECT_ROOT,
    console=True,
    verbose=QL_VERBOSE.DEBUG
)

➊ ql.add_fs_mapper(r'/dev/urandom', r'/dev/urandom')
ql.add_fs_mapper(r'/dev/nvram', r'/tmp/nvram')
ql.add_fs_mapper(r'/etc/TZ', r'/tmp/TZ')

def my_daemon(ql: Qiling):
    ql.log.info(f'hijacking daemon')
    return 0

def my_wait_action_idle(ql: Qiling):
    ql.log.info(f'hijacking wait_action_idle')
    return 0

def my_fork(ql: Qiling):
    ql.log.info(f'hijacking fork')
    return 0

with cov_utils.collect_coverage(ql, 'drcov', 'output5.cov'):
    ql.os.set_api('daemon', my_daemon, QL_INTERCEPT.CALL)
    ql.os.set_api('wait_action_idle', my_wait_action_idle, QL_INTERCEPT.CALL)
➋ ql.os.set_syscall('fork', my_fork, QL_INTERCEPT.CALL)
    ql.run()

你可能已经注意到,由于提取固件文件系统中缺少文件或套接字,存在多次 open 系统调用失败。Qiling 允许你将仿真文件系统中的多个路径映射到宿主机文件系统 ➊,甚至可以精细控制交互。

此外,二进制在 main 函数后续调用了 fork,你可能也想劫持它以确保覆盖率能正确收集。由于 fork 是系统调用而非普通库函数,你需要使用 Qiling 的特殊 API ➋ 进行拦截。然而,覆盖 Qiling 内置系统调用处理器(参考 github.com/qilingframe… 的 fork 处理器)可能导致标准输入输出问题,并破坏其他系统调用如 write 和 execve。因此,虽然 stub 掉 fork 调用可简化覆盖率收集,但最终会引发错误,比如访问 http://127.0.0.1:8080/www/about.asp 时出错。

鉴于 Qiling 仅实现了部分操作系统 API 和系统调用,你可能还需要根据目标环境自行实现部分替代函数。编写自定义替代函数时,可以参考 Qiling 的实现,如:github.com/qilingframe…

Frida、Qiling 以及其他动态插桩框架为你自动化逆向工程和漏洞开发任务提供了极大灵活性。随着你识别常见的源到汇路径经验积累,可以利用这些框架通过通用脚本大规模发现漏洞,就像源代码分析工具中的通用规则集一样。

符号分析

如果你可以在不实际执行程序的情况下“执行”它,会怎么样呢?符号分析介于纯静态分析和动态分析之间,因为它利用静态分析的信息来模拟二进制的执行。然而,与试图在实际内存中翻译并执行指令的仿真器不同,符号执行使用符号输入和状态,并在模拟执行过程中跟踪这些输入上的约束条件,当程序状态分叉时维护这些约束。

为了更好地理解其实际含义,我们用一个简单的示例说明:

// hello-symbolic.c
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    printf("Option: \n");
    char c = getchar();
    if (c > 64) {
        if (c < 91) {
            // 输入必须是大写字母
            printf("hello\n");
            ➊ return 0;
        } else {
            printf("how are you\n");
            ➋ return 1;
        }
    }

    printf("bye\n");
    ➌ return 1;
}

每当代码中执行到 if 语句时,符号状态会根据约束条件分叉。例如,返回 0 ➊ 所需满足的约束是 [c > 64, c < 91];打印 “how are you” 并返回 1 ➋ 的约束是 [c > 64, c >= 91];打印 “bye” 并返回 1 ➌ 的约束则是 [c <= 64]。要回答“我必须提供什么输入才能返回 0?”的问题,需要找到一个满足所有相关约束的合适 c 值。

虽然在此简单示例中用基础代数轻松得出正确答案,但在复杂程序中随着约束和输入的增加,手工计算变得极其困难。幸运的是,这个问题在计算机科学和数学中已被深入研究,可抽象为“模理论可满足性”(SMT)问题,进而可用 SMT 求解器解决。常见的符号分析工具之一是微软研究院的 Z3。

和 Frida、Qiling 类似,angr(发音为“anger”)是用 Python 编写的二进制分析框架,但它专注于符号分析。该框架由多个工具和库组成,包括:

  • angr:核心二进制分析套件,支持符号执行、控制流图恢复等多种分析。
  • angr-management:angr 的图形界面前端。
  • CLE:二进制加载器,将可执行文件解析成适合 angr 分析的抽象形式。
  • archinfo:提供跨架构支持的类和分析工具。
  • PyVEX:VEX 中间语言的 Python 绑定,抽象 CPU 架构差异,便于统一分析。
  • Claripy:Z3 SMT 求解器的简易前端,允许 angr 解析符号约束并获取解。

实际上,直接使用 angr 主包访问部分组件已足够入门,但理解各组件的用途很重要,以便需要时能更细粒度地操作。

此外,虽然 angr-management 是极好的前端工具,建议先熟悉 angr API 脚本编写。由于其功能前沿且 API 持续演进,angr 若不阅读文档甚至源码,使用起来较为困难。因而刚开始降低抽象层级,直接编写脚本会更有帮助。

使用 angr 分析二进制时,建议从 Python 交互式控制台入手,这样可以在关键点暂停并检查状态,类似调试器。同时,angr 正处于积极开发阶段,可能会引入破坏性变更,因此务必使用版本 9.2.108,以确保后续练习能够正常运行。

符号执行

符号分析的第一步是执行符号执行,它使用占位符(符号)输入值来模拟程序执行,而不是使用真实的具体值。这允许模拟执行在程序中达到多个可能的条件分支,同时对这些输入添加约束,这些约束之后可以被求解成具体数值。接下来的示例将帮你深入理解这一点。

首先用 angr 加载编译好的二进制,angr 使用 CLE 组件(递归缩写“CLE Loads Everything”)解析二进制文件。虽然 CLE 确实可以加载几乎所有内容,但对不同二进制类型的支持程度可能不同。这里的示例基于 Kali Linux 编译的 ELF 文件。加载的对象可以通过项目实例的 .loader 属性访问:

$ gcc hello-symbolic.c -o hello-symbolic
$ sudo apt-get install -y pipenv
$ pipenv install angr===9.2.108
$ pipenv shell
$ python
Python 3.11.2 (main, Feb 12 2023, 00:48:52) [GCC 12.2.0] on linux
>>> import angr
>>> proj = angr.Project('hello-symbolic', auto_load_libs=False)  # ➊
>>> proj.filename
'hello-symbolic'
>>> proj.loader.shared_objects
OrderedDict([
    ('hello-symbolic', <ELF Object hello-symbolic, maps [0x400000:0x404027]>),
    ('extern-address space', <ExternObject Object cle##externs, maps [0x600000:0x607fff]>),
    ('cle##tls', <ELFTLSObjectV2 Object cle##tls, maps [0x700000:0x71500f]>)
])

加载项目时,将 auto_load_libs 设置为 False ➊,否则 angr 会尝试自动加载和分析共享库依赖,导致加载时间大幅增加。虽然全面分析时很有用,但如果你只关注二进制自身代码,则无需自动加载库。

接下来,可以观察二进制入口点的静态表示,既有汇编指令,也有 VEX 中间表示:

>>> block = proj.factory.block(proj.entry)
>>> block.pp()
        _start:
401060  xor     ebp, ebp
401062  mov     r9, rdx
401065  pop     rsi
401066  mov     rdx, rsp
401069  and     rsp, 0xfffffffffffffff0
40106d  push    rax
40106e  push    rsp
40106f  xor     r8d, r8d
401072  xor     ecx, ecx
401074  lea     rdi, [main]
40107b  call    qword ptr [0x403fc0]

>>> block.vex.pp()
IRSB {
    t0:Ity_I32 t1:Ity_I32 t2:Ity_I32 t3:Ity_I64 t4:Ity_I64 ...
    00 | ------ IMark(0x401060, 2, 0) ------
    01 | PUT(rbp) = 0x0000000000000000
    02 | ------ IMark(0x401062, 3, 0) ------
    03 | t30 = GET:I64(rdx)
    04 | PUT(r9) = t30
    05 | PUT(rip) = 0x0000000000401065
}

如前所述,angr 支持多种静态分析,比如值集分析、数据依赖图分析和控制流图恢复。虽然本节不详细讲解,但牢记这些分析能增强动态符号分析效果很有帮助。例如,你可以用控制流图或到达定义分析找到通往敏感点的路径:

>>> cfg = proj.analyses.CFGFast()
>>> puts_func = proj.kb.functions['puts']
>>> node = cfg.get_any_node(puts_func.addr)
>>> cfg.get_predecessors(node)
[<CFGNode main [30]>, <CFGNode main+0x5e [15]>, <CFGNode main+0x32 [15]>, <CFGNode main+0x48 [15]>, <CFGNode 0x40102c[4]>]

现在,继续进行符号执行。首先需要实例化一个模拟程序状态(SimState),表示程序在某个时刻的状态。可以用 .entry_state() 获取入口点状态:

>>> state = proj.factory.entry_state()
>>> state.regs.rip
<BV64 0x401060>

然后,将该状态传给模拟管理器(simulation manager),它负责从给定状态模拟执行,产生新的状态并存储在状态集中(stash)中。默认状态集可通过 .active 属性访问。调用 .run() 可模拟执行直到满足指定条件。

例如,angr 每遇到一个分支语句,默认状态集中的状态数就会增加,因为执行路径分叉为两个状态。hello-symbolic 中有两个 if 语句,即两个分支,因此可以尝试运行模拟执行直到存在三个状态,也就是走过了两个分支。

之后,可以检查最新状态的约束:

>>> simgr = proj.factory.simulation_manager(state)
>>> simgr.run(until=lambda sm_: len(sm_.active) > 2)
<SimulationManager with 3 active>
>>> simgr.active[2].solver.constraints
[
    <Bool (packet_0_stdin_6_8 - 64[7:7] ^ (packet_0_stdin_6_8[7:7] ^ 0) & (packet_0_stdin_6_8[7:7] ^ packet_0_stdin_6_8 - 64[7:7]) | (if packet_0_stdin_6_8 == 64 then 1 else 0)) == 0>,
    <Bool (packet_0_stdin_6_8 - 90[7:7] ^ (packet_0_stdin_6_8[7:7] ^ 0) & (packet_0_stdin_6_8[7:7] ^ packet_0_stdin_6_8 - 90[7:7]) | (if packet_0_stdin_6_8 == 90 then 1 else 0)) == 0>
]

虽然约束看起来较复杂,但仔细观察会发现这些值对应于源代码中 if 语句使用的 ASCII 字符的十进制值。

求解约束

在获取到程序中某个感兴趣点的符号值约束后,你可以求解这些约束,得到具体的数值。这告诉你需要什么输入才能达到该点。

在 angr 中,可以通过 .concretize() 方法(或其包装方法 .dumps(0))来获得具体值:

>>> simgr.active[0].posix.stdin.concretize()
[b'\x00']
>>> simgr.active[1].posix.stdin.concretize()
[b'Z']
>>> simgr.active[2].posix.stdin.concretize()
[b'x']

这些具体值只是满足约束的若干可能值之一,它们是基于某种具体化策略(如 SimConcretizationStrategyAny)选择的。如果更改具体化策略,可能会得到不同的具体值。例如,SimConcretizationStrategyMax 会返回最大可能值:

>>> state.memory.read_strategies
[<angr.concretization_strategies.range.SimConcretizationStrategyRange object at 0x7f834040c2d0>, <angr.concretization_strategies.any.SimConcretizationStrategyAny object at 0x7f834038fb10>]

>>> state_with_different_concretization_strategy = proj.factory.entry_state(add_options={angr.options.CONSERVATIVE_READ_STRATEGY})

>>> state_with_different_concretization_strategy.memory.read_strategies
[<angr.concretization_strategies.range.SimConcretizationStrategyRange object at 0x7f83406cf410>]

>>> simgr = proj.factory.simulation_manager(state_with_different_concretization_strategy)
>>> simgr.run(until=lambda sm_: len(sm_.active) > 2)
<SimulationManager with 3 active>
>>> print(simgr.active[2].posix.stdin.concretize())
[b'`']

你也可以使用更方便的 .explore() 方法,基于 find 参数找到达到某个地址或满足某个条件的状态。例如,可以符号执行直到程序向标准输出输出 "hello",并确定需要的标准输入:

>>> simgr = proj.factory.simulation_manager(state)
>>> simgr.explore(find=lambda s: b"hello" in s.posix.dumps(1))
<SimulationManager with 2 active, 1 found>
>>> simgr.found[0].posix.dumps(0)
b'Z'

此时,你可能会想起前一节中对 httpd 的操作。你结合了 Qiling 的覆盖收集和 Ghidra 的静态分析,找出了启动 httpd 所需的命令行参数,尤其是分析了 getopt 调用,明白了要提供哪些命令行参数才能进入程序的更深层(地址为 0x405184)。你可以用如下脚本尝试用 angr 来求解:

import angr
import claripy

proj = angr.Project('httpd', auto_load_libs=False)
# 创建长度为 12 的符号命令行参数 argv1 ➊
argv1 = claripy.BVS('argv1', 12 * 8)

# 将符号作为命令行参数传入模拟
state = proj.factory.entry_state(args=["./httpd", argv1])
simgr = proj.factory.simulation_manager(state)

# 符号执行直到达到解析端口选项后的指令地址 0x405184 ➋
simgr.explore(find=0x405184)

# 打印找到状态下 argv1 的求解值 ➌
found = simgr.found[0]
print(found.solver.eval(argv1, cast_to=bytes))  # ➍
print(found.solver.constraints)

脚本创建了一个模拟的位向量符号,表示第一个命令行参数 ➊;然后符号执行,直到程序执行到通过端口选项解析检查后的地址 0x405184 ➋;最后,通过求解约束,计算出通过该检查所需的参数值 ➌。

然而,尽管模拟到了目标地址,求解得到的 argv1 仍然是一串空字节。仔细查看求解器计算的约束 ➍:

[<Bool unconstrained_ret_getopt_13_32{UNINITIALIZED} != 0xffffffff>, <Bool
unconstrained_ret_getopt_13_32{UNINITIALIZED} != 0x4e>, <Bool unconstrained_ret_getopt_13_32
{UNINITIALIZED} == 0x70>, <Bool unconstrained_ret_strrchr_16_32{UNINITIALIZED} == 0x0>, <Bool
unconstrained_ret_getopt_13_32{UNINITIALIZED} != 0x73>]

虽然约束被准确捕获,但 angr 并未将这些约束施加到 argv1 符号上,而是施加到了 getopt 的返回值。看起来 angr 无法关联 getopt 与 argv1。

这正是符号分析与动态分析的关键区别:在带有外部库的仿真器(如 Qiling)中执行 httpd 时,可以追踪实际数值在指令间的流动;而符号执行则受限于路径爆炸问题——随着分支增多,状态和约束数量呈指数增长,最终导致资源耗尽。

编写 SimProcedures

即使是像 strlen 这样简单的库函数,在符号字符串上运行时也可能导致路径爆炸。为减轻这一问题,angr 使用称为 SimProcedures 的钩子替代常见的库函数。例如,为替代返回伪随机整数的标准库函数 rand,angr 提供了一个 SimProcedure,返回一个符号位向量:

class rand(angr.SimProcedure):
    def run(self):
        rval = self.state.solver.BVS("rand", 31, key=("api", "rand"))
        ➊ return rval.zero_extend(self.arch.sizeof["int"] - 31)

你会发现这里从未生成或求值真实值;angr 始终返回符号值 ➊,这使得 rand 的返回值能被用于后续的约束求解。

不过,正如官方文档(docs.angr.io/en/latest/a…)所说:

不幸的是,我们的 SimProcedures 远非完美。如果 angr 表现异常,可能是由 SimProcedure 的缺陷或不完整导致。你可以尝试以下做法:

  • 禁用该 SimProcedure(通过传递参数给 angr.Project 来排除特定 SimProcedures)。但这样往往会引发路径爆炸,除非对函数输入进行非常严格的约束。其他 angr 特性(如 Veritesting)可以部分缓解路径爆炸。
  • 用针对特定场景的自定义实现替代 SimProcedure。例如,angr 对 scanf 的实现并不完整,但如果只需支持单一已知格式字符串,可以自己写钩子实现。
  • 修复 SimProcedure。

对于较复杂的 getopt 函数,angr 没有实现 SimProcedure,仅留有函数原型的空壳。幸运的是,httpd 中的 getopt 使用较为简单,无需完全重写,只需硬编码部分功能即可。

本书示例中不要求你成为 angr 编程专家,下面给出了硬编码 getopt SimProcedure 的示例(详见书中代码仓库 Listing 6-1):

import angr
import claripy
import archinfo

class GetOptHook(angr.SimProcedure):
    def run(self, argc, argv, optstr):  # ➊
        # 模拟外部变量 optind,表示下一个将被处理的 argv 索引
        try:
            self.state.globals["optind"] += 1
        except KeyError:
            self.state.globals["optind"] = 1

        strlen = angr.SIM_PROCEDURES["libc"]["strlen"]

        # 读取以 null 字节分隔的 argv 数组缓冲区
        argv_buf = self.state.memory.load(
            argv, self.state.arch.bytes, endness=self.arch.memory_endness
        )  # ➋

        # 根据 optind 计算当前 argv 元素地址
        for i in range(self.state.globals["optind"]):  # ➌
            argv_elem_len = self.inline_call(strlen, argv_buf)
            argv_buf += argv_elem_len.max_null_index + 1

        argv_elem_len = self.inline_call(strlen, argv_buf)
        argv_elem_expr = self.state.memory.load(
            argv_buf, argv_elem_len.max_null_index, endness=archinfo.Endness.BE
        )  # ➍

        # 计算 optstr 的实际值
        optstr_len = self.inline_call(strlen, optstr)
        optstr_expr = self.state.memory.load(
            optstr, optstr_len.max_null_index, endness=archinfo.Endness.BE
        )
        optstr_val = self.state.solver.eval(optstr_expr, cast_to=bytes)  # ➎

        # 情况一:argv 元素为具体值,简单匹配 '-<有效选项字符>' 前缀
        if argv_elem_expr.concrete:
            argv_elem_val = self.state.solver.eval(argv_elem_expr, cast_to=bytes)  # ➏
            for optkey in optstr_val.strip(b":"):
                if argv_elem_val[0] == ord(b"-") and argv_elem_val[1] == optkey:
                    return optkey
        # 情况二:argv 元素为符号值,基于 optstr 添加条件
        else:
            or_expressions = []
            for optkey in optstr_val.strip(b":"):
                or_expressions.append(argv_elem_expr.get_byte(1) == optkey)

            # 如果匹配 '-<有效选项字符>',返回该字符;否则返回 '?'
            return self.state.solver.If(
                self.state.solver.And(
                    argv_elem_expr.get_byte(0) == b"-",
                    self.state.solver.Or(*[c for c in or_expressions]),
                ),
                argv_elem_expr.get_byte(1),
                ord("?"),
            )

        # 未匹配具体的 '-<有效选项字符>',返回 '?'
        return ord("?")

proj = angr.Project("httpd", auto_load_libs=False)
proj.hook_symbol("getopt", GetOptHook())

# 创建长度为12的符号命令行参数
argv1 = claripy.BVS("argv1", 12 * 8)

# 传入符号命令行参数启动模拟
state = proj.factory.entry_state(args=["./httpd", argv1])
simgr = proj.factory.simulation_manager(state)

# 符号执行,直到达到选项解析相关指令地址
simgr.explore(find=0x405184)

# 打印求解的 argv1 值及约束
found = simgr.found[0]
print(found.solver.eval(argv1, cast_to=bytes))
print(found.solver.constraints)

如你所见,即使是硬编码简化版的 getopt,angr 中的实现也非常复杂。SimProcedure 必须定义与原函数参数数量相同的 run 函数 ➊。由于函数需要解析命令行参数,它先加载模拟内存中的 argv 缓冲区 ➋,然后根据 getopt 下一个将处理的元素索引找到对应参数地址 ➌。

接着加载该地址处的参数值 ➍,并获取选项字符串的实际值 ➎。随后根据参数是具体值 ➐ 还是符号值 ➏,SimProcedure 要么返回该参数的求解值,要么为它添加约束。

花点时间理解 angr 如何处理具体值和符号值。如果运行该脚本,你应能获得准确求解的 argv1 和更完整的约束集合。

尽管实现复杂,符号分析在求解到达特定敏感点或基本块所需的具体输入时非常有用。此外,angr 丰富的分析工具和 Python API 支持你无需对应处理器架构或完整文件系统,就能大规模进行二进制的源到汇路径分析。

总结

虽然从黑盒角度进行程序逆向增加了复杂度和混淆,但仍然可以应用类似代码审查中的策略。定位源点与汇点、枚举路径、识别可利用攻击面这些原则依旧适用。

有效逆向的关键在于自动化。本章中,你使用了 DynamoRIO、Qiling 和 angr 对 FreshTomato 的 Web 服务器进行了深入分析,这些工作若靠手工方式几乎无法完成。你还见识到了结合静态与动态分析(如代码覆盖率和符号分析)如何显著提升分析已编译二进制的效率与准确性。

当你从手工技术和图形界面工具过渡到二进制分析框架时,能够大幅扩展工作规模,并打造属于自己的脚本和小工具库。基于此建立可靠的工作流程,将帮助你持续且高效地发现漏洞。

尽管逆向分析能帮助你深入理解目标并发现潜在弱点,但将分析结果转化为实际漏洞发现仍需大量手工投入。接下来的章节中,你将学习如何应用逆向技术和动态插桩等工具,结合模糊测试自动生成触发漏洞的输入,从而实现自动化漏洞挖掘。