赛事档案库 YQHL Competition Archive 返回主站
MISC-DFIR-02

静默外传

网络取证:多源交叉验证识别明文外传通道,锁定静默外传的时间窗口与目标。

方向取证
难度 Medium
分值250 分
题号18 / 20
项目 内容
分类 / 难度 Misc / DFIR / Medium
分值 250
案件号 NET-26-104
类型 网络取证(多源交叉验证 + 明文外传识别)
目标 定位"用 POST 向未批准域名外传"的完整五元组,解封证明字段

1. 案情

SOC 在某端点发出异常庞大的明文 HTTP POST 后收到一条低置信度告警。既有业务遥测同样使用 POST,因此仅凭请求方法不足以构成证据。

题目描述里给了三条交付要求:

  1. 确定来源主机、目标、URI、相关进程;
  2. 至少关联三个相互独立的来源;
  3. 不要把单条 IDS 式标签当作结论。

案情窗口:2026-10-08 20:00–22:40 UTC,主对象端点 WS-104。

这是这道题的核心训练点:真实 SOC 里最贵的错误就是"看到一个 POST 就定案"。本题刻意放了一堆正常的 POST 遥测当噪音。

2. 资产与白名单

case/assets.csv:

asset,ip,owner,role,approved_egress,notes
WS-104,10.10.4.17,构建运维,Linux 工程工作站,status.example.test; telemetry-approved.example.test; packages.example.test,主要对象
WS-109,10.10.4.22,构建运维,Windows 工作站,status.example.test; telemetry-approved.example.test,对等工作站
BUILD-03,10.10.4.31,发布工程,构建 runner,packages.example.test; mirror.example.test,计划内的软件包获取

case/known-good-domains.txt 列了 7 个已批准域名:

status.example.test
telemetry-approved.example.test
packages.example.test
updates.example.test
docs.example.test
auth.example.test
mirror.example.test

注意这行的措辞:

不在本列表中的域名是调查线索,本身并不构成入侵证据。

意思是:未批准域名只是"嫌疑名单",必须靠别的证据坐实。这是本题防止你"看到未知域名就交卷"的第二道闸。

3. 第一步:从端点和白名单收缩

盯住 WS-104(10.10.4.17)。它的批准外传域名是:status / telemetry-approved / packages。

先过滤出这个 IP 的所有出站请求,找不在白名单里的:

grep '10.10.4.17' logs/http.csv | awk -F, '$5 !~ /(status|telemetry-approved|packages)\.example\.test/'

结果只剩一条:

2026-10-08T21:14:22Z,req-71f2a,10.10.4.17,198.51.100.88,sync-cache.example.test,POST,/api/v2/collect,202,486,92,TelemetryBridge/0.9

四个字段一眼可疑:

字段 值 为什么可疑
host sync-cache.example.test 不在批准列表
method POST 同 telemetry-approved 的遥测也是 POST —— 单看方法无意义
request_bytes 486 上行远大于下行的 92,是"往外送"的形状
user_agent TelemetryBridge/0.9 伪装成自家遥测组件

4. 第二步:多源交叉验证(题目硬要求)

同一个 request_id=req-71f2a 在五份日志里都出现,这才是本题的判据:

来源 A · logs/dns.log

2026-10-08T21:14:19Z 10.10.4.17 sync-cache.example.test A 198.51.100.88

解析记录:21:14:19 解析,比 HTTP 早 3 秒 —— 合理的先后顺序。

来源 B · logs/proxy.log

2026-10-08T21:14:22Z request_id=req-71f2a client=10.10.4.17
host=sync-cache.example.test method=POST uri=/api/v2/collect
category=unclassified action=allowed sent=486 received=92

category=unclassified —— 代理自己也不知道这是什么流量,allowed 说明只按域名放行,没做内容分类。

来源 C · logs/tls.log(决定性证据)

2026-10-08T21:14:22Z req-71f2a 10.10.4.17 198.51.100.88 tcp/80
cleartext-host=sync-cache.example.test note=payload-inspection-enabled

