Polaris-Obfuscator中AliasAccess简要分析-反混淆

admin 2026-04-13 03:07:56 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 该文档分析了Polaris-Obfuscator中AliasAccesspass的反混淆技术,其核心通过将局部变量嵌入随机结构体并建立多跳指针链来实现混淆。关键发现包括7阶段实现流程:变量收集、结构体构造、随机分桶、节点生成和代码重写,并指出Getter函数中静态索引常量的设计弱点可能成为反混淆突破口。文档提供了详细的技术实现路径和潜在优化方向。 综合评分: 85 文章分类: 逆向分析,代码审计,混淆技术,二进制安全


cover_image

Polaris-Obfuscator中AliasAccess简要分析-反混淆

Taardisaa Taardisaa

看雪学苑

2026年4月11日 18:00 上海

在小说阅读器读本章

去阅读

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

#

原本的代码:

int x = 42;
use(x);

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

// 穿过若干层 getter 调用,最终 GEP 到那个字段
v9 = getter_1(v50);         // transit hop 1
v10 = getter_2(*v9);        // transit hop 2
*(int*)(*v10 + offset) = 42; // 最终写入 raw struct 的字段

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

v9 = (_QWORD *)sub_1BE0(v50);
*(_DWORD *)(*(_QWORD *)sub_1BF0(*v9) + 28LL) = 42;

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

混淆 Pass 的实现

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

数据结构

每个节点用ReferenceNode表示:

struct ReferenceNode {
    AllocaInst *AI;                                      // 对应的 alloca 指令
bool IsRaw;                                          // 是否是叶节点(raw node)
unsigned Id;
&nbsp; &nbsp; std::map<AllocaInst *, ElementPos> RawInsts; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// raw node 专用:alloca -> 字段位置
&nbsp; &nbsp; std::map<unsigned, ReferenceNode *> Edges; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 出边:slot index -> 子节点
&nbsp; &nbsp; std::map<AllocaInst *, std::vector<unsigned>> Path; &nbsp;// 可达性:alloca -> 到达它所需的 slot 序列
};
  • Raw node

    IsRaw = true):叶节点,真正存储变量数据的地方。其AI是一个自定义 struct 的 alloca,该 struct 里混杂了真实字段和i8*dummy 字段。

  • Transit node

    IsRaw = false):中间节点,不存储任何变量数据,只持有指向其他节点的指针。其AITransST的 alloca,TransST本质上就是{ i8* slot[BRANCH_NUM] }

Phase 1:收集 alloca

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

