99、自动化逆向(四):机器学习在逆向中的应用(函数识别、相似性分析)

说实话,做了这么多年逆向,最让我头疼的就是面对一个几万函数的二进制文件。一个个看?不现实。全靠经验猜?容易翻车。后来我开始琢磨:能不能让机器帮我干这活?

嗯,这就是今天要聊的——机器学习在逆向工程里的实战应用。说白了,就是让模型学会看二进制代码,帮我们做两件最核心的事:函数识别相似性分析

为什么需要机器学习?

传统逆向靠的是模式匹配。比如找函数边界,我们写一堆规则:看call指令、看ret指令、看栈帧调整。但问题是——编译器花样太多了。同一个函数,GCC编译出来一个样,MSVC编译出来另一个样,加上O3优化后,你根本认不出来。

我在项目中遇到过最典型的例子:一个ARM架构的固件,函数开头没有标准的push r7, lr,而是用了奇怪的指令序列。手工分析花了我两天,后来用模型跑一遍,十分钟全标出来了。

为什么会这样?因为机器学习不看死规则,它看的是统计特征。指令序列、控制流图结构、基本块大小分布——这些特征在不同编译选项下仍然保持一定的相似性。

核心观点: 规则匹配是「如果A则B」,机器学习是「根据大量历史数据,A有90%概率是B」。后者在复杂场景下更鲁棒。

函数识别:让模型帮你找边界

函数识别是逆向的第一步。你连函数从哪里开始、到哪里结束都不知道,后面分析个啥?

传统方法靠线性扫描或递归遍历。但遇到花指令、代码重叠、或者非标准调用约定,基本就跪了。我见过一个恶意软件,故意把函数入口伪装成数据段,IDA直接识别失败。

机器学习怎么做?我习惯用图神经网络(GNN)来处理这个问题。思路是这样的:

  1. 把二进制代码切分成基本块
  2. 构建控制流图(CFG)
  3. 提取每个基本块的特征(指令类型分布、常量值、字符串引用等)
  4. 用GNN学习CFG的结构特征
  5. 输出每个地址是函数入口的概率

下面是我之前项目里用过的简化流程:

# 伪代码:函数入口预测
def predict_function_entries(binary_path):
    # 1. 反汇编
    asm = disassemble(binary_path)
    
    # 2. 构建CFG
    cfg = build_cfg(asm)
    
    # 3. 提取节点特征
    features = []
    for block in cfg.nodes:
        feat = extract_block_features(block)
        # 包括:指令数、跳转类型、常量分布、API调用模式
        features.append(feat)
    
    # 4. 用训练好的GNN模型预测
    model = load_gnn_model('func_entry_model.pth')
    predictions = model.predict(cfg, features)
    
    # 5. 阈值过滤
    entries = [addr for addr, prob in predictions if prob > 0.85]
    return entries
我的经验: 阈值不要设太高。0.85是个不错的起点。我曾经为了追求精度设到0.95,结果漏掉了好几个被混淆过的函数入口。宁可多标几个假阳性,也别漏掉真函数。

相似性分析:找同款函数

函数识别搞定了,接下来就是相似性分析。这个场景太常见了:

  • 你分析一个漏洞,想知道其他固件版本里有没有同样的漏洞函数
  • 你怀疑某个恶意软件是已知家族的变种
  • 你想知道两个二进制文件里有没有共享的第三方库代码

传统做法是算哈希。但哈希太敏感了——改一个寄存器、换一个立即数,哈希就全变了。你想想看,这能行吗?

我建议用语义嵌入(Semantic Embedding)的方法。把函数映射到一个向量空间,相似的函数在空间里距离近,不相似的远。具体做法:

  1. 对每个函数提取控制流图
  2. 用图卷积网络(GCN)把CFG编码成固定长度的向量
  3. 计算向量之间的余弦相似度

下面是我画的一个流程图,展示了整个相似性分析的流水线:

函数相似性分析流水线 二进制文件 A 二进制文件 B 反汇编 + CFG 反汇编 + CFG 特征提取 特征提取 GCN 编码 GCN 编码 向量 vA 向量 vB cos(vA, vB) → 相似度

你看,整个流程其实不复杂。关键在特征提取这一步——到底要提取哪些特征?我总结了一个表格:

特征类别 具体特征 说明
指令级特征 指令类型分布、操作数模式、常量值统计 反映函数的计算逻辑
结构级特征 基本块数量、循环深度、调用图拓扑 反映函数的控制流结构
语义级特征 API调用序列、字符串引用、全局变量访问 反映函数的功能语义
统计级特征 指令熵值、基本块大小方差、跳转密度 反映函数的整体统计特性
注意: 特征不是越多越好。我踩过这个坑——一开始提取了200多个特征,模型训练慢得要死,效果反而下降了。后来用特征选择算法筛到40个左右,精度提升了5%。记住:特征质量 > 特征数量

实战:用预训练模型做跨架构函数匹配

讲个我实际做过的案例。当时需要对比一个x86的恶意软件和一个ARM版本的固件,看它们有没有共享的代码。架构都不一样,传统方法根本没法比。

我的做法是这样的:

  1. 中间表示(IR)统一表示——把x86和ARM都翻译成LLVM IR
  2. 在IR级别提取控制流图和语义特征
  3. 用预训练的函数嵌入模型(在大量开源库上训练过)生成向量
  4. 做最近邻搜索

结果呢?找到了一个加密函数,两个版本相似度0.92。后来人工确认,确实是同一个算法,只是编译器不同导致的差异。

# 跨架构函数匹配示例
from func_embed import FunctionEmbedder

embedder = FunctionEmbedder(model_path='func_embed_v2.pth')

# 加载两个不同架构的函数
func_x86 = load_function('malware_x86.bin', addr=0x401000, arch='x86')
func_arm = load_function('firmware_arm.bin', addr=0x8000, arch='arm')

# 生成嵌入向量
vec_x86 = embedder.embed(func_x86)
vec_arm = embedder.embed(func_arm)

# 计算相似度
similarity = cosine_similarity(vec_x86, vec_arm)
print(f'相似度: {similarity:.4f}')  # 输出: 0.9213
我的建议: 别自己从头训练模型。去GitHub上找开源的预训练模型,比如trex或者Asm2Vec。我在项目里直接微调了trex模型,只用了200个标注样本就达到了85%的准确率。省时省力。

避坑指南

最后分享几个我踩过的坑:

  • 别迷信准确率。 模型说99%准确,但那是实验室数据。真实场景下,混淆、加壳、自定义编译器都会让准确率掉到70%以下。我建议你始终保留人工验证环节。
  • 注意训练数据偏差。 如果你只在GCC编译的二进制上训练,那遇到MSVC编译的就废了。我曾经犯过这个错,后来强制要求训练集覆盖至少3种编译器、5种优化级别。
  • 计算资源要够。 GNN训练很吃GPU。我一开始用笔记本跑,一个模型训练了三天没出结果。后来换了云上的V100,两小时搞定。
  • 结果要可解释。 模型说「这两个函数相似」,你得能解释为什么相似。我习惯在输出相似度的同时,也输出匹配的关键特征,方便人工复核。

嗯,机器学习在逆向中的应用远不止这些。函数识别和相似性分析只是最基础的两个方向。后面还有更高级的——比如用强化学习做自动化漏洞挖掘、用生成模型做代码反混淆。那些我们后面再聊。

今天就到这里。记住:工具再强,也只是辅助。最终做决策的,还是你这个人。


公众号:蓝海资料掘金营,微信deep3321