首页
社区
课程
招聘
[原创]unicorn获取魔改ollvm平坦化控制流
发表于: 2025-5-27 17:22 1895

[原创]unicorn获取魔改ollvm平坦化控制流

2025-5-27 17:22
1895

现在企业 app 基本上都是 ollvm 平坦化,研究还原这玩意也是大势所趋。废话不多说,这篇文章主要是使用 unicorn 还原 ollvm 的执行流。

1、获取所有的真实块

2、获取真实块之间的运行流程

3、还原实际工作流程

下面是经典老图(来自于e7dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6k6h3y4#2M7X3W2@1P5g2)9J5k6i4c8W2L8X3y4W2L8Y4c8Q4x3X3g2U0L8$3#2Q4x3V1k6A6L8X3c8W2P5q4)9J5k6i4m8Z5M7q4)9J5c8X3u0D9L8$3N6Q4x3V1k6E0M7$3N6Q4x3V1j5I4x3e0t1`.

这里我们主要关注真实块部分,其余的分发器和处理器都是 ollvm 中间生成的,并不是程序原本的运作流程。上图中的 ollvm 是标准的 ollvm,可以明显的发现所有的真实块都会跳转到预处理器块。但是现在基本都会魔改 ollvm 把这个预处理器给去除,或者说可能有多个预处理器,而且真实块也不一定会跳转到预处理器,也有直接跳转到到主分发器的。

具体情况具体分析,下图是没有预处理器的 ollvm。

这里是所有的真实块都会跳转到主分发器,那么可以考虑将所有主分发器的前驱节点(序言块除外)当做真实块。

这里笔者使用 angr 获取真实块,也可以使用 IDApython 获取。载入文件到 angr,然后使用 angr 的analyses 模块获取 CFG 图,但是 angr 获取到的 CFG 图与 IDA 中看到的有点区别,可以使用3e9K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6U0M7e0j5%4y4o6x3#2x3o6f1J5z5g2)9J5c8X3c8W2k6X3I4S2N6l9`.`.里的 am_graph.py 转换。

接下来是识别所有块的类型

这里的返回块判断后使用前驱节点是考虑到出度为 0 的返回块除了 ret 外还有堆栈检查

如果没有堆栈检查的情况,返回块只有一个,这里返回块的前驱节点是子分发器,不能使用。

下面这个里面看起来是有三个预处理器,而且真实块也不一定会跳转到预处理器,也可能是跳转到主分发器。

这里通过观察其实可以发现以下特征:

主分发器的特征是入度很多,出度为 2(分到两个子分发器中)

子分发器的特征是入度为 1,出度为 2

预处理器的特征是入度大于 2,出度为 1(跳转到主分发器)

返回块的特征是没有出度

序言块的特征是没有入度,也不用分析,就在函数起始位置

真实块的特征是入度为 1,出度为 1

真实块里面也不一定是真正的程序流程使用到的指令,但是可以知道入度为 1,出度为 1 这个特征里面一定会包含真正的流程。根据这个特征,稍微修改一下上面的脚本即可。

自此拿到所有的真实块

这里使用 unicorn 模拟执行来拿到真实块之间的执行流。为什么不用 angr?(有可能是我太菜了不会用)因为笔者在使用 angr 进行还原时,会被 angr 的符号变量干扰。例如图中的第一个分支:

使用 angr 模拟执行时,由于没有写入函数参数,这些参数在 angr 中会以符号变量进行表示,在后面某处的分发器时 angr 会自行生成两个状态机,但是这样子会干扰到分析流程,因为不只是真实块中的分支,其他块中的分支也会生成多个状态机。使用 unicorn 时可以直接将 csel 修改成两个分支,其他的可以过滤掉。

笔者根据网上的资料,从最初的思路是遍历每一个真实块,通过模拟执行拿到真实块的后继块,如果真实块中含有 csel 指令则可以认为含有判断分支,即后继块会有两个。因为真实块的末尾会更新主分发器所使用的索引,这个索引必定可以找到下一个真实块。但是在具体分析中发现序言块中含有两个 csel 指令。

除此之外,笔者发现用于主分发器的索引存在于 x28 寄存器中,而在当前真实块里面没有对 x28 寄存器进行写入的操作。

综上来说,通过遍历每一个真实块来获取后继块的方法无法在这里使用。

在上文的分析中,可以知道想要还原控制流需要在每个真实块执行后保存当前状态,包括寄存器和内存。这种重新构建每一个真实块的控制流可以想象成有向图,deepseek 向笔者推荐了 BFS 算法(广度优先算法),相比于 DFS 算法(深度优先算法),DFS 算法可能会在执行过程中遇到循环导致无法跳出等问题。

在上文获取到真实块之后,还需要把返回块也放入到真实块列表中,然后为了下文所需要,还要拿到真实块中的地址和大小。

unicorn 不同于 angr,该模拟器只能模拟 cpu 指令,并且需要自行配置内存。

这里将文件读取后写入到模拟器内存中,然后再拓展出一个栈以供模拟器使用

unicorn 可以使用hook_add 函数添加回调函数对每一次运行指令进行 hook。这里可以使用capstone 模块对指令进行反编译。

当指令是 csel 时进行处理,并且如果 csel 指令在真实块中执行的,则解析 csel 指令后面的四个操作数,并保存出两个分支。

当指令是 bl、blr 调用函数时,将其跳过

当运行到真实块时,则记录下来,并更新状态

这里可以衍生出一个笔者遇到的问题,由于 unicorn 没有单步执行功能,如果在下一次恢复状态后运行的情况下同样会进入该判断然后死循环。这里添加了一个公共变量用于跳过第一条指令进入后续判断流程。

首先执行一次函数,无论是遇到分支还是后继块都是保存状态到队列中,然后循环执行队列中的状态,遇到后继块或分支则保存状态到新的队列中,最终执行到返回块,当队列中没有了新的状态则代表完成运行。针对陷入循环的情况,在更新状态时有一次剪枝判断,同时使用了两个队列,一个队列用于运行另一个队列用于保存新的状态,当一个队列执行完之后可以认定为一个轮次,当执行多个轮次后,如果控制流的记录列表没有再更新的情况下可以认为是进入了循环,直接结束。

结果如下

这一部分还没有完成,从网上有的资料来看,基本上都是将 csel 直接修改成

b.{cond} addr

b addr

但是在该样本中,序言块会有两个 csel,并将结果存储下来到了后面的真实块再使用,在后续真实块时如果再改变指令为跳转的话无法与前面的 cmp 对应上。

import am_graph
import angr
import logging
 
file_path = "文件地址"
start_addr = 0x19D870 #需要分析函数的起始地址
 
project = angr.Project(file_path, load_options={'auto_load_libs': False})
cfg = project.analyses.CFGFast(normalize=True, force_complete_scan=False)
base_addr = project.loader.main_object.mapped_base >> 12 << 12
target_function = cfg.functions.get(start_addr)
if target_function is None:
    target_function = cfg.kb.functions.get_by_addr(base_addr + start_addr)
 
# 转为ida对应的块,angr的块不同
super_gragh = am_graph.to_supergraph(target_function.transition_graph)
import am_graph
import angr
import logging
 
file_path = "文件地址"
start_addr = 0x19D870 #需要分析函数的起始地址
 
project = angr.Project(file_path, load_options={'auto_load_libs': False})
cfg = project.analyses.CFGFast(normalize=True, force_complete_scan=False)
base_addr = project.loader.main_object.mapped_base >> 12 << 12
target_function = cfg.functions.get(start_addr)
if target_function is None:
    target_function = cfg.kb.functions.get_by_addr(base_addr + start_addr)
 
# 转为ida对应的块,angr的块不同
super_gragh = am_graph.to_supergraph(target_function.transition_graph)
# 真实块
real_node = []
real_node_addr = []
ret_node = []
 
for node in super_gragh.nodes():
    # 入度为0的是序言块
    if super_gragh.in_degree(node) == 0:
        prologue_node = node
 
    # 出度为0的是返回块
    if super_gragh.out_degree(node) == 0:
        ret_node.append(node)
         
# 序言块后面的是主分发器
main_dispatcher_node = list(super_gragh.successors(prologue_node))[0]
 
# 遍历主分发器的前驱节点
for node in super_gragh.predecessors(main_dispatcher_node):
    if node.addr != prologue_node.addr:
        real_node.append(node)
        real_node_addr.append(node.addr - base_addr)
         
# 检查返回块数量
if len(ret_node) > 1:
    ret_node = list(super_gragh.predecessors(ret_node[0]))[0]
else:
    ret_node = ret_node[0]
     
print("序言块:", prologue_node.addr - base_addr)
print("返回块:", ret_node.addr - base_addr)
print("真实块:", real_node_addr)
print("主分发器:", main_dispatcher_node.addr - base_addr)
# 真实块
real_node = []
real_node_addr = []
ret_node = []
 
for node in super_gragh.nodes():
    # 入度为0的是序言块
    if super_gragh.in_degree(node) == 0:
        prologue_node = node
 
    # 出度为0的是返回块
    if super_gragh.out_degree(node) == 0:
        ret_node.append(node)
         
# 序言块后面的是主分发器
main_dispatcher_node = list(super_gragh.successors(prologue_node))[0]
 
# 遍历主分发器的前驱节点
for node in super_gragh.predecessors(main_dispatcher_node):
    if node.addr != prologue_node.addr:
        real_node.append(node)
        real_node_addr.append(node.addr - base_addr)
         
# 检查返回块数量
if len(ret_node) > 1:
    ret_node = list(super_gragh.predecessors(ret_node[0]))[0]
else:
    ret_node = ret_node[0]
     
print("序言块:", prologue_node.addr - base_addr)
print("返回块:", ret_node.addr - base_addr)
print("真实块:", real_node_addr)
print("主分发器:", main_dispatcher_node.addr - base_addr)
real_node = []
real_node_addr = []
preprocessor_node = []
preprocessor_node_addr = []
ret_node = []
for node in super_gragh.nodes():
    # 入度为0的是序言块
    if super_gragh.in_degree(node) == 0:
        prologue_node = node
 
    # 出度为0的是返回块
    if super_gragh.out_degree(node) == 0:
        ret_node.append(node)
         
    # 出度为1,入度为1的是真实块
    if super_gragh.in_degree(node) == 1 and super_gragh.out_degree(node) == 1:
        real_node.append(node)
        real_node_addr.append(node.addr - base_addr)
         
    # 入度大于1,出度等于1的是预处理器
    if super_gragh.in_degree(node) > 1 and super_gragh.out_degree(node) == 1:
        preprocessor_node.append(node)
        preprocessor_node_addr.append(node.addr - base_addr)
         
# 序言块的后继节点是主分发器
main_dispatcher_node = list(super_gragh.successors(prologue_node))[0]
 
# 从预处理器列表中去除主分发器
if main_dispatcher_node in preprocessor_node:
    preprocessor_node.remove(main_dispatcher_node)
    preprocessor_node_addr.remove(main_dispatcher_node.addr - base_addr)
     
# 判断是否有堆栈检查
if len(ret_node) > 1:
    ret_node = list(super_gragh.predecessors(ret_node[0]))[0]
else:
    ret_node = ret_node[0]
real_node = []
real_node_addr = []
preprocessor_node = []
preprocessor_node_addr = []
ret_node = []
for node in super_gragh.nodes():
    # 入度为0的是序言块
    if super_gragh.in_degree(node) == 0:
        prologue_node = node
 
    # 出度为0的是返回块
    if super_gragh.out_degree(node) == 0:
        ret_node.append(node)
         
    # 出度为1,入度为1的是真实块
    if super_gragh.in_degree(node) == 1 and super_gragh.out_degree(node) == 1:
        real_node.append(node)
        real_node_addr.append(node.addr - base_addr)
         
    # 入度大于1,出度等于1的是预处理器
    if super_gragh.in_degree(node) > 1 and super_gragh.out_degree(node) == 1:
        preprocessor_node.append(node)
        preprocessor_node_addr.append(node.addr - base_addr)
         
# 序言块的后继节点是主分发器
main_dispatcher_node = list(super_gragh.successors(prologue_node))[0]
 
# 从预处理器列表中去除主分发器
if main_dispatcher_node in preprocessor_node:
    preprocessor_node.remove(main_dispatcher_node)
    preprocessor_node_addr.remove(main_dispatcher_node.addr - base_addr)
     
# 判断是否有堆栈检查
if len(ret_node) > 1:
    ret_node = list(super_gragh.predecessors(ret_node[0]))[0]
else:
    ret_node = ret_node[0]
real_node_addr.append(prologue_node.addr - base_addr)
real_node_addr.append(ret_node.addr - base_addr)
real_node.append(prologue_node)
real_node.append(ret_node)
print("所有块:", real_node_addr) #输出地址
real_node_new = []
for node in real_node:
    real_node_new.append([node.addr - base_addr, node.size])
print("所有块:", real_node_new)#输出地址和大小
real_node_addr.append(prologue_node.addr - base_addr)
real_node_addr.append(ret_node.addr - base_addr)
real_node.append(prologue_node)
real_node.append(ret_node)
print("所有块:", real_node_addr) #输出地址
real_node_new = []
for node in real_node:
    real_node_new.append([node.addr - base_addr, node.size])
print("所有块:", real_node_new)#输出地址和大小
BASE = 0x400000
SIZE = 0x100000000
PATH = "文件路径"
STACK_BASE = 0
STACK_SIZE = 0x1000
 
def read(name):
    with open(name, 'rb') as f:
        return f.read()   
 
def main():
    mu = Uc(UC_ARCH_ARM64, UC_MODE_LITTLE_ENDIAN)
    mu.mem_map(BASE, SIZE)
    mu.mem_write(BASE, read(PATH))
    mu.mem_map(STACK_BASE, STACK_SIZE)
    mu.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE - 4)