for (BasicBlock &BB : F) {
&nbsp; for (Instruction &I : BB) {
&nbsp; &nbsp; if (isa<AllocaInst>(I)) {
&nbsp; &nbsp; &nbsp; AllocaInst *AI = (AllocaInst *)&I;
&nbsp; &nbsp; &nbsp; if (AI->getAlign().value() <= 8) {
&nbsp; &nbsp; &nbsp; &nbsp; AIs.push_back((AllocaInst *)&I);
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
&nbsp; }
}

Phase 2:构造 TransST

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

for (unsigned i =&nbsp;0; i < BRANCH_NUM; i++) {
&nbsp; Slots.push_back(PtrType);
}
TransST->setBody(Slots);

Phase 3:随机分桶

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

std::vector<std::vector<AllocaInst *>> Bucket;
for&nbsp;(unsigned&nbsp;i =&nbsp;0; i < AIs.size(); i++) {
&nbsp; Bucket.push_back(std::vector<AllocaInst *>());
}
for&nbsp;(AllocaInst *AI : AIs) {
unsigned&nbsp;Index =&nbsp;getRandomNumber() % AIs.size();
&nbsp; Bucket[Index].push_back(AI);
}

Phase 4:构造 Raw Node

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

  • 真实字段:Items.size()个,类型是对应 alloca 的原始类型,放在随机选的位置
  • Dummy 字段:剩余位置填i8*,纯粹是迷惑性 padding

下面是一个例子:

struct&nbsp;RawST&nbsp;{
i8&nbsp; &nbsp; &nbsp;*dummy_0; &nbsp;&nbsp;// padding
&nbsp; &nbsp; int32_t real_x; &nbsp; &nbsp;// <- 真正的变量 x,放在随机位置
i8&nbsp; &nbsp; &nbsp;*dummy_2; &nbsp;&nbsp;// padding
&nbsp; &nbsp; float &nbsp; real_y; &nbsp; &nbsp;// <- 真正的变量 y
i8&nbsp; &nbsp; &nbsp;*dummy_4; &nbsp;&nbsp;// padding
};

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

ReferenceNode *RN = new&nbsp;ReferenceNode();
RN->IsRaw =&nbsp;true;
StructType *ST = StructType::create(F.getContext());
unsigned Num = Items.size() *&nbsp;2&nbsp;+&nbsp;1;

// 随机选 Items.size() 个不重复的位置放真实字段
getRandomNoRepeat(Num, Items.size(), Random);
for&nbsp;(unsigned i =&nbsp;0; i < Items.size(); i++) {
&nbsp; AllocaInst *AI = Items[i];
&nbsp; unsigned Idx = Random[i];
&nbsp; Slots[Idx] = AI->getAllocatedType(); &nbsp;// 真实类型
&nbsp; ElementPos EP;
&nbsp; EP.Type = ST;
&nbsp; EP.Index = Idx;
&nbsp; RN->RawInsts[AI] = EP; &nbsp;// 记录 alloca -> 字段位置的映射
}
// 剩余位置填 i8* dummy
for&nbsp;(unsigned i =&nbsp;0; i < Num; i++) {
if&nbsp;(!Slots[i]) Slots[i] = PtrType;
}
ST->setBody(Slots);
RN->AI = IRB.CreateAlloca(ST);

Phase 5:构造 Transit Node

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

unsigned Num = Graph.size() *&nbsp;3;
for&nbsp;(unsigned i =&nbsp;0; i < Num; i++) {
&nbsp; ReferenceNode *Parent = new&nbsp;ReferenceNode();
&nbsp; AllocaInst *Cur = IRB.CreateAlloca(TransST);
&nbsp; Parent->AI = Cur;
&nbsp; Parent->IsRaw =&nbsp;false;
&nbsp; unsigned BN =&nbsp;getRandomNumber() % BRANCH_NUM; &nbsp;// 随机决定出边数量
getRandomNoRepeat(BRANCH_NUM, BN, Random); &nbsp; &nbsp; &nbsp;&nbsp;// 随机选 BN 个不重复的 slot index
for&nbsp;(unsigned j =&nbsp;0; j < BN; j++) {
&nbsp; &nbsp; unsigned Idx = Random[j];
&nbsp; &nbsp; ReferenceNode *RN = Graph[getRandomNumber() % Graph.size()]; &nbsp;// 随机选子节点
&nbsp; &nbsp; Parent->Edges[Idx] = RN;
// 运行时:Cur->slot[Idx] = RN->AI
&nbsp; &nbsp; IRB.CreateStore(RN->AI,
&nbsp; &nbsp; &nbsp; &nbsp; IRB.CreateGEP(TransST, Cur, {IRB.getInt32(0), IRB.getInt32(Idx)}));
// 传播可达性到 Parent->Path
if&nbsp;(RN->IsRaw) {
for&nbsp;(auto Iter = RN->RawInsts.begin(); Iter != RN->RawInsts.end(); Iter++)
&nbsp; &nbsp; &nbsp; &nbsp; Parent->Path[Iter->first].push_back(Idx);
&nbsp; &nbsp; }&nbsp;else&nbsp;{
for&nbsp;(auto Iter = RN->Path.begin(); Iter != RN->Path.end(); Iter++)
&nbsp; &nbsp; &nbsp; &nbsp; Parent->Path[Iter->first].push_back(Idx);
&nbsp; &nbsp; }
&nbsp; }
&nbsp; Graph.push_back(Parent); &nbsp;// push_back 在末尾,保证不会有环
}

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

Phase 6:改写 Use-Site

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

  • 从 Graph 中随机选一个能到达该 alloca 的入口节点
  • 沿 Path 逐跳 emit getter 调用(每跳一次CreateCall+CreateLoad),直到到达 raw node
  • 在 raw node 上 emit GEP,取得该 alloca 对应的字段地址
  • U.set(VP)原地替换操作数
for&nbsp;(Use &U : I.operands()) {
&nbsp; AllocaInst *AI = (AllocaInst *)Opnd;
&nbsp; IRB.SetInsertPoint(&I);
&nbsp; std::shuffle(Graph.begin(), Graph.end(), std::default_random_engine());
// 找一个能到达 AI 的入口节点
&nbsp; ReferenceNode *Ptr = nullptr;
for&nbsp;(ReferenceNode *RN : Graph) {
if&nbsp;(RN->Path.find(AI) != RN->Path.end() ||
&nbsp; &nbsp; &nbsp; &nbsp; (RN->IsRaw && RN->RawInsts.find(AI) != RN->RawInsts.end())) {
&nbsp; &nbsp; &nbsp; Ptr = RN;&nbsp;break;
&nbsp; &nbsp; }
&nbsp; }
&nbsp; Value *VP = Ptr->AI;
// 沿链逐跳 emit getter 调用
while&nbsp;(!Ptr->IsRaw) {
&nbsp; &nbsp; std::vector<unsigned> &Idxs = Ptr->Path[AI];
&nbsp; &nbsp; unsigned Idx = Idxs[getRandomNumber() % Idxs.size()];
if&nbsp;(Getter.find(Idx) == Getter.end())
&nbsp; &nbsp; &nbsp; Getter[Idx] =&nbsp;buildGetterFunction(*F.getParent(), TransST, Idx);
&nbsp; &nbsp; VP = IRB.CreateLoad(PtrType, IRB.CreateCall(FunctionCallee(Getter[Idx]), {VP}));
&nbsp; &nbsp; Ptr = Ptr->Edges[Idx];
&nbsp; }
// 到达 raw node,GEP 取字段地址
&nbsp; ElementPos &EP = Ptr->RawInsts[AI];
&nbsp; VP = IRB.CreateGEP(EP.Type, VP, {IRB.getInt32(0), IRB.getInt32(EP.Index)});
&nbsp; U.set(VP); &nbsp;// 原地替换操作数
}

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

Phase 7:清理

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

for&nbsp;(AllocaInst *AI&nbsp;: AIs)
AI->eraseFromParent();
for&nbsp;(auto Iter = Graph.begin(); Iter != Graph.end(); Iter++)
delete&nbsp;*Iter;

Getter 函数

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

i8*&nbsp;getter(i8* ptr) {
return&nbsp;&((TransST*)ptr)->slot[Index];
}

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

混淆效果分析

两层叠加:

  • Struct 内部:真假字段混杂,字段位置随机。
  • Struct 之间:多跳指针链,每个 use-site 的入口节点随机选取,call chain 深度不固定

源码片段:

print_hash_value&nbsp;=&nbsp;1;

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

// 访问 print_hash_value = 1,实际经过两跳
v9 &nbsp;= (_QWORD *)sub_1BE0(v50); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// transit hop 1
*(_DWORD *)(*(_QWORD *)sub_1BF0(*v9) +&nbsp;28LL) =&nbsp;1; &nbsp; &nbsp; &nbsp;// transit hop 2 + GEP(offset=0x1c)

其中:

  • v50是某个 transit node 的 alloca(栈上的local_xxx)
  • sub_1BE0/sub_1BF0是 getter 函数(或其 inline 展开)
  • +28(0x1c)是print_hash_value在 raw struct 里的字段偏移

在high level optimization下的表现

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

int __fastcall main(int argc, const char **argv, const char **envp)
{
&nbsp; &nbsp; // 省略variable declarations

if&nbsp;(&nbsp;argc ==&nbsp;2&nbsp;)
&nbsp; {
&nbsp; &nbsp; v3 = strcmp(argv[1],&nbsp;"1");
&nbsp; &nbsp; v4 = v3 !=&nbsp;0;
&nbsp; &nbsp; v5 = v3 ==&nbsp;0;
&nbsp; }
else
&nbsp; {
&nbsp; &nbsp; v5 =&nbsp;0;
&nbsp; &nbsp; v4 =&nbsp;1;
&nbsp; }
&nbsp; si128 = _mm_load_si128((const __m128i *)&xmmword_2010);
&nbsp; v7 = 0LL;
&nbsp; v8 = _mm_load_si128((const __m128i *)&xmmword_2020);
&nbsp; v9 = _mm_load_si128((const __m128i *)&xmmword_2030);
&nbsp; do
&nbsp; {
&nbsp; &nbsp; v10 = _mm_srai_epi32(_mm_slli_epi32(si128, 0x1Fu), 0x1Fu);
&nbsp; &nbsp; v11 = _mm_srli_epi32(si128, 1u);
&nbsp; &nbsp; v12 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v11, v8), v10), _mm_andnot_si128(v10, v11));
&nbsp; &nbsp; v13 = _mm_slli_epi32(v12, 0x1Fu);
&nbsp; &nbsp; v14 = _mm_srli_epi32(v12, 1u);
&nbsp; &nbsp; v15 = _mm_srai_epi32(v13, 0x1Fu);
&nbsp; &nbsp; v16 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v14, v8), v15), _mm_andnot_si128(v15, v14));
&nbsp; &nbsp; v17 = _mm_srli_epi32(v16, 1u);
&nbsp; &nbsp; v18 = _mm_srai_epi32(_mm_slli_epi32(v16, 0x1Fu), 0x1Fu);
&nbsp; &nbsp; v19 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v17, v8), v18), _mm_andnot_si128(v18, v17));
&nbsp; &nbsp; v20 = _mm_srli_epi32(v19, 1u);
&nbsp; &nbsp; v21 = _mm_srai_epi32(_mm_slli_epi32(v19, 0x1Fu), 0x1Fu);
&nbsp; &nbsp; v22 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v20, v8), v21), _mm_andnot_si128(v21, v20));
&nbsp; &nbsp; v23 = _mm_srai_epi32(_mm_slli_epi32(v22, 0x1Fu), 0x1Fu);
&nbsp; &nbsp; v24 = _mm_srli_epi32(v22, 1u);
&nbsp; &nbsp; v25 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v24, v8), v23), _mm_andnot_si128(v23, v24));
&nbsp; &nbsp; v26 = _mm_slli_epi32(v25, 0x1Fu);
&nbsp; &nbsp; v27 = _mm_srli_epi32(v25, 1u);
&nbsp; &nbsp; v28 = _mm_srai_epi32(v26, 0x1Fu);
&nbsp; &nbsp; v29 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v27, v8), v28), _mm_andnot_si128(v28, v27));
&nbsp; &nbsp; v30 = _mm_srli_epi32(v29, 1u);
&nbsp; &nbsp; v31 = _mm_srai_epi32(_mm_slli_epi32(v29, 0x1Fu), 0x1Fu);
&nbsp; &nbsp; v32 = _mm_or_si128(_mm_and_si128(_mm_xor_si128(v30, v8), v31), _mm_andnot_si128(v31, v30));
&nbsp; &nbsp; v33 = _mm_srli_epi32(v32, 1u);
&nbsp; &nbsp; v34 = _mm_srai_epi32(_mm_slli_epi32(v32, 0x1Fu), 0x1Fu);
&nbsp; &nbsp; *(__m128i *)((char *)&crc32_tab + v7)&nbsp;= _mm_or_si128(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _mm_and_si128(_mm_xor_si128(v33, v8), v34),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _mm_andnot_si128(v34, v33));
&nbsp; &nbsp; si128 = _mm_add_epi32(si128, v9);
&nbsp; &nbsp; v7 += 16LL;
&nbsp; }
&nbsp; while&nbsp;(&nbsp;v7 !=&nbsp;1024&nbsp;);
&nbsp; transparent_crc(0LL,&nbsp;"g_4", v5);
&nbsp; transparent_crc(g_7,&nbsp;"g_7[i]", v5);
if&nbsp;(&nbsp;v4&nbsp;)
&nbsp; {
&nbsp; &nbsp; transparent_crc(dword_4024,&nbsp;"g_7[i]", v5);
&nbsp; &nbsp; transparent_crc(dword_4028,&nbsp;"g_7[i]", v5);
&nbsp; &nbsp; transparent_crc(g_11,&nbsp;"g_11[i].f0", v5);
&nbsp; }
else
&nbsp; {
&nbsp; &nbsp; printf("index = [%d]\n", 0LL);
&nbsp; &nbsp; transparent_crc(dword_4024,&nbsp;"g_7[i]", v5);
&nbsp; &nbsp; printf("index = [%d]\n", 1LL);
&nbsp; &nbsp; transparent_crc(dword_4028,&nbsp;"g_7[i]", v5);
&nbsp; &nbsp; printf("index = [%d]\n", 2LL);
&nbsp; &nbsp; transparent_crc(g_11,&nbsp;"g_11[i].f0", v5);
&nbsp; &nbsp; printf("index = [%d]\n", 0LL);
&nbsp; }
&nbsp; printf("checksum = %X\n",&nbsp;(unsigned int)~crc32_context);
return&nbsp;0;
}

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

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

