抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

去指令替换混淆

LLVM 提供了一些优化 Pass,可以用于简化和优化编译后的 IR(中间表示)代码,去除无意义的混淆指令。尤其是使用 llvm-dis 反汇编指令后,我们可以通过 opt 工具结合 -O3 优化级别来简化程序。此方法的核心思想是利用 LLVM 编译工具链中内置的优化技术,自动剖析和去除冗余的指令替换。

或者使用Miasm框架进行匹配,然后优化处理即可。Miasm 是一个专注于逆向工程的框架,它允许研究人员进行二进制分析、控制流恢复和混淆去除。Miasm 通过模拟二进制代码的执行,自动化地识别程序中的混淆模式,并通过去除冗余指令来简化程序。它尤其适用于静态分析与指令替换的混淆。

反字符串加密

字符串加密的的常规解决方式:

(1)特征搜索

思路:

  • 在很多使用字符串加密的二进制中,会存在一个解密函数,例如 datadiv_decode 或其他命名类似的函数。这些解密函数通常通过某种算法(如异或、加法等)将加密的字符串还原成明文。
  • 通过在二进制中搜索特定的解密函数,可以快速定位到解密的逻辑。

(2)init_array中解密

在某些情况下,程序可能会在 init_array 中进行字符串解密。init_array 是在程序启动时执行的代码区域,通常用于初始化操作。在 OLLVM 混淆中,解密可能发生在此区域中。

思路:

  • init_array 中的解密操作在程序启动时执行,因此通过模拟程序启动过程,可以将解密后的字符串提取出来。
  • 可以通过动态分析工具或模拟执行来查看该区域的解密过程,并获取解密后的字符串。

(3)jni_onload解密

jni_onload 是 JNI(Java Native Interface)中的一个特殊函数,通常会在 JNI 库加载时被调用。在 OLLVM 中,字符串解密操作有时会放在 jni_onload 中进行。

思路:

  • 在 JNI 库加载时,解密操作可能会在 jni_onload 函数中执行,通常是为了准备一些加密数据供 Java 层使用。
  • 可以通过 hook jni_onload 函数,或者使用 Unicorn 模拟执行,从中获取解密后的字符串。

反虚假控制流

虚假控制流去除的思路一般为除去不可达块和不透明谓词。但是难点在于不透明谓词,现在不透明谓词的研究不断发展,有永真/永假型不透明谓词,也有可真可假型不透明谓词。当然针对复杂的虚假控制流,在反混淆过程中还需要考虑死循环等问题

不透明谓词:

1
2
永真/假型:插入的后续基本块中必有一个不被执行
可真可假型:插入的两个后继基本块的语义应相同

针对简单的控制流混淆,去不透明谓词的思想主要是:

1
2
3
(1)不直接处理不透明谓词,通过让不透明谓词的变量地址可读,则IDA便可以优化
(2)直接将不透明谓词赋值为0或者将不透明谓词中变量x,y赋值为0
(3)编译器优化去干掉不透明谓词

不可达块:

不可达块是指控制流永远无法到达的基本块,一般我们可以使用符号执行或模拟执行来除去不可达基本块

idapython脚本;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import ida_xref
import ida_idaapi
from ida_bytes import get_bytes, patch_bytes
from ida_segment import get_segm_by_name

# 将 mov 寄存器, 不透明谓词 修改为 mov 寄存器, 0
def do_patch(ea):
"""
检查并替换不透明谓词的 mov 操作,将其替换为 mov 寄存器, 0
"""
# 获取指令字节
opcode = get_bytes(ea, 1)

# 判断是否为 mov 寄存器, [寄存器/内存] 指令 (例如 mov eax, edi)
if opcode == b"\x8B":
# 获取目标寄存器
reg = (ord(get_bytes(ea + 1, 1)) & 0b00111000) >> 3

# 将原始的 mov 指令替换为 mov 寄存器, 0 (即 mov eax, 0)
patch_bytes(ea, (0xB8 + reg).to_bytes(1, 'little') + b'\x00\x00\x00\x00\x90')
else:
print(f"Unsupported instruction at {hex(ea)}")

def get_bss_segment():
"""
获取 BSS 段的地址范围
"""
seg = get_segm_by_name('.bss')
if seg is None:
print("BSS segment not found.")
return None, None
return seg.start_ea, seg.end_ea

def patch_control_flow(start, end):
"""
对指定的地址范围内的虚假控制流进行修复
"""
for addr in range(start, end, 4):
# 获取所有对该地址的交叉引用
ref = ida_xref.get_first_dref_to(addr)
print(f"Processing references for address {hex(addr)}".center(40, '-'))