tcp/80 + cleartext —— 明文 HTTP,不是 TLS。题目描述里提到的那次"明文 HTTP POST"就落在这里,也是能读到载荷内容的前提。

来源 D · logs/http.csv

request_id / src_ip / dst_ip / host / method / uri / 字节数全都在这里,是主表。

来源 E · edr/network.jsonl(端点侧)

{"ts":"2026-10-08T21:14:22Z","endpoint":"WS-104","src_ip":"10.10.4.17",
 "process":"/usr/local/bin/telemetry-bridge","pid":4172,"parent_pid":1208,
 "dst_host":"sync-cache.example.test","dst_ip":"198.51.100.88",
 "action":"connect","request_id":"req-71f2a","bytes_out":486}

进程路径 /usr/local/bin/telemetry-bridge —— 端点侧直接给了"谁发的"。

五个来源相互独立(DNS 解析、代理、TLS 元数据、HTTP 主表、端点 EDR),request_id、时间戳、IP、字节数全部对齐。DNS、代理、TLS 元数据、HTTP、EDR 五路互相印证,足够支撑结论。

5. 第三步:为什么不是普通遥测

telemetry-bridge 这个名字在 edr/network.jsonl 里反复出现,多数流向 packages.example.test(已批准):

{"ts":"2026-10-08T20:06:06Z","endpoint":"WS-104","src_ip":"10.10.4.17",
 "process":"/usr/local/bin/telemetry-bridge","pid":7944,
 "dst_host":"packages.example.test", ...}

对比一下:

维度 正常遥测 异常这一次
目标 packages.example.test(批准) sync-cache.example.test(未批准)
协议 — tcp/80 明文(题目描述里点名的那种)
上行/下行 相对均衡 486 / 92(重外发)
UA — 伪装 TelemetryBridge/0.9

同一个进程名走了两个目标,只有未批准的那个是明文的 —— 这是"合法组件被滥用/被替换"的典型形状。

6. 第四步:从 pcap 里取密封字段

captures/egress.pcap 里有完整的请求体。手工解析(无 tshark 环境):

import struct, urllib.parse

d = open("captures/egress.pcap", "rb").read()
off, body = 24, None     # 跳过 pcap global header
while off + 16 <= len(d):
    ts, tu, incl, orig = struct.unpack("<IIII", d[off:off+16]); off += 16
    pkt = d[off:off+incl]; off += incl
    ip  = pkt[14:34]                       # 跳过以太网头
    ihl = (ip[0] & 0xf) * 4
    tcp = pkt[14+ihl:]
    doff = (tcp[12] >> 4) * 4
    pl  = tcp[doff:]
    if b"batch=" in pl:
        body = pl.decode("latin1")

head, _, payload = body.partition("\r\n\r\n")
q = urllib.parse.parse_qs(payload)
print("node =", q["node"], "event =", q["event"])
seal = q["batch"][0].replace(" ", "+")     # ★ base64 的 '+' 在 urlencoded 里被解成空格,要补回来
print("seal len =", len(seal))

请求体是 application/x-www-form-urlencoded,四个字段:

node=WS-104&event=flush&batch=ZUs+7PncpEC94Yn3wn+0Fu3D8aHXq6zHenkRRY/TdHlhFjy455A=&trace=TTTT...

承载证明值的就是 batch。

要交的是"承载证明值的编码字段",答案是 batch——它的内容是 base64 密封串,不是明文。

这里有一个必踩的坑:batch 的 base64 里含 +,而 + 在 application/x-www-form-urlencoded 里是空格的转义。直接 urllib.parse.parse_qs() 会把 + 解成空格:

ZUs+7PncpEC94Yn3wn+0Fu3D8aHXq6zHenkRRY/TdHlhFjy455A=
                ↓ parse_qs
ZUs 7PncpEC94Yn3wn 0Fu3D8aHXq6zHenkRRY/TdHlhFjy455A=
                ↓ base64.b64decode
binascii.Error: Invalid base64-encoded string:
number of data characters (49) cannot be 1 more than a multiple of 4

修法:把解析出来的空格还原成 +。

s = urllib.parse.parse_qs(raw_body)["batch"][0].replace(" ", "+")