反混淆方案

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

总览

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

prologue init stores →&nbsp;getter&nbsp;call →&nbsp;getter&nbsp;call → ... → deref → 数据访问

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

结构化定位:基于 CFG 结构找到所有经过 getter 链的数据访问点(不区分读/写)

Chain-walk 求解:从 use site 沿 CFG 后向遍历 getter 链,利用 prologue 初始化的 transit node 内存逐跳读取,得到最终的

rbp相对偏移。不需要跑全函数符号执行,不涉及路径爆炸。

二进制 Patch:把最后一跳 getter call 替换成lea rax, [rbp+K],NOP 掉 deref 指令,最后清理残留的 dead getter call。

步骤一:识别 Getter 函数

Getter 函数是 AliasAccess 生成的小函数,签名统一为i8*(i8*),做的事情就是return rdi + offset

在二进制层面:

mov &nbsp;rax, rdi
add&nbsp; rax,&nbsp;0x10&nbsp; &nbsp; &nbsp;;&nbsp;offset&nbsp;是编译期常量
ret

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

def&nbsp;_extract_getter_offset(proj, getter_addr):
&nbsp; &nbsp; irsb = proj.factory.block(getter_addr).vex
if&nbsp;irsb.jumpkind !=&nbsp;'Ijk_Ret':
return&nbsp;None
&nbsp; &nbsp; rax_off = proj.arch.registers['rax'][0]
for&nbsp;s&nbsp;in&nbsp;irsb.statements:
if&nbsp;isinstance(s, pyvex.stmt.Put)&nbsp;and&nbsp;s.offset == rax_off:
if&nbsp;isinstance(s.data, pyvex.expr.RdTmp):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tmp = s.data.tmp
for&nbsp;s2&nbsp;in&nbsp;irsb.statements:
if&nbsp;isinstance(s2, pyvex.stmt.WrTmp)&nbsp;and&nbsp;s2.tmp == tmp:
if&nbsp;isinstance(s2.data, pyvex.expr.Binop)&nbsp;and&nbsp;'Add'&nbsp;in&nbsp;s2.data.op:
for&nbsp;arg&nbsp;in&nbsp;s2.data.args:
if&nbsp;isinstance(arg, pyvex.expr.Const):
return&nbsp;arg.con.value
return&nbsp;None

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

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