# 遍历所有交叉引用
while ref != ida_idaapi.BADADDR:
print(f"Patch reference at {hex(ref)}")
do_patch(ref)
ref = ida_xref.get_next_dref_to(addr, ref)

print('-' * 40)

def main():
# 获取 .bss 段的地址范围
start, end = get_bss_segment()
if start is None or end is None:
return # 如果找不到 .bss 段则退出

# 对虚假控制流进行修复
patch_control_flow(start, end)

if __name__ == "__main__":
main()

反控制流平坦化

其代码的真实逻辑在:序言块、真实块(相关块)、retn块中。

反控制流平坦化的核心在于准确区分真实块与分发器,并恢复真实块的顺序。通过特征匹配与动态执行相结合,可以高效完成大多数情况的去混淆工作。修补过程中优先使用简单的跳转逻辑,必要时对函数进行整体重构,以最大限度恢复代码的可读性和逻辑完整性。

一般通用的反控制流平坦化思路:

(1)先保存所有的基本块
(2)区分真实块和分发器(虚假块) 一般通过规则匹配来做,但是并无法使用所有情况 (难点)
(3)连接真实块的顺序 一般静态可以通过IDA trace然后编写IDApython脚本,动态可以通过符号执行、模拟执行
(4)编写patch修复 对目标函数进行修复、恢复原始逻辑

保存所有的基本块

控制流平坦化将代码逻辑碎片化,因此反混淆的首要任务是提取所有的基本块:

  • 基本原理: 控制流平坦化重构执行流为三类链:
    • 入口链(Prologue Chain): 原始函数的入口到主分发器的路径。
    • 循环链(Loop Chain): 主分发器之间的循环跳转路径。
    • 返回链(Return Chain): 主分发器到函数结束的路径。
  • 操作: 通过静态分析工具(如 IDA Pro)或动态分析工具(如调试器)提取目标函数的所有基本块,并初步分类。重点关注分发器(Dispatcher)的识别(详见第 2 步)。

区分真实块和分发器

分发器是控制流平坦化的核心逻辑节点,其作用是引导流程跳转到下一个基本块。区分真实块与分发器的关键如下:

  • 分发器的特征:
    1. 引用次数较高: 分发器是执行链的核心节点,其引用次数远高于其他基本块。
    2. 结构固定: 分发器通常包含跳转逻辑,如 switch-case 或复杂的 if-else
  • 真实块的特征:
    1. 内存操作: 包含内存访问指令(如 ldr, str)。
    2. 函数调用: 出现 blblx 指令。
    3. 确定性跳转: 出现明确的条件跳转指令(如 beq, bne)。
  • 方法:
    • 遍历函数的所有基本块,统计每个块的引用次数。
    • 通过特征匹配(如常用指令模式)识别真实块和分发器。

连接真实块的顺序

重建真实块的顺序是反控制流平坦化的核心

  • 静态方法:
    1. 使用 IDA Pro 的 Trace 功能获取执行路径。
    2. 编写 IDAPython 脚本解析每个基本块的连接关系。
    3. 判断分支条件,重建代码逻辑。
  • 动态方法:
    1. 使用符号执行工具(如 angr)模拟执行代码。
    2. 跟踪每条路径的执行结果。
    3. 遇到复杂分支时,结合人工分析调整路径。
  • 特殊情况处理:
    • 对于真实块包含双路径(movwne/movtne r1)的情况,需要分别处理两条路径并连接至对应的真实块。

编写patch

修补代码是反混淆的最后一步,以下是两种主要方法:

  • 方法一:直接 Patch

    1. 清理无用块: 将分发器或虚假块替换为 NOP 指令。
    2. 修补无分支块: 将真实块的最后一条指令改为无条件跳转(jmp)到下一个真实块。
    3. 修补分支块: 将条件跳转指令(如 cmovz)替换为明确的条件跳转(如 jz),并添加无条件跳转指令跳向另一分支。

    优点: 简单直接,适合快速恢复代码。 缺点: 适用性有限,对复杂分支结构支持较弱。

  • 方法二:重构函数逻辑

    1. 提取所有真实块的指令,并根据其关系重新排列。
    2. 计算每个真实块的相对偏移,生成新的函数代码。
    3. 替换混淆后的目标函数。

    优点: 适用性广,适合复杂函数。 缺点: 工作量大,依赖精确分析。

基于angr的脚本学习:

cq674350529/deflat: use angr to deobfuscation

deflt.py

