engineering

进攻性行动中的可观测性

邮件在某个周二早上 09:14 到达。客户的 CISO 言简意赅:"昨晚 03:42 你们的代理究竟做了什么?SOC 看到一个不在运行手册里的出站连接并将其阻断。我们需要知道这是不是你们干的。"

我们在十一分钟内回复了他。不是因为我们记性好,而是因为我们查找了 SOC 发给我们的命令的 trace_id,并重建了整条链路:代理发现了一个 /api/internal/health 端点,该端点响应了一个版本 banner,代理决定验证它是否可被利用,在发送任何东西之前 Sentinel 切断了该操作,因为目标不在授权的 CIDR 范围内。所谓的"出站连接"是一个连 handshake 都没完成的 SYN。我们把完整的 span 发给客户,包含 ATT&CK ID、策略引擎的决策,以及穿过网线的那 47 个字节。

那天我们决定:可观测性要成为一个产品,而不是一个 nice-to-have

进攻性可观测性与防御性的不一样

我多年来一直在为 SOC 搭建遥测管道。Loki、Tempo、Jaeger,凡是合适的都用过。一开始我以为给进攻性代理做检测是同样的问题,只是换了个 schema。我错了。

防御性 SIEM 假设系统的噪声就是信号:每个 auth.fail 都重要,每个奇怪的 DNS 都重要。基数会爆炸,但思维模型很清晰:把所有东西都存下来,以后再决定。在进攻中模型反过来。代理 本身就是 定义上的噪声:它扫描、探测、失败、重试。如果你按 EDR 的粒度保存所有内容,一次四小时的行动会产生 18 GB 没人会看的遥测数据。

但有一样东西你 必须 能以外科手术般的精度重建:任何触及客户的动作的因果链。任何一个。如果代理向生产环境发送了一个 payload,你必须知道它为什么决定使用那个 payload、之前有什么信息支撑这个决策、策略引擎验证了什么,以及目标响应了什么。这不是偏执:这是专业 red team 与有预算的 script kiddie 之间的区别。

MITRE ATT&CK 的映射指南直白地说:证据必须扎根于真实的遥测数据,而不是操作员的假设(CISA, 2023)[1]。如果你的报告说"对主机 X 执行了 T1190",我想看到包含 http.request.body 的 span、代理的决策,以及响应码。没有这些,就只是看法。

事件设计:span、trace、属性

我们以 OpenTelemetry 作为骨干。不是因为它流行,而是因为它的 semantic conventions 解决了我们一个真实的问题:每个操作员都按自己的喜好写日志。今天是 target_host,明天是 dst,后天又是 victim_ip。无法关联。OpenTelemetry 强制使用共享的 schema,更重要的是,它在进程和 worker 之间传播 trace context(OpenTelemetry, 2024)[2]

Gandalf CLI 把每个命令作为一个 span 发出。每个 span 都带着它的 trace_idspan_id、指向其产生原因的推理过程的 parent_span_id,以及在标准语义之上遵循我们 berialabs.* 约定的属性。内部规则是:如果某个属性存在于官方 spec 中,就照搬使用。如果是进攻特有的(ATT&CK 技术、代理决策、payload 哈希),就加前缀以避免污染命名空间。

举个具体的例子。这是一个代理在搜索参数上测试 SQL 注入时的真实 span(已脱敏):

{
  "name": "gandalf.exploit.sqli_attempt",
  "trace_id": "4a1f9b2c8e3d7f6a5b4c3d2e1f0a9b8c",
  "span_id": "7c8b9a0d1e2f3a4b",
  "parent_span_id": "6b7a8c9d0e1f2a3b",
  "start_time_unix_nano": 1709823742891000000,
  "end_time_unix_nano": 1709823743104000000,
  "kind": "SPAN_KIND_CLIENT",
  "status": { "code": "STATUS_CODE_OK" },
  "attributes": {
    "http.request.method": "GET",
    "http.response.status_code": 500,
    "url.full": "https://target.example.com/api/search?q=*REDACTED*",
    "server.address": "10.42.7.18",
    "server.port": 443,
    "berialabs.attack.tactic": "TA0001",
    "berialabs.attack.technique": "T1190",
    "berialabs.agent.decision_id": "dec_8f3a",
    "berialabs.agent.reasoning_ref": "trace://4a1f9b2c.../6b7a8c9d",
    "berialabs.payload.sha256": "9e3f...c7a1",
    "berialabs.payload.family": "boolean_blind_sqli",
    "berialabs.sentinel.scope_check": "passed",
    "berialabs.sentinel.cidr_match": "10.42.0.0/16",
    "berialabs.evidence.response_signature": "mysql_error_xpath"
  },
  "events": [
    {
      "name": "sentinel.validation",
      "attributes": {
        "policy.id": "scope_v3",
        "policy.result": "allow"
      }
    },
    {
      "name": "response.received",
      "attributes": {
        "response.size_bytes": 1247,
        "response.contains_error_signature": true
      }
    }
  ]
}

这个格式里我看重三件事。第一,parent_span_id 指向的是代理的推理过程,而不是上一个命令;这让我能够重建它 为什么 做了它做的事,而不只是 做了什么。第二,payload 的 sha256 是被引用的,不是内联的:完整的 body 存在另一个存储中,span 只保留哈希。第三,span 的事件捕捉了关键的状态转换(Sentinel 的验证、响应),这些是平铺的属性无法很好表达的。