(注意:服务端生成这个请求体时如果对 + 做了 %2B 转义,parse_qs 会正确还原;本题没转义,所以要手工补。)

7. 第五步:拼指纹并解封

case/VERIFICATION.txt 规定了指纹组成:

来源主机 | 目标主机 | 请求 URI | 端点进程路径

顺序照抄,字符串用证据里的精确原值:

WS-104|sync-cache.example.test|/api/v2/collect|/usr/local/bin/telemetry-bridge

注意第一个字段是来源主机名 WS-104,不是 IP 10.10.4.17。用 IP 解出来是乱码(我一开始就试过 10.10.4.17|...,输出不可读)。

解封:

import base64, hashlib

def unseal(fp, seal_b64):
    raw = base64.b64decode(seal_b64)
    ks  = hashlib.sha256(fp.encode()).digest()
    return bytes(b ^ ks[i % 32] for i, b in enumerate(raw)).decode()

fp = "WS-104|sync-cache.example.test|/api/v2/collect|/usr/local/bin/telemetry-bridge"
print(unseal(fp, seal))
flag{1c9a4b7e2d5f8a3b6c0e9d2f4a7b1c3e}

8. 完整求解脚本

#!/usr/bin/env python3
import base64, hashlib, struct, urllib.parse, csv, json, re

ROOT = "."

# 1) 白名单
approved = {l.strip() for l in open(f"{ROOT}/case/known-good-domains.txt")
            if l.strip() and not l.startswith("#") and "本次演练" not in l
            and "不在本列表" not in l and l.strip().endswith(".test")}

# 2) HTTP 主表里筛 WS-104 的未批准目标
sus = []
with open(f"{ROOT}/logs/http.csv") as f:
    for r in csv.DictReader(f):
        if r["src_ip"] == "10.10.4.17" and r["host"] not in approved:
            sus.append(r)
print("[1] 未批准目标:", [(r["host"], r["uri"], r["method"]) for r in sus])
rid = sus[0]["request_id"]

# 3) 多源确认
for src, pat in [
    (f"{ROOT}/logs/dns.log",      r"sync-cache"),
    (f"{ROOT}/logs/proxy.log",    rid),
    (f"{ROOT}/logs/tls.log",      rid),
    (f"{ROOT}/edr/network.jsonl", rid),
]:
    for line in open(src, errors="replace"):
        if pat in line:
            print("[src]", src.split("/")[-1], "->", line.strip()[:160])

# 4) EDR 取进程
proc = None
for line in open(f"{ROOT}/edr/network.jsonl"):
    e = json.loads(line)
    if e.get("request_id") == rid:
        proc = e["process"]
print("[2] 进程 =", proc)

# 5) pcap 取 seal
d = open(f"{ROOT}/captures/egress.pcap", "rb").read()
off, body = 24, None
while off + 16 <= len(d):
    ts, tu, incl, orig = struct.unpack("<IIII", d[off:off+16]); off += 16
    pkt = d[off:off+incl]; off += incl
    ip = pkt[14:34]; ihl = (ip[0] & 0xf) * 4
    tcp = pkt[14+ihl:]; doff = (tcp[12] >> 4) * 4
    pl = tcp[doff:]
    if b"batch=" in pl:
        body = pl.decode("latin1")
q = urllib.parse.parse_qs(body.partition("\r\n\r\n")[2])
seal = q["batch"][0].replace(" ", "+")     # ★ 关键:还原 base64 的 '+'

# 6) 解封
fp = f"{sus[0]['host'] and 'WS-104'}|{sus[0]['host']}|{sus[0]['uri']}|{proc}"
print("[3] 指纹 =", fp)
raw = base64.b64decode(seal)
ks = hashlib.sha256(fp.encode()).digest()
print("[4] FLAG =", bytes(b ^ ks[i % 32] for i, b in enumerate(raw)).decode())

9. 踩坑提示