def&nbsp;_symex_getter_offset(proj, getter_addr):
&nbsp; &nbsp; irsb = proj.factory.block(getter_addr).vex
if&nbsp;irsb.jumpkind !=&nbsp;'Ijk_Ret':
return&nbsp;None&nbsp;&nbsp;# 只对 ret 函数尝试
&nbsp; &nbsp; rdi = claripy.BVS("rdi",&nbsp;64)
&nbsp; &nbsp; state = proj.factory.call_state(getter_addr, rdi)
&nbsp; &nbsp; simgr = proj.factory.simgr(state)
&nbsp; &nbsp; simgr.run()
if&nbsp;not&nbsp;simgr.deadended:
return&nbsp;None
&nbsp; &nbsp; rax = simgr.deadended[0].regs.rax
return&nbsp;simgr.deadended[0].solver.eval(rax - rdi)

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

步骤二:定位所有被混淆的数据访问

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

[前驱块]&nbsp;call getter &nbsp;→ &nbsp;[使用块]&nbsp;mov REG,&nbsp;[REG]&nbsp; → &nbsp;数据访问&nbsp;[REG+offset]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ↑ deref(自引用加载)

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

检测分两步:

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

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

import&nbsp;capstone.x86&nbsp;as&nbsp;cx

def&nbsp;_find_self_deref_insn(block):
for&nbsp;insn&nbsp;in&nbsp;block.capstone.insns:
if&nbsp;insn.mnemonic !=&nbsp;'mov'&nbsp;or&nbsp;len(insn.operands) !=&nbsp;2:
continue
&nbsp; &nbsp; &nbsp; &nbsp; dst, src = insn.operands
if&nbsp;(dst.type&nbsp;== cx.X86_OP_REG&nbsp;and&nbsp;src.type&nbsp;== cx.X86_OP_MEM
and&nbsp;src.mem.base == dst.reg
and&nbsp;src.mem.index ==&nbsp;0&nbsp;and&nbsp;src.mem.disp ==&nbsp;0):
return&nbsp;insn
return&nbsp;None

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

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

