4、Zeek(Bro)脚本基础:Zeek事件驱动模型、Zeek脚本语法(事件、函数、变量)、Zeek日志分析
好,咱们进入第四讲。说实话,Zeek(以前叫Bro)这套东西,我第一次接触的时候也觉得有点怪——跟Snort那种规则匹配的思路完全不一样。但用久了你会发现,它的设计哲学其实非常优雅。
Zeek的核心是什么?是事件驱动。说白了,它不是在那儿傻乎乎地一条一条比对特征,而是等着网络里发生“事情”。一个TCP连接建立了,一个HTTP请求过来了,一个DNS响应返回了——这些都是事件。Zeek会捕捉这些事件,然后触发你写的脚本去处理。
我个人习惯把Zeek比作一个“网络世界的监听者”。它不主动说话,但耳朵特别好使。你告诉它:“嘿,如果看到有人访问某个IP,就记下来。”它就会照做。
4.1 事件驱动模型:Zeek的灵魂
为什么说事件驱动是灵魂?你想想看,传统的IDS要一条一条规则去匹配,流量大了CPU直接飙到100%。Zeek不一样,它只在你关心的事件发生时才会干活。平时?它就在那儿挂着,几乎不占资源。
Zeek内置了上百种事件类型。我列几个最常用的:
| 事件名称 | 触发时机 | 典型用途 |
|---|---|---|
new_connection |
检测到新TCP连接 | 记录连接建立 |
http_request |
捕获到HTTP请求 | 分析URL、User-Agent |
dns_request |
捕获到DNS查询 | 检测恶意域名 |
ssl_established |
TLS握手完成 | 检查证书异常 |
conn_state |
连接状态变化 | 判断连接是否正常关闭 |
嗯,这里要注意:事件是有顺序的。比如一个HTTP请求,一定是先触发new_connection,然后才是http_request。我在项目中遇到过有人写脚本时搞反了顺序,结果数据一直取不到,排查了半天才发现是事件没对上。
4.2 Zeek脚本语法:其实没那么复杂
很多人一看到Zeek脚本就头大,觉得语法怪。其实它的设计很简洁,核心就三样东西:事件、函数、变量。
4.2.1 变量
Zeek的变量分两种:全局变量和局部变量。全局变量用global声明,局部变量用local。我建议你尽量用local,除非你真的需要跨事件共享数据。
# 全局变量,所有事件都能访问
global suspicious_ips: set[addr] = set();
# 局部变量,只在当前事件内有效
event http_request(c: connection, method: string, uri: string, ...)
{
local my_local_var: string = "临时数据";
# 只在当前事件内使用
}
我曾经踩过一个坑:在多个事件里用了同一个全局变量,结果并发访问时数据全乱了。后来我加了个锁机制才解决。所以啊,全局变量要慎用。
4.2.2 函数
函数就是封装逻辑用的。Zeek的函数定义挺直观:
function is_suspicious_ip(ip: addr): bool
{
# 检查IP是否在黑名单中
if (ip in suspicious_ips)
return T;
return F;
}
event http_request(c: connection, method: string, uri: string, ...)
{
if (is_suspicious_ip(c$id$orig_h))
{
print fmt("发现可疑IP: %s", c$id$orig_h);
}
}
你看,函数可以返回布尔值、字符串、甚至自定义的记录类型。我个人习惯把复杂的判断逻辑都封装成函数,这样事件处理函数里就干净多了。
4.2.3 事件处理函数
这是Zeek脚本的核心。每个事件对应一个处理函数,格式固定:
event 事件名称(参数列表)
{
# 你的处理逻辑
}
举个例子,检测DNS隧道攻击:
event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count)
{
# 检测超长域名,可能是DNS隧道
if (|query| > 50)
{
print fmt("疑似DNS隧道: %s -> %s", c$id$orig_h, query);
}
}
为什么会检测超长域名?因为正常的DNS查询很少超过50个字符。攻击者为了在DNS里塞数据,会把域名搞得很长。这是我在一次红蓝对抗中发现的技巧。
4.3 Zeek日志分析:从数据中找线索
Zeek默认会生成很多日志文件。我挑几个最重要的说说:
| 日志文件 | 记录内容 | 分析价值 |
|---|---|---|
conn.log |
所有网络连接 | 基础流量分析 |
http.log |
HTTP请求/响应 | Web攻击检测 |
dns.log |
DNS查询/响应 | 恶意域名、隧道检测 |
ssl.log |
TLS握手信息 | 证书异常、加密流量分析 |
notice.log |
告警事件 | 安全事件汇总 |
日志格式是TSV(制表符分隔),可以直接用cut、awk等工具分析。比如我想看所有HTTP请求的URL:
cut -f 3,9 http.log | head -20
或者用Zeek自带的zeek-cut工具:
zeek-cut ts uid id.orig_h id.resp_h uri < http.log
嗯,这里有个小技巧:notice.log里的告警是Zeek自己生成的,但很多时候你需要自定义告警规则。比如检测到某个IP在短时间内发起大量连接:
event new_connection(c: connection)
{
local src_ip = c$id$orig_h;
# 假设我们有一个函数统计连接频率
if (connection_rate(src_ip) > 100)
{
NOTICE([$note=High_Connection_Rate,
$msg=fmt("IP %s 连接频率过高", src_ip),
$conn=c]);
}
}
核心要点:Zeek的日志分析不是看单条日志,而是看模式。单个HTTP请求可能没问题,但100个请求都指向同一个IP,那就值得怀疑了。
4.4 实战:写一个简单的检测脚本
咱们来写一个完整的脚本,检测SSH暴力破解。思路很简单:如果同一个源IP在短时间内发起大量SSH连接,就告警。
# 保存为 detect_ssh_brute.zeek
module DetectSSHBrute;
export {
# 阈值:10秒内超过5次SSH连接
global ssh_threshold: count = 5;
global time_window: interval = 10sec;
# 记录每个IP的连接次数
global ssh_attempts: table[addr] of count &create_expire=time_window;
}
event new_connection(c: connection)
{
# 只关注SSH端口(22)
if (c$id$resp_p == 22/tcp)
{
local src = c$id$orig_h;
# 更新计数
if (src in ssh_attempts)
++ssh_attempts[src];
else
ssh_attempts[src] = 1;
# 检查是否超过阈值
if (ssh_attempts[src] > ssh_threshold)
{
NOTICE([$note=SSH_Brute_Force,
$msg=fmt("SSH暴力破解检测: %s 在 %s 内发起 %d 次连接",
src, time_window, ssh_attempts[src]),
$conn=c]);
}
}
}
提示:脚本里的&create_expire=time_window是个好东西。它会让表项在指定时间后自动过期,省得你手动清理。我刚开始写Zeek脚本时不知道这个,结果内存越占越多,最后把服务器搞崩了。
4.5 避坑指南
最后分享几个我踩过的坑:
- 事件参数别搞错:每个事件的参数是固定的,多一个少一个都不行。我建议你写之前先查一下官方文档的事件原型。
- 注意大小写:Zeek是大小写敏感的。
event和Event是两个东西。我曾经因为把new_connection写成New_Connection,脚本死活不触发。 - 日志别忘开:默认情况下,Zeek只记录部分日志。如果你需要自定义日志,记得用
Log::write函数手动写入。 - 性能问题:事件处理函数里别做太重的操作。比如查数据库、写文件这些,尽量异步处理。否则流量一大,Zeek会丢包。
好了,这一章的内容就这些。Zeek脚本其实不难,难的是理解事件驱动的思维方式。你多写几个脚本,慢慢就习惯了。下一章咱们聊聊如何用Zeek检测更复杂的攻击,比如SQL注入和XSS。