ATT&CK 作为属性,而不是松散的 tag

我们在发出 span 的时刻就将每个动作映射到对应的 ATT&CK 技术,而不是 事后 处理。代理内部有一张表,把 payload 家族关联到技术 ID,该属性会跟随 span 一直传到 Tempo。当客户向我们要报告时,这不是一次考古作业:这是在 Grafana 里按 berialabs.attack.technique 过滤的一次 query

用 eBPF 捕获代理没告诉你的事

这是我花了时间才接受的一个技巧。无论你把代理检测得多好,有些东西代理 自己也不知道自己在做。一个第三方库打开了一个你没预料到的 socket。一个 DNS 调用经过 getaddrinfo 但没经过你的 HTTP 客户端。一个子进程写了一个临时文件。如果你只依赖用户态的检测,你的可追溯性就有漏洞。

所以我们在运行代理的主机内核里部署了 eBPF 钩子。eBPF 允许在内核里执行沙箱化的程序,捕获事件而不修改内核(Gregg, 2019; ebpf.io)[3]。我们钩了四样东西:tcp_connectexecveopenat,以及通过 udp_sendmsg 的 DNS 解析。每个事件都用进程的 cgroup_id 进行了富化,我们通过一个共享内存中的小表把它和代理活跃的 trace_id 关联起来。

结果是:如果代理打开了一个 span 中记录过的 IP 的连接,完美,一切对得上。如果它打开了一个 没出现在 任何 span 中的 IP 的连接,就会触发警报。我们用这个发现过几次某个爬虫库在不打招呼的情况下 预取 favicon。这并非恶意,但本来可能是,客户有权知道。

一个尴尬的注脚:eBPF 很强大,但并非不可破。有公开的研究展示了专门设计的 rootkit 在攻击者已经控制内核的情况下如何让基于 eBPF 的工具失明(Matheuz, 2024)[4]。在我们的场景里威胁模型不同(我们要审计自己的代理,而不是防御一个拥有 root 的对手),但还是值得记住。

管道

从代理到 Grafana,跳数是这样的。Gandalf CLI 通过 gRPC 把 OTLP 发到作为 sidecar 运行的 OpenTelemetry Collector。Collector 做三件事:用一个 processor 过滤敏感属性,把任何匹配 PII 模式的东西(邮箱、看起来像信用卡号的数字、auth 头)写到 /dev/null,然后 batch 并重新导出。trace 进入 Tempo。结构化日志通过 otlphttp exporter 进入 Loki,Loki 从 3.0 起原生支持这种方式(Grafana Labs, 2024)[5]。指标(每个技术的延迟、被 Sentinel 阻止的 payload 比例、请求吞吐量)进入 Prometheus。

最上层是 Grafana。killer feature 不是什么漂亮的仪表盘:是 trace-to-logs。点任何一个 span,跳到按 trace_id 关联的日志。点任何一条日志,跳到对应的 span。正是这种关联让我们能在十一分钟内回复 CISO。

那些刺痛人的取舍

不是所有事情都美好。我们仍在权衡三种真实的张力。

噪声 vs 信号。如果你把代理的每个内部决策都检测,一次行动会产生数百万个 span。如果只检测对外命令,你又会丢掉因果链。我们找到了一个合理的平衡点:检测代理的决策节点(不是每个 token),以及对外的 side-effects,毫无例外。即便如此,在长时间的行动中我们也见过 200k spans/小时的峰值。

Retention。Tempo 放在 S3 上很便宜,但 Loki 中高基数的日志成本就高了。我们当前的策略:完整 trace 90 天,日志 30 天,聚合数据(指标和摘要)三年。来自 SOC 2 和 red team 合同的法律压力推着我们保留更多,而不是更少。

日志中的 PII。这是最让我担心的一项。如果代理从数据库里提取了一份 dump 来证明影响,那份 dump 不能 留在日志里。Collector 的过滤器有帮助,但不够。我们维持第二层防护:敏感发现 接触管道 之前 就用客户的公钥加密,只保留引用。

这怎么改变了我们的工作方式

在有这个管道之前,我们手工做 red team 报告。截图、复制粘贴输出、凭记忆重建叙事。一做就是好几天。现在大部分报告是通过查询 Tempo 和 Loki 生成的,人类操作员专注于解释,而不是抄写。

更重要的是:我们和客户从共同的基础上讨论。不是"我们觉得代理做了 X",而是"这是 03:42:18 的 span,有决策、有 payload、有响应"。上个月一个客户告诉我们,这是第一次有 red team 交给他们的遥测可以原封不动地被他们的 SOC 摄取来训练检测规则。对我来说,这才是重要的指标:进攻性可观测性 对防御者有用。

而当一个周二早上 09:14 收到邮件时,我们用十一分钟回复。

参考资料

  1. CISA (2023). Best Practices for MITRE ATT&CK Mapping.
  2. OpenTelemetry Authors (2024). Semantic Conventions Specification 1.41.
  3. eBPF Foundation (2024). What is eBPF? An Introduction and Deep Dive into the eBPF Technology.
  4. Matheuz (2024). Breaking eBPF Security: How Kernel Rootkits Blind Observability Tools.
  5. Grafana Labs (2024). Ingesting logs to Loki using OpenTelemetry Collector.