步骤三:Chain-walk 求解

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

Chain-walk:分层求解

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

  • Prologue

    (一次性):执行函数prologue block来初始化 transit node 内存

  • Per-getter

    (按需):当 VEX pattern match 失败时,对单个 getter 函数做 symex 求解 offset

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

prologue symex (一次) &nbsp;→ &nbsp;chain-walk (纯内存读取)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ↑ getter&nbsp;offset: VEX&nbsp;match&nbsp;‖&nbsp;per-getter symex

具体步骤:

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

  • Self-deref 边界

    (主要):如果当前块自身包含 self-deref,说明它是上一条链的 use site,同时也是本条链的起点(AliasAccess 的块结构是交错的)。

  • 非 getter 前驱

    (fallback):如果当前块的前驱不是 getter call(比如 prologue),则它就是链的第一跳。

for&nbsp;depth&nbsp;in&nbsp;range(MAX_DEPTH):
&nbsp; &nbsp; pred = _find_getter_pred(proj, current, func_blocks, _resolve_getter_offset)
if&nbsp;pred&nbsp;is&nbsp;None:
break
&nbsp; &nbsp; chain.append(pred['offset'])
&nbsp; &nbsp; current = pred['block_addr']

# 链边界检测:当前块有 self-deref → 是上条链的 endpoint,也是本链的起点
if&nbsp;_has_self_deref(proj.factory.block(current)):
&nbsp; &nbsp; &nbsp; &nbsp; chain_entry = current
break

