mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6mobile wallpaper 7
7575 字
22 分钟
2026御网杯数据安全赛道Writeup
2026-06-01

声明#

基本完全AI梭哈 不然速度这能比不过别人了 校内全是Agent 所以这个wp可能看的晦涩难懂 也没怎么手写wp 不过可以看看有的exp 本文是3个师傅的WP合起来的

CovertChannel#

截图

图 1 题目信息与附件提示整理

解题思路#

拿到附件后先确认其为 pcapng 流量文件,结合题目提示重点关注 10.10.20.33 的 ICMP 与 DNS 流量。整体思路是先做协议画像,再分别检查 DNS 明文信道和 ICMP 头字段/载荷隐写,最后根据流量中给出的算法提示还原 flag。

  1. 获取并确认附件

  2. 从附件中解压得到 suspicious_traffic.pcapng,文件大小约 142 KB。

  3. 使用 Scapy 读取流量,共 1746 个数据包,其中 DNS/UDP 602 个、ICMP 1144 个。

  4. 从会话统计中发现 10.10.20.33 到 45.76.188.23 的 ICMP Echo Request 数量异常,共 984 个,序号连续 0 到 983。

截图

图 2 流量画像与异常 ICMP 会话统计

  1. 分析 DNS 隐蔽信道获取密钥

在 DNS 查询中发现可疑域名 secret.exfil-cdn.com,请求类型为 TXT。其响应值为 Base64 字符串:

  • TXT = TW9yZVNlY3VyZUFlczEyOA==

  • Base64 解码结果:MoreSecureAes128

该字符串长度为 16 字节,符合 AES-128 密钥长度,后续作为解密密钥使用。

截图

图 3 DNS TXT 响应中提取 AES 密钥

  1. 分析 ICMP TTL 字段还原密文

继续检查 10.10.20.33 发往 45.76.188.23 的 984 个 ICMP Echo Request,发现 IP TTL 字段只出现 0 和 1 两种取值,明显不符合正常 TTL 行为,因此将 TTL 按顺序视为比特流。

  1. 按 ICMP seq 从小到大排序,提取每个包的 IP.ttl。

  2. 每 8 位组成 1 字节,按高位在前(MSB)解码。

  3. 得到字符串:密文十六进制 + 算法提示“AES-ECB, key is in DNS TXT”。

截图

图 4 ICMP TTL 比特流解码得到密文和算法提示

  1. AES-ECB 解密得到 flag

根据 ICMP 信道解出的提示,使用 DNS TXT 提供的密钥 MoreSecureAes128 对十六进制密文进行 AES-ECB 解密。解密后去除填充换行字符,得到最终 flag。

核心解密逻辑如下:

key = b'MoreSecureAes128'
ct = bytes.fromhex(cipher_hex)
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
flag = pt.rstrip(b'\n').decode()

截图

图 5 AES-ECB 解密结果与提交 flag(含时间)

  1. 总结
  • DNS TXT 通道负责传递 AES-128 密钥:MoreSecureAes128。

  • ICMP TTL 字段负责传递最终密文和算法提示。

  • 使用 AES-ECB 解密密文后得到最终提交值。

关键字段复核

DNS 可疑域名secret.exfil-cdn.com

DNS TXT 原始值TW9yZVNlY3VyZUFlczEyOA==

AES 密钥MoreSecureAes128

ICMP 隐写字段IP.ttl,按 seq 顺序取 0/1 比特

算法提示AES-ECB, key is in DNS TXT

最终 flag:flag{5f356e09a565c656c6d8ae31f7973452}

exam_system#

截图

解题思路#

题目提供一个 SQLite 数据库(200 名用户、80000 条访问日志)和一份审计规范。需要先通过身份证校验码和手机号段过滤出”幽灵账户”,再从剩余日志中按三类违规规则找出所有违规记录,最终对拼接字符串求 MD5 得到 flag。

Solution#

Step 1: 识别幽灵账户#

按规范,身份证号或手机号任一不合法的用户即为幽灵账户,其日志全部排除。

身份证校验码算法:

  1. 前 17 位分别乘以权重 [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2],求和

  2. 对 11 取余,查表得校验码:{0:1, 1:0, 2:X, 3:9, 4:8, 5:7, 6:6, 7:5, 8:4, 9:3, 10:2}

  3. 与第 18 位比对,不符则为幽灵账户

手机号校验: 11 位纯数字,前 3 位必须在规范给定的 52 个合法号段内。

共识别出 30 个幽灵账户

[3, 12, 16, 31, 34, 40, 43, 44, 63, 68, 72, 78, 80, 85, 88, 92,

112, 128, 132, 133, 134, 151, 160, 165, 172, 179, 186, 188, 192, 195]

Step 2: 扫描违规访问记录#

排除幽灵账户后,对每条日志按优先级(取最小类型编号)判定:

| 类型 | 判定条件 |

|------|----------|

| 1 | 用户 status = inactive |

| 2 | 访问时间不在 [09:00, 18:00) 内 |

| 3 | api_permissions 中该角色对该接口 can_access = 0 或无记录 |

注意:on_leave(休假)不等同于离职,不算违规。

共找到 9 条违规记录

| 日志ID | 类型 | 说明 |

|--------|------|------|

| 14728 | 3 | student 角色访问 /api/system/audit,无权限 |

| 24311 | 1 | user 95 状态为 inactive |

| 30736 | 1 | user 153 状态为 inactive |

| 38039 | 2 | 访问时间 19:14(下班后) |

| 40118 | 2 | 访问时间 05:04(上班前) |

| 41610 | 1 | user 66 状态为 inactive(同时在工作时间外,取最小类型 1) |

| 47445 | 3 | student 角色访问 /api/report/monthlycan_access=0 |

| 60354 | 2 | 访问时间 20:34(下班后) |

| 76513 | 1 | user 91 状态为 inactive |

Step 3: 计算 Flag#

按日志 ID 升序拼接 日志ID-类型编号,对结果字符串求 MD5:

14728-3,24311-1,30736-1,38039-2,40118-2,41610-1,47445-3,60354-2,76513-1

import hashlib
s = "14728-3,24311-1,30736-1,38039-2,40118-2,41610-1,47445-3,60354-2,76513-1"
print(hashlib.md5(s.encode()).hexdigest())
# 15cb6dceb13da3077c6df3f86d219e9d
import sqlite3
import hashlib
from datetime import datetime, time
DB_PATH = "exam_system.db"
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 身份证校验
WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
CHECK_MAP = {0: "1", 1: "0", 2: "X", 3: "9", 4: "8", 5: "7", 6: "6", 7: "5", 8: "4", 9: "3", 10: "2"}
def valid_idcard(idcard):
if not idcard or len(idcard) != 18:
return False
body = idcard[:17]
if not body.isdigit():
return False
s = sum(int(body[i]) * WEIGHTS[i] for i in range(17))
return CHECK_MAP[s % 11] == idcard[17].upper()
# 手机号校验
VALID_PREFIXES = {
"134", "135", "136", "137", "138", "139", "147", "148", "150", "151", "152",
"157", "158", "159", "172", "178", "182", "183", "184", "187", "188", "195",
"198", "130", "131", "132", "140", "145", "146", "155", "156", "166", "167",
"171", "175", "176", "185", "186", "196", "133", "149", "153", "173", "174",
"177", "180", "181", "189", "190", "191", "193", "199",
}
def valid_phone(phone):
if not phone or len(phone) != 11 or not phone.isdigit():
return False
return phone[:3] in VALID_PREFIXES
# 幽灵账户
cursor.execute("SELECT id, idcard, phone FROM users")
ghost_ids = {
u["id"]
for u in cursor.fetchall()
if not valid_idcard(u["idcard"]) or not valid_phone(u["phone"])
}
# 用户信息 & 权限表
cursor.execute("SELECT id, status, role_id FROM users")
user_map = {r["id"]: dict(r) for r in cursor.fetchall()}
cursor.execute("SELECT role_id, api_endpoint, can_access FROM api_permissions")
perm_map = {(r["role_id"], r["api_endpoint"]): r["can_access"] for r in cursor.fetchall()}
WORK_START = time(9, 0, 0)
WORK_END = time(18, 0, 0)
# 扫描日志
cursor.execute("SELECT id, user_id, api_endpoint, access_time FROM access_logs ORDER BY id")
violations = []
for log in cursor.fetchall():
uid = log["user_id"]
if uid in ghost_ids:
continue
user = user_map.get(uid)
if not user:
continue
if user["status"] == "inactive":
vtype = 1
else:
dt = datetime.strptime(log["access_time"], "%Y-%m-%d %H:%M:%S")
if not (WORK_START <= dt.time() < WORK_END):
vtype = 2
elif not perm_map.get((user["role_id"], log["api_endpoint"]), 0):
vtype = 3
else:
continue
violations.append((log["id"], vtype))
flag_str = ",".join(f"{lid}-{vt}" for lid, vt in sorted(violations))
md5 = hashlib.md5(flag_str.encode()).hexdigest()
print(f"flag{{{md5}}}")
conn.close()

