如何仅显示程序代码的 IR

逆向工程 反编译
2021-06-14 19:38:45

我有一个简单的 C 程序

int main(void) {
    return 0;
}

当我将其转换为程序集时gcc -S,它会增长到大约 10 行。正如预期的那样。

然后,当我将其转换为二进制文件,然后再将其转换为 VEX IR 时,它会增长到 * 很多 * 条指令。你可以看到这个valgrind --tool=lackey --trace-mem=yes <FILE>

我的问题有两个方面:

  1. 为什么当您查看 IR 时,即使程序集很小,程序也会增长这么多。

    我认为这是因为您现在拥有运行二进制文件所需的所有开销调用/指令,而不仅仅是您的程序代码。但更详细的解释会有所帮助

  2. 如何在我编写的代码中隔离代表指令的 IR?

    我不确定这是否 100% 可能,所以如果不是,有什么方法可以帮助我缩小我想看的范围吗?

1个回答

以下内容主要基于Valgrind: A Framework for Heavyweight Dynamic Binary Instrumentation 中的信息


第1部分

为什么当您查看 IR 时,即使程序集很小,程序也会增长这么多。

至少有两个原因:

  1. 虽然 GCC 为例程生成的机器代码main()可能很小,但二进制中的所有代码都由 Valgrind 转换,包括动态链接库中的代码。

    Valgrind 是一个动态二进制仪器 (DBI) 框架,在某些方面类似于DynamoRIOPIN它被实现为一个进程虚拟机(PVM),它将二进制文件加载到自己的虚拟内存空间中,并执行其代码的转换和插装版本:

    Valgrind 使用动态二进制重新编译,类似于许多其他 DBI 框架。通过valgrind --tool=<toolname>在命令前添加(加上任何 Valgrind 或工具选项)来调用Valgrind 工具。命名工具启动,将客户端程序加载到同一进程中,然后(重新)编译客户端的机器代码,一次一个小代码块,以即时、执行驱动的方式。核心将代码块分解为中间表示 (IR),由工具插件使用分析代码对其进行检测,然后由核心将其转换回机器代码。生成的翻译存储在代码缓存中,以便在必要时重新运行。Valgrind 的核心大部分时间都用于制作、查找和运行翻译。没有运行客户端的原始代码。

    正确处理的代码包括:正常的可执行代码、动态链接库、共享库和动态生成的代码。[1]

  2. 在 Valgrind 使用的中间表示 (IR) 中,二进制中的机器代码指令所具有的每个效果都由 IR 操作明确表示。这意味着具有副作用的 CISC 指令将由多个 IR 操作表示。

    • Valgrind 使用反汇编和重新合成 (D&R):机器代码被转换为 IR,其中每条指令都变成一个或多个 IR 操作。这个 IR 被检测(通过添加更多的 IR),然后转换回机器代码。所有原始代码对客户状态的影响(例如条件代码设置)都必须在 IR 中明确表示,因为原始客户端指令被丢弃,最终代码完全由 IR 生成。

    • IR 有一些类似于 RISC 的特性:它是加载/存储,每个原始操作只做一件事(许多 CISC 指令被分解为多个操作),并且当扁平化时,所有操作仅对临时和文字进行操作。尽管如此,支持所有不同大小的标准整数、FP 和 SIMD 运算需要 200 多个原始算术/逻辑运算。

    测试二进制文件的指令集架构在原始帖子中没有明确说明,假设为 x86。x86 是CISC ISA 的一个例子,这意味着 IR 操作的数量>>原始机器代码指令的数量。这是由于 x86 指令在副作用和作为执行单个指令的结果执行的操作方面的复杂性。

当我执行时valgrind --tool=lackey --trace-mem=yes testtest从使用 GCC 的原始帖子中的示例 C 代码创建的 ELF32 二进制文件在哪里,这些是结果(被截断,加上指向将在随后讨论的行的箭头):

.
.
.
I  04cec101,3
I  04cec104,3
I  04cec107,2
==11736== 
==11736== Counted 0 calls to main()                <---------- (1)
==11736== 
==11736== Jccs:
==11736==   total:         44,420
==11736==   taken:         21,288 ( 47%)
==11736== 
==11736== Executed:
==11736==   SBs entered:   44,083
==11736==   SBs completed: 30,750
==11736==   guest instrs:  211,953                 
==11736==   IRStmts:       1,304,900               
==11736== 
==11736== Ratios:
==11736==   guest instrs : SB entered  = 48 : 10
==11736==        IRStmts : SB entered  = 296 : 10
==11736==        IRStmts : guest instr = 61 : 10   <---------- (2)
==11736== 
==11736== Exit code:       0