# 兜底:前驱不是 getter call → 本块就是第一跳
if&nbsp;_find_getter_pred(proj, current, func_blocks, _resolve_getter_offset)&nbsp;is&nbsp;None:
&nbsp; &nbsp; &nbsp; &nbsp; chain_entry = current
break

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

if&nbsp;chain_entry&nbsp;==&nbsp;func.addr:
# 链从 prologue 开始:prologue 状态已经持有正确的 rdi
initial_ptr&nbsp;=&nbsp;base_state.regs.rdi.concrete_value
else:
# 非 prologue 入口:单块 symex
entry_state&nbsp;=&nbsp;base_state.copy()
entry_state.regs.rip&nbsp;=&nbsp;claripy.BVV(chain_entry,&nbsp;64)
succ&nbsp;=&nbsp;entry_state.step()
initial_ptr&nbsp;=&nbsp;succ.flat_successors[0].regs.rdi.concrete_value

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

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

ptr = initial_ptr + chain[0]
for&nbsp;getter_offset&nbsp;in&nbsp;chain[1:]:
&nbsp; &nbsp; mem = base_state.memory.load(ptr,&nbsp;8, endness=proj.arch.memory_endness)
&nbsp; &nbsp; ptr = mem.concrete_value + getter_offset

# 最终 deref:读取 slot 指针得到 raw struct 地址
mem = base_state.memory.load(ptr,&nbsp;8, endness=proj.arch.memory_endness)
return&nbsp;mem.concrete_value - RBP_CONCRETE