get_relevant_nop_nodes 函数从一个超级控制流图(supergraph)中提取:

  1. 相关节点(relevant_nodes):
    • 与主要分发节点(pre_dispatcher_node)相连并且逻辑上有用的节点。
  2. NOP 节点(nop_nodes):
    • 与程序主逻辑无关,可能是填充代码或冗余的节点。
1
2
3
4
5
6
7
8
9
10
11
12
13
def get_relevant_nop_nodes(supergraph, pre_dispatcher_node, prologue_node, retn_node):
# relevant_nodes = list(supergraph.predecessors(pre_dispatcher_node))
relevant_nodes = []
nop_nodes = []
for node in supergraph.nodes():
if supergraph.has_edge(node, pre_dispatcher_node) and node.size > 8:
# XXX: use node.size is faster than to create a block
relevant_nodes.append(node)
continue
if node.addr in (prologue_node.addr, retn_node.addr, pre_dispatcher_node.addr):
continue
nop_nodes.append(node)
return relevant_nodes, nop_nodes

symbolic_execution 函数利用 angr 库进行符号执行(Symbolic Execution),分析程序的动态行为,目标是:

  1. 从给定起始地址(start_addr)开始,模拟执行程序的控制流。
  2. 判断当前路径是否到达指定的相关基本块地址列表(relevant_block_addrs)。
  3. 通过设置断点和hook,控制程序执行过程,并根据需求修改状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def symbolic_execution(project, relevant_block_addrs, start_addr, hook_addrs=None, modify_value=None, inspect=False):
#模拟返回(retn)过程:当程序执行到特定hook地址时,调用此函数移除hook,模拟程序返回逻辑。
def retn_procedure(state):
ip = state.solver.eval(state.regs.ip)
project.unhook(ip)
return
#在执行指定语句前检查当前语句的表达式:如果表达式是条件语句,则修改条件值为 modify_value。修改后移除断点(避免重复触发)。
def statement_inspect(state):
expressions = list(state.scratch.irsb.statements[state.inspect.statement].expressions)
if len(expressions) != 0 and isinstance(expressions[0], pyvex.expr.ITE):
state.scratch.temps[expressions[0].cond.tmp] = modify_value
state.inspect._breakpoints['statement'] = []
#如果提供了 hook_addrs,为这些地址设置hook:hook函数为 retn_procedure,模拟返回逻辑。根据架构调整钩子覆盖指令的长度。
if hook_addrs is not None:
skip_length = 4
if project.arch.name in ARCH_X86:
skip_length = 5

for hook_addr in hook_addrs:
project.hook(hook_addr, retn_procedure, length=skip_length)
#创建一个空白状态,从指定的 start_addr 开始执行。
state = project.factory.blank_state(addr=start_addr, remove_options={angr.sim_options.LAZY_SOLVES})
if inspect:#如果启用了 inspect,在语句执行前设置断点,执行 statement_inspect。
state.inspect.b('statement', when=angr.state_plugins.inspect.BP_BEFORE, action=statement_inspect)
#每次执行一个步骤。检查当前活跃状态的地址是否在 relevant_block_addrs 列表中:如果是,则返回该地址。否则继续执行下一步,直到没有活跃状态为止。
sm = project.factory.simulation_manager(state)
sm.step()
while len(sm.active) > 0:
for active_state in sm.active:
if active_state.addr in relevant_block_addrs:
return active_state.addr
sm.step()

return None

创建 angr 项目对象,加载二进制文件。

使用 CFGFast 构建快速控制流图(CFG),启用 normalize 以避免基本块重叠。

1
2
3
4
project = angr.Project(filename, load_options={'auto_load_libs': False})
cfg = project.analyses.CFGFast(normalize=True, force_complete_scan=False)
#对二进制文件的基地址(mapped base)向下对齐到 4KB(页面大小)的整数倍。
base_addr = project.loader.main_object.mapped_base >> 12 << 12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
 # 从控制流图中查找目标函数。
target_function = cfg.functions.get(start)
#如果未找到目标函数,则通过基地址调整后重新查找。
if target_function is None:
target_function = cfg.kb.functions.get_by_addr(base_addr + start)

# 将函数的转换图转换为“超级图”
supergraph = am_graph.to_supergraph(target_function.transition_graph)