正如我们在(2)中看到的,IR 语句明显多于机器代码指令,这符合将 CISC 指令翻译成 Valgrind 的 IR 的预期。

(1) 与问题的第二部分有关

以下是生成多个 IR 语句的单个 x86 指令示例:

0x24F27C: addl %ebx,%eax                 <---------- x86 instruction + operands
4: ------ IMark(0x24F27C, 2) ------
5: PUT(60) = 0x24F27C:I32       # put %eip
6: t3 = GET:I32(0)              # get %eax
7: t2 = GET:I32(12)             # get %ebx
8: t1 = Add32(t3,t2)            # addl
9: PUT(32) = 0x3:I32            # put eflags val1
10: PUT(36) = t3                # put eflags val2
11: PUT(40) = t2                # put eflags val3
12: PUT(44) = 0x0:I32           # put eflags val4
13: PUT(0) = t1                 # put %eax

第2部分

如何在我编写的代码中隔离代表指令的 IR?

由于 Valgrind 如何转换机器代码(反汇编 + 重新合成),这似乎是不可能的。

我们在 (1) 中观察到main(),当 Valgrind 检测测试二进制文件时,调用了 0 次由于main()什么都不做,它有可能在机器代码 -> IL -> 检测 IR -> 机器代码翻译过程中被优化掉。

翻译过程实际上包括 8 个阶段,其中

所有阶段都由核心执行,除了由工具执行的仪器。标有“*”的阶段是特定于体系结构的。

  1. 阶段 1. 反汇编*:机器码 → 树 IR反汇编器将机器代码转换为(未优化的)树 IR。每条指令都独立地分解为一个或多个语句。这些语句完全更新内存中受影响的访客寄存器:访客寄存器从 ThreadState 中提取到临时寄存器中,对其进行操作,然后写回。

  2. 阶段 2. 优化 1:树 IR → 平面 IR第一个优化阶段使 IR 变平并进行了几个优化:冗余 get 和 put 消除(从 ThreadState 中移除不必要的来宾寄存器复制)、复制和常量传播、常量折叠、死代码移除、公共子表达式消除、甚至对块内循环进行简单的循环展开。

  3. 阶段 3. 检测:平坦 IR → 平坦 IR然后将代码块传递给该工具,该工具可以对其进行任意转换。在这一点上将 IR 变平是很重要的,因为它使检测更容易,特别是对于阴影值工具。

  4. 第 4 阶段。 优化 2:平坦 IR → 平坦 IR。第二个更简单的优化过程执行常量折叠和死代码删除。

  5. 阶段 5. 树构建:平面 IR → 树 IR树生成器将平面 IR 转换回树 IR,为指令选择做准备。分配给只使用一次的临时表的表达式通常会被代入临时表的使用点,并删除分配。生成的代码可能以与原始代码不同的顺序执行加载,但加载永远不会经过存储

  6. 阶段 6. 指令选择*:树 IR → 指令列表指令选择器将树 IR 转换为使用虚拟寄存器的指令列表(除了那些硬连线以使用特定寄存器的指令;这些在 x86 和 AMD64 上很常见)。指令选择器使用简单的、贪婪的、自顶向下的树匹配算法。

  7. 阶段7.寄存器分配:指令列表→指令列表线性扫描寄存器分配器 [26] 用主机寄存器替换虚拟寄存器,必要时插入溢出。始终保留一个通用主机寄存器以指向 ThreadState。

  8. 阶段 8. 汇编*:指令列表 → 机器代码最后的组装阶段只是对选定的指令进行适当的编码,并将它们写入内存块。

在优化和可能的任意转换之后,关于lackey由 GCC 为main().


补充资源:

https://fosdem.org/2017/schedule/event/valgrind_vex_future/

https://fosdem.org/2017/schedule/event/valgrind_vex_future/attachments/slides/1842/export/events/attachments/valgrind_vex_future/slides/1842/valgrind_vex_future.pdf

https://github.com/trailofbits/libvex/blob/master/VEX/pub/libvex_ir.h

https://arxiv.org/pdf/0810.0372.pdf

http://www.ittc.ku.edu/~kulkarni/teaching/EECS768/slides/chapter3.pdf

https://docs.angr.io/docs/ir.html


1. Nicholas Nethercote 和 Julian Seward。Valgrind:重量级动态二进制检测框架。在过程中。2007 年 6 月 ACM SIGPLAN 2007 编程语言设计与实现 (PLDI) 会议。