ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

事件图谱实战:从安全告警到攻击链路的关键技术解析

事件图谱实战:从安全告警到攻击链路的关键技术解析

先交代一下背景,MiroFish 是我在内部安全分析工作中持续迭代的一个项目代号,它做的事情可以简单概括为:把安全运营平台上零散的告警、日志和资产信息,按照实体和关系组织成一张图,再沿着这张图把孤立的点连成一条完整的事件链路。这里的“情报链路”需要先做个解释,我指的不是外部信息渠道,而是安全数据从观测、清洗、关联到最终还原攻击过程的完整信息链条。事件图谱就是这个链条的载体和呈现方式。

我当初做这件事,纯粹是因为告警太多了。每天涌入平台的有效告警有几十万条,但运营人员能深入查看分析的,一天下来往往不到十条。更要命的是,这些告警之间没有任何关系:一台服务器被爆破的同时,另一台机器出现异常外连,从监控界面上看是完全割裂的,谁也不会想到它们可能属于同一起攻击。问题的本质不是缺数据,而是缺一条把数据串起来的主线。这条主线,就是事件链路。

这篇文章我不打算讲太多高深理论,重点是想清楚为什么需要事件图谱、图谱怎么建模、链路怎么打通、以及真正落地时会踩哪些坑。适合正在做安全运营、SIEM建设、或者想引入知识图谱但又不知道怎么开头的人看。如果你只是对“图谱”这个概念感兴趣,也可以从里面捞到一些可以直接上手的思路。

1. 整体设计:从告警噪声变成可读的事件链条

1.1 我看到的真实痛点

在安全运营场景里,大家最常见的感受是“告警风暴”。一台业务服务器上有正常扫描、有攻击探测、有误报、有真实入侵,交织在一起,平台一旦接入的日志来源变多,一天产生几十万条告警是常态。问题是,这些告警大多是单点事件。

举个例子,一个内网主机在凌晨三点出现外连行为,同一时段内网另一台数据库服务器出现了异常账号登录。从告警列表看,这两条记录相隔几十行,没有任何关联标识。但放到攻击者视角,这很可能是一条完整链路:先拿下一台跳板机,再通过它横向移动到数据库服务器,用计划任务或白名单程序做持久化。如果只盯单条告警,第一反应就是“可能是误报”“可能是正常业务流量”,很难看清背后的意图。

所以我在设计 MiroFish 的第一天就定了一个原则:不要做一个告警聚合平台,而是做一个能表达“事件之间关系”的系统。单纯把相似告警合并成事件,只是在数量上做了减法,并没有在信息上做加法。真正该做的是,把产生告警的实体、时间、行为、结果之间的关系建出来,让运营人员顺着关系走,就能走出一条完整的攻击路径。

1.2 为什么选择图谱而不是规则引擎

在决定用事件图谱之前,我先试过一段时间规则引擎。规则引擎的思路是预先定义好“如果A发生且B紧接着发生,就触发一条高级事件”,比如“主机A外连异常后,主机B出现登录失败”,听起来直接,但维护成本非常高。

规则引擎有两个绕不开的问题。第一,规则是静态的,攻击者的手法稍微变一下顺序,规则就失效了;第二,规则之间没有记忆,很难表达跨多步的复杂关联。比如一条链路涉及四五台主机、三四个账号、多种操作类型,规则引擎只能通过“全局状态变量”硬编码,写出来又长又难维护,事后排查出了问题也很难定位。

事件图谱的思路就不一样。图谱天然是数据模型,不是逻辑模型。我不用预先定义“什么情况算一条攻击链路”,只要把实体和关系按统一标准存进去,再通过图查询和路径分析,让它自然长出链路来。规则引擎回答的是“命中某条规则”的问题,事件图谱回答的是“这条行为路径是否完整、是否合理”的问题。

这两种模式并不冲突,后来我把规则引擎产生的告警作为图谱里的一种特殊实体节点保留,再和图谱路径分析的结果互相验证。