步骤四:Patch

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

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

lea_bytes, _ = ks.asm(f"lea rax, [rbp + ({rbp_rel})]", addr=call_insn.address)
patch =&nbsp;bytes(lea_bytes) +&nbsp;b'\x90'&nbsp;* (call_insn.size -&nbsp;len(lea_bytes))

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

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

修改前: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;修改后:
call&nbsp;getter &nbsp; &nbsp;&nbsp;(5B)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; lea rax, [rbp+K] &nbsp;(4B)&nbsp;+&nbsp;nop&nbsp;(1B)
mov edi, [rbp-0x22c] &nbsp;← 保持不变 &nbsp; &nbsp;mov edi, [rbp-0x22c] &nbsp;← 保持不变
mov rax, [rax] &nbsp;(3B)&nbsp; ← deref &nbsp; &nbsp; &nbsp;&nbsp;nop&nbsp;nop&nbsp;nop&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;← NOP'd
mov [rax], edi &nbsp; &nbsp; &nbsp; &nbsp;← store &nbsp; &nbsp; &nbsp; mov [rax], edi &nbsp; &nbsp; &nbsp; &nbsp;← 现在 rax 直接指向目标

步骤五:清理中间跳转的 Dead Code

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

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

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

效果

IDA 反编译效果对比:

// 混淆后
v9 = (_QWORD *)sub_1BC0(v27, argv, v5);
sub_1BD0(*v9);
// ... 大量 getter 调用和间接指针操作 ...

// Patch 后
func_1();
transparent_crc(g_4,&nbsp;"g_4", v6);
for&nbsp;( i =&nbsp;0; i <&nbsp;3; ++i ) {
transparent_crc(g_7[i],&nbsp;"g_7[i]", v6);
if&nbsp;( v6 )&nbsp;printf("index = [%d]\n", (unsignedint)i);
}

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

已知Bug

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

在 O1+ 下,被混淆的 alloca 作为 PHI 操作数时会导致崩溃

Phase 6 做错了什么

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

IRB.SetInsertPoint(&I); &nbsp;&nbsp;// 在指令 I 前插入
// … emit getter calls and GEP …
U.set(VP); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 把操作数替换成 GEP 结果

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 的语义。

为什么 O1+ 会触发而 O0 不会

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

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

int32_t&nbsp;*p = cond ? &local_var : &global_var;
*p =&nbsp;42;

提升之后会变成:

merge_block:
&nbsp; %p = phi ptr [ %alloca_local_var, %then ], [ @global_var, %else ]
&nbsp; store i32&nbsp;42, ptr %p

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

