简介:这套资料是基于源代码图融合的智能合约漏洞检测完整实现,采用Python语言开发,面向区块链安全方向的毕业设计、课程设计及科研入门者,解决漏洞特征提取与模型训练全流程问题。压缩包共48个文件,以35个Python源码为主体,另含环境配置yml、依赖清单txt、操作手册docx及项目配置xml等辅助文件,整体约14.84MB,目录划分清楚。已有111人浏览学习,适合作为高分毕设参考。代码经调试可运行,覆盖reentry、arithmetic、timestamp等攻击标签构造、控制流/数据流信息融合、图模型训练与预测等模块,并附带详细文档,便于理解原理、复现结果或在基础上扩展新功能。
1. 基于源代码做智能合约漏洞检测,为什么要先谈“图融合”
把智能合约漏洞检测做成一个可交付的课题,最容易踩的坑不是模型选得不够新,而是输入表示没选对。Solidity 源码不是一行行字符串,它天然带有嵌套的抽象语法树、跨函数跳转的控制流和变量间的数据依赖。序列化模型只看到 token 顺序,纯规则工具又难以覆盖跨函数的隐蔽漏洞。图融合的思路是把 AST、控制流图、数据依赖和调用关系装进同一张带类型的异构图,让后续模型同时感知代码结构、跨过程调用和状态变量走向。这也是“源代码级智能合约漏洞检测”区别于字节码分析的根因。本文按图构造、特征抽取、GNN 选型到误报定位的顺序展开,适合要复现一条完整检测管线的开发者,也适合要写区块链智能合约安全方向论文的人,整个过程不依赖闭源数据,用 Solidity 源码就能跑通。
2. 从 Solidity 源码到融合图:AST、CFG、数据依赖的抽取与合并
2.1 为什么选源代码而不是字节码作为检测入口
字节码分析对部署在链上的真实合约有效,但对开发阶段和毕设场景存在两个硬伤:一是反编译工具对高版本 Solidity 的支持不稳定,二是字节码丢失源码中的变量名、注释和结构性类型信息,导致漏洞定位无法回写到具体行号。基于源代码的做法先拿到solc编译产物的 AST JSON,再从中恢复控制流和数据流,整个过程可解释、可验证。
常见的解析路径有两种。第一种是直接调用 solc 编译器接口,获得 AST 后再自行遍历;第二种是用 Slither 这类静态分析框架,它已经帮你把 AST 转换成了函数级 CFG 和 SlithIR 中间表示。对工程落地来说,Slither 更省力,因为它的数据模型里已经包含了函数调用关系、状态变量读写、外部调用节点,这些都是构建融合图的关键材料。
2.2 四类基础图的节点与边定义
在构造融合图之前,先把基本信息源拆成表。每张子图都在回答一个不同的问题,融合不是简单叠加,而是让漏洞模式在跨结构路径上可被模型捕捉。
| 子图 | 节点含义 | 边语义 | 典型漏洞线索 |
|---|---|---|---|
| AST | 表达式、语句、声明、函数定义 | 父节点到子节点的语法嵌套 | 危险函数调用是否落在某个条件分支内部 |
| CFG | 基本块或语句节点 | 顺序执行、if/else 跳转、循环回边 | 未检查返回值、死代码、循环边界失控 |
| 数据依赖图 DFG | 变量定义和引用点 | 定义覆盖到引用 | 整数溢出传播、未初始化存储变量 |
| 调用图 CG | 函数 | 调用者到被调用者 | 重入、从任意合约地址调用外部函数 |
以重入漏洞为例,单独看 AST 能看到call.value()这个调用节点,但判断它是否可被重入,需要看到该调用是否发生在对状态变量的写入之后,并且调用点是否处在可被递归进入的函数路径上。这一信息只有在 CFG 和 DFG 融合后才能体现出来。
2.3 图融合的三种设计方式
第一种是拼接图。把 AST、CFG、DFG 的节点集合取并集,边直接拷贝到同一个MultiDiGraph,不同来源的边用类型字段区分。这种实现最简单,但会造成一个语句节点同时在 AST、CFG 里出现两份,需要做节点去重和映射。
第二种是异构图。这是工程上最常用、也是 GNN 最容易处理的方式。做法是保留一个统一节点集合,节点是 Solidity 源码中的表达式、语句、函数入口和出口,边的类型分别记为AST_EDGE、CFG_EDGE、DFG_EDGE、CALL_EDGE。模型端不需要把所有边混成同构图,而是按边类型分组做消息传递。
第三种是全展开 CPG。把多张子图合并成代码属性图,本质上和异构图等价,区别在于边界处理更规范,例如会引入REACHING_DEF、CONDITION等实验性的边。对绝大多数漏洞检测课题,第二种方案性价比最高,GNN 库对异构图支持也更成熟。
需要特别提醒,编译器优化里的“算子融合”和这里完全是两回事。前者是计算图中的算子合并,后者是多种代码分析视图的数据结构融合。写文档时不要混用概念。
import networkx as nx def build_fused_graph_from_ir(ir_items, cfg_edges, dfg_edges, call_edges): G = nx.MultiDiGraph() for item in ir_items: # node_id 使用源码位置 + 节点类型,保证唯一 G.add_node( item["id"], label=item["label"], kind=item["kind"], # expression / statement / function start_line=item["start_line"], end_line=item["end_line"], ) for src, dst, etype in cfg_edges: G.add_edge(src, dst, etype=etype, weight=1.0) # 控制流 for src, dst, var_name in dfg_edges: G.add_edge(src, dst, etype="DFG_EDGE", var=var_name) for src, dst in call_edges: G.add_edge(src, dst, etype="CALL_EDGE") return G上面代码的核心设计有两点。第一,节点 ID 必须能反查源码位置,否则后续做漏洞行号定位会失效;第二,边类型不只是给 NetworkX 看的,之后喂进 GNN 时要按照etype拆分消息传递矩阵。MultiDiGraph允许多条不同语义的边指向同一对节点,这正好对应 AST 父子关系、CFG 后继关系和 DFG 依赖可能同时存在于两个节点之间的情况。
2.4 融合边带来的信息增益
节点去重后产生的融合边是整个流程里信息量最大的部分。举例来说,一个require语句的 AST 节点和一个if分支的 CFG 节点原本属于不同图。融合后,模型可以直接学到“条件检查节点 → 分支入口 → 状态写入”这三跳路径上的模式。跨函数漏洞的路径长度通常在 5 到 10 跳之间,这个范围正是 GNN 层数设计的依据,一般 2 到 4 层即可覆盖。
3. 最小可复现管线:用 Slither 解析合约,用 NetworkX 输出融合图
3.1 准备 Solidity 编译环境和静态分析框架
这里假设已经安装 Python 3.9 以上版本。先用solc-select管理编译版本,再用 Slither 做静态分析。命令行操作如下:
pip install slither-analyzer solc-select networkx pyyaml solc-select install 0.8.19 solc-select use 0.8.19solc-select use会切换当前 shell 的编译器版本。Slither 在解析合约时需要调用和源码pragma匹配的 solc 版本,否则会报 ParserError。如果手头是混合版本项目,优先细分目录逐个解析,不要一键遍历全部import文件。
3.2 提取函数级中间表示并构造异构图
下面的代码基于 Slither 的对象模型,读取合约中的函数、节点、表达式和状态变量读写记录,生成统一图结构。API 名称和字段在不同 0.9.x 版本下可能略有差异,但整体流程稳定。
from slither import Slither import networkx as nx def parse_sol_into_graph(sol_path): slither = Slither(sol_path) G = nx.MultiDiGraph() for contract in slither.contracts: for function in contract.functions: func_id = f"{contract.name}.{function.name}" G.add_node(func_id, kind="FUNCTION", label=func_id, start_line=function.source_mapping.start_line) for node_ref in function.nodes: nid = f"{func_id}:{node_ref.node_id}" expr_text = "" if node_ref.expression: expr_text = str(node_ref.expression) G.add_node(nid, kind="EXPRESSION", label=expr_text, start_line=node_ref.source_mapping.start_line) # 继承关系:函数入口指向函数体语句 G.add_edge(func_id, nid, etype="CFG_EDGE") # 控制流边 for son in node_ref.sons: sid = f"{func_id}:{son.node_id}" G.add_node(sid, kind="EXPRESSION", label=str(son.expression) if son.expression else "") G.add_edge(nid, sid, etype="CFG_EDGE") # 调用关系 for call_expr in function.calls: G.add_edge(f"{contract.name}.{function.name}", f"{contract.name}.{call_expr.name}", etype="CALL_EDGE") return G代码里有四个关键点需要说明。function.nodes返回的是 CFG 节点集合,每个节点通常对应一个语句或表达式。node_ref.sons是 Slither 提供的 CFG 后继节点列表,它已经处理了普通分支和回边,不需要自己解析if/else结构。function.calls给出的是被调用函数列表,这里直接映射成调用边。节点 ID 统一为合约名.函数名:节点编号,这样在融合多份合约时不会碰撞。这个设计的代价是模型输入阶段需要再做一次 ID 到序列号的映射,但换来的是可回溯源文件行号。
3.3 节点特征和边特征的工程定义
GNN 不能直接吃字符串标签,要把节点映射成数值特征向量。推荐按“类别特征 + 敏感标记 + 深度嵌入”三部分拼接,维度不需要太大,常见做法控制在 128 到 384 之间。
| 特征分组 | 具体维度 | 来源 | 说明 |
|---|---|---|---|
| 语法类别 | 32 | 表达式类型 one-hot,如 Call、BinaryOperation | 区分算术表达式、赋值、函数调用 |
| 敏感算子标记 | 16 | 正则匹配和关键字表 | msg.value、tx.origin、.call()、transfer标记为独立位 |
| 控制流位置 | 8 | 入度、出度、是否在循环内、是否条件入口 | 帮助模型识别 unsafe loop 和条件包裹 |
| 函数角色 | 8 | 是否 fallback、是否 receive、是否 external | 重入漏洞常和 fallback 强相关 |
| 文本嵌入 | 64 | 基于训练数据集预训练的 token 向量均值 | 简单用 fastText 即可,无需上大模型 |
边特征方面,最简单有效的是在多类边上各设置一个weight维度,并结合两种边属性:etype的 one-hot 编码和边所在函数深度。深度计算可以在建图时完成,入口函数深度为 0,每次跨调用加 1。深层调用边上的漏洞模式往往对应跨合约攻击,特征越明显,模型越容易捕捉。
3.4 导出方案与正确性自检
图构造完成之后,先不要着急训练。用下面这几条命令检查融合图是否符合预期:
python -c " import networkx as nx G = nx.read_graphml('contract_fused.graphml') print('节点数:', G.number_of_nodes()) print('边数:', G.number_of_edges()) print('CFG边数:', sum(1 for _,_,d in G.edges(data=True) if d['etype']=='CFG_EDGE')) "将contract_fused.graphml替换成自己的导出路径。至少检查三件事:合约中明显的外部调用节点是否存在;msg.value相关表达式是否和赋值语句之间存在 CFG 边;跨函数调用路径上是否存在孤立函数节点。孤立节点往往说明 Slither 在解析library或interface时提前终止了,这时需要单独解析被 import 的文件并在建图阶段合并进同一个MultiDiGraph。
4. 异构图上的漏洞检测模型:GCN、GAT 和 HGT 的取舍与训练细节
4.1 为什么不把融合图压平成序列再用 LSTM
图融合后的代码数据如果重新压平成 token 序列,会丢掉两个关键信息:跨层跳转的长距离依赖和不同类型的依赖边。LSTM 擅长处理时间顺序,但处理 if/else 回边、跨函数跳转时需要把长距离跳转建模成特殊 token,这种做法既难训练又难解释。图神经网络直接在图结构上做消息传递,每个节点的表示经过若干层聚合后,能自然融合多跳邻居特征。这里的“邻居”由所有边类型共同定义,重入漏洞也就从“两个语句隔得远不远”变成了“图上是否有一条高权重的调用边路径”。
4.2 三类 GNN 模型的适配场景对比
针对智能合约的融合图,选择模型前先看三个指标:图的异构程度、节点总量、是否强依赖高阶交互。参考下表进行选型。
| 模型 | 异构支持 | 训练成本 | 适用场景 | 注意点 |
|---|---|---|---|---|
| GCN | 弱,需先对边类型求和 | 低 | 图规模大、节点特征强 | 容易过平滑,堆 4 层以上反而掉点 |
| GAT / GATv2 | 中,需按 etype 分组更新 | 中 | 漏洞触发条件和邻域权重相关 | 注意力头太多会过拟合小样本 |
| HGT | 强,原生处理异构图 | 高 | 边类型多、合约数量大 | 适合跨合约聚合,数据少时不推荐 |
对于课程设计或起步验证,推荐先用 GCN 打通整条 Pipeline,拿到一个可信的 baseline。之后若要在准确率上做提升,替换成 GATv2。HGT 通常在样本量超过 5 万张图时收益才明显,数据量不足时容易在验证集上震荡。
下面的代码给出一份基于 PyTorch Geometric 的典型实现骨架,重点在边类型拆分组和全局池化。
import torch import torch.nn.functional as F from torch_geometric.nn import GCNConv, global_add_pool, Linear class ContractGNN(torch.nn.Module): def __init__(self, in_dim, hidden_dim=128, num_layers=3): super().__init__() self.convs = torch.nn.ModuleList() for _ in range(num_layers): self.convs.append(GCNConv(hidden_dim, hidden_dim)) self.encoder = Linear(in_dim, hidden_dim) self.classifier = Linear(hidden_dim, 2) def forward(self, x, edge_index, batch): x = self.encoder(x) for conv in self.convs: x = torch.relu(conv(x, edge_index)) # 整张融合图池化为一个图级向量 graph_vec = global_add_pool(x, batch) return self.classifier(graph_vec)这个模型里真正影响检测结果的不是 GCN 层本身,而是edge_index的构造。如果只把所有类型边拼在一起,GCNConv会按同质边处理,相当于在融合时丢掉了边类型语义。改造方式有两种:一种是对四类边分别跑 GCN,最后把四份节点表示做拼接或加权求和;另一种是简化 CFG 和 AST 边,只保留 DFG 和调用边,把图规模降下来。多数情况下保留 CFG 和 DFG 两类边就已经能覆盖大部分溢出和重入场景,AST 边引入的噪声大于收益,在建图阶段可以做边裁剪。
4.3 样本类别不均衡与损失函数设置
漏洞样本在数据集中通常只占 5% 到 15%,直接使用交叉熵损失会让模型退化成全预测正常合约。常见做法是改用 Focal Loss 加上合约级别的风险加权。
import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=0.25, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, logits, labels): probs = torch.softmax(logits, dim=-1) eps = 1e-7 ce = -torch.log(probs[range(len(labels)), labels] + eps) p_t = probs[range(len(labels)), labels] weight = (1 - p_t) ** self.gamma if labels.sum() > 0: neg_weight = 1 - self.alpha else: neg_weight = 1 return (weight * ce * self.alpha).mean() if labels.sum() > 0 else (weight * ce * neg_weight).mean()参数alpha控制正负样本权重,当正样本极少时提高到 0.5 甚至 0.6。gamma控制对易分样本的抑制程度,2.0 是比较稳妥的起点。训练时不要只看准确率,要看正类的召回率和 F1,因为漏洞检测遗漏的成本远高于误报。如果验证集的 F1 始终无法超过 0.8,优先回查融合图是否存在跨合约孤岛,而不是继续堆模型层数。
4.4 训练/验证/测试切分与超参数速查
为避免同一份代码的不同版本污染数据集,切分不能按样本随机抽,而应该按智能合约项目路径切分。如果同一份 Solidity 源码被稍做修改生成多个版本,随机切分会造成训练集和测试集的相似合约同时出现,指标虚高。常见做法是把合约文件夹打乱后按 7:1.5:1.5 切分。训练超参可以这样起步:
| 超参数 | 推荐值 | 调整方向 |
|---|---|---|
| 学习率 | 3e-4 | 损失震荡时降到 1e-4 |
| batch size | 64 到 128 | 节点多的图调小到 32 |
| GNN 层数 | 3 | 超过 4 层过平滑,用残差连接缓解 |
| dropout | 0.3 | 小数据集加到 0.5 |
| 采样邻居数 | 40 | 跨合约图调大到 80 |
5. 结果可解释性:用融合图找攻击路径,而不是只给一个风险分数
模型输出一个[1, 0]或[0, 1]的分数矩阵只是第一步,真正能说服别人的是把风险分数还原成一条具体路径。融合图在这里提供了天然优势:每条边都有源头和类型,因此能在模型输出后回查高危子路径。
实践中建议给每个合约保留“Top-K 敏感路径”的导出功能。K 一般取 5。做法是先取出图里所有标记为CALL_EDGE和DFG_EDGE的边,再以msg.value、外部合约调用、delegatecall为起点,require、状态写入为终点,在融合图上求带权最短路径。边权重可以直接用模型倒数第二层学到的注意力得分,也可以简单用边类型权重表,比如CALL_EDGE=0.8,DFG_EDGE=0.6,CFG_EDGE=0.3。
# 在融合图上检索重入攻击路径 def find_sensitive_paths(G, sources, sinks, weight_map=None): weight_map = weight_map or { "CALL_EDGE": 0.8, "DFG_EDGE": 0.7, "CFG_EDGE": 0.4, "AST_EDGE": 0.1, } paths = [] for s in sources: for t in sinks: try: path = nx.dijkstra_path( G, s, t, weight=lambda u, v, d: weight_map.get(d.get("etype"), 1.0) ) paths.append((s, t, path)) except nx.NetworkXNoPath: continue return sorted(paths, key=lambda x: len(x[2]))[:5]dijkstra_path的前提是图里每条边都设置了权重。上面用 lambda 动态从etype字段取值,把不同语义的边映射到同一套可比较权重,从而在融合图上求解最短路。路径长度越短,代表从敏感源到危险汇点的可达性越强,漏洞可利用性风险越高。代码返回的path里每个节点都带有start_line属性,可以直接打印为具体源码行号。
如果发现某条高危路径被模型判为正常,多数时候是两个原因:一是该路径上的跨函数边在图构造时被漏掉,二是样本类别不均衡导致模型把该路径的中间节点特征学成了安全模式。排错方式是在验证集里把预测错误的样本单独提取出来,对异常路径上的节点做逐点特征对比,观察是语法类别缺失还是敏感算子标记位没生效。这套方法不需要额外引入大型解释器,也不需要可视化组件,只靠融合图本身的边语义就能完成闭环。
本文还有配套的精品资源,点击获取