一、目标
現在很多程式利用ollvm的控制流平坦化來增加逆向分析的難度。 控制流平坦化 (control flow flattening)的基本思想主要是通過一個主分發器來控制程式基本塊的執行流程,例如下圖是正常的執行流程
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI0gTMx81dsQWZ4lmZf1GLlpXazVmcvwFciV2dsQXYtJ3bm9CX9s2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xCMy81dvRWYoNHLwEzX5xCMx8FesU2cfdGLwMzX0xiRGZkRGZ0Xy9GbvNGLpZTY1EmMZVDUSFTU4VFRR9Fd4VGdsYTMfVmepNHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-AnYldnL0MjM3UWZ0Y2M3UGZlNTNyYzX5ETO1YTMxEzLcRDMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.webp)
1:show1
經過控制流平坦化後的執行流程就如下圖:
1:show2
這樣可以模糊基本塊之間的前後關系,增加程式分析的難度。
二、分析
這裡我們以 check_passwd_arm_flat 為例來嘗試恢複被ollvm混淆後的程式。先拖進ida,從流程圖上可以看到典型的 控制流平台化 之後的結果:
1:cfg
恢複的流程是 分塊→找出真實塊→确定真實塊之間的調用關系→Patch二進制程式
分塊
分塊我們使用 angr 來實作。
filename = "./check_passwd_arm_flat"
start_addr = 0x83B0
end_addr = 0x87D4
project = angr.Project(filename, load_options={'auto_load_libs': False})
print(hex(project.entry))
cfg = project.analyses.CFGFast(regions=[(start_addr,end_addr)],normalize='True',force_complete_scan=False)
target_function = cfg.functions.get(start_addr)
#将angr的cfg轉化為轉化為類似ida的cfg
supergraph = am_graph.to_supergraph(target_function.transition_graph)
找出真實塊、序言、retn塊和無用塊
- 函數的開始位址為序言塊的位址
- 無後繼的塊為retn塊
# get prologue_node and retn_node
prologue_node = None
for node in supergraph.nodes():
if supergraph.in_degree(node) == 0:
prologue_node = node
if supergraph.out_degree(node) == 0 and len(node.out_branches) == 0:
retn_node = node
print("序言塊={},retn塊={}".format(hex(prologue_node.addr),hex(retn_node.addr)))
在本例中,真實塊的特點如下:
- 序言的後繼為主分發器
- 後繼為主分發器的塊為預處理器
- 後繼為預處理器的塊為真實塊
- 剩下的為無用塊
Tip:
在實戰中,需要具體分析出 主分發器, 不一定序言塊之後的就一定是主分發器,也不一定存在預處理器。
def get_relevant_nop_nodes(supergraph, main_dispatcher_node, prologue_node, retn_node):
# relevant_nodes = list(supergraph.predecessors(pre_dispatcher_node))
relevant_nodes = []
nop_nodes = []
for node in supergraph.nodes():
# print(hex(node.addr))
# 和主分發器有聯系,并且size大于8的,認為是真實塊
if supergraph.has_edge(node, main_dispatcher_node) and node.size > 8:
# XXX: use node.size is faster than to create a block
relevant_nodes.append(node)
# print(hex(node.addr))
continue
if node.addr in (prologue_node.addr, retn_node.addr, main_dispatcher_node.addr):
continue
# 非真實塊的預設要幹掉
nop_nodes.append(node)
return relevant_nodes, nop_nodes
輸出結果:
******************* 真實塊 ************************
序言塊: 0x83b0
主分發器: 0x87d0
retn塊: 0x87c0
真實塊: ['0x875c', '0x86f4', '0x8794', '0x8658', '0x8628', '0x8714', '0x86d4', '0x87ac', '0x8770', '0x8694', '0x864c', '0x8734', '0x8788', '0x86b0', '0x867c']
确定真實塊之間的調用關系
網上确定真實塊之間的調用關系大多都是用 angr 的符号執行來實作,不過angr是個強大的工程,強練容易走火入魔,需要從基礎練起。 無名俠 提供了一個Unicorn模拟執行的思路來尋找兩個真實塊的關系。是以本文使用Unicorn來确定真實塊之前的調用關系。
我們隻想得到ollvm路徑,而不是真實代碼塊的運作結果,是以要盡可能屏蔽非ollvm的記憶體操作。具體屏蔽方法稍後介紹。下面這段代碼初始化Unicorn的虛拟CPU,并映射程式代碼記憶體以及棧空間,最後調用hook_add設定UC_HOOK_CODE和UC_HOOK_MEM_UNMAPPED的事件回調。UC_HOOK_CODE回調會在每條指令執行前被調用,UC_HOOK_MEM_UNMAPPED會在記憶體異常的時候調用。
# 初始化
load_base = 0
emu = Uc(UC_ARCH_ARM, UC_MODE_ARM | UC_MODE_LITTLE_ENDIAN)
# 映射代碼段 0x8000是 check_passwd_arm_flat 代碼段的基址
emu.mem_map(0x8000, 4 * 1024 * 1024)
emu.mem_write(0x8000,binByte)
STACK_ADDR = 0x7F000000
STACK_SIZE = 1024 * 1024
start_addr = None
emu.mem_map(STACK_ADDR, STACK_SIZE)
emu.hook_add(UC_HOOK_CODE, hook_code)
emu.hook_add(UC_HOOK_MEM_UNMAPPED,hook_mem_access)
真實塊之間的關系有兩種:1、順序 2、分支,針對本文的例子,真實塊裡面的分支指令有 moveq movne movlt movgt,是時候祭出這個表了:
條件字段表
條件字尾 | 标志寄存器 | 含義 |
EQ | Z == 1 | 等于 |
NE | Z == 0 | 不等于 |
CS/HS | C == 1 | 無符号大于或相同 |
CC/LO | C == 0 | 無符号小于 |
MI | N == 1 | 負數 |
PL | N == 0 | 整數或零 |
VS | V == 1 | 溢出 |
VC | V == 0 | 無溢出 |
HI | C == 1 and Z == 0 | 無符号大于 |
LS | C == 1 or Z == 0 | 無符号小于或相同 |
GE | N == V | 有符号大于或等于 |
LT | N != V | 有符号小于 |
GT | Z == 0 and N == V | 有符号大于 |
LE | Z == 1 or N != V | 有符号小于或等于 |
AL | 任何 | 始終。不可用于B{cond}中 |
Tip:
分支指令需要具體情況具體分析,沒有通用一勞永逸的解決。除非多費點功夫把所有的分支指令都處理一遍。我懷疑 angr 的符号執行就是把這個活給幹了。
# 分支處理
if ins.mnemonic != 'mov' and ins.mnemonic.startswith('mov'):
print(">>> branch 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
if branch_control == 1 :
vZ = (uc.reg_read(UC_ARM_REG_CPSR) & 0x40000000) >> 30
vN = (uc.reg_read(UC_ARM_REG_CPSR) & 0x80000000) >> 31
vV = (uc.reg_read(UC_ARM_REG_CPSR) & 0x10000000) >> 28
if ins.mnemonic == 'moveq' or ins.mnemonic == 'movne' :
if vZ == 0:
uc.reg_write(UC_ARM_REG_CPSR,uc.reg_read(UC_ARM_REG_CPSR) | 0x40000000)
print("Z 0->1 change cpsr = 0x%x" % uc.reg_read(UC_ARM_REG_CPSR))
else:
uc.reg_write(UC_ARM_REG_CPSR,uc.reg_read(UC_ARM_REG_CPSR) & 0xBFFFFFFF)
print("Z 1->0 change cpsr = 0x%x" % uc.reg_read(UC_ARM_REG_CPSR))
elif ins.mnemonic == 'movgt':
if vZ == 0 and vN == vV:
uc.reg_write(UC_ARM_REG_CPSR,uc.reg_read(UC_ARM_REG_CPSR) | 0x40000000)
print("GT 0->1 change cpsr = 0x%x" % uc.reg_read(UC_ARM_REG_CPSR))
else:
uc.reg_write(UC_ARM_REG_CPSR,uc.reg_read(UC_ARM_REG_CPSR) & 0x2FFFFFFF)
print("GT 1->0 change cpsr = 0x%x" % uc.reg_read(UC_ARM_REG_CPSR))
elif ins.mnemonic == 'movlt':
if vN != vV :
uc.reg_write(UC_ARM_REG_CPSR,uc.reg_read(UC_ARM_REG_CPSR) & 0x6FFFFFFF)
print("lt != -> = change cpsr = 0x%x" % uc.reg_read(UC_ARM_REG_CPSR))
else:
uc.reg_write(UC_ARM_REG_CPSR,uc.reg_read(UC_ARM_REG_CPSR) & 0xEFFFFFFF)
print("lt = -> != change cpsr = 0x%x" % uc.reg_read(UC_ARM_REG_CPSR))
else:
print(">>> None " + ins.mnemonic)
啟動虛拟機的函數叫find_path,用于尋找真實塊的下一個代碼塊。branch為分支控制。 如果branch = 1,則虛拟機在遇到movxx指令的時候會走movxx條件分支。
def find_path(uc,start_addr,branch = None):
global block_startaddr
global distAddr
global isSucess
global branch_control
try:
block_startaddr = start_addr
isSucess = False
distAddr = 0
branch_control = branch
uc.emu_start(start_addr,0x10000)
print("emu end..")
except UcError as e:
pc = uc.reg_read(UC_ARM_REG_PC)
if pc != 0:
print("find_path UcError: %s pc:%x" % (e,pc))
return None
else:
print("find_path ERROR: %s pc:%x" % (e,pc))
if isSucess:
return distAddr
return None
控制流內建 使用隊列的方式來路徑搜尋,起始搜尋從函數入口開始。函數入口根據offset變量指定。 queue中的元素是一個二進制組,第一項為執行位址,第二項為寄存器環境。每次搜尋開始的時候從queue中擷取一個将要搜尋的真實塊,設定寄存器,調用find_path搜尋下一個真實塊,将搜尋到的真實塊與新寄存器放入隊列(保證上下文完整)使用這樣做的好處就是可以搜尋任意隊列中的代碼塊,并且寄存器環境一定是和該代碼塊一緻的。
queue = [(push_entry,None)]
flow = defaultdict(list)
patch_instrs = {}
while len(queue) != 0:
env = queue.pop()
pc = env[0]
set_context(emu,env[1])
if pc in flow:
#print "???"
continue
flow[pc] = []
print('------------------- run %#x---------------------' % pc)
block = project.factory.block(pc)
has_branches = False
# 尋找有分支的代碼塊
for ins in block.capstone.insns:
if ins.insn.mnemonic != 'mov' and ins.insn.mnemonic.startswith('mov'):
if pc not in patch_instrs:
patch_instrs[pc] = ins
has_branches = True
if has_branches:
# 有分支的代碼塊跑兩次,一次正常,一次分支
ctx = get_context(emu)
p1 = find_path(emu,pc,0)
if p1 != None:
queue.append((p1,get_context(emu)))
flow[pc].append(p1)
set_context(emu,ctx)
p2 = find_path(emu,pc,1)
if p1 == p2:
p2 = None
if p2 != None:
queue.append((p2,get_context(emu)))
flow[pc].append(p2)
else:
p = find_path(emu,pc)
if p != None:
queue.append((p,get_context(emu)))
flow[pc].append(p)
print("Emulation arm code done")
路徑探索,需要禁用掉一切函數調用、非棧空間記憶體通路,當虛拟機指令有記憶體操作需求時,判斷目标記憶體位址範圍是否在棧中,如果不在棧中則跳過該指令, 在本例中有一些記憶體通路代碼段的固定值,這部分指令需要支援。 禁用的指令有bl、blx,隻要識别bl字首即可。
flag_pass = False
for b in ban_ins:
if ins.mnemonic.find(b) != -1:
flag_pass = True
break
if ins.op_str.find('[') != -1:
if ins.op_str.find('[sp') == -1:
flag_pass = True
for op in ins.operands:
# print(op.type)
if op.type == ARM_OP_MEM:
addr = 0
if op.value.mem.base != 0:
addr += uc.reg_read(reg_ctou(ins.reg_name(op.value.mem.base)))
elif op.value.index != 0:
addr += uc.reg_read(reg_ctou(ins.reg_name(op.value.mem.index)))
elif op.value.disp != 0:
addr += op.value.disp
# 記憶體操作在棧區域
if addr >= 0x7F000000 and addr < 0x7F000000 + 1024 * 1024 :
flag_pass = False
# 記憶體操作在代碼區域
if addr >= 0x8000 and addr < 0x9000:
flag_pass = False
if flag_pass:
print("will pass 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
uc.reg_write(UC_ARM_REG_PC, address + size)
return
最後列印出找到的真實塊之前的調用關系
************************flow******************************
0x83b0: ['0x8628']
0x8628: ['0x864c', '0x8658']
0x8658: ['0x867c']
0x867c: ['0x8628']
0x864c: ['0x8694']
0x8694: ['0x8794', '0x86b0']
0x86b0: ['0x8788', '0x86d4']
0x86d4: ['0x8788', '0x86f4']
0x86f4: ['0x8714', '0x8788']
0x8788: ['0x87ac']
0x87ac: ['0x87c0']
0x87c0: []
0x8714: ['0x8788', '0x8734']
0x8734: ['0x8770', '0x875c']
0x875c: ['0x87c0']
0x8770: ['0x8788']
0x8794: ['0x87ac']
Patch二進制程式
首先把無用塊都改成nop指令
for nop_node in nop_nodes:
fill_nop(origin_data, nop_node.addr-base_addr,nop_node.size, project.arch)
然後針對沒有産生分支的真實塊把最後一條指令改成jmp指令跳轉到下一真實塊
print("{} jmp {}".format(hex(parent),hex(childs[0])) )
# 把最後一條指令改成jmp指令跳轉到下一真實塊
parent_block = project.factory.block(parent)
last_instr = parent_block.capstone.insns[-1]
file_offset = last_instr.address - base_addr
patch_value = ins_b_jmp_hex_arm(last_instr.address, childs[0], 'b')
if project.arch.memory_endness == "Iend_BE":
patch_value = patch_value[::-1]
patch_instruction(origin_data, file_offset, patch_value)
針對産生分支的真實塊把MOVX指令改成相應的條件跳轉指令跳向符合條件的分支,例如moveq 改成beq ,再在這條之後添加b 指令跳向另一分支
instr = patch_instrs[parent]
# print("0x%x: %s \t%s\t%s" % (instr.insn.address,getByteStr(instr.insn.bytes), instr.insn.mnemonic, instr.insn.op_str))
file_offset = instr.insn.address - base_addr
parent_block = project.factory.block(parent)
fill_nop(origin_data, file_offset, parent_block.addr + parent_block.size - base_addr - file_offset, project.arch)
# patch the movx instruction to bx instruction
bx_cond = 'b' + instr.insn.mnemonic[len('mov'):]
patch_value = ins_b_jmp_hex_arm(instr.insn.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 the next instruction to b instrcution
patch_value = ins_b_jmp_hex_arm(instr.insn.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)
最後用ida檢視修複之後的cfg
1:cfgex
可以看到CFG跟原來的大緻一樣,然後反編譯恢複出原始代碼
bool __fastcall check_password(_BYTE *a1)
{
int v2; // [sp+18h] [bp-10h]
int i; // [sp+1Ch] [bp-Ch]
v2 = 0;
for ( i = 0; a1[i]; ++i )
v2 += (unsigned __int8)a1[i];
return i == 4
&& v2 == 0x1A1
&& (signed int)(unsigned __int8)a1[3] > 'c'
&& (signed int)(unsigned __int8)a1[3] >= 'e'
&& *a1 == 'b'
&& ((unsigned __int8)a1[3] ^ 0xD) == (unsigned __int8)a1[1];
}
三、總結
本文主要針對arm架構下Obfuscator-LLVM的控制流平坦化,重要的是采用unicorn模拟執行來确定真實塊的關系。
參考:
bbs.pediy.com/thread-2523… ARM64 OLLVM反混淆[無名俠]