1.3 图谱方案的核心技术选型

事件图谱听起来是个偏概念的东西,但落地时需要选的技术栈很具体。我当时的选型大致分成四层:

  • 存储层:用图数据库作为主存储,用于保存实体、关系、属性,并支撑路径查询。
  • 计算层:用流式处理框架做实时数据接入和实体关系构建,离线批处理做历史链路回扫和模型打分。
  • 分析层:图查询语言加少量图算法,主要做连通分量分析、关键路径提取、中心度计算。
  • 可视化层:把查询结果渲染成可交互的图谱界面,支持节点展开、关系过滤、时间轴拖动。

选型阶段我对比过几类方案。传统关系型数据库可以做关联查询,但关联深度一旦超过四层,SQL 写起来非常痛苦,查询性能也急剧下降。文档型数据库适合存日志,但不擅长表达多跳关系。最后还是决定把核心放在图数据库上,因为事件链路的本质就是多跳路径问题,图查询语言可以用几行代码完成“从某个IP出发,经过登录、写入、外联三种关系,在三步之内能到达哪些资产”这类查询,如果用关系型数据库来写,光联表逻辑就要搭半天。

2. 图谱建模:实体、关系与时间维度

2.1 实体类型怎么定义

事件图谱的建模,不是把所有字段都塞进图里。我一开始犯过贪多求全的毛病,把几十种日志字段全部映射成节点属性,结果图谱导出来非常臃肿,查询也慢。后来砍到两轮,留下了最核心的几类实体:

  • 资产节点:代表主机、服务器、网络设备,属性包括IP、主机名、操作系统、所属业务。
  • 账号节点:代表登录过的用户账号、服务账号,属性包括账号名、是否为特权账号。
  • 文件节点:代表进程创建、释放或加载过的可执行文件、脚本、动态链接库,属性包括文件路径、哈希、签名状态。
  • 域名节点和IP节点:代表外联、解析过程中出现的网络实体,属性包括域名、IP归属、首次出现时间。
  • 告警节点:代表规则引擎或模型产出的告警事件,属性包括告警类型、严重等级、原始描述。
  • 行为节点:代表登录、进程启动、外连、写文件、读取敏感文件等具体行为事件。

实体类型不宜太多,六个左右就够了。实体定义得越少,后面的实体对齐越简单,图谱的一致性也越好。如果业务需要更细的维度,可以在属性里扩充,而不是不断新增节点标签,否则图谱会变得很碎片化。

2.2 关系方向与语义

图谱里节点是名词,关系是动词。关系设计得对不对,直接决定链路能不能连起来。我在 MiroFish 里用得最多的关系有几类:

  • 属于:资产节点属于某个业务组或机房,这个关系主要用于分组和过滤。
  • 登录到:账号节点登录到资产节点,带时间属性和登录方式。
  • 创建/写入:行为节点在某个资产上创建文件或写入文件,带文件路径和操作结果。
  • 连接:资产节点或行为节点连接某个IP或域名,带目的端口和连接方向。
  • 触发:行为节点触发告警节点,带告警的命中规则。
  • 包含:告警节点关联到资产节点或账号节点,用于把告警定位到具体对象。

关系一定要有方向。比如“账号登录到资产”和“资产被账号登录”,虽然描述的是同一件事,但方向在路径查询中意义不同。我建议所有关系都按业务动作的方向建模,这样在图查询时才能准确表达攻击者的路径。

2.3 时间维度是事件图谱的灵魂

最初我把图谱做成了一张静态关系网,后来发现这会造成严重的问题。静态图里,“一个账号曾经登录过一台主机”和“这个账号五分钟前登录过这台主机”被表达成同一条关系,攻击链路的时效性完全丢了。攻击行为的价值就在于时间窗口,凌晨三点的登录和下午三点的登录,含义完全不同。

