这篇文章将介绍 7 个自定义通道,一旦添加到优化管道中,将使整个 LLVM-IR 输出更具可读性。一些词汇将用于不受支持的指令提升和重新编译主题上。最后,将显示 6 个去虚拟化函数的输出。
自定义通道
本节将介绍一些用于以下目的的自定义通道:
◼解决VMProtect的具体优化问题;
◼解决现有 LLVM通道的一些限制,但无法达到正式LLVM通道标准;
SegmentsAA
此通道属于VMProtect特定优化问题的范畴,因为它可能给LLVM提供不安全的假设。Liveness 和别名信息部分中描述的别名信息最终会派上用场。事实上,通道的目标是识别两个指针的类型,并确定它们是否是一个类型。
使用前面几节中定义的结构,LLVM已经能够推断出来自以下来源的两个指针没有别名:
通用寄存器;
VmRegisters;
VmPassingSlots;
GS 零大小数组;
FS 零大小数组;
RAM 零大小数组(具有常量索引);
RAM 零大小数组(带符号索引);
此外,LLVM 还可以使用简单的符号索引区分具有 RAM 基址的指针。例如,与对 [rsp + 0x10](传入堆栈参数)的访问相比,对 [rsp - 0x10](本地堆栈槽)的访问将被视为 NoAlias。
但是 LLVM 的别名分析在处理以 RAM 数组为基础并使用更复杂的符号索引的指针时存在不足,其原因完全与在编译为二进制期间丢失的类型和上下文信息的缺乏有关。
该通道的灵感来自现有的实现(1 、2 、3),这些实现基于对属于不同段和地址空间的指针的识别进行检查。
对 RAM 数组访问中使用的符号索引进行切片,我们可以很有信心地辨别下列附加的NoAlias内存访问:
间接访问:如果访问是堆栈参数([rsp] 或 [rsp + positive_constant_offset + symbols_offset])、取消引用的通用寄存器([rax])或嵌套取消引用(val1 = [rax]、val2 = [val1]) ),在代码中标识为 TyIND。
本地堆栈槽:如果访问的形式为 [rsp - positive_constant_offset +symbolic_offset],在代码中标识为 TySS。
本地堆栈数组:如果访问 if 的形式为 [rsp - positive_constant_offset + phi_index],在代码中标识为 TyARR。
如果无法可靠地检测到指针类型,则会使用未知类型(在代码中标识为 TyUNK),并自动跳过指针之间的比较,如果通道无法返回 NoAlias 结果,则查询将返回默认别名分析管道。
有人可能会说,这个通道实际上并不需要,因为我们成功探索虚拟化 CFG 所需的敏感信息的传播不太可能受到别名问题的影响。事实上,VmBlock 末尾的条件分支的计算保证不会受到跳转 VmHandler 访问分支目标之前发生的符号内存存储的影响。但在某些情况下,VMProtect会将下一个VmStub的地址推入第一个VmBlocks中,在中间进行内存存储,并仅在一个或多个VmExits中访问推入的值。在这种情况下,区分本地堆栈槽和间接访问可以实现推送地址的传播。
不管前面提到的问题是什么,这个问题都可以用一些特别的从存储到加载的检测逻辑来解决,利用可以提供给LLVM的别名分析信息可以使非虚拟化代码更具可读性。我们必须记住,可能存在原始代码打破我们假设的边缘情况,因此至少对运行时访问的相关指针有一个模糊的概念可以让我们更有信心或迫使我们在安全方面犯错,仅依赖于内置的 LLVM 别名分析过程。
下面所示的程序集片段已经通过向管道添加SegmentsAA通道进行了去虚拟化。如果我们确定在运行时,在push rax指令之前,rcx不包含值rsp – 8,我们可以安全地启用SegmentsAA通道并获得更清晰的输出。
别名分析是一个复杂的问题,经验告诉我,在使用 LLVM 对某些代码进行反混淆时发生的大多数传播问题都与 LLVM 的别名分析通道受到某些指针计算的阻碍有关。因此,能够为 LLVM 提供上下文感知信息可能是处理某些类型混淆的唯一方法。请注意,你习惯的其他工具很可能在幕后做类似的“安全”假设,例如,使用具体指针来回答别名查询的 concolic 执行工具。
本节的要点是,如果需要,你可以定义自己的别名分析回调通道以集成到优化管道中,这样预先存在的通道就可以利用改进的别名查询结果。这类似于使用适当的类型定义更新 IDA 的堆栈变量以改善传播结果。
KnownIndexSelect
这个问题属于VMProtect特定优化问题的范畴,实际上,任何研究过VMProtect 3.0.9的人都知道下面的技巧(为了简单起见,被重新实现为高级C代码)在内部用于在条件跳转的两个分支之间进行选择。
在底层分支目标被写入相邻的堆栈槽,然后由先前计算的标志控制的条件加载将在一个槽或另一个槽之间进行选择以获取正确的跳转目的地。
LLVM 不会自动查看条件加载,但它为我们提供了自己编写此类优化所需的所有信息。事实上,ValueTracking 分析公开了computeKnownBits 函数,我们可以使用它来确定getelementptr 指令中使用的索引是否必须只有两个值。
此时,我们可以生成两个独立的加载指令,使用推断索引访问堆栈槽,并将它们提供给由索引本身控制的选择指令。在下一次从存储到加载的传播中,LLVM 会很高兴地识别匹配的存储和加载指令,传播表示条件分支目的地的常量,并生成一个带有第二个和第三个常量操作数的选择指令。
上面的代码片段显示了匹配的模式、适合LLVM传播的展开形式以及最终优化的形状。在本例中,ValueTracking分析提供值0和8作为 %index 值的唯一可行值。
在LLVM邮件列表中的消息链中可以找到关于此通道的简要介绍。
SynthesizeFlags
这个问题介于VMProtect特定的优化问题和LLVM优化限制之间。事实上,该通道基于Souper实现的枚举合成逻辑,并进行了一些细微的调整,以使其更适合我们的示例。
现在正在谈论的模式是由 VMProtect 在计算条件分支的条件时所做的标志操作生成的模式,LLVM 在简化部分模式方面已经做得很好,但要获得类似 mint 的结果,我们还需要一些帮助。
关于这个通道没有太多可说的,它基本上是调用Souper的枚举合成与一组选定的组件(Inst::CtPop, Inst::Eq, Inst::Ne, Inst::Ult, Inst::Slt, Inst::Ule, Inst::Sle, Inst::SAddO, Inst::UAddO, Inst::SSubO, Inst::USubO, Inst::SMulO, Inst::UMulO),需要合成一个单一指令,启用数据流修剪选项,并将LHS候选项限制为最大50个。另外,pass只在select和br指令使用的i1条件下执行合成。
此 Godbolt 页面显示了将 SynthesizeFlags 传递到管道后获得的去虚拟化 LLVM-IR 输出以及带有正确重新编译的条件跳转的结果程序集。原始汇编代码可以在下面看到。这是一个伪指令序列,其中关键部分是驱动条件分支 jcc 的 rax 和 rbx 寄存器之间的比较。
MemoryCoalescing
此通道属于通用 LLVM 优化通道的类别,不可能包含在主线框架中,因为它们不符合稳定通道的质量标准。尽管通过这种方式完成的转换适用于一般的LLVM-IR代码,即使处理的情况最有可能在模糊代码中找到。
像 DSE 这样的通道已经尝试处理存储指令与其他存储指令部分或完全重叠的情况。尽管更复杂的情况是多个存储对单个内存插槽的价值有贡献,但在某种程度上只能部分处理。
此过程侧重于处理以下代码段中说明的情况,其中多个较小的存储有助于创建更大的值,随后由单个加载指令访问该值。
现在,你可以耐心地手动匹配所有的存储和加载操作。
该通道在块内级别运行,它依赖于 MemorySSA、ScalarEvolution 和 AAResults 接口提供的分析结果来向后遍历定义链,并创建块中每个加载指令获取的值。这样做时,它会填充一个结构来跟踪别名存储指令、存储的值以及与每次加载获取的内存插槽重叠的偏移量和大小。如果找到完全定义整个内存槽值的存储分配序列,则处理该链以删除存储到加载的间接性。随后的通道可能会依赖这个新的无间接链来应用更多的转换。例如,当在执行 InstCombine 通道之前应用 MemoryCoalescing 通道时,先前的 LLVM-IR 代码段会转换为以下优化的 LLVM-IR 代码段。
PartialOverlapDSE
此通道也属于通用 LLVM 优化通道的类别,不可能包含在主线框架中,因为它们不符合稳定通道的质量标准。尽管此阶段完成的转换适用于通用 LLVM-IR 代码,即使处理的情况最有可能在混淆代码中被找到。
这在概念上类似于MemoryCoalescing通道,该通道的目标是扫描一个函数,以识别后支配单个存储指令的存储指令链,并在它实际被获取之前阻止它的值。像 DSE 这样的通道正在做类似的工作,但仅限于由单个后主导存储上的多个存储引起的某些形式的完全重叠。
将 -O3 管道应用于以下示例不会删除 RAM[%0] 的前 64 位已经失效的存储,即使随后的 64 位完全存储在 RAM[%0 - 4] 和 RAM[%0 + 4]重叠它,也应重新定义它的价值。
将 PartialOverlapDSE 通道添加到管道将识别并终止第一个存储,使其他通道能够最终终止对存储值有贡献的计算链。内置的DSE通道很可能不会执行这样的失效操作,因为收集关于多个重叠存储的信息是一项很费事的操作。
PointersHoisting
这个过程与我提交的 IsGuaranteedLoopInvariant 补丁严格相关,实际上它只是识别所有可以安全提升到入口块的指针,因为仅依赖于直接来自入口块的值。在执行DSE通道之前应用这种转换可能会得到更好的优化结果。
在这个示例中,包含一个相当无用的 switch case 的非虚拟化函数。之所以说相当无用,因为 case 块中的每个存储都被store i32 22, i32* %85 指令后支配和阻止,但是 LLVM 不会阻止这些store,直到我们将指针计算移到入口块。
当在执行DSE通道之前应用PointersHoisting通道时,我们得到以下代码,其中switch case已经被完全删除,因为它已经被认为是无效的。
ConstantConcretization
此通道属于通用 LLVM 优化通道的类别,在处理混淆代码时很有用,但在标准编译管道中基本上无用,至少在当前形状中是这样。事实上,发现依赖于存储在保护阶段添加的数据段中的常量的混淆代码并不少见。
例如,在某些版本的 VMProtect 上,当使用 Ultra 模式时,条件分支计算涉及从数据部分获取的虚拟常量。或者,如果我们考虑虚拟化跳转表,例如,由原始二进制文件中的switch case生成,我们还必须处理从数据部分获取的一组常量。
因此,使用自定义通道的原因是,在管道执行期间,识别潜在的常量数据访问并将相关的内存载荷转换为 LLVM 常量,这个过程可以称为常数具体化。
该通道将识别函数中的所有加载内存访问,并确定它们是否属于以下类别:
1.使用二进制部分之一中包含的地址的 constantexpr 内存加载;这种情况是你在处理某种基于数据的混淆时会遇到的情况;
2.一种符号内存载荷,它使用二进制部分中包含的地址作为基址,并将限制为有限值的表达式用作索引;这种情况是你在处理跳转表时会遇到的情况;
在这两种情况下,用户都需要提供一组安全的内存范围,通道可以认为是只读的,否则通道会将具体化限制为位于二进制只读部分的地址。
在第一种情况下,地址是直接可用的,并且可以通过简单地解析二进制文件来解析关联的值。
在第二种情况下,计算符号内存访问的表达式被切片,来自前一个驱块的约束被收集并以增量方式查询 Souper来获取访问二进制文件的地址集。然后验证每个地址是否确实位于二进制部分中,并获取相应的值。在这一点上,我们在每个地址和它的值之间有一个唯一的映射,我们可以把它变成一个选择级联,如下面的 LLVM-IR 片段所示:
%99 值将根据由 %89 值计算的地址保持适当的常数。上面的示例表示下一个片段中显示的提升跳转表,你可以在其中注意到跳转表基址 0x14003E770 (5368964976) 以及相应的地址和值:
如果我们看一下实现虚拟化 switch case 的切片跳转条件(如下),这就是在管道中调度 ConstantConcretization 通道并且进一步的 InstCombine 执行更新选择级联以计算 switch case 地址之后的样子。因此,Souper 将能够识别 6 个可能的输出边界缘,从而导致 PointersHoisting 部分中出现的去虚拟化开关案例:
不支持的指令
众所周知,所有基于虚拟化的保护程序仅支持目标 ISA 的一个子集。因此,当发现不支持的指令时,执行从虚拟机的退出(上下文切换到主机代码),运行不支持的指令并重新进入虚拟机(上下文切换回虚拟化代码)。
UnsupportedInstructionsLiftingToLLVM 概念验证尝试将不受支持的指令提升到 LLVM-IR,生成一条 InlineAsm 指令,该指令配置了一组破坏约束和(ex|im)强制访问的寄存器。在提升过程中使用表示通用寄存器的执行上下文结构,以向内联程序集调用指令提供已加载的寄存器,并在内联程序集执行之后存储更新的寄存器。
这种方法保证了两个虚拟化 VmStub 和不支持指令的中间序列之间的平滑连接,从而在重新编译阶段实现了一些 LLVM 优化和更好的寄存器分配。
下面是被取消的不支持指令rdtsc的示例:
提升的不受支持的指令 cpuid 的示例如下:
重新编译
到目前为止,我还没有真正深入探索重新编译的进程,因为我的主要目标是获得可读的 LLVM-IR 代码,但有一些注意事项如下:
如果目标是能够执行并最终反编译恢复的代码,那么使用通用寄存器指针提供的间接层编译去虚拟化函数是一种有效的方法。它在概念上类似于 Remill 使用的那种具有自己状态结构的间接。当无法应用堆栈槽和参数恢复时,SATURN 就会使用此技术。
如果目标是实现 1:1 的寄存器分配,那么事情会变得更加复杂,因为不能简单地将所有通用寄存器指针映射到硬件寄存器,希望不会出现副作用。
尝试 1:1 映射时要处理的主要问题与重新编译如何意外更改堆栈布局有关。如果在寄存器分配阶段,在堆栈上分配了一些溢出槽,则可能会发生这种情况。如果这些额外的溢出+重新加载语义没有得到充分处理,函数使用的一些指针可能会访问不可预见的堆栈槽,从而导致灾难性的结果。
本文翻译自:https://secret.club/2021/09/08/vmprotect-llvm-lifting-3.html如若转载,请注明原文地址