ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于图融合的智能合约源码漏洞检测与GNN实践

基于图融合的智能合约源码漏洞检测与GNN实践 简介这套资料是基于源代码图融合的智能合约漏洞检测完整实现采用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], labelitem[label], kinditem[kind], # expression / statement / function start_lineitem[start_line], end_lineitem[end_line], ) for src, dst, etype in cfg_edges: G.add_edge(src, dst, etypeetype, weight1.0) # 控制流 for src, dst, var_name in dfg_edges: G.add_edge(src, dst, etypeDFG_EDGE, varvar_name) for src, dst in call_edges: G.add_edge(src, dst, etypeCALL_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, kindFUNCTION, labelfunc_id, start_linefunction.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, kindEXPRESSION, labelexpr_text, start_linenode_ref.source_mapping.start_line) # 继承关系函数入口指向函数体语句 G.add_edge(func_id, nid, etypeCFG_EDGE) # 控制流边 for son in node_ref.sons: sid f{func_id}:{son.node_id} G.add_node(sid, kindEXPRESSION, labelstr(son.expression) if son.expression else ) G.add_edge(nid, sid, etypeCFG_EDGE) # 调用关系 for call_expr in function.calls: G.add_edge(f{contract.name}.{function.name}, f{contract.name}.{call_expr.name}, etypeCALL_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(dataTrue) 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_dim128, num_layers3): 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, alpha0.25, gamma2.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-4batch size64 到 128节点多的图调小到 32GNN 层数3超过 4 层过平滑用残差连接缓解dropout0.3小数据集加到 0.5采样邻居数40跨合约图调大到 805. 结果可解释性用融合图找攻击路径而不是只给一个风险分数模型输出一个[1, 0]或[0, 1]的分数矩阵只是第一步真正能说服别人的是把风险分数还原成一条具体路径。融合图在这里提供了天然优势每条边都有源头和类型因此能在模型输出后回查高危子路径。实践中建议给每个合约保留“Top-K 敏感路径”的导出功能。K 一般取 5。做法是先取出图里所有标记为CALL_EDGE和DFG_EDGE的边再以msg.value、外部合约调用、delegatecall为起点require、状态写入为终点在融合图上求带权最短路径。边权重可以直接用模型倒数第二层学到的注意力得分也可以简单用边类型权重表比如CALL_EDGE0.8DFG_EDGE0.6CFG_EDGE0.3。# 在融合图上检索重入攻击路径 def find_sensitive_paths(G, sources, sinks, weight_mapNone): 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, weightlambda u, v, d: weight_map.get(d.get(etype), 1.0) ) paths.append((s, t, path)) except nx.NetworkXNoPath: continue return sorted(paths, keylambda x: len(x[2]))[:5]dijkstra_path的前提是图里每条边都设置了权重。上面用 lambda 动态从etype字段取值把不同语义的边映射到同一套可比较权重从而在融合图上求解最短路。路径长度越短代表从敏感源到危险汇点的可达性越强漏洞可利用性风险越高。代码返回的path里每个节点都带有start_line属性可以直接打印为具体源码行号。如果发现某条高危路径被模型判为正常多数时候是两个原因一是该路径上的跨函数边在图构造时被漏掉二是样本类别不均衡导致模型把该路径的中间节点特征学成了安全模式。排错方式是在验证集里把预测错误的样本单独提取出来对异常路径上的节点做逐点特征对比观察是语法类别缺失还是敏感算子标记位没生效。这套方法不需要额外引入大型解释器也不需要可视化组件只靠融合图本身的边语义就能完成闭环。本文还有配套的精品资源点击获取
返回列表