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

打烊之后

八类日志的时间线归一化与身份归因,从打烊后的异常活动还原完整攻击路径。

方向取证
难度 Medium
分值250 分
题号17 / 20
项目 内容
分类 / 难度 Misc / DFIR / Medium
分值 250
类型 多源日志取证(时间线归一化 + 身份归因)
输入 evidence.tar.gz(8 类日志 + 导出元数据)
目标 还原「谁在非工作时段用服务账号导出了受限数据」,并用五元指纹解封证明字段

1. 案情

工作日 20:00–23:00(UTC)本该无人操作。但这段时间里,一个服务账号做了它不该做的事:交互式登录、拨 VPN、然后导出 finance/quarterly-ledger。

题目给的证据是一包日志,重点是每份日志的时钟都不一样:

{"path": "logs/idp-auth.jsonl",   "clock": "UTC"},
{"path": "logs/vpn.csv",          "clock": "America/New_York UTC-04:00"},
{"path": "logs/api-gateway.jsonl","clock": "UTC, approximately 800ms fast"},
{"path": "logs/app-audit.jsonl",  "clock": "Unix epoch milliseconds UTC"},
{"path": "logs/cloud-audit.jsonl","clock": "UTC"},
{"path": "logs/edr-process.jsonl","clock": "America/Los_Angeles UTC-07:00, 18 seconds slow"},
{"path": "logs/dns.log",          "clock": "UTC"},
{"path": "logs/egress.csv",       "clock": "UTC"}

这是这道题真正的考点:先校正时间,再拼时间线。若不归一化,VPN 的 18:17(EDT)会被误读成 18:17 UTC,整整差 4 小时,导致"是不是工作时间"的判断直接反掉。

2. 第一步:找出被滥用的身份

logs/idp-auth.jsonl 里搜交互式登录:

jq -c 'select(.event=="auth.login.success" and .interactive==true)' \
   logs/idp-auth.jsonl
{"event_id":"A-2201","event":"auth.login.success","interactive":true,
 "user":"svc-build","source_ip":"198.51.100.77","session_id":"idp-88301",
 "ts":"2026-10-07T20:16:42Z", ...}

关键判断:svc-build 是服务账号。服务账号走的是 client credentials / 机器证书,永远不该出现 interactive: true。这种"服务账号做人机交互登录",本身就是最强的异常信号。

于是锁定:

  • 被滥用身份 = svc-build
  • 首个公共来源 IP = 198.51.100.77(198.51.100.0/24 是 RFC 5737 文档保留段,在这里代表外网来源)

同一份日志里还有 IDP-ALERT-002 / IDP-ALERT-004 两条告警,确认了"非工作时段 + 服务账号 + 交互式"三重合。

3. 第二步:VPN 会话与隧道地址(注意时区)

logs/vpn.csv 用的是 America/New_York(UTC-04:00,夏令时):

session_id,user,connect_time,disconnect_time,tunnel_ip,client_ip
vpn-9921,svc-build,2026-10-07 18:17:05,2026-10-07 21:40:12,10.8.9.44,198.51.100.77
vpn-9922,deploy,2026-10-07 09:02:11,2026-10-07 10:15:33,10.8.9.91,203.0.113.24
vpn-9923,qa-bot,2026-10-07 11:41:20,2026-10-07 12:02:44,10.8.9.137,203.0.113.88

换算:

2026-10-07 18:17:05 EDT  ->  2026-10-07 22:17:05 UTC

对应 IdP 那次登录(20:16:42Z)后 1 分钟 —— 时间线严丝合缝。

于是:

  • VPN 会话 ID = vpn-9921
  • 隧道地址 = 10.8.9.44

对照 vpn-9922(deploy,09:02 EDT = 13:02 UTC)与 vpn-9923(qa-bot,11:41 EDT = 15:41 UTC),这两个都是正常工作时段,属于基线,不是目标。

4. 第三步:找出真正的导出事件

logs/app-audit.jsonl 用的是 epoch 毫秒,要先转:

jq -c 'select(.action=="export.create")' logs/app-audit.jsonl
{"ts":1791416705123,"action":"export.create","actor":"svc-build",
 "export_id":"EXP-771","dataset":"finance/quarterly-ledger",
 "classification":"restricted","session":"vpn-9921"}

1791416705123 ms = 2026-10-07T22:25:05Z。

这几个 blob 内容相近,得逐个排除才能确定哪个是真正被用过的那份:

导出 ID 创建者 分类 判读
EXP-766 deploy internal 例行每周发布报告(WEEKLY-REPORT-OK),会话 vpn-9922,工作时段 → 正常
EXP-771 svc-build restricted 非工作时段 + 服务账号 + finance/quarterly-ledger → 目标
EXP-772 qa-bot test CHG-4418 批准的 QA 夹具(QA-FIXTURE-001) → 正常
EXP-770 report-worker internal 计划内的仪表盘快照 → 正常

记住判断规则:看的是(身份 × 时段 × 分类 × 会话)的交集,不是单独看"是不是导出"。四个导出里只有一个四维全红。

5. 第四步:拼五元指纹

题目的证明字段是这样密封的:

seal = base64( 明文 XOR repeat( sha256( 指纹utf8 ) ) )

指纹是五个字段用 | 连接:

指纹 = 被滥用身份 | 首个公共来源IP | VPN会话ID | 隧道地址 | 导出ID
     = svc-build|198.51.100.77|vpn-9921|10.8.9.44|EXP-771

顺序不是随便定的,它是本案的因果链顺序:

谁(svc-build) → 从哪来(198.51.100.77) → 怎么进来(vpn-9921) → 拿到什么地址(10.8.9.44) → 干了什么(EXP-771)

6. 解封

import base64, hashlib, json

FINGERPRINT = "svc-build|198.51.100.77|vpn-9921|10.8.9.44|EXP-771"

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

meta = json.load(open("exports/EXP-771.metadata.json"))
print(unseal(FINGERPRINT, meta["payload_b64"]))
flag{local_verify_dfir01}

要点:密钥流 sha256(指纹) 只有 32 字节,长度不够时要循环复用(i % 32)。密文长度由明文决定,短于 32 字节时看不出循环,长了就必须循环,否则抛 IndexError。

7. 完整求解脚本

#!/usr/bin/env python3
import base64, hashlib, json, os, datetime

root = "."

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("utf-8", "replace")

# --- 1. 服务账号交互式登录 ---
sus_user = sus_ip = None
for line in open(os.path.join(root, "logs/idp-auth.jsonl")):
    e = json.loads(line)
    if e.get("event") == "auth.login.success" and e.get("interactive"):
        if "svc" in e.get("user", ""):
            sus_user, sus_ip = e["user"], e["source_ip"]
print("[1] 被滥用身份 =", sus_user, " 来源 =", sus_ip)

# --- 2. VPN:EDT -> UTC(+4h) ---
vpn_id = tunnel = None
for line in open(os.path.join(root, "logs/vpn.csv")).read().splitlines()[1:]:
    sid, user, ct, dt, tip, cip = line.split(",")
    if user == sus_user:
        vpn_id, tunnel = sid, tip
        utc = datetime.datetime.strptime(ct, "%Y-%m-%d %H:%M:%S") + datetime.timedelta(hours=4)
        print("[2] VPN", sid, ct, "EDT ->", utc, "UTC  tunnel =", tip)

# --- 3. 找到 restricted 导出 ---
export_id = None
for line in open(os.path.join(root, "logs/app-audit.jsonl")):
    e = json.loads(line)
    if e.get("action") == "export.create" and e.get("classification") == "restricted":
        export_id = e["export_id"]
print("[3] 导出 =", export_id)

# --- 4. 五元指纹 + 解封 ---
fp = "|".join([sus_user, sus_ip, vpn_id, tunnel, export_id])
print("[4] 指纹 =", fp)
meta = json.load(open(os.path.join(root, f"exports/{export_id}.metadata.json")))
print("[5] FLAG =", unseal(fp, meta["payload_b64"]))

输出:

[1] 被滥用身份 = svc-build  来源 = 198.51.100.77
[2] VPN vpn-9921 2026-10-07 18:17:05 EDT -> 2026-10-07 22:17:05 UTC  tunnel = 10.8.9.44
[3] 导出 = EXP-771
[4] 指纹 = svc-build|198.51.100.77|vpn-9921|10.8.9.44|EXP-771
[5] FLAG = flag{local_verify_dfir01}

8. 踩坑提示

坑 说明
时区不归一化 vpn.csv 是 EDT。18:17 EDT = 22:17 UTC,不是 18:17 UTC。差 4 小时会让"非工作时段"的判断失效
api-gateway.jsonl 快 800ms / edr-process.jsonl 慢 18s 这两份日志在本题里不参与指纹构造,但做时间线对账时必须带上偏差,否则跨源事件会被判成"倒序"
app-audit.jsonl 是 epoch 毫秒 直接当秒读会得到 1970 年,务必 /1000
export 有四个,只有一个是目标 看 classification == "restricted",再看 created_by 是否为服务账号,别只看时间
指纹顺序 不是按字母、不是按时间,是 `身份
密钥流循环 sha256 只有 32 字节,明文更长时必须 i % 32

9. 从防守角度看

这类事件在真实环境里的信号链非常典型,值得写进检测规则:

  1. 服务账号出现 interactive=true 登录 —— 服务账号应只允许 client_credentials,出现交互式登录直接告警;
  2. 非工作时段 VPN 接入 + 服务账号 —— 二维组合,单看任一维度都容易被噪音淹没;
  3. restricted 分类的数据被服务账号导出 —— 服务账号的导出白名单应该按 dataset 收紧;
  4. 同一凭证短时间内跨层动作(IdP → VPN → API → 导出),尤其是带外网源 IP 的。

把这四条串起来,本案在 22:17 就能拦下,而不是等到事后取证。

10. 小结

这道题不难在技术,难在耐心:8 份日志、4 种时钟、4 个导出、3 个 VPN 会话,其中大部分是基线噪音。真正的信号只有一条,判据是四维交集(身份 × 时段 × 分类 × 会话)。

答案:

被滥用身份   svc-build
来源 IP      198.51.100.77
VPN 会话     vpn-9921
隧道地址     10.8.9.44
导出 ID      EXP-771
指纹         svc-build|198.51.100.77|vpn-9921|10.8.9.44|EXP-771
FLAG         flag{local_verify_dfir01}