产生的具体非法 IR(sample_021,func_38,block102

Pass 执行前(概念上):

102: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ; preds = %100, %32
&nbsp; %orig = phi ptr [ %alloca_l_1219, %32 ], [ @g_1219, %100 ]
&nbsp; ; … uses of %orig …

Pass 改写%alloca_l_1219之后:

102: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ; preds = %100, %32
&nbsp; ; ← non-PHI 指令被 SetInsertPoint(&phi) 插入到此处
&nbsp; %103 = call ptr @__obfu_aliasaccess_getter.146(ptr %12)
&nbsp; %104 = load ptr, ptr %103, align&nbsp;8
&nbsp; %105 = call ptr @__obfu_aliasaccess_getter(ptr %104)
&nbsp; %106 = load ptr, ptr %105, align&nbsp;8
&nbsp; %107 = getelementptr %1, ptr %106, i32&nbsp;0, i32&nbsp;2
&nbsp; ; ← PHI node 现在出现在 non-PHI 指令之后 → 非法 IR
&nbsp; %108 = phi ptr [ %107, %32 ], [ @g_1219, %100 ]
&nbsp; ; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^^^^ 定义在当前块&nbsp;102&nbsp;内,而非前驱块 %32 → 同样非法

两个同时发生的违规:

  1. Non-PHI 指令出现在了 block102中 PHI node 的前面。
  2. PHI 操作数%107定义在102本身,而不是在其声称的前驱块%32中。

为什么崩溃发生在 SelectionDAG 而不是更早被捕获

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

修复方案

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

for&nbsp;(BasicBlock &BB : F) {
for&nbsp;(Instruction &I : BB) {
if&nbsp;(isa<AllocaInst>(I)) {
&nbsp; &nbsp; &nbsp; AllocaInst *AI = (AllocaInst *)&I;
if&nbsp;(AI->getAlign().value() <=&nbsp;8) {
bool&nbsp;hasPHIUse =&nbsp;false;
for&nbsp;(User *U : AI->users())
if&nbsp;(isa<PHINode>(U)) { hasPHIUse =&nbsp;true;&nbsp;break; }
if&nbsp;(!hasPHIUse)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; AIs.push_back(AI);
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
&nbsp; }
}

仅在 Phase 6 中跳过替换是不够的。如果 alloca 仍然被打包进了 raw struct(Phase 4),但其 PHI use 没有被替换,Phase 7 的AI->eraseFromParent()就会删除一个仍有活跃 use 的 alloca,从而导致另一个崩溃。

即使对 Phase 7 的删除加了保护,结果在语义上也是错误的:non-PHI use 已经被重定向到 raw struct 字段,而 PHI 仍然持有原始 alloca 的地址。通过 PHI 结果进行的任何 load/store 都会命中未初始化的 alloca,而不是 raw struct 字段。

通过在 Phase 1 中将该 alloca 排除出AIs,它永远不会获得 raw struct slot,其 use 永远不会被修改,也永远不会被删除。这些 alloca 不会被混淆,但正确性得以保证,pass 的其余部分继续正常工作。

总结

这是Polaris-Obfuscator自创的一套obfuscation pass,而且主要进行的是data-flow obfuscation,而不是传统的像Flat control flow那样在control flow上的obfuscation。分析过程还是挺有意思的。最后的反混淆代码带了不少vibe-coding,不过都进行了人工检查,避免了因AI偷懒而引入太多heuristics。

不过我发现大家好像还是更偏好分析OLLVM自带的几个原生pass,下次看看重新研究一下control flow flattening。在我几年前自己的研究中发现, O0/O1下的控制流平坦化是非常容易被去混淆的,但是在O2/O3下, 又被编译器暴力魔改结构一遍后,原本平坦化的结构就不是那么清晰了,因此下次就专门研究研究O2/O3下的情况。

代码仓库位于: https://github.com/Taardisaa/DePolaris

#

看雪ID:Taardisaa

https://bbs.kanxue.com/user-home-1070326.htm

*本文为看雪论坛优秀文章,由 Taardisaa 原创,转载请注明来自看雪社区

往期推荐

安卓逆向基础知识之frida Hook

2025 强网杯和强网拟态部分题解

在逆向分析方面-unidbg真的适合 MCP 吗?

AI静态分析,内核模块隐藏 Frida 特征,绕过linker私有结构遍历崩溃链

某安全so库深度解析

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 Taardisaa Taardisaa《Polaris-Obfuscator中AliasAccess简要分析-反混淆》

评论:0   参与:  0