赛制与数据格式

做出来的题目一共 19 道。离线题包 16 个,编号 HG-01 到 HG-14,外加 HG-17 和 HG-19,其中 HG-20 与 HG-19 共用同一份 transactions.csv;靶机题两道,编号 HG-16 和 HG-18,需要带着平台下发的 Cookie 访问实例靶机。HG-15 没有见到题包,全程未做。

<工单尾段>|<段名>|<主键>|<SHA256("题号|段名|主键")前8位>

提交时外面再套一层 flag{}。也就是说真正工作量在找主键上,主键一旦确定,校验位用 hashlib 一行就能算出来:

import hashlib

def make_flag(ticket, seg, key):
    h = hashlib.sha256(f"{ticket}|{seg}|{key}".encode()).hexdigest()[:8]
    return f"flag{{{ticket}|{seg}|{key}|{h}}}"

print(make_flag("IM-0044", "uid", "user583921"))

离线题包

HG-01 群聊身份溯源(IM-0044)

检材是 hg01_chat.zip 解出的 chat_export.json 和 contacts.json,聊天数据 12.5 万条。2026-09-20 换了新题包重做过一次,新包 chat_export.json 的 SHA256 是 61ca7d0c 开头,和旧包逐字节一致,检材本身没有变化。

这题的题干是穿透一个多次改名的账号。思路是按 from_id 聚合,统计每个账号用过的昵称和每个昵称下的消息条数与起止时间:

import json
from collections import defaultdict

msgs = json.load(open("chat_export.json", encoding="utf-8"))
nick = defaultdict(lambda: defaultdict(int))
first_seen = {}
for m in msgs:
    uid, name, ts = m["from_id"], m["nickname"], m["ts"]
    nick[uid][name] += 1
    first_seen[(uid, name)] = min(first_seen.get((uid, name), ts), ts)

for uid, names in nick.items():
    if len(names) > 1:
        for name, cnt in sorted(names.items(), key=lambda x: first_seen[(uid, x[0])]):
            print(uid, name, cnt, first_seen[(uid, name)])

跑完之后整个数据集里只有 user583921 一个多次改名账号,昵称轨迹是 K7搬运(3422 条,02-11 起)、阿刀(4239 条,02-21 起)、老K(4340 条,02-23 起)。

单有改名轨迹还不够,需要找到改名的自证。往下翻到 id 70909 附近,有两条换号声明,id 紧邻、时间只差两分钟:

id=70909  2026-02-23 06:07  刀哥(user441208)   「这号要弃了,以后认准他」
id=70916  2026-02-23 06:09  老K(user583921)    「换号了,旧号别再转账」

这两条对上了:441208 声明弃号,583921 声明接棒,时间上一前一后。

题目的干扰设计值得单独说。user441208(刀哥)出了 25001 条消息,出镜率和总量都是第一,但它恰恰是诱饵——工单的处置口径里明确写了禁止按总量第一结案,误报案例也是拿这个当反面教材。user662190 则是同名干扰:只用「阿刀」这个昵称,02-21 起共 400 条,和 583921 的「阿刀」阶段同名,单看昵称会撞车。再拿通讯录做交叉验证:441208 备注是刀哥(旧号),583921 备注是 K(说是新号),662190 无备注。

结论:换号账号是 user583921,校验位 sha256("HG-01|uid|user583921") 前 8 位为 49e597aa,flag 为 flag{IM-0044|uid|user583921|49e597aa}。

有一个备选口径值得一提:如果平台按「旧号」判,答案会是 user441208,校验位 c58e8188。但工单事项写的是「昵称变更后的账号」,题干也是穿透改名账号,所以主解是 583921。

HG-02 多源身份归一(ID-0018)

检材三份:chat.json、contacts.csv、payments.csv。这题的核心是把聊天里的代称和支付记录里的收款人归一到同一个人。

聊天记录里散落着几条关键信息:阿飞说「王总尾号 7916 那个」,小周说「王工也是 7916」「人事王工在白天」「别找错王」,阿飞又补了一句「W哥用招行那张」。两个王、尾号相同,光看聊天根本分不开。

