首页
社区
课程
招聘
[原创]Polaris-Obfuscator中AliasAccess简要分析 反混淆
发表于: 10小时前 160

[原创]Polaris-Obfuscator中AliasAccess简要分析 反混淆

10小时前
160

AliasAccess pass 的核心思路是:把函数里的局部变量(alloca)藏进随机生成的 struct 里,再通过一条多跳的间接指针链来访问它们,而不是直接引用。

原本的代码:

经过混淆后,变量 x 被塞进某个 struct 的某个随机字段里,访问时变成:

反编译出来的伪代码大概是这样:

其中每一个 sub_1Bxx 都是一个 getter 函数,+28 是该变量在 raw struct 里的字段偏移。

实现代码见 src/llvm/lib/Transforms/Obfuscation/AliasAccess.cppprocess() 函数分 7 个阶段完成混淆。

每个节点用 ReferenceNode 表示:

遍历函数所有指令,收集对齐 <= 8 的 AllocaInst,这些是待混淆的候选变量。

构造一个固定的 transit struct 类型,本质上就是 { i8* slot[BRANCH_NUM] }。每个 slot 要么指向下一个节点,要么为 null。BRANCH_NUM 控制每个 transit 节点最多有几条出边。

把所有 alloca 随机分配到 AIs.size() 个 bucket 里,分布不均匀,其中同一个 bucket 里的 alloca 会被打包进同一个 raw struct。

对每个非空 bucket,创建一个 raw node。struct 的 slot 数为 Items.size() * 2 + 1

下面是一个例子:

其中,5个字段里面,有两个是真的有用的;剩下的dummy只用于增加逆向时的难度。

创建 Graph.size() * 3 个 transit 节点,形成一个有向无环图(DAG)。每个 transit 节点随机选几条出边,指向已有的节点(raw 或 transit),并在函数入口处 emit store 指令把子节点指针写入对应的 slot。同时,Path 自底向上传播可达性:一个 transit 节点知道"从我的 slot[N] 出发,能到达哪些 alloca",这是 Phase 6 use-site 改写的依据。

原本本人以为这样的链式结构有可能会意外引入环,导致无限死循环;然而后面仔细观察发现,Transit 节点只能指向比自己更早加入 Graph 的节点,push_back 在 for 循环末尾执行,天然保证了 DAG 结构,链条一定终止于 raw leaf.

对每个用到原始 alloca 的操作数,in-place 替换成 chain 计算的结果:

控制流、基本块结构不变,只是操作数从直接引用 alloca 变成了一串 call chain 的结果。

删除原始的 alloca 指令(已被 struct 字段替代),释放 graph 节点内存。

Getter 函数由 buildGetterFunction 生成,签名为 i8*(i8*)

关键弱点Index 是编译期静态确定的常量,直接 baked 进 GEP 的 immediate 里。每个唯一的 index 对应一个独立的 getter 函数,lazy 创建并缓存在 Getter map 里。这是一个潜在的反混淆突破口。

两层叠加:

源码片段:

从混淆后binary反编译的伪代码来看(IDA Pro 示例,O0优化):

其中:

我又在O2优化下进行了一次测试:

仔细一看,感觉AliasAccess添加的混淆被优化掉了不少。比如v4 = 1;对应的就是源码print_hash_value = 1;。然后上面一坨乱七八糟的SIMD指令,似乎是Csmith源码里面本来就有的crc32计算逻辑,和AliasAccess没啥关系。

因此得出初步结论:AliasAccess需要配合Linear MBA等其他反优化手段,才能在O2/O3优化下获得更好的混淆效果。

下面介绍如何在二进制层面还原 AliasAccess 的混淆。整体流程分为三个阶段:定位求解Patch,逐步展开。

AliasAccess 混淆后,每一次对局部变量的读写都变成了一条 getter 调用链:

我们的目标是把每个数据访问点的间接链恢复成直接的 rbp 相对寻址。核心思路是分层求解

Getter 函数是 AliasAccess 生成的小函数,签名统一为 i8*(i8*),做的事情就是 return rdi + offset。在二进制层面:

我们通过 VEX IR 来匹配这个 pattern:检查函数是否为单基本块、以 Ijk_Ret 结尾、且包含 Add64(GET(rdi), Const) → PUT(rax) 模式:

返回值就是该 getter 的常量偏移(如 0x100x18 等),如果不匹配则返回 None

如果 getter 被 MBA(Mixed Boolean-Arithmetic)混淆,VEX pattern match 会失败。此时自动回退到 per-getter symex,即对这单个小函数做符号执行,输入 symbolic rdi,求解 rax - rdi 得到 offset:

通过约束符号执行的粒度和范围,我们能尽量避免符号执行出现符号爆炸的情况。

核心观察:每一个被混淆的数据访问(不管是读还是写)都有相同的结构:

也就是说,使用块的前驱块一定以 getter 调用结尾(Ijk_Call),而使用块内一定包含一条 self-deref 指令,即从某个寄存器加载到同一个寄存器(mov rax, [rax])。

检测分两步:

1. 前驱检测:遍历函数所有基本块,找到以 getter call 结尾的块(Ijk_Call 且 callee 是已识别的 getter 函数)。

2. Self-deref 检测:在后继块中找到 mov REG, [REG] 指令。使用 capstone 的结构化操作数 API,完全不依赖硬编码的字节序列或特定寄存器名:

两个条件同时满足的块就是被混淆的数据访问点。中间跳转块(mov [rax], rdi 传参给下一个 getter)不包含 self-deref,所以不会被误报。

早期方案使用 DDG(数据依赖图)的后向切片来判断 use site 是否与 getter 函数有关,但 DDG 存在精度问题(寄存器别名导致误报),且只能检测 VEX Store 语句,漏掉了所有的读操作。最终方案完全基于 CFG 结构,不需要 DDG,也不需要 CFGEmulated; 最简单的CFGFast 就够了。

对于每个被混淆的使用块,我们需要知道 getter 链最终解析出的 rbp 相对地址。

该方案不跑全函数的符号执行。符号执行只用在两个最小粒度的地方:

中间的use chain通过纯内存读取完成。

具体步骤:

1. 后向遍历 getter 链。从使用块出发,沿 CFG 向后走,每一步找到以 getter call 结尾的前驱块,记录 getter offset。当遇到链起点时停止,其判定条件有两个:

2. 获取起始指针。链入口块包含一条 lea rdi, [rbp-K](加载 transit node 地址)和紧接着的 call getter。我们不解析 lea 指令的编码,而是对这个单块做一次 symex,即用 prologue 状态的寄存器和内存执行这一个块,然后读取 rdi 的具体值:

这样即使 O2/O3 把 lea rdi, [rbp-K] 优化成其他形式(比如 mov rdi, rbp; add rdi, -K)也能正确处理。

3. 前向读取 transit node 内存。拿到起始指针后,沿链的每一跳:ptr = prologue_mem[ptr + getter_offset]。最后一次读取得到的就是 raw struct 的地址。

得到每个使用块的 rbp 相对偏移后,在二进制上做两处修改:

1. 替换最后一跳 getter call。把前驱块的 call getter 指令替换为 lea rax, [rbp + K]。LEA 指令通常比 CALL 短(disp8 编码只需 4 字节 vs CALL 的 5 字节),多余的字节填 NOP:

2. NOP 掉 deref 指令。使用块的 mov rax, [rax] 必须去掉,否则会把我们 LEA 设置的直接地址再解引用一次,导致访问错误的内存。

两处 patch 是分开做的,因为它们之间可能夹着用于计算存储值的指令(比如 mov edi, [rbp-0x22c] 加载要写入的值),这些指令必须保持不变。

经过步骤四之后,所有数据访问端点都已经被 LEA 替代。但中间的 getter 跳转指令(lea rdi,...; call getter; mov rdi,[rax])还残留着。它们现在是 dead code,因为计算出来的 rax 值总会被后续的 LEA 覆盖。

对每个残留的 getter call 块,NOP 掉 call 指令和后继块开头的结果加载。

注意:不能 NOP 整个块。因为 AliasAccess 的块结构是交错的,同一个块可能既包含上一条链的数据存储,又包含下一条链的 getter call。只 NOP call 指令本身和后继块的结果加载指令就够了。

IDA 反编译效果对比:

与原始源码在结构上完全一致。

在分析和测试 AliasAccess pass 的过程中,我们还发现它在 O1+ 优化级别下存在一个会导致崩溃的 bug。

Phase 6 会原地改写每一个被混淆的 alloca 的 use:

I 是普通指令(load、store、call……)时,这样做没有问题。但当 IPHI node 时就会出错。

LLVM 要求一个基本块内所有 PHI node 必须出现在任何 non-PHI 指令之前。调用 IRB.SetInsertPoint(&phi) 然后插入 call + load + getelementptr 序列,会把这些 non-PHI 指令放到 PHI 之前,从而产生非法 IR。

此外,GEP 结果 %VP 现在定义在与 PHI node 同一个基本块中。当 U.set(VP) 把旧的 alloca 操作数替换为 %VP 时,PHI 就会引用来自自己所在块的值,而不是来自前驱块的值,这违反了 PHI 的语义。

O0 下,每个局部变量都有自己的 alloca。所有读写都通过显式的 load/store 指令完成。alloca 地址永远不会直接出现在 PHI 操作数中,因为存在的 PHI node 合并的是 loaded 的标量值,而不是 alloca 指针。

O1+ 下,mem2reg 会把指针变量提升为 SSA 形式。一个类似这样的 C 模式:

提升之后会变成:

现在 alloca 地址本身(%alloca_local_var)成了 PHI 的操作数。Phase 6 找到这个 use,调用 IRB.SetInsertPoint(&phi),在 PHI 之前插入 getter chain,从而产生了如下所示的非法块布局。

Pass 执行前(概念上):

Pass 改写 %alloca_l_1219 之后:

两个同时发生的违规:

在 O1 下,该 pipeline 配置中 LLVM verifier 并不会在每个 pass 之间运行,因此损坏的 IR 不会被检测到。后续的优化 pass(GVN、LICM……)恰好没有修改这个畸形的基本块。SelectionDAG 随后假设 IR 是良构的,对不存在的 PHI 状态进行解引用,导致在 X86DAGToDAGISel::runOnMachineFunction 内部产生空指针崩溃。

Phase 1 中过滤掉作为 PHI 操作数使用的 alloca,使其永远不会被打包进 raw struct:


[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!

收藏
免费 2
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回