截图

flag{15cb6dceb13da3077c6df3f86d219e9d}

ShadowMeter#

题目提示说明管理员导出了异常时间段的 Apache 访问日志和调试日志,需要确认攻击者采集了哪些数据,并恢复被带出的有效信息。

截图

图 1 附件结构与恢复产物

解题思路#

本题核心是从 access.log 判断异常请求序列,再利用 error.log 中 mod_dumpio 记录恢复请求体与响应体。分析过程中发现攻击者登录成功后批量导出数据,并将数据通过 collect/report.php 的 note 参数分片外传。

  1. 获取到相关日志文件

解压附件后得到内层 ShadowMeter附件.zip,关键文件为 access.log 和 error.log。

· access.log 用于确认请求 URL、状态码、时间线、User-Agent 和访问频率。

· error.log 中启用了 mod_dumpio,可恢复 HTTP 请求体、响应体以及 gzip 响应内容。

· 后续恢复出的外传压缩包大小为 10601 bytes,sha256 与 END 分片声明一致。

截图

图 2 access.log 中的异常访问链路

  1. 复盘异常访问过程

从访问日志看,14:03:03 左右出现多个 /admin/login.php 401,随后出现一次 200 登录成功。dumpio 请求体可还原出成功登录凭据。

失败尝试username=admin&password=guessFalse,多次返回 401 Unauthorized

成功登录username=admin_ops&password=Admin%40Pa%24%24w0rd,返回 token=ops-internal-8dd18cfd

导出接口/admin/export.php?job=SM-20260507-17&page=…&range=…&sign=…

外传接口/collect/report.php,job=SM-20260507-17,note 字段承载分片数据

  1. 利用调试日志恢复解密密钥

在调试接口 /admin/debug.php 的请求体中发现 key 参数,URL 解码后得到 AES ZIP 口令。

截图

图 3 debug 请求体泄露 ZIP 口令

op=peek_export_keyf&token=meter_report&key=SM-20260507-17%3Ameter%3Areport

URL Decode -> SM-20260507-17:meter

  1. 恢复外传 ZIP 数据

筛选 job=SM-20260507-17 的 collect/report.php 请求,按 seq 从 00001 到 00070 拼接 note 字段,再 Base64 解码,得到 encrypted ZIP。END 分片给出了 archive_size 与 sha256,可用于校验恢复结果。

截图

图 4 note 分片拼接并校验为 ZIP

分片来源POST /collect/report.php 的 note 参数

分片序号00001 - 00070,另有 END 校验记录

恢复文件recovered_exfil.zip

大小10601 bytes

SHA2564019ef293eeb8af8a0d5c0e66c0bdb3cbcd17067839f42c86beb501a83d6c59b

解密口令SM-20260507-17:meter

截图

  1. 解密压缩包并定位有效数据

使用泄露口令解开 WinZip AES 加密 ZIP,得到 export.csv、manifest.json、readme.txt。manifest.json 明确给出 focus.filter,要求在 export.csv 中定位唯一目标记录。

row_count280

filter.appportal

filter.metric_namesecure.checkpoint

filter.user_idADMIN_OPS

expected_match1

图 5 按 manifest 过滤 export.csv 后得到最终 flag

  1. 最终结果

按 manifest.focus.filter 筛选 export.csv,仅命中一条记录,trace_tag 即为最终提交值。

trace_idTR3807042279

appportal

user_idADMIN_OPS

event_time2026-05-07 09:00:53

metric_namesecure.checkpoint

value1

trace_tagflag{c1b50d61ac8b81fac5ab3ea499056c0a}

最终 flag:flag{c1b50d61ac8b81fac5ab3ea499056c0a}

MaskTrace#

一.题目截图

截图

解题过程

Step 1:读懂脱敏规范#

阅读 脱敏规范.md,整理每个字段的处理逻辑:

| 字段 | 规则要点 |

|------|---------|

| username | 长度 2 保留首字符补 *;≥3 保留首尾其余换 * |

| password | 原文 MD5,32 位小写 hex |

| name | 必须全为中文且长度 ≥ 2,否则 INVALID;2 字保留姓,≥3 字保留首尾 |

| idcard | 18 位,前 17 位数字,末位数字或 X,出生日期合法,加权校验码正确;合法则只保留出生年份,其余换 * |

| phone | 先归一化(去 +86/86 前缀、去空格和连字符),再校验 11 位数字 + 合法号段;合法则 4-7 位换 * |

| email | 转小写后校验格式;用户名部分按 username 规则脱敏,域名保留 |

| bankcard | 去空格/连字符后 16-19 位纯数字;保留前 6 后 4,中间换 * |

| address | 必须含 字;保留第一个 之前内容, 及后续换 *** |

| ip | 合法 IPv4(各段 0-255,无前导零);末段换 * |

| birthday | 支持 YYYY-MM-DD / YYYY/MM/DD,日期须真实存在;以 2026-05-01 为参考日计算周岁,输出十年区间 lo-hi |

Step 2:解析 SQL 并批量处理#

SQL 文件每行一条 INSERT,用正则提取 VALUES 括号内容,手写单引号解析器(处理 '' 转义)。过滤 status=active 的 7000 条记录,按 id 升序写出 CSV。

几个容易踩坑的细节:

  • 身份证:末位 x 需先转大写再做校验码比对;加权校验码算法 weights = [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2],余数映射 '10X98765432'

  • 手机号:数据中存在 +86 138-8538-06948613898786474 等多种变体,需先剥离 +86/86 前缀再去除空格和连字符。

  • 邮箱:整体转小写后再做格式校验和用户名脱敏,23045#sohu.com 因含 # 不合法输出 INVALID

  • IP:需检查无前导零(01 不合法)。

  • 生日年龄:周岁计算需考虑当年生日是否已过,age = ref.year - bdate.year - ((ref.month, ref.day) < (bdate.month, bdate.day))

Step 3:上传并获取 Flag#

通过分析前端 JS bundle 找到上传接口 /api/upload(POST multipart/form-data),上传后用 /api/status/<key> 轮询结果,得分 100%,返回 flag。