分不开就往支付记录里找。payments.csv 里三条记录把边界画清楚了:P1001 备注给老王,收款人是 PERSON_017(王磊,13800007916,夜班);P1002 备注是 W哥通道费,收款人同样是 PERSON_017,用的是招行卡 CMB-8817,和聊天里 W哥用招行那张对上;P1003 是王工午餐,收款人 PERSON_004(王强,13911117916,人事、白天活动)。王工的尾号确实也是 7916,但小周那句「别找错王」已经提前否定了混淆的可能,而王总/老王/W哥三条线索全部收敛到 PERSON_017。

结论:王总/老王即 PERSON_017,flag{ID-0018|who|PERSON_017|f969099a}。

HG-03 群组源头还原(GRP-0006)

检材只有一份 group_events.csv。要找的是群 G-8821 的建群者,但不能把所有建群事件都当答案,得先区分系统写入和自然人动作。

筛出 G-8821 相关的建群/导入事件:E00001 的 actor 是 SYSTEM,通道 tg-migrate;E00002 也是 SYSTEM,legacy-import 导入。这两条是平台迁移产生的系统记录,排除。E00003 是 actor_id=1028、client=android、2026-01-08 21:14 的建群动作,有真实的客户端指纹,这才是自然人建的群。另一条 E00006 是 7712 用 iOS 建的,但建的是另一个群 G-9910,纯粹是瞄着看错群号设计的诱饵。

结论:建群者 1028,flag{GRP-0006|uid|1028|4fabaddd}。

HG-04 群管权限审计(ADM-0011)|两段

检材:group_events.csv、members.csv、authorization_windows.csv。两问,一问要登录名,一问要人员编号。

做法是把权限类事件全部捞出来再逐个对授权窗口:

import csv
from collections import defaultdict