# 入口节点提取:入度为零的节点被认为是函数的入口节点
prologue_node = None
for node in supergraph.nodes():
if supergraph.in_degree(node) == 0:
prologue_node = node
# Return 结点:出度为零且没有后续分支的节点被认为是返回节点。
if supergraph.out_degree(node) == 0 and len(node.out_branches) == 0:
retn_node = node
#校验入口节点,确保入口节点地址与用户指定的起始地址匹配。
if prologue_node is None or prologue_node.addr not in [start, base_addr + start]:
print("Something must be wrong...")
sys.exit(-1)
#主分发节点结点提取:入口节点的第一个后继节点通常是主分发节点。
main_dispatcher_node = list(supergraph.successors(prologue_node))[0]
#前序分发节点提取:主分发节点的前序节点中,地址与入口节点不同的节点是前序分发节点。
for node in supergraph.predecessors(main_dispatcher_node):
if node.addr != prologue_node.addr:
pre_dispatcher_node = node
break
#relevant_nodes: 与控制流分析相关的基本块集合。nop_nodes: 可能的无操作(NOP)指令集合,用于混淆分析或补丁检测。
relevant_nodes, nop_nodes = get_relevant_nop_nodes(supergraph, pre_dispatcher_node, prologue_node, retn_node)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
relevants = relevant_nodes
relevants.append(prologue_node)
relevants_without_retn = list(relevants)
relevants.append(retn_node)
relevant_block_addrs.extend([prologue_node.addr, retn_node.addr])

flow = defaultdict(list)
patch_instrs = {}
for relevant in relevants_without_retn:
print('-------------------dse %#x---------------------' % relevant.addr)
block = project.factory.block(relevant.addr, size=relevant.size)
has_branches = False
hook_addrs = set([])
for ins in block.capstone.insns:
...
elif project.arch.name in ARCH_ARM:
if ins.insn.mnemonic != 'mov' and ins.insn.mnemonic.startswith('mov'):
if relevant not in patch_instrs:
patch_instrs[relevant] = ins
has_branches = True
elif ins.insn.mnemonic in {'bl', 'blx'}:#记录函数调用地址
hook_addrs.add(ins.insn.address)
elif project.arch.name in ARCH_ARM64:
if ins.insn.mnemonic.startswith('cset'):#条件设置指令
if relevant not in patch_instrs:
patch_instrs[relevant] = ins
has_branches = True
elif ins.insn.mnemonic in {'bl', 'blr'}:#函数调用
hook_addrs.add(ins.insn.address)
#如果当前块包含条件分支,进行两次符号执行:
#条件为真(claripy.BVV(1, 1))。
#条件为假(claripy.BVV(0, 1))。
#对于每次执行,返回的后续地址(tmp_addr)记录在 flow 中
if has_branches:
tmp_addr = symbolic_execution(project, relevant_block_addrs,relevant.addr, hook_addrs, claripy.BVV(1, 1), True)
if tmp_addr is not None:
flow[relevant].append(tmp_addr)
tmp_addr = symbolic_execution(project, relevant_block_addrs,relevant.addr, hook_addrs, claripy.BVV(0, 1), True)
if tmp_addr is not None:
flow[relevant].append(tmp_addr)
#对无分支块,直接执行一次符号执行,记录后续地址。
else:
tmp_addr = symbolic_execution(project, relevant_block_addrs,relevant.addr, hook_addrs)
if tmp_addr is not None:
flow[relevant].append(tmp_addr)

patch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#填充无操作指令(NOP),移除不必要的代码块。
for nop_node in nop_nodes:
fill_nop(origin_data, project.loader.main_object.addr_to_offset(nop_node.addr),
nop_node.size, project.arch)

# remove unnecessary control flows
for parent, childs in flow.items():
#将代码块的最后一条指令替换为无条件跳转指令b
if len(childs) == 1:
parent_block = project.factory.block(parent.addr, size=parent.size)
#使用 capstone 提取最后一条指令。
last_instr = parent_block.capstone.insns[-1]
#将文件偏移量 定位到最后一条指令的位置。
file_offset = project.loader.main_object.addr_to_offset(last_instr.address)
# ...
elif project.arch.name in ARCH_ARM64:
#若代码块为起始块,跳过 4 字节
if parent.addr in [start, base_addr + start]:
file_offset += 4
patch_value = ins_b_jmp_hex_arm64(last_instr.address+4, childs[0], 'b')
else:
patch_value = ins_b_jmp_hex_arm64(last_instr.address, childs[0], 'b')
#若架构为大端模式(Iend_BE),对指令字节码进行字节序反转。
if project.arch.memory_endness == "Iend_BE":
patch_value = patch_value[::-1]
patch_instruction(origin_data, file_offset, patch_value)
#根据条件,生成多个跳转指令(如条件跳转 jx 和无条件跳转 jmp),重构分支逻辑
else:
instr = patch_instrs[parent]
file_offset = project.loader.main_object.addr_to_offset(instr.address)
# 移除从 cmovx 指令开始到块结束的原始指令内容
block_end_offset = project.loader.main_object.addr_to_offset(parent.addr + parent.size)
#使用 fill_nop 将目标指令范围内的内容替换为 NOP 指令
fill_nop(origin_data, file_offset, block_end_offset - file_offset, project.arch)
#...
elif project.arch.name in ARCH_ARM:
#从 movx 指令生成条件跳转指令 bx_cond
bx_cond = 'b' + instr.mnemonic[len('mov'):]
patch_value = ins_b_jmp_hex_arm(instr.address, childs[0], bx_cond)
#若为大端模式(Iend_BE),反转字节序。
if project.arch.memory_endness == 'Iend_BE':
patch_value = patch_value[::-1]
patch_instruction(origin_data, file_offset, patch_value)

