用 Query DSL 找「某个进程创建了子进程」是简单的 term 匹配,但要回答「同一个主机上先出现登录失败、随后出现登录成功、中间没有出现锁定」这类问题,DSL 就非常笨拙:你得写多轮查询,再用应用层代码把时间戳排好、手工做关联。EQL(Event Query Language)正是为这类事件序列问题设计的:它把时间维度写进语法,用 sequence 与 until 直接表达「A 之后跟着 B,除非中间出现 C」。本文讲清 EQL 的语法、边界控制、安全建模方式与性能代价。
1. 为什么需要 EQL
1.1 从文档检索到事件序列
Elasticsearch 的默认检索模型是「文档匹配」:给定查询,返回满足条件的文档集合,文档之间彼此独立、没有顺序关系。日志与安全场景的数据本质上是一条条带时间戳的事件,很多检测逻辑天然带顺序:「暴力破解成功」不是单条事件,而是「N 次失败 + 1 次成功」这个模式。
1.2 用 Query DSL 硬做的代价
用 DSL 表达序列,标准做法是查询一个时间窗口内的所有相关事件,按 host 分组、按时间排序,再在应用层做状态机匹配。这会带来三个问题:一是需要把大量原始事件拉到客户端,网络与内存成本高;二是关联逻辑散落在应用代码里,难以复用与审计;三是窗口切分(一条序列跨越两个查询窗口)容易出错,产生漏报。
1.3 EQL 的定位
EQL 把「序列」提升为一等公民。请求发给 _eql/search 接口,引擎在分片内部完成候选事件的时间排序与状态匹配,只把命中的序列返回。它不替代 Query DSL:单事件的过滤、统计、聚合仍用 DSL 与聚合框架;EQL 专注于「多个事件按时间组成的模式」。
1.4 与 DSL 的能力对照
| 能力 | Query DSL | EQL |
|---|---|---|
| 单事件过滤 | 完整支持 | 支持 |
| 跨事件顺序 | 不支持,需应用层实现 | 原生支持 |
| 分组关联 | 聚合分组 | sequence by |
| 时间边界 | 靠 range 手工圈定 | maxspan、until |
| 聚合统计 | 完整支持 | 不支持 |
| 排序打分 | 完整支持 | 按时间返回 |
| 更新删除 | 支持 | 只读查询 |
结论很直接:需要统计与排序时用 DSL,需要「谁在谁之后」时用 EQL。
2. EQL 语法基础
2.1 数据前提:event category
EQL 要求事件文档带有 event.category 字段,取值是固定枚举(process、network、authentication、file、registry、database 等)。查询必须以 category 开头声明数据类别,引擎据此决定加载哪个字段映射与序列缓冲策略。
process where process.name == "powershell.exe"
这条查询的含义是:在所有 process 类别的事件里,匹配进程名为 powershell.exe 的文档。字段访问用点号路径,比较用 ==(等于)、!=(不等于)、:(大小写不敏感的包含)等运算符。
2.2 运算符与字面量
EQL 支持完整的比较运算符与逻辑组合:
process where process.name in ("cmd.exe", "powershell.exe") and
process.command_line : "*Invoke-Expression*" and
process.pid >= 1000 and
not process.name == "svchost.exe"
in 做集合匹配,: 做通配与大小写不敏感匹配,and/or/not 组合条件。字面量支持字符串、整数、浮点与布尔。注意 EQL 不做分词检索,它匹配的是字段原始值,因此字段必须是 keyword 类型或已开启 doc_values,否则会报映射错误。
2.3 基本事件匹配与缺失字段
字段不存在时,比较结果视为 null,null 参与任何比较都不会命中。若希望「字段不存在也算命中」,要显式写成:
process where process.parent.name == null or
process.parent.name != "explorer.exe"
这条规则常用于捕获「父进程信息缺失的可疑进程」,因为很多注入类攻击会伪造或抹掉父进程字段。
2.4 sequence 与方括号
序列用方括号包裹每个阶段,阶段之间按时间顺序隐含连接:
sequence
[ authentication where event.outcome == "failure" ]
[ authentication where event.outcome == "success" ]
方括号的每一段是一个独立的事件匹配器,引擎按时间戳顺序寻找「第一段命中后,第二段才命中」的组合。默认情况下,同一序列内的事件必须是不同文档,同一个文档不能同时充当两个阶段。
3. 序列的边界控制
3.1 until 的语义
until 是 EQL 最有价值的设计:它声明「序列在遇到某类事件时立即作废」。
sequence by user.name
[ authentication where event.outcome == "failure" ]
[ authentication where event.outcome == "success" ]
until [ authentication where event.action == "account_locked" ]
没有 until 时,一个用户在锁定前后产生的失败与成功会被错误地拼成一条「爆破成功」序列。加上 until 后,账户锁定事件会打断序列,避免误报。这是把业务语义写进查询、而不是写进后处理代码的典型做法。
3.2 by 字段关联
sequence by 指定分组键,同一键值的事件才会被串成序列。分组键等价于 DSL 里按字段分组,但由引擎在分片内完成:
sequence by host.name, user.name with maxspan=10m
[ process where process.name == "whoami.exe" ]
[ network where destination.port == 445 ]
多个分组字段用逗号分隔,语义是「同时按这些字段取值分组」。分组键应选基数适中、语义稳定的字段:host.name、user.name、source.ip 都合适;process.pid 这类高基数且不稳定的字段会让每条序列都孤立,失去关联意义。
3.3 maxspan 的取舍
with maxspan=10m 限定整条序列的总时间跨度。跨度越大,引擎需要保留的候选事件越多,内存占用越高;跨度越小,跨窗口的慢速攻击越容易漏掉。实践中按检测语义设定:横向移动检测常用 5~15 分钟,慢速爆破可能需要小时级,此时更适合改用聚合统计而非序列匹配。
sequence by source.ip with maxspan=30s
[ authentication where event.outcome == "failure" ] with runs=5
[ authentication where event.outcome == "success" ]
3.4 runs 与连续事件
对「连续 N 次失败」这类模式,可以用 runs 语法指定重复次数:
sequence by source.ip with maxspan=1m
[ authentication where event.outcome == "failure" ] with runs=5
runs=5 表示该阶段需要连续命中 5 次才算成立,比写五段方括号更简洁。runs 只能用于非 until 阶段,且与 by 组合时按分组键独立计数。
4. 高级匹配能力
4.1 missing events 与 not
阶段前加 ! 表示「该阶段必须不发生」,用于表达「A 之后没有 B」:
sequence by host.name
[ process where process.parent.name == "winword.exe" ]
! [ process where process.name == "outlook.exe" ]
这在检测「宏文档启动了子进程但没有拉起正常办公程序」时非常直观。注意 ! 阶段同样受 maxspan 约束:若在窗口内始终没有该事件,序列才成立;一旦出现,序列作废。
4.2 sample 采样
sample 用于在同一时间窗口内随机采样事件,适合做数据探查而非精确检测:
sample by host.name
[ process where true ]
[ network where true ]
它保证每个分组最多返回一条组合,主要用于验证字段是否齐备、序列是否可构造,不要用于生产告警。用 sample 配合 size 可以在几秒内判断一批新接入的日志是否满足 EQL 的数据要求。
4.3 内置函数
EQL 提供了一批字符串与集合函数,常用的是:
| 函数 | 作用 | 示例 |
|---|---|---|
wildcard | 通配匹配 | wildcard(process.name, "p*.exe") |
stringContains | 子串包含(大小写敏感) | stringContains(user.name, "admin") |
startsWith | 前缀匹配 | startsWith(file.path, "/etc/") |
endsWith | 后缀匹配 | endsWith(file.name, ".dll") |
length | 字符串长度 | length(process.command_line) > 200 |
concat | 拼接 | concat(host.name, "-", user.name) |
between | 数值区间 | between(process.pid, 1000, 2000) |
cidrMatch | IP 网段匹配 | cidrMatch(source.ip, "10.0.0.0/8") |
number | 转数值 | number(process.args[0]) > 100 |
函数只能作用于字段,不能嵌套调用过深;引擎会拒绝无法下推到 Lucene 查询的复杂表达式,报 verification_exception。把能下推的条件(如 host.name == "web-01")放在 filter 参数里预筛,能显著减少引擎需要评估的文档数。
4.4 数组字段与索引访问
ECS 里 process.args、file.path 等字段常是数组。EQL 对数组字段做「任一元素匹配」的语义:
process where process.args : "*--encrypt*" and process.args : "*.pdf"
两条件可以命中数组的不同元素。若要求同一元素同时满足,需要用函数组合或改写数据模型,EQL 不提供「同一下标」约束。
5. 安全场景建模
5.1 检测规则的写法
一条可用的检测规则应当包含三部分:序列模式、分组键、时间边界。下面是一个「可疑下载并执行」的完整规则:
sequence by host.name, user.name with maxspan=5m
[ network where network.protocol == "http" and
process.name in ("curl", "wget", "certutil.exe") ]
[ process where event.type == "start" and
process.parent.name in ("curl", "wget", "certutil.exe") and
process.name : ("*.sh", "*.exe", "*.ps1") ]
规则用 _eql/search 执行:
curl -X POST "localhost:9200/logs-*/_eql/search?pretty" \
-H "Content-Type: application/json" -d'
{
"query": "sequence by host.name with maxspan=5m [ network where true ] [ process where true ]",
"size": 100,
"timestamp_field": "@timestamp",
"event_category_field": "event.category"
}'
返回结构里每条命中包含 sequences 数组,每个序列下是多个阶段的 events,附带 join_keys 说明分组键取值,可直接映射到告警面板。
5.2 与检测规则的集成
在 Kibana 的 Detection Engine 里,EQL 规则与 DSL 规则共享调度与告警通道。把 EQL 用于「多步攻击链」,把 DSL 用于「单事件 IOC 匹配」,两者互补。规则要配上 false_positives 说明与 interval 调度周期,避免序列窗口与调度周期错配导致漏报——若 maxspan 是 10 分钟,调度周期必须显著小于它。
5.3 测试与回放
上线前用历史数据回放验证:把规则跑在 -30d 的时间范围上,统计命中数量与样本,人工判定真阳性比例。EQL 查询支持 filter 参数先做粗筛,把回放范围缩小到特定主机或用户,避免全量扫描拖慢集群。
curl -X POST "localhost:9200/logs-*/_eql/search?pretty" \
-H "Content-Type: application/json" -d'
{
"query": "sequence by host.name with maxspan=5m [ network where true ] [ process where true ]",
"filter": { "term": { "host.name": "web-01" } },
"size": 50
}'
5.4 常见误报与调优
安全规则的第一版几乎总是误报偏高。常见来源有三类:一是分组键选得太宽,把不同主机的事件混在一起;二是缺少 until,把正常业务操作拼成攻击链;三是 maxspan 过大,把间隔数小时的不相关事件串起来。调优顺序建议是:先收窄分组键,再补 until,最后缩短 maxspan。
6. 性能与限制
6.1 索引与字段要求
EQL 对字段类型敏感:序列阶段涉及的字段若是 text 类型,匹配会退化成无法下推的脚本执行,性能急剧下降。安全数据务必用 ECS 映射,@timestamp、event.category、host.name、process.name 都应是 keyword 或 date。索引的 index.query.default_field 对 EQL 无效,它只按显式字段路径匹配。
6.2 序列的内存开销
引擎需要为每个分组键保留候选事件直到 maxspan 过期。分组基数越高、maxspan 越长,堆内存压力越大。经验值:单次 EQL 查询的候选事件数控制在百万级以内;超过时应缩小时间范围或增加 filter 预筛。查询是分片级并发的,协调节点还要把各分片的序列结果做跨分片归并,因此序列跨分片时开销会进一步上升。
6.3 跨分片与时间排序
EQL 要求每个分组的事件在时间上可排序。若同一分组键的事件散落在多个分片,引擎需要拉取各分片的局部序列再归并,正确性由 @timestamp 保证,但延迟会增加。写日志时用 _routing 或数据流按时间分片,能减少跨分片归并。
6.4 常见报错
| 报错 | 原因 | 处理 |
|---|---|---|
unknown field | 字段不在映射中 | 补 mapping 或改用存在字段 |
verification_exception | 表达式无法下推 | 拆成更简单的条件 |
too_many_nested_clauses | 阶段过多或嵌套过深 | 拆分规则 |
| 超时 | 时间范围过大 | 加 filter、缩小 maxspan |
| 结果为空 | event.category 缺失 | 补 ECS 字段 |
search_phase_execution_exception | 字段类型不匹配 | 检查 keyword 映射 |
排查时先用 sample 查询确认字段有值,再用最简 sequence 逐步加条件,定位是哪一段匹配不上。配合慢查询日志可以看到 EQL 被翻译后的 Lucene 查询,判断是否发生了下推失败。
7. 与其他查询方式的分工
EQL 不是万能工具,它和 DSL、ES|QL 各有分工:
| 需求 | 推荐方式 |
|---|---|
| 单事件精确过滤 | Query DSL |
| 统计、分组、指标 | 聚合框架 |
| 表格化分析查询 | ES |
| 多事件顺序检测 | EQL |
| 向量语义检索 | kNN 查询 |
把 EQL 当成「检测专用语言」而非通用查询入口,是最省心的用法。它适合写进检测规则、由调度器周期执行;不适合塞进业务 API 做交互式查询,因为它的响应结构(序列数组)对前端并不友好。
8. 总结
| 环节 | 要点 |
|---|---|
| 适用场景 | 多事件按时间组成的模式,如爆破成功、攻击链 |
| 数据前提 | 必须有 event.category 与 ECS 字段映射 |
| 核心语法 | sequence 方括号分阶段,until 打断序列 |
| 分组与边界 | by 指定关联键,maxspan 控制时间跨度 |
| 高级能力 | ! 表达不发生,runs 表达重复,内置函数做字符串判断 |
| 性能红线 | 字段须 keyword、控制分组基数与 maxspan |
| 排错顺序 | sample 验证字段 → 最简序列 → 逐条加条件 |
| 定位 | 检测语言,不替代 DSL 与聚合 |
EQL 把序列检测从应用层代码搬进了查询引擎,代价是对数据模型的严格要求与内存开销。用得好,它能用一条可审计的规则替代上百行关联代码;用得不好,它会因为 maxspan 与分组基数失控把集群拖垮。落地时先统一 ECS 映射,再谈规则编写。日志管道的搭建可阅读《日志分析与 ELK 栈》,字段与映射设计可阅读《数据建模与 Mapping 设计》。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。