| 项目 | 内容 |
|---|---|
| 分类 / 难度 | Misc / DFIR / Medium |
| 分值 | 250 |
| 案件号 | NET-26-104 |
| 类型 | 网络取证(多源交叉验证 + 明文外传识别) |
| 目标 | 定位"用 POST 向未批准域名外传"的完整五元组,解封证明字段 |
1. 案情
SOC 在某端点发出异常庞大的明文 HTTP POST 后收到一条低置信度告警。既有业务遥测同样使用 POST,因此仅凭请求方法不足以构成证据。
题目描述里给了三条交付要求:
- 确定来源主机、目标、URI、相关进程;
- 至少关联三个相互独立的来源;
- 不要把单条 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,不是 IP10.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. 防守建议
- DNS 层做白名单:端点不应能解析非批准域名。
sync-cache.example.test在21:14:19就该被拦下; - 代理按分类而非域名放行:
category=unclassified的 POST 不该默认action=allowed; - 上行字节做基线:486B 相对同主机日常流量是否异常,需要基线比对而不是固定阈值;
- 进程 × 目标关联检测:
telemetry-bridge平时只去packages,去了别处就该告警 —— 这是"合法组件被滥用"最有效的检测点; - 强制 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}