file_offset += 4## 下一条指令位置
# 在条件跳转之后添加无条件跳转,以确保控制流安全跳转到目标地址。
patch_value = ins_b_jmp_hex_arm(instr.address+4, childs[1], 'b')
if project.arch.memory_endness == 'Iend_BE':
patch_value = patch_value[::-1]
patch_instruction(origin_data, file_offset, patch_value)
elif project.arch.name in ARCH_ARM64:
# 从 cset.xx 指令中提取条件操作符
bx_cond = instr.op_str.split(',')[-1].strip()
#创建条件跳转指令的机器码
patch_value = ins_b_jmp_hex_arm64(instr.address, childs[0], bx_cond)
if project.arch.memory_endness == 'Iend_BE':
patch_value = patch_value[::-1]
patch_instruction(origin_data, file_offset, patch_value)

file_offset += 4
# 添加无条件跳转确保代码流逻辑完整。
patch_value = ins_b_jmp_hex_arm64(instr.address+4, childs[1], 'b')
if project.arch.memory_endness == 'Iend_BE':
patch_value = patch_value[::-1]
patch_instruction(origin_data, file_offset, patch_value)

补充

ollvm混淆的通用解决流程:IDApython脚本跟踪

(1)我们编写相应的IDApython脚本,可以去记录真实寄存器值的变化并记录地址

(2)开启IDA动态调试附加程序,并导入IDApython脚本

(3)触发断点,并开启trace指令跟踪(针对不同的OLLVM混淆可以开启不同级别的trace)

(4)待脚本执行完毕,分析保存的相关文件

(5)分析参数寄存器的逻辑关系,并编写算法还原代码

ollvm混淆解决方案:

基于Unicorn的模拟执行 这里首先我收集大佬编写的三篇相关博客:

(1)unidbg去对抗字符串混淆:AndroidNativeEmu和unidbg对抗ollvm的字符串混淆 | king的博客

(2)unidbg去除ollvm虚假控制流:使用unidbg去ollvm虚假分支反混淆 | king的博客

(3)unidbg还原控制流平坦化:使用unidbg还原标准ollvm的fla控制流程平坦化 | king的博客

简单总结一下核心思路:

字符串混淆: 在.init_array中使用解密函数对字符串进行还原。也就是说。当我们执行完.init_array后。就会将正常的字符串写入内存中。这时我们就得到了真正的字符串了 (1)需要监控内存的读写,模拟运行.init_array,这样发生的内存写入时,基本可以确定是字符串还原函数在写入恢复的字符串

(2)我们需要把所有真实字符串以及写入真实字符串的位置给保存下来

(3)使用脚本将我们的真实字符串再写回so中,写入的so就能直接在ida中打开就看到真实字符串了,保存的address是有一个基址的。

虚假控制流:

虚假分支的混淆会在增加大量的if else分支。增加静态分析的复杂度。但是实际在动态执行的时候。很多if else实际都是没有执行的。所以去掉虚假分支其实就是删除掉那些没有执行到的代码块。

还原: 那么我们只要知道目标函数中,哪些汇编代码执行了,并且记录下执行汇编的address。然后把这些汇编以外的代码全部标记为nop。然后再用ida反汇编看到的结果。就直接是去掉虚假分支的结果了。 (当然现在一些IDA的插件也可以支持去除虚假控制流了,就是利用IDAPython脚本实现)

控制流平坦化:通过符号执行、中间语言分析等方式获取真实块之间的关系

(1)找出所有真实块以及对应的汇编地址,标准的ollvm虚假块中一般只有简单的修改v6的值,其他的基本都是真实块

(2)找出所有真实块的地址后。接着就是顺着逻辑将他们全部串联起来。

评论