解决办法是给每一条关系都打上时间戳,并且把时间窗口作为链路查询的强制过滤条件。具体操作上,我为每类关系设计了两个时间属性:发生时间、失效时间。登录关系和文件写关系是瞬时事件,发生时间等于事件时间,失效时间置空;外联关系可以设置一个有效期,比如五分钟内重复外联归并为一条关系;账号与资产的信任关系,可以设置一个较长的有效期,但并不是永久有效。

有了时间维度之后,所有路径查询都会附带一个时间范围参数。比如查“过去一小时内从这个IP出发能到达的资产”,就必须先从关系里过滤出符合时间条件的关系,再做路径展开。这一步会显著增加计算量,但没有它,事件图谱就只是知识图谱,不是事件图谱。

2.4 实体对齐与归一化

实体对齐是整个项目中我投入最多的地方,也是决定图谱质量的最关键环节。同一个资产,日志里可能同时出现内网IP、公网IP、主机名、云实例ID,如果不对齐,图谱里就会多出好几个孤立节点,链路在中间断开。

我的做法是建立一个“实体主键优先级”的规则。对于资产节点,优先用资产唯一标识,比如主机ID或实例ID;没有唯一标识时,用主机名加IP的组合;连主机名都没有,才退而用IP。这样保证了同一个资产在绝大多数情况下只有一个图节点。

对于账号节点,也存在类似问题。同一个Windows账号在域控日志里显示为“域名\用户名”,在应用日志里显示为“用户名”,需要统一转换规则。IP节点更麻烦,既有公网IP又有内网IP,内网IP可能存在复用,公网IP可能被多台主机通过NAT共用。我最终把内外网IP分开建模,内网IP直接关联资产,公网IP作为独立网络节点,再通过时间窗关联关系把两者连接起来,而不是粗暴地合并。

3. 核心链路实现:从日志到图谱的完整路径

3.1 数据接入与标准化

图谱要能持续运转,第一步是把多来源数据灌进来。我接入的数据源包括终端日志、服务器登录日志、DNS日志、网络设备流日志、告警平台JSON接口等。不同来源的数据格式千差万别,有的用JSON,有的用Syslog,有的是CSV导出的离线文件。

接入之后再标准化,这一步很枯燥但必须做。我为每一种数据源都定义了一个中间标准格式,字段固定为事件时间、来源IP、目标IP、来源账号、目标账号、进程名、文件路径、域名、事件类型、原始描述。后续所有实体抽取、关系构建都基于这个标准格式,而不是原始字段。

标准化过程中有一个容易踩的坑:时间字段。不同日志的时间格式不一样,有的带时区,有的不带,有的用毫秒级时间戳,有的用字符串时间。如果不统一处理成UTC时间戳,图谱里的时间关系就会乱,路径查询的结果也会错乱。我在接入层专门加了一个时间解析模块,所有事件时间统一转成UTC纳秒级存储,界面展示时再按浏览器时区转换回来。

3.2 实体抽取与关系构建的关键逻辑

实体抽取这一层,本质上是从标准化日志里识别出实体节点和关系。举个例子,一条登录日志的标准化结果是“A账号在10点12分通过SSH登录到主机B”,我需要从里面抽出三条信息:

  • 账号节点A是否存在,不存在则创建,存在则更新属性。
  • 资产节点B是否存在,不存在则创建。
  • 建立“A登录到B”的关系,并写上发生时间。

这些逻辑听起来很直白,但真正做的时候要处理非常多的边界情况。比如有的业务日志里登录账号是空值,我需要根据源IP反查资产对应的服务账号来补全;有的终端日志里进程路径非常长,但真正能标识进程身份的其实是文件哈希,我就把哈希作为文件节点的唯一键,路径作为辅助属性。

下面是一段简化后的实体抽取示意逻辑,实际工程里还要套很多防御性判断:

def extract_event_entities(event): entities = [] relationships = [] # 账号实体 acct = get_or_create_account(event.get("src_account")) entities.append(acct) # 资产实体 asset = get_or_create_asset(event.get("dst_ip")) entities.append(asset) # 行为实体 action = create_action_node( action_type=event.get("event_type"), timestamp=event.get("event_time"), raw=event.get("raw_desc") ) # 关系:行为产生在资产上 relationships.append(create_relationship( action["id"], "发生于", asset["id"], time=event.get("event_time") )) # 关系:账号发起了行为 relationships.append(create_relationship( acct["id"], "发起", action["id"], time=event.get("event_time") )) return entities, relationships

这段代码只是骨架,但核心思想很明确:把一条日志拆成一个行为节点,然后用“谁发起、在哪发生”把账号和资产关联起来。后续多条日志共享同一个账号节点或资产节点时,自然就形成了多条路径,链路也就慢慢长出来了。

3.3 图数据库写入与索引设计

实体和关系抽出来以后,接下来就是写入图数据库。这一块的性能影响非常大,因为安全场景的数据写入量通常高于普通业务系统。每天几千万条日志,即使过滤掉大部分噪声,也会有大量关系需要落图。

我第一次上线时发现,写入速度完全跟不上。后来定位到原因,是没有合理使用批量写入。逐条写入图库时,每条记录都要走一次事务提交和索引更新,性能非常差。改成批量写入后,每批次几百条关系一起提交,写入速度提升了不止一个量级。

还要注意索引设计。图数据库里最频繁的是两类查询:按实体属性查节点、按关系类型和属性查关系。我建了三个索引:节点标签加唯一键索引、关系类型加时间戳索引、以及资产IP属性索引。这三个索引覆盖了绝大多数路径查询场景,其他属性一概不加索引,避免索引膨胀拖慢写入。

3.4 链路生成的算法流程

当图谱里有了大量实体和关系之后,链路生成的核心问题就变成:怎么把相关的一批节点和关系圈出来,形成一条有意义的路径。

我用的方法分为三步。第一步是候选子图圈定。根据一个种子实体,比如某个被重点关注的资产节点,以它为中心,在指定时间窗口内向外扩展,把相关的账号、行为、目标IP都拉入候选子图。第二步是路径打分。对候选子图里的边按威胁权重计算,权重维度包括登录是否来自陌生IP、是否使用了特权账号、操作对象是否敏感文件、是否在非常规时间段发生等。第三步是链路归纳。把得分最高的几条路径提取出来,丢弃掉那些孤立的低分边,最终形成可供展示的事件链路。

这里最关键的设计决策是:不是把所有关联都展示出来,而是主动做剪枝。刚开始做图谱时,我最怕漏掉信息,结果图界面上一展开就是几百个节点,运营人员根本看不过来。后来我意识到,事件图谱不是数据展示工具,而是研判辅助工具,它应该在保持完整性的同时,把那些最需要关注的信息推到最前面。

4. 落地过程中踩过的坑与排查实录

4.1 节点数量急速膨胀,图谱性能劣化

项目运行两周后,我遇到一个严重的问题:图数据库里的节点数量和关系数量增长远超预期,路径查询从原本的毫秒级退化到秒级,某些复杂查询甚至直接超时。排查发现,问题出在顶级域名的建模方式上。DNS日志里有大量的子域名解析关系,比如“a.example.com”和“b.example.com”,我最初都创建了独立域名节点,导致域名类节点数量爆炸式增长。

后来我把域名节点合并处理。只对注册主域名创建独立节点,子域名作为主域名节点的属性存储,同时把子域名到IP的解析关系合并到主域名关系上。这样图谱上的域名节点数量减少了大概六成,查询性能恢复到了可用范围。

这个教训让我明白,图谱里的节点粒度必须结合查询场景来定。如果查询很少关心某个子域名的单独行为,就不要为它单独建节点。节点粒度越粗,图谱越稳定,宁可把信息放在属性里,也不要随意增加节点类型。