BASE = 0x400000
SIZE = 0x100000000
PATH = "文件路径"

[培训]科锐逆向工程师培训第53期2025年7月8日开班!

上传的附件:
收藏
免费 4
支持
分享
最新回复 (11)
雪    币: 6058
活跃值: (5573)
能力值: ( LV9,RANK:143 )
在线值:
发帖
回帖
粉丝
2
DFS和BFS都不会死循环的,看处理手法了,比如可以记录分析过的分支,这样就可以跳出循环了;另外unicorn有个上下文保存和恢复的功能的;另外unicorn是可以单步执行的,uc结束运行有三个附加的限制,地址、时间、指令数
2025-5-27 18:35
0
雪    币: 2213
活跃值: (330)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
3
wx_御史神风 DFS和BFS都不会死循环的,看处理手法了,比如可以记录分析过的分支,这样就可以跳出循环了;另外unicorn有个上下文保存和恢复的功能的;另外unicorn是可以单步执行的,uc结束运行有三个附加的 ...
DFS我写出来的路径回溯奇奇怪怪的,处理循环也不知道怎么构思(代码菜狗)。上下文保存选项这个一开始有使用到,后面为了调试方便所以自己实现了一个。至于单步执行我是没找到相关资料。谢谢大佬
2025-5-27 19:10
0
雪    币: 6058
活跃值: (5573)
能力值: ( LV9,RANK:143 )
在线值:
发帖
回帖
粉丝
4
孤恒 DFS我写出来的路径回溯奇奇怪怪的,处理循环也不知道怎么构思(代码菜狗)。上下文保存选项这个一开始有使用到,后面为了调试方便所以自己实现了一个。至于单步执行我是没找到相关资料[em_055]。谢谢大佬
emu_start后面还有两个参数,一个是时间限制,一个是指令数限制,默认都是0就没限制
2025-5-27 19:23
0
雪    币: 729
活跃值: (5843)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
5
函数中带有bl怎么处理 的 其中 x0返回值可能会被当成状态 决定下一个分支 对应for 
2025-5-27 19:36
0
雪    币: 2213
活跃值: (330)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
6
method 函数中带有bl怎么处理 的 其中 x0返回值可能会被当成状态 决定下一个分支 对应for
bl就直接跳过,不执行内容。x0作为返回值如果用于判断情况也会走cmp,csel流程,除非是x0返回值是ollvm的索引值
2025-5-27 19:59
0
雪    币: 2213
活跃值: (330)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
7
wx_御史神风 emu_start后面还有两个参数,一个是时间限制,一个是指令数限制,默认都是0就没限制
这样子调用的话,我下一次调用会有原来的上下文吗
2025-5-27 20:01
0
雪    币: 1449
活跃值: (2716)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
大佬可以加个好友么一起交流?
2025-5-27 23:55
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
9
请问可以提供样本学习吗
2025-5-28 08:28
0
雪    币: 2213
活跃值: (330)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
10
mb_ldbucrik 请问可以提供样本学习吗
这里放样本出来感觉有点风险
2025-5-28 09:54
0
雪    币: 2213
活跃值: (330)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
11
hacker521 大佬可以加个好友么一起交流?
wx:gujiwuyuan369   
2025-5-28 10:05
0
雪    币: 220
活跃值: (51)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12

最后于 2025-6-7 13:24 被s626编辑 ,原因:
2025-6-7 13:23
0
游客
登录 | 注册 方可回帖
返回