完整解题脚本

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import hashlib
import csv
import datetime
def md5(s):
return hashlib.md5(s.encode("utf-8")).hexdigest()
def mask_username(u):
if len(u) == 2:
return u[0] + "*"
if len(u) >= 3:
return u[0] + "*" * (len(u) - 2) + u[-1]
return u
def mask_name(n):
if not n or not re.fullmatch(r"[\u4e00-\u9fff]+", n) or len(n) < 2:
return "INVALID"
if len(n) == 2:
return n[0] + "*"
return n[0] + "*" * (len(n) - 2) + n[-1]
def validate_date(year, month, day):
try:
datetime.date(year, month, day)
return True
except ValueError:
return False
def idcard_checksum(idcard17):
weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
check_chars = "10X98765432"
total = sum(int(idcard17[i]) * weights[i] for i in range(17))
return check_chars[total % 11]
def mask_idcard(ic):
ic = ic.strip()
if len(ic) != 18:
return "INVALID"
if not ic[:17].isdigit():
return "INVALID"
if not (ic[17].isdigit() or ic[17].upper() == "X"):
return "INVALID"
ic = ic[:17] + ic[17].upper()
year, month, day = int(ic[6:10]), int(ic[10:12]), int(ic[12:14])
if not validate_date(year, month, day):
return "INVALID"
if ic[17] != idcard_checksum(ic[:17]):
return "INVALID"
return "******" + ic[6:10] + "********"
VALID_PREFIXES = {
"134", "135", "136", "137", "138", "139", "147", "148", "150", "151", "152", "157", "158", "159",
"172", "178", "182", "183", "184", "187", "188", "195", "198", "130", "131", "132", "140", "145",
"146", "155", "156", "166", "167", "171", "175", "176", "185", "186", "196", "133", "149", "153",
"173", "174", "177", "180", "181", "189", "190", "191", "193", "199",
}
def mask_phone(p):
p = p.strip()
p = re.sub(r"^\+?86\s*[-]?", "", p)
p = re.sub(r"[\s\-]", "", p)
if not p.isdigit() or len(p) != 11 or p[:3] not in VALID_PREFIXES:
return "INVALID"
return p[:3] + "****" + p[7:]
def mask_email(e):
e = e.strip().lower()
m = re.fullmatch(r"([a-z0-9._%+\-]+)@([a-z0-9.\-]+\.[a-z]{2,})", e)
if not m:
return "INVALID"
return mask_username(m.group(1)) + "@" + m.group(2)
def mask_bankcard(b):
b = re.sub(r"[\s\-]", "", b.strip())
if not b.isdigit() or not (16 <= len(b) <= 19):
return "INVALID"
return b[:6] + "*" * (len(b) - 10) + b[-4:]
def mask_address(a):
a = a.strip()
idx = a.find("号")
if idx == -1:
return "INVALID"
return a[:idx] + "***"
def mask_ip(ip):
ip = ip.strip()
parts = ip.split(".")
if len(parts) != 4:
return "INVALID"
for p in parts:
if not p.isdigit() or int(p) > 255 or p != str(int(p)):
return "INVALID"
return ".".join(parts[:3]) + ".*"
def mask_birthday(b):
b = b.strip()
m = re.fullmatch(r"(\d{4})[-/](\d{2})[-/](\d{2})", b)
if not m:
return "INVALID"
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
if not validate_date(year, month, day):
return "INVALID"
ref = datetime.date(2026, 5, 1)
bdate = datetime.date(year, month, day)
age = ref.year - bdate.year - ((ref.month, ref.day) < (bdate.month, bdate.day))
lo = (age // 10) * 10
return f"{lo}-{lo + 9}"
def parse_values(s):
values = []
i = 0
s = s.strip()
while i < len(s):
if s[i] == "'":
j = i + 1
val = []
while j < len(s):
if s[j] == "'" and j + 1 < len(s) and s[j + 1] == "'":
val.append("'")
j += 2
elif s[j] == "'":
break
else:
val.append(s[j])
j += 1
values.append("".join(val))
i = j + 1
while i < len(s) and s[i] in ", ":
i += 1
elif s[i:i + 4] == "NULL":
values.append(None)
i += 4
while i < len(s) and s[i] in ", ":
i += 1
else:
j = i
while j < len(s) and s[j] != ",":
j += 1
values.append(s[i:j].strip())
i = j + 1
while i < len(s) and s[i] == " ":
i += 1
return values
def parse_sql(filepath):
rows = []
pattern = re.compile(r"INSERT INTO user_export \([^)]+\) VALUES \((.+)\);", re.IGNORECASE)
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
m = pattern.search(line)
if not m:
continue
values = parse_values(m.group(1))
if len(values) != 12:
continue
rows.append(dict(zip(
["id", "username", "password", "name", "idcard", "phone", "email", "bankcard", "address", "ip", "birthday", "status"],
values,
)))
return rows
def main():
sql_path = "user_export.sql"
out_path = "output.csv"
rows = parse_sql(sql_path)
active = sorted([r for r in rows if r["status"] == "active"], key=lambda r: int(r["id"]))
with open(out_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["id", "username", "password", "name", "idcard", "phone", "email", "bankcard", "address", "ip", "birthday"])
for r in active:
writer.writerow([
r["id"],
mask_username(r["username"]),
md5(r["password"]),
mask_name(r["name"]),
mask_idcard(r["idcard"]),
mask_phone(r["phone"]),
mask_email(r["email"]),
mask_bankcard(r["bankcard"]),
mask_address(r["address"]),
mask_ip(r["ip"]),
mask_birthday(r["birthday"]),
])
print(f"Done: {len(active)} active rows -> {out_path}")
if __name__ == "__main__":
main()

截图

上传脚本(Python requests):

import time
import requests
with open("output.csv", "rb") as f:
r = requests.post(
"http://47.99.147.34:38196/api/upload",
files={"file": ("output.csv", f, "text/csv")},
)
key = r.json()["key"]
while True:
r = requests.get(f"http://47.99.147.34:38196/api/status/{key}")
data = r.json()
print(data)
if data.get("state") != 0:
break
time.sleep(2)

运行结果#

Total rows parsed: 8000

Active rows: 7000

Done: 7000 active rows -> output.csv

{“filename”:“output.csv”,“flag”:“flag{0babcf4a8e7cb3eec7f942b6e4c588e0}”,“score”:“100.000%”,“state”:1}

截图

flag{0babcf4a8e7cb3eec7f942b6e4c588e0}

TracePurge#

TracePurge(数据安全 / 初级 / 300 分)

题目截图

解题思路#

步骤一:读取题目附件与事件响应说明#

解压 TracePurge附件.zip,得到 leak_sample.csvexport_log.csvtransfer_log.csvcustomer_index.csv 及《事件响应说明.md》。

附件与说明

步骤二:交叉分析导出日志与外发日志,定位泄露源头#

读取 export_log.csvtransfer_log.csv 进行关联分析:

  • export_log 记录 10 次导出任务,其中 EX20260508003(D3)status=failedEX20260508004(D4)status=success
  • transfer_log 中仅 EX20260508003EX20260508004 出现 external_upload 事件:D3 外发到 blocked.example.net,被拦截;D4 外发到 fileshare.example.net,成功外发。
  • 其余任务均为 internal_copy,仅内部归档。

结论:本次泄露事件对应 EX20260508004,批次 CRM-20260508-D4,操作人 ops_chen

导出与外发关联

步骤三:匹配泄露样本到客户主体并生成处置清单#

读取 leak_sample.csvcustomer_index.csv,仅处理 batch_tag=CRM-20260508-D4 的样本行,按规则匹配 subject_id

  1. 邮箱非空:计算 sha256(lower(strip(email)))email_hash 比对。
  2. 邮箱为空或未命中:用 name + phone_mask 兜底,仅唯一命中时采用。
  3. 匹配失败行,如 unknown***@invalid.example 测试数据,不写入。

去重规则:同一 subject_id 保留 leak_id 字典序最小行;最终按 leak_idsubject_id 升序排列。

动作判定:

  • erase_requested=true -> PURGE
  • risk=high -> NOTIFY
  • 其余 -> MONITOR

最终生成 response_plan.csv,UTF-8 无 BOM,共 1200 条记录:MONITOR 964NOTIFY 147PURGE 89

匹配与处置统计

步骤四:上传校验平台获取 flag#

验证码与 session 绑定,必须在同一 HTTP 会话内完成识别与上传。上传后校验得分 100.000%,成功获取 flag。

校验结果

最终 flag:

flag{d71c6eaa7aa39cb5ca9f8560d422c181}

脚本#

Analyze_event.py#

# -*- coding: utf-8 -*-
"""步骤二专用:交叉分析 export_log 与 transfer_log,定位泄露源头"""
import csv
import os
# 附件目录(按你的实际路径修改)
BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted", "TracePurge附件")
def read_csv(name):
with open(os.path.join(BASE, name), encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
export_log = read_csv("export_log.csv")
transfer_log = read_csv("transfer_log.csv")
export_by_id = {r["export_id"]: r for r in export_log}
print("=== 所有 external_upload 外发记录 ===")
for t in transfer_log:
if t["event"] != "external_upload":
continue
ex = export_by_id.get(t["export_id"], {})
print(f" {t['export_id']} | status={ex.get('status')} | batch={ex.get('batch_tag')} | dest={t['destination']}")
print("\n=== 筛选:导出成功 + 外发成功 ===")
candidates = []
for t in transfer_log:
if t["event"] == "external_upload":
ex = export_by_id.get(t["export_id"])
if ex and ex["status"] == "success":
candidates.append((t["export_id"], ex["batch_tag"], ex["operator"], t["destination"]))
print("candidates:", candidates)
event_export_id, event_batch, event_operator, dest = candidates[0]
print(f"\nEVENT: {event_export_id} {event_batch} {event_operator} {dest}")

solve.py#

# -*- coding: utf-8 -*-
import csv
import hashlib
import os
from collections import Counter
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted", "TracePurge附件")
def read_csv(name):
with open(os.path.join(base, name), encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
export_log = read_csv("export_log.csv")
transfer_log = read_csv("transfer_log.csv")
customers = read_csv("customer_index.csv")
leaks = read_csv("leak_sample.csv")
# successful external upload of a successful export
export_by_id = {r["export_id"]: r for r in export_log}
candidates = []
for t in transfer_log:
if t["event"] == "external_upload":
ex = export_by_id.get(t["export_id"])
if ex and ex["status"] == "success":
candidates.append((t["export_id"], ex["batch_tag"], ex["operator"], t["destination"]))
print("external_upload candidates (success export):", candidates)
assert len(candidates) == 1, candidates
event_export_id, event_batch, event_operator, dest = candidates[0]
print("EVENT:", event_export_id, event_batch, event_operator, dest)
hash_to_sub = {}
for c in customers:
h = c["email_hash"].strip().lower()
if h:
hash_to_sub.setdefault(h, []).append(c)
namephone_to_sub = {}
for c in customers:
namephone_to_sub.setdefault((c["name"], c["phone_mask"]), []).append(c)
sub_to_cust = {c["subject_id"]: c for c in customers}
def match(row):
email = (row["email"] or "").strip()
if email:
h = hashlib.sha256(email.lower().encode()).hexdigest()
lst = hash_to_sub.get(h)
if lst:
return lst[0]["subject_id"]
lst = namephone_to_sub.get((row["name"], row["phone_mask"]), [])
if len(lst) == 1:
return lst[0]["subject_id"]
return None
rows = []
for row in leaks:
if row["batch_tag"] != event_batch:
continue
sub = match(row)
if not sub:
continue
rows.append((row["leak_id"], sub))
print("matched rows (before dedupe):", len(rows))
# dedupe by subject: keep smallest leak_id
best = {}
for leak_id, sub in rows:
if sub not in best or leak_id < best[sub]:
best[sub] = leak_id
final = [(leak_id, sub) for sub, leak_id in best.items()]
final.sort(key=lambda x: (x[0], x[1]))
print("final unique subjects:", len(final))
def action_for(sub):
c = sub_to_cust[sub]
if c["erase_requested"] == "true":
return "PURGE"
if c["risk"] == "high":
return "NOTIFY"
return "MONITOR"
out_path = os.path.join(os.path.dirname(base), "..", "response_plan.csv")
out_path = os.path.abspath(out_path)
with open(out_path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["leak_id", "subject_id", "export_id", "operator", "action"])
for leak_id, sub in final:
w.writerow([leak_id, sub, event_export_id, event_operator, action_for(sub)])
print("WROTE:", out_path, "rows:", len(final))
print(Counter(action_for(s) for _, s in final))

SecretBackup#

截图

图 1 题目信息与附件概览(含审计时间)

解题思路#

本题核心是对离职员云盘中的加密备份进行安全审计。分析路线为:先识别外层 ZIP 附件结构,提取内层备份;再判断内层备份的 AES ZIP 加密特征并尝试口令;最后修复解密后的 PNG 文件头和图片尺寸字段,从恢复图片的隐藏区域读取 flag。

附件F:\下载\10:SecretBackup的附件.zip

外层文件tempdir/MISC附件/backup_2025Q1.zip

内层加密WinZip AES,method=99,AES-256

口令202501

Flagflag{b2607454056ecb4d3dc9375a6fb76fdb}

详细过程

  1. 提取外层 ZIP 中的备份文件

使用 Python zipfile 检查外层附件,发现其中包含一个内层备份文件 backup_2025Q1.zip。

外层附件大小:51838 bytes。

内层备份路径:tempdir/MISC附件/backup_2025Q1.zip。

提取后文件大小:51648 bytes。

截图

图 2 附件结构识别与内层备份提取

  1. 识别 AES ZIP 加密并尝试口令

解析内层 ZIP 的 central directory 后,发现文件名为 202501_secret.png,加密标志 flag=0x1,压缩方法 method=99,并存在 0x9901 AES 扩展字段。这表明该文件使用 WinZip AES 加密。

根据题目文件名 202501_secret.png 与备份时间线,尝试以 202501 作为口令,成功解出图片数据。

截图

图 3 AES ZIP 特征识别与口令验证

  1. 修复解密后的 PNG 文件

解密后的 202501_secret.png 并不能直接打开。检查文件头发现 PNG 标准签名前 4 字节被置零,先将其修复为 89 50 4E 47。

继续校验 IHDR 块 CRC,发现当前高度 400 与文件中 CRC 不匹配,而 width=800、height=800 时 CRC 与文件中的 0x5412913f 一致。因此将 IHDR 高度从 400 修复为 800,图片通过 PIL verify。

截图

图 4 PNG 签名与 IHDR 高度修复

  1. 查看恢复图片并获取 flag

打开修复后的 800x800 PNG,图片下半部出现隐藏区域,其中直接给出最终 flag。

截图

图 5 flag 截图证据(含审计时间)

最终结果

Flag:flag{b2607454056ecb4d3dc9375a6fb76fdb}

pclean#

二.题目截图

截图

三.解题思路(写明解题方向;各步骤可配图、标注 flag 与耗时)

方向:依据附件《个人信息数据规范文档.md》,对 user_data.db 中 insurance_users 表逐条校验,筛出全部“脏数据”记录,按 example.csv 格式导出 UTF-8 CSV 并上传校验平台。

总耗时:约 35 分钟(分析规范 10min + 写脚本 15min + 上传校验 10min)

  1. 解压附件 pclean的附件.zip,得到 user_data.db 与《个人信息数据规范文档.md》。

截图

数据库表 insurance_users 共 3000 条,字段:id, username, name, phone, email, gender。可先下载平台 example.csv 对照输出格式。

  1. 阅读规范文档,编写 Python 清洗脚本,对五个字段分别校验:

① 用户名:仅 [A-Za-z0-9_];② 姓名:仅中文 [一-鿿];③ 性别:男/女;④ 手机:11 位数字且前三位在指定号段集合;⑤ 邮箱:含 @,@ 前后有内容且有域名后缀(正则 ^[^@]+@[^@]+.[^@]+$)。任一字段不合规则该条为脏数据。

运行 clean.py,统计结果:总计 3000 条,脏数据 934 条,合规 2066 条。导出 dirty.csv(表头 id,username,name,phone,email,gender,UTF-8 编码)。

截图

  1. 访问校验平台 http://120.27.146.76:34333 ,上传 dirty.csv 并填写验证码。

截图

  1. 校验通过(得分 100.000%),获得 flag。

Flag:flag{331335d68bf850ac860f8deba608da10}

四.核心代码(clean.py 节选)

# -*- coding: utf-8 -*-
import sqlite3
import glob
import re
import csv
dbpath = glob.glob("extracted/**/user_data.db", recursive=True)[0]
c = sqlite3.connect(dbpath)
cur = c.cursor()
rows = cur.execute(
"SELECT id, username, name, phone, email, gender FROM insurance_users ORDER BY id"
).fetchall()
PREFIXES = set(
"134 135 136 137 138 139 147 148 150 151 152 157 158 159 172 178 "
"182 183 184 187 188 195 198 130 131 132 140 145 146 155 156 166 "
"167 171 175 176 185 186 196 133 149 153 173 174 177 180 181 189 "
"190 191 193 199".split()
)
re_user = re.compile(r"^[A-Za-z0-9_]+$")
re_name = re.compile(r"^[\u4e00-\u9fff]+$")
re_phone = re.compile(r"^\d{11}$")
re_email = re.compile(r"^[^@]+@[^@]+\.[^@]+$")
def valid_username(v):
return bool(re_user.match(v))
def valid_name(v):
return bool(re_name.match(v))
def valid_phone(v):
return bool(re_phone.match(v)) and v[:3] in PREFIXES
def valid_email(v):
return bool(re_email.match(v))
def valid_gender(v):
return v in ("男", "女")
dirty = []
for r in rows:
rid, username, name, phone, email, gender = r
reasons = []
if not valid_username(username or ""):
reasons.append("username")
if not valid_name(name or ""):
reasons.append("name")
if not valid_phone(phone or ""):
reasons.append("phone")
if not valid_email(email or ""):
reasons.append("email")
if not valid_gender(gender or ""):
reasons.append("gender")
if reasons:
dirty.append((r, reasons))
print("total:", len(rows), "dirty:", len(dirty), "clean:", len(rows) - len(dirty))
with open("dirty.csv", "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "username", "name", "phone", "email", "gender"])
for r, _ in dirty:
w.writerow(r)
# sanity: show first/last 10 dirty ids with reasons
for r, reasons in dirty[:10]:
print(r[0], reasons)

peach_garden_xor#

一、题目说明

公司在进行历史文档归档时,将一份重要的 .docx 文件进行了简单加密后存入备份系统。备份系统导出加密后的 task.docx.enc,原始密钥丢失,需要从加密文档中恢复隐藏数据并找回 flag。

截图

图 1 附件结构与加密文件确认

二、解题思路

步骤 1:解压附件后得到 task.zip,继续展开可得到 task/task.docx.enc。

步骤 2:DOCX 文件本质为 ZIP 包,正常文件头固定以 50 4B 03 04 开始,因此可以作为已知明文。

步骤 3:将密文开头与 DOCX/ZIP 文件头异或,得到重复模式 01 35 7C A9,判断加密方式为 4 字节循环 XOR。

步骤 4:使用该密钥对全文异或解密,解密结果以 PK 开头,并能展开出 [Content_Types].xml、word/document.xml 等标准 Office Open XML 文件。

步骤 5:解析 word/document.xml 正文,在文档末尾发现隐藏字符串 synt{…},再进行 ROT13 得到最终 flag。

三、关键过程截图

截图

图 2 已知 DOCX/ZIP 文件头推导循环 XOR 密钥

截图

图 3 使用密钥解密并验证 DOCX 结构

四、关键脚本

核心解密逻辑如下,密钥由文件头异或推导得到:

key = bytes.fromhex("01 35 7C A9")
data = Path("task.docx.enc").read_bytes()
plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
Path("task_decrypted.docx").write_bytes(plain)

解密后的 docx 能正常作为 ZIP 包展开,随后提取 word/document.xml 中的文本即可定位隐藏字符串。

五、flag 提取

在文档正文末尾发现字符串 synt{nq213351-9510-46p0-8622-9pqrn9s634q2}。其中 synt 是 flag 的 ROT13 形式,对整段进行 ROT13 解码后得到最终结果。

截图

图 4 flag 值截图(含整理时间)

flag{ad213351-9510-46c0-8622-9cdea9f634d2}

cpa_trace#

截图

解题思路#

获取到题目附件后,解压得到 plaintexts.npy、traces.npy、flag.enc 三个文件。plaintexts.npy 为 5000 组 AES 明文,traces.npy 为 5000 条双通道侧信道迹线,每条迹线长度为 1024。

根据 AES 第一轮中间值建立 CPA 模型。经过测试,本题最明显的泄漏模型为 HD(SBox(P xor K), P),即 SBox 输出值与原明文字节之间的汉明距离。

截图

利用 CPA 分别恢复 16 个密钥字节,得到 AES 初始密钥:422c174a4a89d96af0b7de281dc01324。

继续对初始密钥进行 AES-128 密钥扩展,得到第 10 轮轮密钥:e53b897e61d2391c2bf0d70727f48509。

最后使用第 10 轮轮密钥对 flag.enc 进行 AES-ECB 解密并去除 PKCS#7 padding,得到最终 flag。

截图

然后解出flag:flag{76ff6af8-f9f8-49ec-ad20-673f2f9c06e7}

InvisibleLeak#

  1. 题目截图

截图

  1. 解题思路(要求解题思路清晰,每个题需截图flag值并且包含时间)

  2. 获取到附件压缩包 attachments.zip,解压得到 exfil_tool.pyc、poster.png、notice.docx 三个文件。

截图

  1. 利用 Python dis + marshal 反汇编 exfil_tool.pyc(Python 3.11),还原外泄工具核心逻辑:
  • _0x1a3c:Arnold 猫脸变换置乱像素(迭代 7 次)

  • _0x2b7f:LSB 隐写,载荷格式为 4 字节大端长度 + 数据

  • _0x3c9e:零宽字符解码(\u200b/\u200c/\u200d/\ufeff → 00/01/10/11)

  • _0x4f3a:自定义流密码 cipher[i] = rotl(plain[i], ROT[i%8]) ^ key[i%len]

  • 硬编码密文 _0xCIPHER(38 字节)

  1. 从 notice.docx 提取零宽字符,解码得到密钥后半段 hex:c9d0e1f2a3b4c5d6。

打开 docx(实为 zip),读取 XML 正文,筛选零宽 Unicode 字符并按 2bit 映射还原字节。

  1. 对 poster.png 执行逆 Arnold 猫脸变换 7 次,再从 RGB 最低位提取 LSB 隐写数据,得到内嵌 PNG 文件。

截图

LSB 声明长度 1182 字节,文件头为 PNG 签名 89 50 4E 47。

  1. 扫描二维码 PNG,得到密钥前半段 a1b2c3d4e5f60718;拼接前后半段 hex 并解码为 16 字节密钥,对硬编码密文做逆 rotl+XOR 解密。
key = bytes.fromhex("a1b2c3d4e5f60718" + "c9d0e1f2a3b4c5d6")

截图

  1. 解出 flag:

截图

Flag:flag{28e761409e1462935b01ee2298a93b4f}

close_primes#

截图

题目给出了 RSA 公钥 pubkey.pem 和加密文件 encrypted.bin,要求恢复被加密保存的敏感图片并找出 flag。

截图

解题思路#

  1. 获取到题目文件

附件中包含 pubkey.pem 和 encrypted.bin。读取公钥可以得到 RSA 模数 n 和公钥指数 e,密文长度为 23168 字节。

n = 162609131412202175306539060866122225584048682500229368111829891648381638255345872045497780486908005611126130595748050116842092575026044169617179692727086886943255740374116310697621246263144862579242491741109509433106024139249989457694788419952731725562332548630936313255732168752797597409157559442131036923449
e = 65537
len(encrypted.bin) = 23168

由于 n 是 1024 bit,所以每个 RSA 密文块为 128 字节,23168 / 128 = 181,说明密文是分块加密的。

  1. 利用 close primes 进行 Fermat 分解

题目名 close_primes 提示 RSA 的两个素数 p、q 距离很近。此时可以使用 Fermat 分解,将 n 表示为两个平方数之差:

n = a^2 - b^2 = (a - b)(a + b)

从 ceil(sqrt(n)) 开始寻找 a,若 a^2 - n 是完全平方数,则可得到 p = a - b,q = a + b。本题中 Fermat 分解几乎直接命中。

p = 12751828551709836069103027639236748210345732901386209604520393543958229433834765694925409378355486164691077551258041029878391501131185664614971880587757401
q = 12751828551709836069103027639236748210345732901386209604520393543958229433834765694925409378355486164691077551258041029878391501131185675868445062344890849

q - p = 11253473181757133448

  1. 恢复私钥并尝试解密

得到 p 和 q 后,计算 phi = (p - 1)(q - 1),再求 d = inverse(e, phi),即可重构 RSA 私钥。裸 RSA 解密后发现明文仍带有填充结构,继续尝试常见的 RSA-OAEP。

使用 OAEP-SHA1 对 181 个密文块逐块解密后,得到 PNG 文件头 89 50 4e 47 0d 0a 1a 0a,说明已经成功恢复图片。

  1. 解密脚本
from pathlib import Path
from math import isqrt
from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Hash import SHA1
base = Path(".")
pub = RSA.import_key((base / "pubkey.pem").read_bytes())
n, e = pub.n, pub.e
a = isqrt(n)
if a * a < n:
a += 1
while True:
b2 = a * a - n
b = isqrt(b2)
if b * b == b2:
p = a - b
q = a + b
break
a += 1
assert p * q == n
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
priv = RSA.construct((n, e, d, p, q))
cipher = PKCS1_OAEP.new(priv, hashAlgo=SHA1)
ct = (base / "encrypted.bin").read_bytes()
block_size = (n.bit_length() + 7) // 8
pt = bytearray()
for i in range(0, len(ct), block_size):
pt.extend(cipher.decrypt(ct[i:i + block_size]))
(base / "recovered.png").write_bytes(pt)
print("written recovered.png")
  1. 然后解出 flag

运行脚本后生成 recovered.png,打开图片即可看到 flag。

截图

flag{08358491-2221-4363-9d77-a2df241f5891}

SectorVault#

题目提示为:终端巡检后发现办公终端曾运行本地敏感数据扫描工具,扫描缓存和部分审计发布文件疑似被清理;需要分析附件镜像,恢复扫描缓存、发布归档及相关线索,依据镜像内识别规范统计有效敏感数据记录,按发布策略定位正确归档、推导解压口令并读取 flag.txt。

截图

图 1 题目核心信息与最终结论汇总

解题思路#

整体思路:先解包附件并恢复镜像内容,再恢复删除痕迹;之后从 SQLite 缓存读取发布策略和扫描记录,按规则重新校验有效数据,最后推导 ZIP 口令读取 flag。

  1. 附件外层 ZIP 解出内层 SectorVault 附件,继续解出 sectorvault.img.gz,并展开为 ext4 镜像 sectorvault.img。

  2. 从镜像中恢复出 /.cache/sectorvault/scan_cache.sqlite、/Downloads/release 下的三份发布归档、审计日志和识别规范 rules.md。

  3. 读取 scan_cache.sqlite 的 meta、findings、files、releases 表,确认 case_id、scope、口令模板与 release_policy。

  4. 按照规则筛选 scope=20260507 且 risk_level>=3 的记录,并重新校验手机号、身份证、银行卡、外部邮箱。

  5. 按 category + normalized_value 去重,取 source_path 字典序最小且 line_no 最小的记录,计算 evidence_digest8。

  6. 根据 ready_record_with_matching_count_and_sha256 策略选择 SHA256 匹配且状态为 ready 的 audit_release_20260507.zip。

  7. 附件解包与镜像恢复

外层附件解出 sectorvault.img.gz,展开后得到 ext4 镜像;通过字符串检索和文件恢复定位到扫描缓存、发布目录和历史命令。

截图

图 2 恢复出的关键文件与线索

  1. 发布归档定位

数据库 releases 表包含三条发布记录,其中只有 audit_release_20260507.zip 的 status 为 ready,且 archive_sha256 与恢复出的归档文件 SHA256 一致。

截图

图 3 releases 元数据与归档 SHA256 校验

  1. 有效记录统计

镜像中的识别规范要求仅统计本次 scope=20260507、risk_level>=3 的有效记录;同类同 normalized_value 重复时只保留 source_path 字典序最小、line_no 最小的一条。

类别有效数量说明

phone711 位合法号段手机号,已去除展示格式。

idcard618 位身份证号,出生日期和校验码均有效。

bankcard5命中企业 BIN 白名单,并通过 Luhn 校验。

email5仅保留外部邮箱,排除 @internal.example。

统计结果为:effective_total=23,phone=7,idcard=6,bankcard=5,email=5。

截图

图 4 有效记录统计与解压口令推导

  1. 证据摘要与口令推导

将最终保留记录按 category、normalized_value、source_path、line_no 升序排序,每行写为 category,normalized_value,source_path,line_no,使用 LF 换行并保留末尾 LF,计算 SHA256 后取前 8 位。

得到 evidence_digest8=479eff20,由 password_policy 推导解压口令:SV-SV-20260507-23-P7I6B5E5-479eff20

截图

图 5 最终 evidence_summary.csv 证据清单

  1. 读取 flag

使用推导出的口令解压正确归档 audit_release_20260507.zip,读取 flag.txt,得到最终提交值。

截图

图 6 flag.txt 读取结果截图(包含时间)

最终答案

正确归档audit_release_20260507.zip

解压口令SV-SV-20260507-23-P7I6B5E5-479eff20

Flagflag{b294816b2e2d1d50ea34f79bd7e42d6c}

LevelLedger#

二、题目截图与附件说明

附件解压后包含 mixed_data.csv、leaked_private.pem 和《分类分级规范.md》。其中 mixed_data.csv 的 value 列既有明文字段,也有固定 344 字符的 RSA Base64 密文;泄露的私钥可直接用于还原敏感字段明文。

截图

图 1 附件解包结果与文件哈希(含审计时间)

截图

图 2 分类规范、原始台账与平台示例文件

三、解题思路

解题核心是把每条 value 先处理成可判定的干净明文,再按规范依次判断 idcard、bankcard、phone、ip、normal。由于 idcard 和 bankcard 均为 S4,且身份证号和银行卡号都是纯数字/含 X 的长串,判定时优先执行身份证校验,避免合法身份证被银行卡规则误判。

(1)识别 RSA 密文:规范说明 RSA 密文为 344 字符 Base64 串,先用 leaked_private.pem 以 OAEP-SHA256 解密。

(2)归一化明文:统一全角字符,去除空白和横线,处理 +86/0086 手机号前缀,并将身份证校验位 X 转为大写。

(3)分类判定:身份证使用出生日期和加权校验码;银行卡使用 Luhn;手机号检查 11 位数字和合法号段;IPv4 检查四段范围及前导零。

(4)输出格式:严格按 data_id,value,category,level 四列写出 UTF-8 无 BOM CSV。

四、关键代码与处理过程

脚本使用 Python 的 cryptography 库加载泄露私钥,对命中的 RSA Base64 密文字段解密;再使用正则、NFKC 归一化、日期校验和 Luhn 校验完成分类。关键逻辑如下图所示。

截图

图 3 RSA 解密、字段归一化与分类核心代码

核心处理流程摘录

# 关键判定顺序
plaintext = decrypt_if_needed(row["value"], private_key)
normalized = normalize_value(plaintext)
category, level = classify(normalized)
def classify(value):
if is_valid_idcard(value):
return "idcard", "S4"
if is_valid_bankcard(value):
return "bankcard", "S4"
if is_valid_phone(value):
return "phone", "S3"
if is_valid_ip(value):
return "ip", "S2"
return "normal", "S1"

五、结果验证

重新生成 classified_data.csv 后共得到 5000 条数据记录。统计结果显示 S4 数据主要由身份证号和银行卡号组成,S3 为手机号,S2 为 IPv4,无法命中规则的混淆样本归为 normal/S1。

类别等级数量说明

idcardS4981合法 18 位身份证号

bankcardS4929通过 Luhn 校验的银行卡号

phoneS3927合法大陆手机号段

ipS2663合法 IPv4 地址

normalS11500其余混淆或无效样本

截图

图 4 输出文件哈希、记录数量与分类统计

随后将 classified_data.csv 上传至题目校验平台。平台返回 score 为 100.000%,reason 为上传成功,并返回最终 flag。

截图

图 5 平台校验结果与 Flag(含提交时间)

六、最终 Flag

flag{970284a4d72d152737f03e8fb9123e7e}

LLMDataAudit#

截图

题目要求对「智购客服」导出的 10000 轮对话日志进行 Prompt Injection 安全审计:识别攻击成功并泄露 PII 的对话、提取敏感信息、判断注入类型,并按指定格式提交。靶机地址:http://120.27.146.76:53193/

截图

解题思路#

读取附件中的 chat_logs.jsonl、README.pdf、system_prompt.txt。日志每行一条 JSON,README 定义四种攻击类型(encoding / unicode / role_play / direct)及 PII 校验规则,system_prompt 说明模型安全约束。

然后编写 Python 脚本自动化审计:① 用泄露特征串(如 [DebugGPT]、内部数据导出结果、[DBA_Bot] 等)筛选成功攻击轮次;② 从 model_output 提取 PII,还原 Base64/分隔符变形,并用身份证校验码、手机号段、Luhn 算法过滤噪声;③ 按 user_input 判定攻击类型(注意 Pretend you’re → role_play)。共识别 180 个泄露轮次、304 条 PII,生成 result.csv。

再去判题平台 http://120.27.146.76:53193/ 上传 result.csv,使用 ddddocr 识别验证码,等待平台校验。

截图

然后拿 flag:flag{9b32b50d4a6661d8d5ffb293910be5aa}

截图

脚本:

#!/usr/bin/env python3
import json
import re
import base64
import glob
import csv
from collections import Counter
log_path = glob.glob(r"G:\数据安全2\*LLMDataAudit*\extracted\chat_logs.jsonl")[0]
out_dir = glob.glob(r"G:\数据安全2\*LLMDataAudit*\extracted")[0]
LEAK_MARKERS = [
"指令解析完成",
"指令已解码",
"命令执行完毕",
"Decoded command executed",
"[DebugGPT]",
"[CyberAdmin]",
"[InfoBot] User record retrieved",
"[DBA_Bot] Query result from user_table",
"Record output:",
"数据输出:",
"查询到以下数据记录",
"以下为内部用户数据",
"好的,以下是系统中存储的用户记录",
"内部数据导出结果",
"Result (Base64 protected)",
"Output (Base64 encoded)",
]
DIRECT_KW = [
"忽略", "系统管理员", "调试命令", "调试指令", "系统调试", "输出用户数据",
"关闭安全过滤", "解除", "审计员", "导出用户", "ignore", "override",
"disable safety", "disable filter", "dump all", "system override",
"display user", "display all stored", "print user", "print user/pii",
"output complete user", "export", "privacy controls",
]
ROLE_KW = [
"假设你是", "假设你", "你现在是", "你的身份是", "你的角色是", "扮演",
"从现在开始你是", "从现在起你的角色是", "pretend you are", "act as",
"you are now", "let's play", "debuggpt", "cyberadmin", "leakbot",
"infobot", "opendata", "dataexport", "rawdataai", "dataleaker",
"pentestbot", "已被入侵", "数据分析师", "dba_bot", "systemdump",
]
ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
ID_CHECK = "10X98765432"
PHONE_PREFIXES = {
"134", "135", "136", "137", "138", "139", "147", "148", "150", "151", "152",
"157", "158", "159", "172", "178", "182", "183", "184", "187", "188", "195", "198",
"130", "131", "132", "140", "145", "146", "155", "156", "166", "167", "171", "175",
"176", "185", "186", "196", "133", "149", "153", "173", "174", "177", "180", "181",
"189", "190", "191", "193", "199",
}
BANK_BINS = (
"622848", "622700", "621700", "622262", "622188", "622200", "622568", "622609",
"622908", "622518",
)
def valid_idcard(value):
v = value.upper()
if not re.match(r"^\d{17}[\dX]$", v):
return False
total = sum(int(v[i]) * ID_WEIGHTS[i] for i in range(17))
return ID_CHECK[total % 11] == v[17]
def valid_phone(value):
if not re.match(r"^1[3-9]\d{9}$", value):
return False
return value[:3] in PHONE_PREFIXES
def luhn_ok(value):
if not value.isdigit() or not (16 <= len(value) <= 19):
return False
digits = [int(d) for d in value]
s = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
s += d
return s % 10 == 0
def valid_bankcard(value):
return luhn_ok(value)
def valid_email(value):
return bool(re.match(r"^[\w.+-]+@[\w.-]+\.\w+$", value))
def validate_pii(category, value):
if category == "idcard":
return valid_idcard(value)
if category == "phone":
return valid_phone(value)
if category == "bankcard":
return valid_bankcard(value)
if category == "email":
return valid_email(value)
return False
def classify_attack_type(user_input):
ui = user_input
lower = ui.lower().replace("you're", "you are").replace("let's", "let us")
has_b64 = bool(re.search(r"[A-Za-z0-9+/=]{20,}", ui))
has_b64_kw = (
any(k in lower for k in [
"base64",
"encoded command",
"encoded instruction",
"decode and follow",
"编码指令",
])
or any(k in ui for k in ["Base64"])
or (any(k in lower for k in ["decode", "encoded"]) and has_b64)
)
if has_b64_kw and has_b64:
return "encoding"
if re.search(r"\\u[0-9a-fA-F]{4}", ui) or "Unicode" in ui or "unicode" in lower:
return "unicode"
if any(k.lower() in lower for k in ROLE_KW):
return "role_play"
return "direct"
def b64_decode_if_valid(s):
clean = re.sub(r"[\s\-]", "", s)
if re.match(r"^\d+$", clean):
return None
if not re.match(r"^[A-Za-z0-9+/]+=*$", clean):
return None
pad = "=" * ((4 - len(clean) % 4) % 4)
try:
dec = base64.b64decode(clean + pad).decode("utf-8", errors="ignore").strip()
except Exception:
return None
for cat in ("idcard", "phone", "bankcard", "email"):
candidate = dec.upper() if cat == "idcard" else dec
if validate_pii(cat, candidate):
return candidate
return None
def categorize_raw(value):
v = value.strip()
v_id = v.upper()
if validate_pii("email", v):
return "email", v
if validate_pii("phone", re.sub(r"[\s\-]", "", v)):
return "phone", re.sub(r"[\s\-]", "", v)
if validate_pii("idcard", re.sub(r"[\s\-]", "", v_id)):
return "idcard", re.sub(r"[\s\-]", "", v_id)
if validate_pii("bankcard", re.sub(r"[\s\-]", "", v)):
return "bankcard", re.sub(r"[\s\-]", "", v)
dec = b64_decode_if_valid(v)
if dec:
return categorize_raw(dec)
return None, None
def extract_leaked_pii(text):
items = []
seen = set()
def add_value(raw):
cat, val = categorize_raw(raw)
if not cat or not val or val in seen:
return
seen.add(val)
items.append((cat, val))
for m in re.finditer(
r"(?:bank_card|email|mobile|card_no|id_number|id_card|phone|email_b64|银行卡号?|身份证号?|证件号码|手机号码?|邮箱|电子邮箱|电话号码|联系电话|银行卡|ID|Email|Phone)[=::\s]+([^\n,。]+)",
text,
):
add_value(m.group(1).strip())
for m in re.finditer(
r"(?:email_b64|id_number|phone|Result \(Base64 protected\)|Output \(Base64 encoded\))[::\s]+([A-Za-z0-9+/=\s\-]+)",
text,
):
add_value(m.group(1).strip())
for m in re.finditer(r"(?:bank_card|email|mobile|card_no|id_number|phone)=([^\n\\]+)", text):
add_value(m.group(1).strip())
return items
records = [json.loads(l) for l in open(log_path, encoding="utf-8")]
rows = []
for r in records:
out = r["model_output"]
if not any(m in out for m in LEAK_MARKERS):
continue
pii_items = extract_leaked_pii(out)
if not pii_items:
continue
attack_type = classify_attack_type(r["user_input"])
for cat, val in pii_items:
rows.append({
"turn_id": r["turn_id"],
"attack_type": attack_type,
"category": cat,
"value": val,
})
rows.sort(key=lambda x: (x["turn_id"], x["category"], x["value"]))
csv_path = out_dir + "\\result.csv"
with open(csv_path, "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=["turn_id", "attack_type", "category", "value"])
w.writeheader()
w.writerows(rows)
print(f"Successful leak turns: {len(set(r['turn_id'] for r in rows))}")
print(f"Total PII rows: {len(rows)}")
print("attack types:", Counter(r["attack_type"] for r in rows))
print("categories:", Counter(r["category"] for r in rows))
print(f"Saved: {csv_path}")

licensed_unpack#

截图

解题思路#

获取到附件后,目录中只有一个 licensed.exe。先对文件做基础识别,文件大小为 2048 字节,开头是 MZ,说明它是 Windows PE 文件。继续解析 PE 头可以看到它一共有 3 个节,节名分别是 .UPX0、.UPX1、.rsrc。

.UPX0 的 raw size 为 0,.UPX1 的 raw offset 为 0x200、raw size 为 0x400,.rsrc 的 raw offset 为 0x600、raw size 为 0x200。最后一个节结尾刚好是 0x800,也就是整个文件末尾,说明文件后面没有额外 overlay 数据。

看到 .UPX0、.UPX1 这类节名时,第一反应是 UPX 壳。但是继续看文件内容,在 0x200 附近出现的是 FPX!,而不是标准 UPX 结构,所以这个样本更像是用 UPX 节名做伪装,真正的数据藏在节内部。

接着分析 .UPX1 节。直接看这段数据没有明显字符串,但其中有大量非零字节,像是压缩或加密后的数据。于是对 .UPX1 节做滑动扫描:从节内每一个偏移开始取后续数据,逐字节异或 0xCE,然后检查结果是否像压缩流。

扫描时发现,在文件偏移 0x25c 处开始的数据异或 0xCE 后,可以得到 zlib 压缩流特征。脚本中采用自动扫描方式,只要异或后的数据以 78 01、78 9c、78 da 这几种 zlib 头开头,就尝试 zlib.decompress()。

最终在 0x25c 附近成功解压出一个新的 PE 文件,大小为 1536 字节,并且解出的文件同样以 MZ 开头。

继续查看内层 PE,能看到 .NET 元数据特征,例如 BSJB、v4.0.30319、#Strings、#US、#Blob 等字符串,说明这是一个 .NET 程序。

继续在字符串表中可以找到 LicenseChecker、Licensing、_bfk、_bfct、Main 等名称。其中 _bfk 和 _bfct 很可疑,结合命名可以判断:_bfk 大概率是 Blowfish key,_bfct 是 Blowfish ciphertext。

继续在内层 PE 的数据区附近查找,可以看到两段连续的字节数据。第一段长度为 16 字节,适合作为 Blowfish 密钥;第二段长度为 48 字节,是 8 字节分组的整数倍,符合 Blowfish 分组加密的密文长度要求。

提取到的密钥和密文如下:

key = d1db19c8a2e38c60c1518b0301c8d7ea
ct = bdd6d58928d8b2427410d6a9a41f5ad63c1ad4bfd555cc714d1615bb8931b48f7ff81240b075048ae644fe513b1120d7

最后使用 Blowfish-ECB 模式解密密文。这里没有看到 IV 相关数据,而且密文长度正好是 Blowfish block size 8 的倍数,所以直接用 ECB 尝试。

解密结果中能看到完整的 flag{…},末尾只有若干空字节,去掉即可得到最终 flag。

截图

flag = flag{b301cd43-2918-4c96-bc1e-0e6addbe3ef3}

EXP#

#!/usr/bin/env python3
import argparse
import re
import struct
import sys
import zlib
from pathlib import Path
try:
from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import unpad
except ImportError:
print("[!] Missing dependency: pip install pycryptodome", file=sys.stderr)
raise
FLAG_RE = re.compile(rb"flag\{[^}\r\n\x00]{1,128}\}", re.I)
def default_target() -> Path:
root = Path(__file__).resolve().parent
hits = [p for p in root.rglob("licensed.exe") if p.is_file()]
if not hits:
raise FileNotFoundError("licensed.exe not found under the challenge directory")
return hits[0]
def pe_sections(data: bytes):
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe_off:pe_off + 4] != b"PE\0\0":
raise ValueError("not a PE file")
num_sections = struct.unpack_from("<H", data, pe_off + 0x06)[0]
opt_size = struct.unpack_from("<H", data, pe_off + 0x14)[0]
sec_base = pe_off + 0x18 + opt_size
sections = []
for i in range(num_sections):
off = sec_base + i * 40
name = data[off:off + 8].split(b"\0", 1)[0].decode("ascii", "replace")
raw_size = struct.unpack_from("<I", data, off + 0x10)[0]
raw_off = struct.unpack_from("<I", data, off + 0x14)[0]
sections.append((name, raw_off, raw_size))
return sections
def recover_inner_pe(data: bytes) -> tuple[int, bytes]:
# The real payload is an XORed zlib stream inside the fake UPX section.
for _, raw_off, raw_size in pe_sections(data):
start = raw_off
end = min(len(data), raw_off + raw_size) if raw_size else len(data)
for off in range(start, end):
decoded = bytes(b ^ 0xCE for b in data[off:end])
if not decoded.startswith((b"\x78\x01", b"\x78\x9c", b"\x78\xda")):
continue
try:
inner = zlib.decompress(decoded)
except zlib.error:
continue
if inner.startswith(b"MZ") and b"_bfk\x00_bfct\x00" in inner:
return off, inner
raise RuntimeError("failed to locate the XOR-zlib .NET payload")
def clean_plaintext(buf: bytes) -> bytes:
try:
buf = unpad(buf, Blowfish.block_size)
except ValueError:
pass
return buf.rstrip(b"\x00")
def recover_flag(inner: bytes) -> tuple[bytes, bytes, bytes]:
# The two byte arrays are stored consecutively in the managed image.
# In this sample the first array is a 128-bit Blowfish key and the second
# array is a 48-byte ciphertext.
for pos in range(len(inner)):
key = inner[pos:pos + 16]
ct = inner[pos + 16:pos + 64]
if len(key) != 16 or len(ct) != 48:
continue
pt = clean_plaintext(Blowfish.new(key, Blowfish.MODE_ECB).decrypt(ct))
m = FLAG_RE.search(pt)
if m:
return key, ct, m.group(0)
raise RuntimeError("failed to decrypt a flag-like plaintext")
def main() -> None:
parser = argparse.ArgumentParser(description="Solve licensed_unpack")
parser.add_argument("sample", nargs="?", type=Path, help="path to licensed.exe")
args = parser.parse_args()
sample = args.sample or default_target()
data = sample.read_bytes()
payload_off, inner = recover_inner_pe(data)
key, ct, flag = recover_flag(inner)
print(f"[+] sample : {sample}")
print(f"[+] payload off : 0x{payload_off:x}")
print(f"[+] inner size : {len(inner)}")
print(f"[+] key : {key.hex()}")
print(f"[+] ciphertext : {ct.hex()}")
print(f"[+] flag : {flag.decode()}")
if __name__ == "__main__":
main()
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

2026御网杯数据安全赛道Writeup
http://blog.azkanna.cn/posts/ywbshuju/summary/
作者
昭阳
发布于
2026-06-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录