ops = defaultdict(list)
with open("group_events.csv", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["event_type"] in {"kick", "promote", "create_invite", "change_permission"}:
            ops[row["actor_id"]].append((row["ts"], row["event_type"]))

for actor, events in sorted(ops.items()):
    print(actor, len(events), events[:3])

一共 11 条权限事件。7704 也就是 lin.hao 占了 9 条:02-13 09:00 到 13:00 的 5 次踢人,02-14 的 2 次提升,02-14 14:00 的 1 次改权限,02-14 15:00 的 1 次发邀请,全部落在他自己的 group-admin 授权窗口(2026-02-12 至 03-31)内。6602 也就是 ah_fei 只有一个小时的临时窗口(02-13 11:00 到 12:00),窗口内做了 1 次踢人和 1 次邀请。

剩下两个数字上的大户都是烟雾弹。90001 是 welcome_bot,在 members.csv 里 role 挂着 admin,但行为上只发了 80 条欢迎语,没有任何权限操作;8801 是 talker,发言 4 万条,权限操作为零。成员表里的角色字段和行为对不上的时候,以行为为准。

结论:权限账号是 lin.hao,人员编号是 ADMIN_04。flag{ADM-0011|login|lin.hao|e1a5dee0} 和 flag{ADM-0011|staff|ADMIN_04|1d682bbc}。

HG-05 现场图像取证(IMG-0003)

检材:screenshot.png 和 mapping.csv。图里是一块排班看板,工位标签是 B17。这里有个小坑:画面文字转录出来的结果和图像本体有出入,以图为准,转录只作辅助。拿着 B17 去 mapping.csv 里查 code=B17、kind=desk,得到 group_id=DESK_B17。

结论:flag{IMG-0003|desk|DESK_B17|69cd0b6a}。

HG-06 图像元数据勘验(GPS-0002)

检材:photo.jpg 和 locations.csv。照片 EXIF 里有完整的 GPS 信息,用 Pillow 提取并换算成十进制度:

from PIL import Image
from PIL.ExifTags import GPSTAGS

img = Image.open("photo.jpg")
gps = img._getexif()[34853]
d = {GPSTAGS.get(k, k): v for k, v in gps.items()}

def to_deg(vals, ref):
    dd = vals[0] + vals[1] / 60 + vals[2] / 3600
    return -dd if ref in ("S", "W") else dd

lat = to_deg(d["GPSLatitude"], d["GPSLatitudeRef"])
lon = to_deg(d["GPSLongitude"], d["GPSLongitudeRef"])
print(lat, lon)

解出来是 N 34°46'6.636"、E 113°39'11.988",换算后 (34.76851, 113.65333)。和 locations.csv 里各点算球面距离,LOC_03(34.7684, 113.6532,标注为工作点-仓储)几乎重合,其余点明显更远。

import math

def dist(lat1, lon1, lat2, lon2):
    p = math.pi / 180
    a = 0.5 - math.cos((lat2 - lat1) * p) / 2
    b = math.cos(lat1 * p) * math.cos(lat2 * p) * (1 - math.cos((lon2 - lon1) * p)) / 2
    return 12742 * math.asin(math.sqrt(a + b))

结论:最近地点 LOC_03,flag{GPS-0002|gps|LOC_03|e02d26e9}。

HG-07 浏览器痕迹取证(HIS-0007)|两段

检材是一个 Chrome 的 History SQLite 文件。urls 表和 visits 表要关联查询,Chrome 的时间戳是 1601 纪元的微秒数,需要换算:

import sqlite3
from collections import Counter
from datetime import datetime, timedelta, timezone

db = sqlite3.connect("History")
rows = db.execute("""
    SELECT u.url, v.visit_time FROM urls u JOIN visits v ON u.id = v.url
""").fetchall()

EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
stats = Counter()
for url, ts in rows:
    dt = EPOCH + timedelta(microseconds=ts)
    host = url.split("/")[2] if "://" in url else url
    stats[(host, dt.hour)] += 1

按主机和小时统计后画像非常清楚:panel.demo.test 共 864 次访问,全部落在凌晨 2 到 4 点,路径只有 /login 和 /admin/jobs,这是典型的夜间自动化访问后台的特征。status.demo.test/health 凌晨 3 点有 40 次,是健康检查探活,行为正常,排除。google、shop、news 那些是全天分布的正常流量,同样排除。

提交格式按结案规范来:host 取去协议、去端口、去路径后的小写主机名;path 取去首尾斜杠、内部斜杠换成点再转小写。

结论:host 是 panel.demo.test,path 是 admin.jobs。flag{HIS-0007|host|panel.demo.test|90088c62} 和 flag{HIS-0007|path|admin.jobs|bb29cf9f}。

HG-08 正式包识别(DL-0015)

检材:Downloads 目录下 5 个 APK、download_history.csv、chat_excerpt.json。这题要在一堆包里找出真正的工作端正式包,聊天记录先给出了时间锚点和排除条件:k7_core 说「工作端发你了,看下载记录 02:17 那条」,qa_bot 说「白天那条是测试通道」,系统更新对应 ota.bin,商店应用「不要当班用」。

那条下载记录的文件名是「计算器.apk」。但文件名不能直接当结论,得解包验证。逐个解开 AndroidManifest.xml 读包名——用 zipfile 直接解,从二进制清单里抠字符串就够了:

import zipfile

def pkg_name(path):
    with zipfile.ZipFile(path) as z:
        raw = z.read("AndroidManifest.xml")
    i = raw.find(b"package")
    return raw[i:i+80]

for name in ["a.apk", "b.apk", "c.apk", "d.apk", "e.apk"]:
    print(name, pkg_name(name))

结果:计算器是 com.demo.workchat,测试包是 com.demo.workchat.test,办公套件是 com.office.suite,开心消消乐是 com.game.happy,系统更新是 com.android.updater。再进 assets/config.json 看渠道字段,计算器标的 ops/shift,是正式工作端渠道;测试包标的是 qa,对应聊天里「白天那条是测试通道」的排除条件。

结论:正式包名 com.demo.workchat,flag{DL-0015|pkg|com.demo.workchat|552cbd0b}。题包实际位于 Reverse\hg08_apk.zip,同目录的 HG-08.txt 是个空文件。

HG-09 消息数据库取证(MSG-0029)

检材 messages.db。这个库是本地缓存,除了消息表还带着编辑历史和撤回墓碑,直接 SQL 查就行,不需要做数据恢复。

先看 message_edits 表:消息 19604(发送人 k_core)被编辑了两次,原文是「改走备用通道,今晚23:40前把测试单结掉,不要发到大群。旧群那条是复述,作废。」,第一次改成空字符串,随后这条消息进了 message_tombstones,撤回类型是 user_revoke。改空再撤回,这条指令被刻意抹掉了。

另一个候选是消息 17382(发送人 qiang),内容也是「通道改走备用…」,但它是群内复述,撤回类型是 admin_revoke。结案规范里的错误示例明确写了 MSG_17382 不得分,这道题的错误示例直接把坑标出来了。

注意提交用 messages.id 的原始编号,不加任何前缀。

结论:被改空再撤回的通知是 19604,flag{MSG-0029|msg|19604|39313f46}。

HG-10 招募链研判(INV-0008)

检材:invites.csv 和 users.csv。要找有效招募量最大的账号,有效两个字是关键:只算 status=joined,且邀请对象必须在 users.csv 里存在、account_kind=user(真人),按 invitee 去重后按 inviter 统计。

import csv
from collections import defaultdict

users = {r["user_id"]: r for r in csv.DictReader(open("users.csv", encoding="utf-8"))}
seen, cnt = set(), defaultdict(int)
for row in csv.DictReader(open("invites.csv", encoding="utf-8")):
    if row["status"] != "joined":
        continue
    inv = row["invitee"]
    if inv in seen or inv not in users or users[inv]["account_kind"] != "user":
        continue
    seen.add(inv)
    cnt[row["inviter"]] += 1

print(cnt.most_common(5))

结果 8807(k7_core)招了 200 个真实用户,对应 recruit0 到 recruit199;第二名 7712 只有 36 个。排除项也要交代清楚:90001 是 puller_bot,UA 是 python-requests 的脚本拉机器人,它的「邀请」全是脚本行为;6601 是 growth_bot;还有 migrated 标记的系统迁移记录。另一个细节是主键要用 users.user_id 的原值,不要用邀请表里的展示前缀。

结论:有效招募账号 8807,flag{INV-0008|uid|8807|61ca10f2}。

HG-11 任务链溯源(ORG-0004)

检材:tasks.csv 和 chat.json。要找 USER_M09 的直接上级,注意是直接上级,不是组织的最高负责人。

tasks.csv 里 USER_M09 的 T2(夜班收款)和 T3(备用卡)都由 USER_A17 直接分派;USER_A17 自己的 T1 则由 USER_C01 分派。聊天记录互相印证:USER_A17 对 USER_M09 说「你的单子我刚派了」,而 USER_C01 只在调度组里喊话催报,从不直接给成员派单。层级结构是总负责人 USER_C01、组长 USER_A17、成员 USER_M09,USER_M09 的直接上级就是 USER_A17。

这道题的陷阱在于 C01 的存在感更强,如果不看分派行为只看头衔,很容易把最高负责人当答案。

结论:直接上级 USER_A17,flag{ORG-0004|boss|USER_A17|9a0f7066}。

HG-12 终端关联研判(DEV-0013)

检材 login.csv。按 device_id 分组,统计每个设备上登录过的账号数和账号特征:

import csv
from collections import defaultdict

dev = defaultdict(lambda: {"accs": set(), "ua": set(), "build": set(), "install": set()})
for row in csv.DictReader(open("login.csv", encoding="utf-8")):
    d = dev[row["device_id"]]
    d["accs"].add(row["account"])
    d["ua"].add(row["user_agent"])
    d["build"].add(row["build_fingerprint"])
    d["install"].add(row["install_id"])

for k, v in dev.items():
    print(k, len(v["accs"]), v["ua"], v["build"], v["install"])

DEVICE_F7A21 的画像:同一个 install_id、同一个 WorkChat/3.2 的 UA、同一个 build_fingerprint,却登录了 acc_a 到 acc_e 共 5 个业务号,200 条登录记录全部 result=ok、app=workchat。一台设备、一份环境指纹、五个业务号轮着登,这是标准的群控工作端。DEVICE_CAFE 是 Chrome UA,挂了 8000 个 guest 临时号,那是网吧公共机,排除;DEVICE_LAB 上是 lab_0 到 lab_4 的实验室测试号,排除;DEVICE_PHONE 单号正常使用。

结论:工作端设备 DEVICE_F7A21,flag{DEV-0013|dev|DEVICE_F7A21|00bf59f9}。

HG-13 共享账号审计(NIGHT-0005)|两段

检材四份:admin_access.jsonl、sessions.jsonl、staff.csv、admin_login.log。共享账号的审计难点在于访问记录只能看到会话,要先把会话绑定回登录名才能归责。

sessions.jsonl 存的是会话到登录名的绑定关系,sess-00 到 sess-11 共 12 个会话,全部是 01:50 发的夜班会话。共享账号 duty.night 在 admin_access.jsonl 里共 36 次访问,全部发生在凌晨 2 点,UA 是真实浏览器。按绑定关系计数:chen.yao 绑了 10 个会话、对应 30 次访问;wang.lei 绑了 2 个会话、6 次访问。

排除项同样要看行为:ops.bot 是 Go-http-client 固定 UA 的巡检脚本,cron.settle 是清算任务,li.na 是白天值班,都不在夜间人为操作的范围里。最后拿 staff.csv 映射,chen.yao 对应 OPERATOR_07。

结论:登录名 chen.yao,人员编号 OPERATOR_07。flag{NIGHT-0005|login|chen.yao|0930e9b1} 和 flag{NIGHT-0005|op|OPERATOR_07|da5a948f}。

HG-14 域名历史溯源(WHOIS-0009)

检材:whois_history.csv、registrar_accounts.csv、seed_domain.txt。种子域是 pay-safe.test。

WHOIS 历史里,种子域有一条记录挂着非隐私邮箱 &#x6f;&#112;&#115;&#46;&#x6c;&#x65;&#x61;&#x66;&#x40;&#x6d;&#97;&#x69;&#108;&#98;&#x6f;&#x78;&#x2e;&#116;&#x65;&#x73;&#116;、电话 +1-555-0100、holder_handle=H-LEAF-17。另一条记录里的 &#98;&#x69;&#x6c;&#108;&#x69;&#x6e;&#x67;&#x2e;&#108;&#101;&#97;&#102;&#64;&#114;&#101;&#x6c;&#97;&#121;&#x2e;&#x74;&#101;&#x73;&#116; 也指向同一个 holder。要注意的是 &#x70;&#114;&#105;&#118;&#x61;&#x63;&#121;&#x40;&#112;&#x72;&#111;&#120;&#121;&#45;&#114;&#101;&#x67;&#46;&#x74;&#101;&#115;&#116; 这种公共隐私代理邮箱,它在整个数据集里有 2 万多条,任何域名都可能挂它,不能作为关联依据。拿 H-LEAF-17 去 registrar_accounts.csv 里查,对应 REGISTRANT_09。

结论:注册主体 REGISTRANT_09,flag{WHOIS-0009|reg|REGISTRANT_09|72d9969f}。

HG-17 APK 家族识别(DEVFAM-0006)

检材:apks 目录下 a 到 f 共 6 个 APK,family_catalog.csv。这题官方埋了一个反直觉点:单看证书签名会判错,必须用资源结构加证书和接口配置交叉判断。

第一步是结构比对。六个包全部解开,逐文件算 SHA-256:

import hashlib, zipfile
from collections import defaultdict

def dump(path):
    m = {}
    with zipfile.ZipFile(path) as z:
        for n in z.namelist():
            m[n] = hashlib.sha256(z.read(n)).hexdigest()
    return m

apps = {n: dump(f"apks/{n}.apk") for n in "abcdef"}

for f in ["assets/config.json", "assets/www/app.js"]:
    ref = apps["a"].get(f)
    same = [n for n in apps if apps[n].get(f) == ref]
    print(f, "same:", same)

跑出来 a/b/c 三个包的 assets/config.json、assets/www/app.js、lib 下的 so 哈希完全一致,说明它们用的是同一套接口配置,只有 manifest、dex、arsc 和签名不同。d/e/f 的结构则各不相同。

第二步解 CERT.RSA 看证书主体,用 certutil -dump 就能看:a 和 b 是 CN:Leaf,c 是 CN:OtherCo,d 是 CN:Game,e 是 CN:Office,f 是 CN:Leaf。

两步合起来判定:a/b/c 结构一致、c 换过签,说明 c 是同一伙人换了签名重打包的,a/b/c 同属一个家族,对应 com.leaf.pay、com.leaf.wallet、com.leaf.loan;f 虽然和 a/b 用了同一张证书,但内部结构完全不同,它是同证书的无关包,本质是个 sdk stub——同证书不等于同作者,证书可以复用;d 和 e 是商店里的无关包。最后查 family_catalog.csv,里面有 3 个候选(01/06/09),a/b/c 这一支按顺序对应 DEV_FAMILY_01。

结论:开发者家族 DEV_FAMILY_01,flag{DEVFAM-0006|fam|DEV_FAMILY_01|84f842ba}。

HG-19 资金链分析(FUND-0015)|两段

检材 hg19_case 目录下的 transactions.csv,90210 笔交易。这是全场数据量最大的一道题,也是资金链主线。

先做基本清洗,只取状态为 SUCCESS 的交易(87585 笔)建转账图。跑第一轮分析时会撞上一个奇怪的账户:1 分钱测试转账共 52 笔,全部流向 ACCOUNT_22,而且这个账户从不转出。报备说明里明确写了这是 1 分连通性测试户,排除。

主链路跟着大额资金走:PAYIN 入金后分散进 8 张收款卡(CARD-03 到 CARD-24),8 张卡的真实资金全部汇入 ACCOUNT_15,合计 7.58M;ACCOUNT_15 的唯一去向是 ACCOUNT_28,7 笔共 6.35M,外加 1 笔失败批次;再往后就是终端了。

排除项比主链路本身更花时间。ACCOUNT_30 到 ACCOUNT_45 共 16 个账户之间存在海量互转,是典型的对敲盘口,网管快报里按总量推荐的是 ACCOUNT_42,那是留给 HG-20 的另一道题的,放在这道里就是诱饵;STAFF 和 ACCOUNT_02 是代发工资户;SHOP_09 是门店小额账户,同样是干扰。

import csv
from collections import defaultdict

out_amt, in_amt = defaultdict(float), defaultdict(float)
with open("transactions.csv", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["status"] != "SUCCESS":
            continue
        out_amt[row["from"]] += float(row["amount"])
        in_amt[row["to"]] += float(row["amount"])

top_in = sorted(in_amt.items(), key=lambda x: -x[1])[:10]
top_out = sorted(out_amt.items(), key=lambda x: -x[1])[:10]
print("in :", top_in)
print("out:", top_out)

按汇总金额排序后再人工核对对手方集合,CARD 一层的汇入方唯一指向 ACCOUNT_15,ACCOUNT_15 的转出方唯一指向 ACCOUNT_28,层级共 2 层:ACCOUNT_15 一级、ACCOUNT_28 二级。

flag 两段:flag{FUND-0015|l1|ACCOUNT_15|5d5329d9} 和 flag{FUND-0015|l2|ACCOUNT_28|01c1d95a}。

HG-20 资金过渡识别(TTL-0022)

检材与 HG-19 共用的 transactions.csv。这题要找的是过渡账户,也就是钱进来很快又出去、账户本身不留存的那个节点。做法是按账户把交易按时间排序,重建入账后 15 分钟内出账的配对关系,并且跑了窗口法和 FIFO 配对法两种口径交叉验证。

ACCOUNT_42 的数据:FIFO 配对 4377 笔,入 207.7M(分)、出 207.5M(分),覆盖率 0.80,累计过渡金额在所有口径里都是第一,窗口法的入加出口径同样是第一。ACCOUNT_43 只在宽松窗口乘快入额这一个口径下略高,差距 0.4%,属于口径敏感型账户,不是稳定的过渡节点。收款卡的覆盖率接近 100%,但金额只有百万级,是小额钱包的小样本,被金额阈值自然淘汰;STAFF 和 1 分测试户按报备直接排除。

结论:目标账户 ACCOUNT_42,flag{TTL-0022|acct|ACCOUNT_42|716edcca}。

这里需要留一个注记:HG-20 的题包没有附独立的结案规范和 rule.txt,尾段 TTL-0022 是从事件编号取的,段名 acct 是按「目标账户」推的,拿到正式规范之后应该按规范字段复核一遍。

线上动态席位

这两道是平台实例题,和离线题的差别在于:先带着 Cookie 登录实例,题面和回执都在实例里,flag 不用自己拼,从回执里解码。

HG-16 移动工作端取证(APP-0016)

题目要求从 APK 里找出核心业务 API 的主机名和保存它的配置键,再拿这个键去席位回执里对号,close_hex 解码得到 flag。

登录实例后先下载回执文件 submission-guide.txt。看 APK 结构,classes.dex 只有 920 字节,明显是个壳,业务逻辑在 assets 和 so 里。反编译 MainActivity,onCreate 里调用了 boot("com.demo.opsconsole", "https://api.chat-demo.test/api", "okhttp/4.12"),但 boot 是个空实现,传进去的参数是烟雾弹。真正的线索在 smali 的 const-string 里:

const-string v3, "configKey=baseUrl"
const-string v4, "hostPartA=api"
const-string v5, "hostPartB=ops-demo.test"
const-string v6, "purpose=job_dispatch_and_receipt"

四个字段拼起来:配置键是 baseUrl,主机名是 hostPartA 加点加 hostPartB,即 api.ops-demo.test。拿这个键去 /export/seat_ticket.json 的 channels 里挑 key=baseUrl 的那条,得到一串十六进制:

666c61677b4150502d303031367c6170697c6170692e6f70732d64656d6f2e746573747c3731363237373536383235387d

解码:

print(bytes.fromhex("666c61677b4150502d303031367c6170697c6170692e6f70732d64656d6f2e746573747c3731363237373536383235387d").decode())

flag:flag{APP-0016|api|api.ops-demo.test|716277568258}。

HG-18 夜间运维审计(OPS-0007)

题目要求从席位回执里定位成功且产生了配置或用户变更的来源。回执 night_ops_export.log,seat=390,SHA256 以 6860b954 开头、b7b78 结尾,内含 ip_catalog、proxy_catalog、admin_audit、nginx_access 四段,nginx_access 有 2446 行。

第一步在 admin_audit 里筛 result=ok 且属于配置或用户变更的记录。候选 5 条,其中 ban_user 和 publish_config 算变更(共 4 条),retry_job 是作业重试不算。这 4 条的 actor_ip 全部是 203.0.113.77,session_id 全部是 SES-7A21,edge_ip 是 10.20.0.14。

第二步拿 session=SES-7A21 回指 nginx_access,命中 6 行,全是 POST、状态码全 200、xff 全是 203.0.113.77:

02:08  /admin/login          登录,不算变更
02:16  /admin/users/ban
02:21  /admin/config/publish
02:26  /admin/jobs/retry     作业重试,不算变更
02:30  /admin/users/ban
02:38  /admin/config/publish

减掉 login 和 retry,剩 4 条变更,与 admin_audit 那 4 条按时间戳逐条对应,两条证据源完全咬合。

第三步是来源字段的取舍,这也是这题最容易错的地方。nginx 日志第一列的 10.20.0.14 是边缘代理,不是客户端,不能当来源。客户端要看 xff,这 6 行全是 203.0.113.77,和 admin_audit 的 actor_ip 一致。proxy_catalog 里把 10.20.0.14 和 10.20.0.15 标成了 internal-edge,策略是 rightmost-untrusted——按这个策略应该取 xff 链的最右一个可信值,但实际上全部 2446 行的 xff 都是单值、没有逗号,根本不存在代理链,所以这个策略不影响取值,直接用 xff 即可。

其他来源逐一排除:10.0.0.8(ADMIN_IP_01)1800 次访问全是 GET /healthz 探活,没有变更;198.51.100.12(ADMIN_IP_09)600 次 masscan 扫描,没有一条 200;203.0.113.20(ADMIN_IP_03)40 次都是 day.ops 白班只读 dashboard。最后查 ip_catalog,203.0.113.77 对应 ADMIN_IP_07,备注「出口待核」,flag 里要的就是这个编号。

取对应行的 x-close 做十六进制解码,4 条变更行和 6 行 POST 的 x-close 是同一个值:

666c61677b4f50532d303030377c7372637c41444d494e5f49505f30377c3764333535633365353635627d

flag:flag{OPS-0007|src|ADMIN_IP_07|7d355c3e565b}。

有几个细节可以补充。这串 hex 有 86 个字符,即 43 字节,和 flag 全文等长。整个文件 2446 个 x-close 里只有这个值能解出可读文本,其余都是随机值,所以就算挑错了行,能解出来的也只有一个,不会误判。格式上 xff 和 session 字段带引号,x-close 不带引号。最后说一下归责边界:来源 IP 可能经过代理、跳板或 NAT,这个结论只能说明控制来源是 ADMIN_IP_07,不等于坐实了具体的人,做案件研判时要把这一层不确定性留着。

Flag 汇总

题

工单

主键

flag 内容

HG-01

IM-0044

user583921

IM-0044|uid|user583921|49e597aa

HG-01 备选

IM-0044

user441208

IM-0044|uid|user441208|c58e8188

HG-02

ID-0018

PERSON_017

ID-0018|who|PERSON_017|f969099a

HG-03

GRP-0006

1028

GRP-0006|uid|1028|4fabaddd

HG-04

ADM-0011

lin.hao

ADM-0011|login|lin.hao|e1a5dee0

HG-04

ADM-0011

ADMIN_04

ADM-0011|staff|ADMIN_04|1d682bbc

HG-05

IMG-0003

DESK_B17

IMG-0003|desk|DESK_B17|69cd0b6a

HG-06

GPS-0002

LOC_03

GPS-0002|gps|LOC_03|e02d26e9

HG-07

HIS-0007

panel.demo.test

HIS-0007|host|panel.demo.test|90088c62

HG-07

HIS-0007

admin.jobs

HIS-0007|path|admin.jobs|bb29cf9f

HG-08

DL-0015

com.demo.workchat

DL-0015|pkg|com.demo.workchat|552cbd0b

HG-09

MSG-0029

19604

MSG-0029|msg|19604|39313f46

HG-10

INV-0008

8807

INV-0008|uid|8807|61ca10f2

HG-11

ORG-0004

USER_A17

ORG-0004|boss|USER_A17|9a0f7066

HG-12

DEV-0013

DEVICE_F7A21

DEV-0013|dev|DEVICE_F7A21|00bf59f9

HG-13

NIGHT-0005

chen.yao

NIGHT-0005|login|chen.yao|0930e9b1

HG-13

NIGHT-0005

OPERATOR_07

NIGHT-0005|op|OPERATOR_07|da5a948f

HG-14

WHOIS-0009

REGISTRANT_09

WHOIS-0009|reg|REGISTRANT_09|72d9969f

HG-17

DEVFAM-0006

DEV_FAMILY_01

DEVFAM-0006|fam|DEV_FAMILY_01|84f842ba

HG-19

FUND-0015

ACCOUNT_15

FUND-0015|l1|ACCOUNT_15|5d5329d9

HG-19

FUND-0015

ACCOUNT_28

FUND-0015|l2|ACCOUNT_28|01c1d95a

HG-20

TTL-0022

ACCOUNT_42

TTL-0022|acct|ACCOUNT_42|716edcca

靶机题(回执解码直接用):

题

flag

HG-16

flag{APP-0016|api|api.ops-demo.test|716277568258}

HG-18

flag{OPS-0007|src|ADMIN_IP_07|7d355c3e565b}