用Python自动化攻击图生成:从漏洞数据到可审计攻击路径
简介基于Python的attack-graph-generator自动化攻击图生成器源码面向安全分析师与渗透测试人员用于自动发现潜在攻击路径、辅助漏洞评估与安全加固。项目以Python为核心并集成Shell脚本兼顾自动化部署与系统交互适合需要快速构建攻击图分析解决方案的团队或个人。代码包共101个文件约37.75MB涵盖JSON/YAML配置、DOT图形描述、Python源码与字节码、PDF报告、ZIP归档及说明文档等多种类型分别承担环境配置、图数据生成、逻辑执行、结果输出与工程管理等职责压缩包内还保留了Git属性与忽略规则等版本管理配置目录结构便于二次开发。通过学习源码可掌握多格式数据在攻击图构建中的流转方式理解攻击图如何直观呈现攻击者可能利用的漏洞链条并能基于现有脚本定制自己的攻击路径可视化流程。已有321人学习下载适合具备一定Python基础的安全工程师参考。1. 攻击图生成为什么必须自动化从手工画图到 Python 生成器一次红蓝演练十几台服务器、几百条漏洞情报按老办法在白板上手工画 attack graph画到凌晨三点复盘时发现又漏了一条关键路径。这是自动化攻击图生成器最典型的起点也是这套基于 Python 的 attack-graph-generator 实现思路存在的意义它把「攻击者从初始能力到目标主机的全部可行路径」从人工推断变成程序化输出。攻击图attack graph本质上是一张有向图节点代表攻击者可控制的主机或权限状态边代表一次原子攻击成功后的状态转移输入是主机暴露面、漏洞信息和初始能力输出是一张图加一组可审计的攻击路径。手工枚举在主机多、漏洞密时必然不完整攻击面越大路径数量越接近组合爆炸人脑根本兜不住。基于 Python 做生成器是因为生态里有 networkx 管图结构、PyVis 做交互可视化、numpy 导邻接矩阵整条链路都能在本地脚本里闭环。这套方案适合三类人渗透测试人员要快速定位优先复现的攻击链风险管理要量化路径数、最短路径、最大攻击深度做攻防研究的人要拿固定场景反复验证某个 CVE 组合能否打通。下面从数据建模开始把一套能落地的生成器源码逐层拆开讲清楚。2. 攻击图生成器的数据准备把漏洞列表改造成原子攻击与 JSON 输入2.1 建模第一件事把漏洞利用抽象成原子攻击很多从漏洞扫描器直接对接的人会犯一个错把 CVE 列表当成攻击图生成器的输入。漏洞列表只是素材攻击图真正需要的是原子攻击——一个具备前置条件、后果和承载对象的攻击动作。不用原子攻击建模生成器就没法回答三个问题谁能发起这次攻击攻击成功后攻击者获得了什么状态变化会不会让后续攻击变得可行常见做法是给每条原子攻击定义五个字段source攻击者已控制的主机、target被打的目标主机、required_access前置条件比如目标开放了可达的 80 端口、gained后果比如拿到远程命令执行、cve关联漏洞标识。这里关键点是 source 不一定等于漏洞宿主机旁边的机器任何攻击者已控制、且网络可达 target 的主机都可以作为 source。于是漏洞记录和原子攻击就成了两回事一条 CVE 影响多个目标主机时会展开成多条原子攻击同一个 CVE 在不同前置条件公网可达还是内网可达下也应该展开成多条。图结构选型在这里就要定严格意义上攻击图可以是 DAG但真实横向移动里带回连、权限互信的情况非常常见——被控主机 A 通过 B 上的漏洞拿到 BB 又通过 A 上的漏洞拿回 A。建图阶段如果限制成 DAG或提前把这种环过滤掉路径枚举会漏掉真实可达路径。所以原子攻击集合应该允许环存在路径枚举时再靠 visited 集合做点级去重而不是在建模阶段就把环删了。这个顺序颠倒后面所有结果都会失真。2.2 输入数据建模用一份 JSON 覆盖主机、漏洞与攻击者能力攻击图生成器我一般从一份 JSON 开始而不是直接连漏洞库。原因很实际演练场景里的漏洞数据经常来自不同格式有 CSV、有扫描器导出、有手工整理的表格统一成 JSON 后生成器只认这一份输入便于本地复现和追溯。直接连库听起来自动化程度高但数据源一换解析逻辑就要跟着改排查问题时又多一个黑匣子。{ hosts: [ {id: web-01, services: [tcp/80, tcp/22], tag: external}, {id: db-01, services: [tcp/3306, tcp/22], tag: internal} ], reachability: [ {from: attacker, to: web-01, ports: [tcp/80]}, {from: web-01, to: db-01, ports: [tcp/3306]} ], cves: [ { id: CVE-2021-XXXX, target: web-01, pre: {port: tcp/80, priv: remote}, post: {priv: rce}, cvss: 9.8 }, { id: CVE-2020-YYYY, target: db-01, pre: {port: tcp/3306, priv: rce}, post: {priv: db-admin}, cvss: 8.1 } ], attacker: {initial: [attacker], goals: [db-01]} }这份输入的结构含义先说清楚hosts 里的 services 是这台机器上运行并对外监听的服务不是漏洞特征reachability 是网络可达关系里面出现了虚拟节点 attacker表示攻击者的初始能力起点cves 里的 pre.port 是目标服务pre.priv 是攻击发起前需要的权限水平post.priv 是成功后的权限cvss 是静态评分后续做加权和排序用attacker.goals 是判定生成目的地的路径枚举时所有能到 goal 节点的路径都要保留。在设计输入时我的经验是「宁可多用两个字段也不要让生成器猜」。pre.priv 不写生成器要么把权限当无限制要么默认最低权限结果都和真实攻防有偏差。cvss 虽然只代表静态评分但至少给路径排序提供了第一层参考价值比纯图论里所有边等权要实用得多。2.3 数据校验脚本先拦住 ID 引用错误和字段缺失生成器最容易在数据阶段出错的是失效引用cves 里 target 写成了 web01hosts 里 id 是 web-01只差一个字符跑出来的图就会多一个「幽灵主机」。另一个常见状况是 reachability 的 from 字段写了下划线版本到建图阶段只剩报错没有提示。所以我会在跑生成器前先过一道校验脚本把这类事故挡在门外。import json import sys def validate_input(data_path: str) - None: with open(data_path, encodingutf-8) as f: data json.load(f) host_ids {h[id] for h in data.get(hosts, [])} errors: list[str] [] # 1. host id 必须唯一否则后续邻接矩阵顺序会乱 if len(host_ids) ! len(data.get(hosts, [])): errors.append(hosts 中存在重复 id) # 2. cve 引用的 target 必须真实存在 for c in data.get(cves, []): if c.get(target) not in host_ids: errors.append(fcve {c.get(id)} 引用了不存在的 target: {c.get(target)}) # 3. reachability 端点必须存在于 host_ids 或虚拟节点 attacker known_nodes host_ids | {attacker} for r in data.get(reachability, []): if r.get(from) not in known_nodes or r.get(to) not in known_nodes: errors.append(freachability 存在未知端点: {r.get(from)} - {r.get(to)}) # 4. cve 必填字段检查 required_cve_fields {id, target, cvss} for c in data.get(cves, []): missing required_cve_fields - set(c.keys()) if missing: errors.append(fcve {c.get(id, no-id)} 缺少字段: {missing}) if errors: print(\n.join(errors)) sys.exit(1) print(f输入数据校验通过: hosts{len(host_ids)} cves{len(data.get(cves, []))})这个脚本逻辑不复杂但四条检查把最常见的事故拦在生成器之前host id 必须唯一否则后续邻接矩阵的节点顺序表会出现键冲突cve 引用检查能拦截手滑写错 target 的场景reachability 端点检查拦截「网络可达关系里藏着不存在的节点」字段检查强制 cvss 无论后面用不用都要带上加权时不用回头补数据。参数方面唯一要根据自己数据集改的是 known_nodes 里的虚拟节点名。如果攻击者初始能力节点不叫 attacker务必同步改名否则校验脚本自己先误报。这道校验还能接进 CI红蓝演练的信息收集阶段资产方给的 CSV 转 JSON 时常有列名错位校验脚本把错位暴露在生成器之前数据干净了后面所有图算法才不是在垃圾上做运算。3. 用 Python 实现攻击图生成核心BFS 状态传播与邻接矩阵构建3.1 选型为什么用 networkx 的 MultiDiGraph 而不是自定义存储攻击图生成器的数据结构选型直接决定代码量。常见做法是用 networkx但不要用 Graph 或 DiGraph要选 MultiDiGraph。原因是同一个 source 到同一个 target 可能对应多个 CVEMultiDiGraph 允许平行边同方向多条边各自带 CVE 属性路径枚举时能区分这一跳是靠哪个漏洞打通的。DiGraph 遇到同向两条边时只会保留最后写入的一条攻击路径的漏洞证据链就断了。还有一个决定选型的点环。攻击图里 A 到 B 再到 A 的环很常见内网两台机器互相配了信任关系就会出现。DiGraph 存环没问题但路径枚举时如果不加 visited 集合BFS 会无限展开。这个问题应该放在枚举层解决而不是建图层放弃环——因为很多真实攻击路径会先绕圈再摸到目标提前删环会让结果变成书上的攻击图而不是能打仗的攻击图。节点属性最好在 add_node 时统一塞进去后面可视化按 tag 着色就不用回查 JSON。我还会把原始输入挂在图对象上self.graph.graph[meta] data[attacker]这样导出的图对象可以追溯边界条件。做审计的人拿到图之后第一句就会问「边界怎么定的」这个字段能让答案直接落在图数据里。3.2 核心源码attack_graph_builder.py 的构建流程生成器主流程可以概括成三个动作把 host 节点写入图从攻击者初始能力开始做能力传播传播过程中把可达且满足前置条件的原子攻击写成边。能力传播是攻击图自动化和静态 CVE 列表最大的区别攻击者的控制范围是逐步扩大的每利用一个漏洞就多一个可控节点可控节点一变可发起的攻击集合就变所以必须用循环做不动点传播而不是只遍历一遍漏洞列表。import networkx as nx class AttackGraphBuilder: def __init__(self, data: dict, max_depth: int 10): self.hosts data[hosts] self.cves data[cves] self.reachability data[reachability] self.initial data[attacker][initial] self.goals set(data[attacker].get(goals, [])) self.max_depth max_depth self.graph nx.MultiDiGraph() self.controlled set(self.initial) def build(self) - nx.MultiDiGraph: # 1) 先加主机节点统一用 id 做 node key for h in self.hosts: self.graph.add_node( h[id], tagh.get(tag, ), servicesh.get(services, []), ) # 2) 建可达性查询表: (source, target) - 端口集合 reachable_map {} for r in self.reachability: reachable_map.setdefault((r[from], r[to]), set()).update( r.get(ports, []) ) # 3) 能力传播直到没有新增可控主机为止 changed True while changed: changed False for cve in self.cves: target cve[target] if target in self.controlled: continue # 已可控的主机不需要再打一次 pre_port cve[pre].get(port) for src in list(self.controlled): if not self._can_reach(reachable_map, src, target, pre_port): continue if not self._precondition_ok(cve[pre], src): continue self._add_atomic_edge(src, target, cve) self.controlled.add(target) changed True return self.graph def _can_reach(self, reachable_map, src, target, port): # port 为 None 时视为任意可达关系都满足 if port is None: return (src, target) in reachable_map return port in reachable_map.get((src, target), set()) def _precondition_ok(self, pre, src): # 简化规则只要源节点可控remote 权限的攻击动作即可发起 priv pre.get(priv, remote) return priv remote and src in self.controlled def _add_atomic_edge(self, src, target, cve): key (src, target, cve[id]) if not self.graph.has_edge(src, target, keykey): self.graph.add_edge( src, target, keykey, cvecve[id], cvsscve[cvss], )这段代码的关键点在 while changed 循环外层循环结束后self.controlled 就是「攻击者在不重复利用漏洞情况下能拿下的全部主机」。循环里的if target in self.controlled: continue是一层去重防止同一主机被反复展开for src in list(self.controlled)这里用 list 拷贝很关键因为循环体里会往 controlled 里加元素直接迭代 set 会在运行时抛「set changed size during iteration」这是新手最常见的翻车点之一。_precondition_ok是高度简化过的权限判断。真实场景里这里会写成权限等级比较比如 guest user admin并且从 host 的 services 里读取当前会话级别。你可以按自己的数据模型替换这个函数但注意职责要单一它只回答「该原子攻击能否发起」不要在里面做路径枚举。max_depth 参数在这版 build 里还没被使用路径枚举阶段会传给它做 cutoff建图阶段只解决状态传播不做路径裁剪。3.3 邻接矩阵与攻击路径指标矩阵怎么建指标怎么算攻击图生成器的输出不能只是一张图还要能给出量化指标。常见做法是生成完图后导出邻接矩阵和路径统计。邻接矩阵也是后续做图特征、可视化布局、机器学习输入的基础特别是做图嵌入时节点顺序必须由外部显式固定。import networkx as nx import numpy as np def export_adjacency_matrix(g: nx.MultiDiGraph, host_ids: list[str]) - np.ndarray: # 用传入的 host_ids 固定行列顺序而不是用 g.nodes() 的无序集合 index {hid: i for i, hid in enumerate(host_ids)} n len(host_ids) matrix np.zeros((n, n), dtypenp.float32) for src, dst, key, attrs in g.edges(keysTrue, dataTrue): i, j index.get(src), index.get(dst) if i is None or j is None: continue # 多条平行边取最高 CVSS避免矩阵被 1 填满而失去权重 matrix[i][j] max(matrix[i][j], float(attrs.get(cvss, 1.0))) return matrix def attack_metrics(g: nx.MultiDiGraph, source: str, goal: str, max_depth: int 10): if not nx.has_path(g, source, goal): return {reachable: False, shortest_path: None} shortest nx.shortest_path(g, source, goal) # 深度受限路径枚举用生成器逐个消费避免一次性收成 list all_paths nx.all_simple_paths(g, source, goal, cutoffmax_depth) path_count 0 sample [] try: for p in all_paths: path_count 1 if len(sample) 5: sample.append(p) except nx.NetworkXError: pass return { reachable: True, shortest_path: shortest, path_count: path_count, sample: sample, }这段代码里有三个参数值得定死。第一矩阵用 host_ids 参数指定顺序而不是 g.nodes()原因是 networkx 的节点顺序在 3.x 里虽然稳定但跨版本不保证有序如果矩阵下游要对接 PyTorch Geometric 或做特征拼接顺序一变整个特征都错位。第二平行边合并用 max(cvss) 是最简单的权重合并规则如果你更关心被利用概率可以换成1 - (1 - p1) * (1 - p2)这种概率并集公式但注意 cvss 不是概率混用语义会变模糊报告里要写清楚用的是哪种。第三路径枚举用nx.all_simple_paths(g, source, goal, cutoffmax_depth)它返回生成器示例里用 for 循环逐个消费而不是 list() 一次性收全量主机数一多内存立刻见分晓。矩阵和指标准备好攻击图生成器的主流程就闭环了数据校验、建图传播、路径枚举。但这只是能跑的版本真正把它推到准生产环境的人都知道踩坑才刚开始。4. 攻击图生成器避坑与排查四个常见翻车点4.1 路径爆炸枚举到 30 台主机时时间和内存同时失控现象攻击图生成器在 10 台以内的小环境一切正常到了 30 台主机、上百条边的准生产环境路径枚举阶段要么几十分钟跑不完要么直接把 Python 进程打崩内存占用冲到 2GB 以上。原因攻击图里的路径数量是组合爆炸的nx.all_simple_paths在最坏情况下会输出指数级路径。尤其内网互通、每台主机都能横向到其他主机时路径枚举本身就是阶乘级增长。很多人以为路径有限所以没关系但有限不代表能在可接受时间内枚举完。解决按业务诉求分层处理。如果目标是「找一条最短可达路径」不要用 all_simple_paths直接用nx.shortest_path它走 BFS复杂度是线性的。如果确实要统计路径数量把生成器改成 streaming 模式每次只 yield 一条路径上层决定收多少条不要一次性聚合。同时用 cutoff 限制深度内网攻击链超过 6 跳就很少被实际利用报告里只提「存在更深路径」即可。另一个实用技巧枚举前用nx.weakly_connected_components把图切成连通分量每个分量单独枚举避免无关组件互相拖累路径规模。4.2 循环依赖与平行边让路径结果重复现象同一个「A 到 B」跳转在结果里被数了三次图上也出现多条同向边更诡异的是单条路径里同一个 CVE 出现两次审计的人一眼就质疑生成器有 bug。原因MultiDiGraph 允许平行边而nx.all_simple_paths会把平行边当成不同路径遍历。如果建图时没做 key 去重CVE 录入重复、或同一条边被多个 source 各自写入路径就会重复。循环依赖场景更隐蔽A 和 B 互配信任BFS 的 controlled 集合能拦住同一主机的重复攻击但路径枚举里如果没有节点级 visited 过滤A 到 B 到 A 这种绕圈路径就会混进结果。环本身在攻击图里合理但每次枚举都绕圈既不现实也没有参考价值。解决建图阶段强制key(src, target, cve_id)add_edge 前用 has_edge 检查路径枚举前做一次简化把同向同 CVE 的平行边合并。路径枚举统一用nx.all_simple_paths它在 networkx 内部已经做了节点去重不会走出 A 到 B 到 A如果自己写 DFSvisited 记录的一定是主机 id不是图边 id。环问题不建议删边而是在指标输出时额外标注「该路径存在环变体」保留图的完整性。4.3 邻接矩阵和可视化结果对不上节点顺序是个黑匣子现象导出邻接矩阵后拿去做特征分析发现某一行对应的主机和可视化图里颜色标注的主机不一致重新跑一次脚本矩阵数值变了但图没变生成器看起来像有随机性。原因直接调np.array(nx.to_numpy_array(g))的代码我见过太多次。to_numpy_array 的节点顺序在 networkx 3.x 里默认按节点插入顺序但一旦有人对图做了 remove_node、relabel_nodes 或从 JSON 重新加载节点顺序就会变。顺序问题不体现在图上只体现在矩阵行列里于是矩阵和可视化两个视图各说各话。解决矩阵导出永远显式传节点顺序也就是 3.3 代码里的 host_ids 参数。建议建图时生成一份id_map {hid: idx}并存进graph.graph[id_map]矩阵、可视化、路径枚举三处统一从这份 id_map 取顺序不做第二次排序。还有一条经验节点 id 别用纯字符串数字排序host-10会排到host-9前面字符串排序和数值排序混用是顺序错乱的隐藏来源要排序就先拆出数字再排或者直接用 id_map 里的整数索引。4.4 剪枝阈值设太高把真实攻击路径连根剪掉现象按 CVSS 把低于 7.0 的 CVE 全部过滤后攻击图变得非常干净但用原始数据人工复核时发现有一条真实可利用的横向移动路径中间环节恰好是一个 CVSS 6.5 的漏洞。生成器给出的结论是目标不可达安全报告因此漏报。原因静态评分不等于可利用性。一个 CVSS 6.5 的漏洞可能因为目标系统没有补丁、利用代码公开、正处内网边界等原因实际可利用性远高于某条需要复杂前置条件的 CVSS 9.8 漏洞。按单一阈值剪枝等于用一把粗筛子过滤图的可达性关键桥接边被丢掉后整个连通性就断了。这是攻击图生成里最危险的错误——不是生成不出图而是生成出一张看着合理但实际不完整的图。解决剪枝操作和路径枚举严格分离。枚举前不按 CVSS 硬过滤把 cvss 作为边权重参与排序剪枝放在枚举结果之后按路径权重和排序取 top_k 输出。这样低分 CVE 不会在图上消失只是在排序时靠后。如果必须做硬过滤把阈值语义从「CVSS 小于阈值丢弃」改成「CVSS 小于阈值且不存在替代路径时才丢弃」同时输出剪枝日志让审计人能查到每一条被删的边和删除理由。5. 攻击图生成器进阶路径排序、可视化与回归验证build 和枚举跑通之后离能交给团队用还差三件事路径排序、可视化、回归验证。路径排序是给渗透测试者看的目的是把价值最高的攻击链放前面。常见做法是给每条路径算权重和把边上的 cvss 换成(10 - cvss) / 10作为代价代价越小边越「好走」再用nx.shortest_path(weightcost)拿到加权最短路径要取多条候选路径就用 Yen 算法做 k-shortest path。不想引额外依赖的话简单做法是枚举后按路径平均 cvss 降序排只输出前 10 条日志里写清楚排序维度。可视化我一般用 PyVis 生成 HTML用来做交互检索 CVE 标签团队直接用浏览器打开就能点路径比截图效率高很多。关键参数是边标签用 CVE ID、节点颜色按 host 的 tag 区分布局用 hierarchical 配合directionDU。图特别大时只把枚举出的 top 路径子图单独导出一个 HTML而不是把全量图丢给浏览器。回归验证则不厌其烦地做固定一个 3 主机 2 漏洞的玩具环境把断言写进测试用例每次改生成器就跑一遍断言内容包括最短路径长度、路径总数、矩阵行列顺序。这一环不写后面每次优化都会心里没底。def test_two_hop_attack(tmp_path): data small_scene_json() # 固定场景attacker - web-01 - db-01 g AttackGraphBuilder(data).build() metric attack_metrics(g, attacker, db-01, max_depth4) assert metric[reachable] is True assert len(metric[shortest_path]) 3 mat export_adjacency_matrix(g, [h[id] for h in data[hosts]]) assert mat.shape (2, 2)最后说一个我自己的习惯attack graph 生成器本质是图论加安全领域知识调试时先看边建得对不对再去看路径找得对不对。绕开这个顺序几乎所有异常都会被误判成算法问题最后浪费一整天在路径枚举里找根本不存在的 bug。这些年踩坑总结下来最值得花的功夫就是数据校验和回归测试这两块省下的时间远比写生成器本身多。希望帮到你。本文还有配套的精品资源点击获取