GravityAgent(二):把 6Graph、6Forest、Entropy/IP、6Sense 封装成统一算法层

GravityAgent(二):把 6Graph、6Forest、Entropy/IP、6Sense 封装成统一算法层


IPv6 网络测绘 GravityAgent 算法
本文属于系列

把研究算法封装成统一接口

上一篇介绍了 GravityAgent 的整体架构。这一篇我们下探到第三层——算法适配层,看看那些”只能跑脚本”的研究代码,是怎么被改造成系统可以稳定调用的工程模块的。

GravityAgent 接入了四个上游算法:

算法论文作用
6GraphA graph-theoretic approach to address pattern mining for Internet-wide IPv6 scanning, Computer Networks 2022地址模式挖掘
6ForestAn Ensemble Learning-based Approach to Target Generation for Internet-wide IPv6 Scanning, INFOCOM 2022地址模式挖掘
Entropy/IPUncovering Structure in IPv6 Addresses, IMC 2016分段熵分析 + 规则挖掘
6SenseInternet-Wide IPv6 Scanning and its Security Applications, USENIX Security 2024LSTM 生成 + 在线扫描 + 去别名

统一输出格式

适配层的第一件事,是定义统一的输出结构。对 6Graph 和 6Forest,模式挖掘的结果统一为:

{
    "pattern_hex32": "2001da8********...",  # 32 个十六进制字符,* 表示自由位
    "pattern_colon": "2001:da8::...",        # 冒号分隔形式
    "seed_count": 42,                        # 该模式覆盖的种子数
    "free_nibbles": 5,                       # 自由半字节数量
    "sample_seeds": ["...", "...", "..."],   # 示例种子
}

排序规则也很明确:seed_count 降序 → free_nibbles 升序 → 字典序,最后按 max_patterns 截断。

对 Agent 来说,6Graph 不再是一个”只能跑脚本的算法仓库”,而是一个”可直接返回结构化结果的挖掘器”。

6Graph / 6Forest:模块名冲突的坑

这两个算法的适配层结构几乎一样,核心是三个步骤:

1. IPv6 地址转半字节矩阵

def _ipv6_to_nibbles(ip_str: str) -> list[int]:
    return [int(ch, 16) for ch in ipaddress.IPv6Address(ip_str).exploded.replace(":", "")]

一个 IPv6 地址展开后是 32 个十六进制字符,每个字符转成 0–15 的整数,构成一个 n×32 的 uint8 矩阵,直接喂给上游算法。

2. 动态加载上游模块

这里有个真实的坑:6Graph 和 6Forest 都使用同名模块。

  • 6Graph 用 SpacePartition 和 PatternMining
  • 6Forest 用 SpacePartition 和 OutlierDetection

如果两个算法在同一个 Python 进程里先后被调用,sys.modules 里缓存的 SpacePartition 会是先加载的那个,导致 6Forest 跑到 6Graph 的代码上去。适配层的解法是在加载前主动清理模块缓存:

def _load_modules(self):
    home_str = str(self.home)
    if home_str not in sys.path:
        sys.path.insert(0, home_str)
    # 6Graph / 6Forest 都使用同名模块,先清理缓存避免串模块。
    sys.modules.pop("SpacePartition", None)
    sys.modules.pop("PatternMining", None)
    sp = importlib.import_module("SpacePartition")
    pm = importlib.import_module("PatternMining")
    return sp, pm

这类问题在上游单算法仓库里根本不会出现,因为研究者默认自己的代码独占一个进程。

3. 屏蔽上游噪声输出

6Forest 的上游代码会往标准输出打印大量调试信息,直接污染系统日志。适配层用 redirect_stdout 把它静默掉:

from contextlib import redirect_stdout
from io import StringIO

mute_out = StringIO()
with redirect_stdout(mute_out):
    for region in sp.DHC(arr):
        p, _ = od.OutlierDetect(region)
        patterns.extend(p)

挖掘流程差异

6Graph 比 6Forest 多了一步离群点迭代:

# 6Graph:先划分区域,对每个区域做离群检测
for region in sp.DHC(arr):
    p, o = pm.OutlierDetect(region)
    patterns.extend(p)
    outliers.extend(o)

# 对离群点再迭代 iterations 轮(默认 3)
for _ in range(iterations):
    if len(outliers) < 2:
        break
    stacked = np.vstack(outliers)
    outliers = []
    for region in sp.DHC(stacked):
        p, o = pm.OutlierDetect(region)
        patterns.extend(p)
        outliers.extend(o)

其中 pm.threshold 会被动态设置为默认 12。

从模式矩阵反推可读模式

上游返回的是地址矩阵,适配层需要把每一列做统计,只有单一取值的列才是”固定半字节”:

tarr = p.T
pattern_chars = []
free_nibbles = 0
for idx in range(32):
    splits = np.bincount(tarr[idx], minlength=16)
    nonzero = np.where(splits > 0)[0]
    if len(nonzero) == 1:
        pattern_chars.append(format(int(nonzero[0]), "x"))
    else:
        pattern_chars.append("*")
        free_nibbles += 1
pattern_hex32 = "".join(pattern_chars)

Entropy/IP:脚本型算法的子进程桥接

Entropy/IP 的上游代码本质上是一套串行脚本流程,不适合直接作为库函数复用。它的三个步骤是:

  1. a1-segments.py:对地址做分段,计算每段熵值;
  2. a2-mining.py:挖掘每段的取值规则(固定值 / 区间);
  3. a3-encode.py:编码聚类。

适配层的处理方式是:

  1. 在 agent/vendor/entropy-ip-py3 内维护 Python3 可运行版本;
  2. 以子进程调用三个步骤;
  3. 将输出回读并解析成统一结果;
  4. 为每次运行写出 manifest.json。
run_dir = entropy_ip_run_dir("mine")
ips_path = run_dir / "ips_hex32.txt"
segments_path = run_dir / "segments.txt"
analysis_path = run_dir / "analysis.txt"
encoded_path = run_dir / "encoded.csv"

ips_path.write_text("\n".join(used) + "\n", encoding="utf-8")

self._run_script("a1-segments.py", [str(ips_path)], segments_path)
self._run_script("a2-mining.py", [str(ips_path), str(segments_path)], analysis_path)
self._run_script("a3-encode.py", [str(ips_path), str(analysis_path)], encoded_path)

解析上游的文本输出

上游输出的是人类可读的文本,适配层用正则把它结构化。比如分段行:

# segment	type	start	stop	avg

对应的解析正则是:

_SEGMENT_LINE_RE = re.compile(
    r"^# segment\t(?P<stype>[^\t]+)\t\s*(?P<start>\d+)\t\s*(?P<stop>\d+)\t(?P<avg>[0-9.]+)"
)

规则行则分两类:固定值(const)和区间(range):

_CONST_RULE_RE = re.compile(r"^\s{2}(?P<val>[0-9a-f]+)\s+(?P<pct>[0-9]+(?:\.[0-9]+)?)%$")
_RANGE_RULE_RE = re.compile(r"^\*\s+(?P<start>[0-9a-f]+)-(?P<stop>[0-9a-f]+)\s+(?P<pct>[0-9]+(?:\.[0-9]+)?)%$")

解析后的规则按覆盖率降序排列,返回 segments、rules、rules_all、encoded_top(编码聚类 Top10)以及各产物路径。

这种”脚本型算法工程化接入”的关键是:每次运行都有独立工作目录,且写 manifest,这样出问题能快速定位到某一次运行的输入输出。

6Sense:最重的一条流水线

6Sense 的接入最复杂,因为它不只是一个静态预测器,而是一整条研究流水线:

  • LSTM checkpoint
  • RouteViews pfx2as
  • scanv6 在线扫描器
  • aliasv6 去别名器
  • 训练与生成脚本

它更接近”论文实验环境”,而不是”可直接嵌入 Agent 的生产模块”。