坑 说明
拿 POST 当判据 大量正常遥测也是 POST(telemetry-approved.example.test)。必须叠加"目标是否批准"
拿"未知域名"当定论 题目描述里已经说明未批准域名只是线索。必须多源互证
指纹第一段填了 IP 是主机名 WS-104,不是 10.10.4.17。填错解出乱码
指纹里的进程填成 telemetry-bridge 要完整路径 /usr/local/bin/telemetry-bridge
URI 带上了查询串 这里是 /api/v2/collect,没有查询串,别自己加 /api/v2/collect?x=1
pcap 里找不到 batch 只抓了单个方向、或请求被切到多个 TCP 段。本题单包完整,直接遍历即可
base64.b64decode 报 padding 错误 ★ 最坑的一个。parse_qs() 把 base64 里的 + 解成了空格,必须 .replace(" ", "+") 还原
把 req-71f2a 当成账号 它是 request_id,用来跨源 join

9.1 实测输出

[1] [('sync-cache.example.test', '/api/v2/collect', 'POST')]

[src] logs/dns.log    -> 2026-10-08T21:14:19Z 10.10.4.17 sync-cache.example.test A 198.51.100.88
[src] logs/proxy.log  -> 2026-10-08T21:14:22Z request_id=req-71f2a client=10.10.4.17
                         host=sync-cache.example.test method=POST uri=/api/v2/collect
                         category=unclassified action=allowed sent=486 received=92
[src] logs/tls.log    -> 2026-10-08T21:14:22Z req-71f2a 10.10.4.17 198.51.100.88 tcp/80
                         cleartext-host=sync-cache.example.test note=payload-inspection-enabled
[src] edr/network.jsonl -> {"endpoint":"WS-104","process":"/usr/local/bin/telemetry-bridge",
                            "pid":4172,"parent_pid":1208,"dst_host":"sync-cache.example.test",
                            "request_id":"req-71f2a","bytes_out":486}

[2] proc  = /usr/local/bin/telemetry-bridge
[3] keys  = ['node', 'event', 'batch', 'trace']   seal len = 52
    RAW   = node=WS-104&event=flush&batch=ZUs+7PncpEC94Yn3wn+0Fu3D8aHXq6zHenkRRY/TdHlhFjy455A=&trace=TTTT...
[4] fp    = WS-104|sync-cache.example.test|/api/v2/collect|/usr/local/bin/telemetry-bridge
[5] FLAG  = flag{1c9a4b7e2d5f8a3b6c0e9d2f4a7b1c3e}

注意 trace 字段是一串全同的 T——取它解出来是空的,说明选错了字段。真正的证明值在 batch 里。

10. 防守建议

  1. DNS 层做白名单:端点不应能解析非批准域名。sync-cache.example.test 在 21:14:19 就该被拦下;
  2. 代理按分类而非域名放行:category=unclassified 的 POST 不该默认 action=allowed;
  3. 上行字节做基线:486B 相对同主机日常流量是否异常,需要基线比对而不是固定阈值;
  4. 进程 × 目标关联检测:telemetry-bridge 平时只去 packages,去了别处就该告警 —— 这是"合法组件被滥用"最有效的检测点;
  5. 强制 TLS:本题之所以能取证,是因为走了 tcp/80 明文。全面 TLS + 出站检查,既能增加对手成本,也会让取证变难(需要 EDR 侧可观测性补位)。

11. 小结

这道题的价值全在"不要单源定罪"。技术动作很简单:

WS-104 → 白名单过滤 → sync-cache.example.test → req-71f2a
       → DNS / proxy / TLS / HTTP / EDR 五源对齐
       → 进程 /usr/local/bin/telemetry-bridge
       → pcap 取 batch= → 四元指纹解封

答案:

来源主机   WS-104 (10.10.4.17)
目标主机   sync-cache.example.test (198.51.100.88)
请求 URI   /api/v2/collect          (方法 POST,明文 tcp/80)
相关进程   /usr/local/bin/telemetry-bridge (pid 4172)
编码字段   batch
指纹       WS-104|sync-cache.example.test|/api/v2/collect|/usr/local/bin/telemetry-bridge
FLAG       flag{1c9a4b7e2d5f8a3b6c0e9d2f4a7b1c3e}