4.2 链路“长尾噪声”过多

另一个典型问题是链路里总会出现很多无关分支。比如某个资产的登录事件特别多,当它成为种子节点向外扩展时,会同时拉进来大量正常的业务登录、扫描器探测、甚至CMDB采集产生的虚拟连接,链路易主性很高,关键的攻击路径反而被淹没。

我试过几种过滤策略,最终奏效的组合是“白名单加频次衰减”。白名单是一批已知合法的扫描器IP、监控采集账号、运维堡垒机账号,这些实体会在候选子图圈定时直接过滤;频次衰减则是对同一源IP在同一目标资产上的重复行为做去重,如果某个源IP一天内向同一资产发起了上百次登录失败,只保留前面几次行为作为路径节点,后面的大量重复事件不参与展示。

这套过滤逻辑上线后,链路的信噪比提升非常明显。运营同学报告说,之前看到一个链路要自己分辨哪些是攻击路径,现在横在眼前的路径基本只有一两条,可以直接点击查看原始证据。

4.3 冷数据积累拖慢增量写入

图数据库还有一个容易被低估的问题:冷数据会拖慢热数据的写入和查询。事件图谱天然是时序数据,几天前的事件已经很少被查询,但如果不做处理,它们依然占据着图数据库的活跃存储,影响索引效率和备份速度。

我的做法是为图谱系统引入分区归档机制。时间超过三十天的历史关系,定期迁移到离线归档库中,需要追溯时再按时间范围临时加载。应用层不感知数据是否被归档,只是查询响应会慢一些,不会影响核心链路生成。

这里有一个坑:迁移前一定要验证图查询语句在归档库上同样能跑通。我一开始想当然地认为同一个查询语句在不同图库上结果一致,结果迁移后查询报错才发现部分语法不完全兼容,后来专门写了一套适配层,保证两种存储的查询接口一致。

4.4 事件链路准确率怎么验证

很多做图谱项目的人,最后都会被问到一个问题:你这条链路到底准不准?我最初的验证方式很简单,拿一些已知的历史安全事件回放,看图谱能不能还原出当时的攻击路径。但这样做有一个问题,网安事件本身稀缺,回放样本太少,验证结果不够有说服力。

后来我换了一个思路:人工标注小样本加规则回测。先由有经验的安全运营人员标注出大约一百条真实链路,再让系统在这段时间的日志数据上自动生成链路,对比两者之间的匹配度,计算路径重合率和关键节点命中率。跑下来整体路径重合率在七成以上,已经可以作为辅助研判工具投入使用了。

需要说明的是,事件图谱不可能做到百分之百准确,它解决的是“能不能从海量告警中快速找到值得深入看的几条路径”的问题,而不是“能不能替代安全分析人员做出最终判断”的问题。我愿意把这点直白讲出来,因为很多文章把图谱吹得神乎其神,实际用过的人会明白,它更像一个帮助你聚焦线索的高倍放大镜,而不是全自动的答案生成器。

最后再分享一点个人体会。MiroFish 做了一年多,最让我觉得有价值的时刻,不是它画出来的图有多么好看,而是当运营人员和应急响应同事开始把排查习惯从“打开告警列表逐条看”变成“沿着一条时间链路逐跳走”的时候,很多之前需要多年经验才能形成的判断思路,新人也能顺着图谱走一遍,再结合上下文证据得出自己的结论。这其实比做出一个准确率更高的模型更重要——工具的价值,最终要体现在使用它的人能不能做出更好的决策上。

如果你也想做类似的事件图谱,我建议不要一上来就追求大而全的模型和酷炫的可视化,先把最核心的一两条链路走通,让运营人员用起来,再根据实际使用反馈逐步迭代。图谱这个方向,真正难的从来不是算法,而是对业务事件的理解和对数据质量的打磨。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进