按种子集哈希的 checkpoint 机制

这是整个适配层里最重要的一个改造。

问题:如果整个系统共用一个固定 checkpoint,那么不同组织、不同文件、不同种子分布都落到同一个模型上,存在明显的语义污染。

解法:按种子集内容做哈希,为每个种子集生成独立 checkpoint:

# 1. 对种子集做规范化:去重、地址展开、排序
# 2. 对规范化结果做 sha256
# 3. 取前 16 位哈希作为种子集标识
# 4. 生成 checkpoint 名
checkpoint_name = f"lstm_sid_allocation_base_100_seedset_{seed_hash}_"

这样做的好处:

  1. 同一批种子换个顺序不会重复训练(规范化后哈希一致);
  2. 不同种子集会得到独立模型;
  3. 训练与生成可以稳定复用对应 checkpoint。

训练桥接

agent/sixsense_train.py 把上游训练逻辑桥接为一个受控脚本。默认训练参数:

参数默认值
batch size3200
learning rate0.001
LSTM layers[512, 256]
dropout0.2
epochs50

训练脚本会创建本轮工作目录、准备 all_ips 数据集、为模型目录建立桥接、调用上游训练逻辑、输出最终 all_ips.hdf5,并清理同目录下其他 checkpoint 文件,只保留最后一版。

生成桥接

agent/sixsense_bridge.py 负责建立生成工作目录、写配置文件、调用上游 ComparisonModel_online、读取候选/去别名/别名结果,并统一输出为规范文件名:

candidates_6sense_<label>.txt
dealiased_6sense_<label>.txt
aliased_6sense_<label>.txt
scanner.log
manifest.json

自动探测出口网络

6Sense 的在线扫描器需要知道出口网卡、源 IPv6 和网关 MAC。适配层会从 /proc/net/ipv6_route 和 /proc/net/if_inet6 自动探测,并支持从 EUI-64 link-local 地址还原 MAC。

修复上游除零 bug

适配层还修了上游 6Sense 生成逻辑里的一个除零错误。问题表现是:

  1. 某轮生成结果 hits = 0;
  2. all_allocations_thus_far 也为空;
  3. 原始代码仍计算分式,导致 division by zero。

修复方式是:若当前分母为 0,则把 diff 直接置为 0.0,让”没有命中”被视为正常结果而不是异常崩溃。

运行期路径规范

适配层所有产物路径统一由 agent/runtime_paths.py 管理,把固定资产和运行期产物彻底分开:

runs/agent/exports/                     # 用户可复用的导出文件
runs/agent/jobs/                        # 后台任务和 os_identify 状态目录
runs/agent/tmp/                         # 提交到 scanner 前的临时目标文件
runs/agent/entropy_ip/mine/<ts>/        # Entropy/IP 中间产物
runs/6sense/train/<label>/<ts>/         # 6Sense 训练工作目录
runs/6sense/generation/<label>/<ts>/    # 6Sense 生成工作目录

固定资产(6Graph/、6Forest/、entropy-ip/、6SENSE/)和运行期产物彻底分离,排查时只需要先判断:

  • 看静态资源 → 进 6SENSE/、6Graph/ 等;
  • 看一次训练或生成的中间过程 → 进 runs/6sense/...;
  • 看 Agent 导出的结果 → 进 runs/agent/exports/...。

小结

这一篇的核心结论是:研究代码工程化的关键不是重写算法,而是补上”接口、隔离、可观测性”三件事。

问题工程化手段
输出格式不统一定义统一的 pattern dict / rules 结构
模块名冲突sys.modules.pop + 动态 import
输出污染日志redirect_stdout 静默
脚本型流程子进程桥接 + manifest
模型语义污染种子集哈希 checkpoint
上游 bug定位并修复除零
路径散乱统一 runtime_paths 规范

下一篇,我们进入最底层的扫描执行引擎,看看 Rust 写的 gravity-scanner 是如何在 2¹²⁸ 的地址空间里做无状态探测与任务调度的。