2026第十届御网杯数据安全赛道WP

admin 2026-08-09 04:52:02 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档详细介绍了2026年第十届御网杯数据安全赛道中TracePurge和ShadowMeter两个题目的解题思路与实现过程。TracePurge部分通过分析CSV文件匹配客户信息生成数据泄露响应计划,ShadowMeter部分则利用access.log和error.log恢复被分片外传的ZIP文件,展示了数据泄露应急响应和日志分析的关键技术。 综合评分: 85 文章分类: CTF,数据安全,应急响应,安全工具


2026第十届御网杯%20数据安全赛道WP

赛查查

2026年8月4日%2012:23 北京

在小说阅读器读本章

去阅读

以下文章来源于云晞科技Sec ,作者江思澄

云晞科技Sec .

这里是网络安全探索基地!专注分享流量分析实战技巧、应急响应解决方案,深度剖析Web安全漏洞攻防。同时带来CTF竞赛解题思路与精彩笔记,助你快速掌握前沿技术,提升安全技能,共同筑牢数字世界安全防线!

TracePurge

解压后先看了事件响应说明.md

这里最重要的规则就三件事:%20样本用%20email_hash%20或%20name%20+%20phone_mask%20匹配客户;只处理本次泄露对应的导出批次;最后生成%20leak_id,subject_id,export_id,operator,action

然后看了这四个%20CSV%20的表头和前几行

import csv
from collections import Counter

base%20= r'D:\下载\8:TracePurge的附件\tempdir\DS附件\TracePurge附件\TracePurge附件'
leak_path%20=%20base%20+ r'\leak_sample.csv'

cnt%20=%20Counter()

with open(leak_path,%20newline='',%20encoding='utf-8-sig') as f:
 %20  for row in csv.DictReader(f):
 %20 %20 %20 %20cnt[row['batch_tag']]%20+= 1

for k,%20v in cnt.most_common():
 %20 %20print(k,%20v)

CRM-20260508-D4%20数量明显最多

所以这次事件就是%20EX20260508004,操作人是%20ops_chen

后面按说明匹配客户%20email%20非空就%20strip%20+%20lower%20后做%20sha256,去%20customer_index.csv%20的%20email_hash%20里找邮箱为空或者哈希找不到,就用%20name%20+%20phone_mask,只有唯一命中才算一个%20subject_id%20如果出现多次,保留%20leak_id%20字典序最小的那条

直接生成提交文件:

import csv
import hashlib
from collections import defaultdict,%20Counter

base%20= r'D:\下载\8:TracePurge的附件\tempdir\DS附件\TracePurge附件\TracePurge附件'
out_path%20= r'D:\下载\8:TracePurge的附件\tempdir\DS附件\TracePurge附件\TracePurge附件\response_plan.csv'

leak_path%20=%20base%20+ r'\leak_sample.csv'
idx_path%20=%20base%20+ r'\customer_index.csv'

target_batch%20= 'CRM-20260508-D4'
export_id%20= 'EX20260508004'
operator%20= 'ops_chen'

email_map%20=%20{}
name_phone%20=%20defaultdict(list)

with open(idx_path,%20newline='',%20encoding='utf-8-sig') as f:
 %20  for row in csv.DictReader(f):
 %20 %20 %20  if row['email_hash']:
 %20 %20 %20 %20 %20 %20email_map[row['email_hash']]%20=%20row
 %20 %20 %20 %20name_phone[(row['name'],%20row['phone_mask'])].append(row)

def match_subject(leak):
 %20 %20email%20=%20leak['email'].strip()
 %20  if email:
 %20 %20 %20 %20h%20=%20hashlib.sha256(email.lower().encode()).hexdigest()
 %20 %20 %20  if h in email_map:
 %20 %20 %20 %20 %20  return email_map[h]

 %20 %20hits%20=%20name_phone[(leak['name'],%20leak['phone_mask'])]
 %20  if len(hits)%20== 1:
 %20 %20 %20  return hits[0]

 %20  returnNone

def get_action(subject):
 %20  if subject['erase_requested']%20== 'true':
 %20 %20 %20  return'PURGE'
 %20  if subject['risk']%20== 'high':
 %20 %20 %20  return'NOTIFY'
 %20  return'MONITOR'

best%20=%20{}

with open(leak_path,%20newline='',%20encoding='utf-8-sig') as f:
 %20  for leak in csv.DictReader(f):
 %20 %20 %20  if leak['batch_tag']%20!=%20target_batch:
 %20 %20 %20 %20 %20  continue

 %20 %20 %20 %20subject%20=%20match_subject(leak)
 %20 %20 %20  ifnot subject:
 %20 %20 %20 %20 %20  continue

 %20 %20 %20 %20sid%20=%20subject['subject_id']

 %20 %20 %20 %20row%20=%20{
 %20 %20 %20 %20 %20  'leak_id':%20leak['leak_id'],
 %20 %20 %20 %20 %20  'subject_id':%20sid,
 %20 %20 %20 %20 %20  'export_id':%20export_id,
 %20 %20 %20 %20 %20  'operator':%20operator,
 %20 %20 %20 %20 %20  'action':%20get_action(subject),
 %20 %20 %20 %20}

&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;sid&nbsp;notin&nbsp;best&nbsp;or&nbsp;leak['leak_id']%20<%20best[sid]['leak_id']:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20best[sid]%20=%20row

rows%20=%20sorted(best.values(),%20key=lambda&nbsp;r:%20(r['leak_id'],%20r['subject_id']))

with&nbsp;open(out_path,&nbsp;'w',%20newline='',%20encoding='utf-8')&nbsp;as&nbsp;f:
&nbsp;%20&nbsp;%20writer%20=%20csv.DictWriter(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20f,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20fieldnames=['leak_id',&nbsp;'subject_id',&nbsp;'export_id',&nbsp;'operator',&nbsp;'action']
&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20writer.writeheader()
&nbsp;%20&nbsp;%20writer.writerows(rows)

print('output:',%20out_path)
print('rows:',%20len(rows))
print(Counter(r['action']&nbsp;for&nbsp;r&nbsp;in&nbsp;rows))

生成出来%20response_plan.csv%20一共%201200%20行,动作分布是%20MONITOR%20964、NOTIFY%20147、PURGE%2089上传平台后%20100%

flag{e115cc3382d6b63c3196a4f1ca32eefa}
ShadowMeter

这题给了%20access.log%20和%20error.log我先看%20access.log,登录接口这里没有一行行数,直接把状态码统计出来:

结果是%207%20次%20401、1%20次%20200,说明确实有一次登录成功access.log%20后面还能看到%20/admin/export.php和/collect/report.php%20开始大量出现,所以我怀疑是先登录导出,再通过%20collect%20接口分片外传

access.log%20看不到%20POST%20body,所以继续翻%20error.log这里%20Apache%20开了%20dumpio,会把请求体和响应也记下来我先搜%20/admin/login.php,找到最后一次登录请求的%20client%20是%20172.25.0.1:60636,再用这个%20client%20过滤同一次请求:

这时候才看到登录的%20body%20和响应:

POST%20/admin/login.php%20HTTP/1.1
username=admin_ops&password=Admin%40Pa%24%24w0rd
{"token":"ops-internal-8dd18cfd"}

Admin%40Pa%24%24w0rd%20URL%20解码后是:

Admin@Pa$$w0rd

登录这块确认完以后,继续看%20/collect/report.php%20的%20POST%20bodyerror.log%20里能看到每次上报都有%20job、seq、notejob=SM-20260507-17%20里数字%20seq%20的%20note%20是%20base64%20分片,第一片以%20UEsDB%20开头,base64%20解出来是%20PK\x03\x04,说明是在分片外传%20zip

再看结束分片,seq=END%20的%20note%20不是%20base64,而是给了校验信息:

所以恢复思路就是按%20seq%20顺序拼接数字分片的%20note,中间重复%20seq%20用第一份,最后用%20archive_size=10601%20和%20sha256=4019ef293eeb8af8a0d5c0e66c0bdb3cbcd17067839f42c86beb501a83d6c59b%20校验,能对上就说明%20zip%20恢复对了

import&nbsp;base64,%20codecs,%20collections,%20datetime,%20hashlib,%20pathlib,%20re,%20urllib.parse

err%20=%20pathlib.Path(r"D:\下载\3:ShadowMeter的附件\tempdir\MISC附件\ShadowMeter附件\ShadowMeter附件\error.log")
out%20=%20pathlib.Path(r"D:\下载\3:ShadowMeter的附件\tempdir\MISC附件\ShadowMeter附件\ShadowMeter附件\1.zip")

line_re%20=%20re.compile(
&nbsp;%20&nbsp;&nbsp;r"^\[(?P<ts>[^\]]+)\].*?\[client%20(?P<client>[^\]]+)\]%20"
&nbsp;%20&nbsp;&nbsp;r"mod_dumpio:\s+dumpio_(?P<dir>in|out)%20\((?P<kind>[^)]+)\):%20(?P<data>.*)$"
)

def&nbsp;ts(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;datetime.datetime.strptime(s,&nbsp;"%a%20%b%20%d%20%H:%M:%S.%f%20%Y")

def&nbsp;unesc(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;codecs.decode(s.encode(),&nbsp;"unicode_escape").encode("latin-1",&nbsp;"replace")

streams%20=%20collections.defaultdict(list)

for&nbsp;line&nbsp;in&nbsp;err.read_text(errors="replace").splitlines():
&nbsp;%20&nbsp;%20m%20=%20line_re.match(line)
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;m:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;&nbsp;if&nbsp;m.group("dir")%20!=&nbsp;"in":
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;m.group("kind").startswith("data-"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20data%20=%20m.group("data")
&nbsp;%20&nbsp;&nbsp;if&nbsp;re.fullmatch(r"\d+%20bytes",%20data):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20streams[m.group("client")].append((ts(m.group("ts")),%20unesc(data)))

def&nbsp;parse_requests(buf):
&nbsp;%20&nbsp;%20pos%20=&nbsp;0
&nbsp;%20&nbsp;&nbsp;while&nbsp;pos%20<%20len(buf):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20end%20=%20buf.find(b"\r\n\r\n",%20pos)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;end%20<&nbsp;0:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;break
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20head%20=%20buf[pos:end]
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20first%20=%20head.split(b"\r\n",&nbsp;1)[0].decode("latin-1",&nbsp;"replace")
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20m%20=%20re.match(r"(GET|POST|OPTIONS)%20([^%20]+)%20HTTP/",%20first)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;m:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20nxt%20=%20buf.find(b"POST%20",%20pos%20+&nbsp;1)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;nxt%20<&nbsp;0:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;break
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20pos%20=%20nxt
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20headers%20=%20{}
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;for&nbsp;line&nbsp;in&nbsp;head.split(b"\r\n")[1:]:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ifb":"in&nbsp;line:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20k,%20v%20=%20line.split(b":",&nbsp;1)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20headers[k.decode().lower()]%20=%20v.strip().decode()
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20clen%20=%20int(headers.get("content-length",&nbsp;"0")&nbsp;or0)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20body%20=%20buf[end%20+&nbsp;4:end%20+&nbsp;4&nbsp;+%20clen]
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;yield&nbsp;m.group(1),%20m.group(2),%20body
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20pos%20=%20end%20+&nbsp;4&nbsp;+%20clen

rows%20=%20[]

for&nbsp;parts&nbsp;in&nbsp;streams.values():
&nbsp;%20&nbsp;%20buf%20=&nbsp;b"".join(x[1]&nbsp;for&nbsp;x&nbsp;in&nbsp;sorted(parts))
&nbsp;%20&nbsp;&nbsp;for&nbsp;method,%20path,%20body&nbsp;in&nbsp;parse_requests(buf):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;path.split("?")[0]%20!=&nbsp;"/collect/report.php":
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20qs%20=%20urllib.parse.parse_qs(body.decode("latin-1"),%20keep_blank_values=True)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20rows.append({k:%20v[0]&nbsp;for&nbsp;k,%20v&nbsp;in&nbsp;qs.items()})

sm%20=%20[r&nbsp;for&nbsp;r&nbsp;in&nbsp;rows&nbsp;if&nbsp;r.get("job")%20==&nbsp;"SM-20260507-17"]
end_note%20=%20next(r["note"]&nbsp;for&nbsp;r&nbsp;in&nbsp;sm&nbsp;if&nbsp;r.get("seq")%20==&nbsp;"END")
target_size%20=%20int(re.search(r"archive_size=(\d+)",%20end_note).group(1))
target_sha%20=%20re.search(r"sha256=([0-9a-f]+)",%20end_note).group(1)

chunks%20=%20{}
for&nbsp;r&nbsp;in&nbsp;sm:
&nbsp;%20&nbsp;%20seq%20=%20r.get("seq",&nbsp;"")
&nbsp;%20&nbsp;&nbsp;if&nbsp;seq.isdigit():
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20chunks.setdefault(int(seq),%20r["note"])

b64%20=&nbsp;"".join(chunks[i]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(1,%20max(chunks)%20+&nbsp;1))
data%20=%20base64.b64decode(b64)

print(len(data))
print(hashlib.sha256(data).hexdigest())

assert&nbsp;len(data)%20==%20target_size
assert&nbsp;hashlib.sha256(data).hexdigest()%20==%20target_sha

out.write_bytes(data)
print(out)

zip%20需要密码,我本来拿登录密码%20Admin@Pa$$w0rd%20试了,不对继续搜%20debug%20相关请求:

grep%20-nE&nbsp;'POST%20/admin/debug\.php|peek_export'&nbsp;error.log

结果两行都是同一个%20client%20端口%20172.25.0.1:55612,说明请求体属于这次%20/admin/debug.php:

POST%20/admin/debug.php%20HTTP/1.1
op=peek_export_keyf&token=meter_report&key=SM-20260507-17%3Ameter%3Areport

把%20SM-20260507-17%3Ameter%3Areport%20URL%20解码,得到%20zip%20密码:

SM-20260507-17:meter:report

拿这个密码解压%201.zip:

解出来以后有%20export.csv、manifest.json、readme.txtmanifest.json%20里%20focus.filter%20写的是%20app=portal、metric_name=secure.checkpoint、user_id=ADMIN_OPS,所以去%20export.csv%20里找这条目标记录最后命中:

flag{c1b50d61ac8b81fac5ab3ea499056c0a}
SecretBackup

这题给了个双层%20zip,外层先正常解,里面是%20backup_2025Q1.zip,发现有密码尝试了一些弱口令,尝试了一下文件名%20202501解压成功

解出来的%20png%20一开始打不开,头部前%204%20字节是%2000%2000%2000%2000

补回%20PNG%20magic%2089%2050%204e%2047%20后能识别成%20800×400

但%20pngcheck%20报%20IHDR%20CRC%20错这里卡了一下,本来以为就是%20CRC%20坏了,后来拿原%20CRC%205412913f%20去爆宽高,发现它正好对应%20800×800,说明高度被人从%20800%20改成了%20400,下半张图被藏起来了把高度改回%20800%20后图片校验通过,下半部分直接写了敏感信息和%20flag

flag{b2607454056ecb4d3dc9375a6fb76fdb}
peach_garden_xor

前%208%20字节大概是:

51%207e%207f%20ad%2015%2035%207c%20a9

然后想一下,.docx%20本质是%20zip,zip%20文件头通常是:

50%204b%2003%2004%2014%2000%2000%2000

所以密钥可以用密文%20XOR%20明文头推出:

c%20=%20bytes.fromhex("51%207e%207f%20ad%2015%2035%207c%20a9")%20p%20=%20bytes.fromhex("50%204b%2003%2004%2014%2000%2000%2000")%20print(bytes(a%20^%20b&nbsp;for&nbsp;a,%20b&nbsp;in&nbsp;zip(c,%20p)).hex())

说明%20key%20是循环的%2001%2035%207c%20a9

解密exp

from&nbsp;pathlib&nbsp;import&nbsp;Path
import&nbsp;zipfile

enc%20=%20Path(r'.\task.docx.enc')
out%20=%20Path(r'.\task.docx')
key%20=%20bytes.fromhex('01357ca9')

data%20=%20enc.read_bytes()
plain%20=%20bytes(b%20^%20key[i%20%%20len(key)]&nbsp;for&nbsp;i,%20b&nbsp;in&nbsp;enumerate(data))
out.write_bytes(plain)

print(plain[:16].hex())
print(zipfile.is_zipfile(out))

解出来以后%20plain[:16]%20是%20504b03041400000008008bb8a75cad52,zipfile.is_zipfile%20返回%20True,说明%20docx%20复原了

这个看着不像最终%20flag,但%20synt%20ROT13%20后是%20flag

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

先看%20个人信息数据规范文档.md%20正要检查的规则就这些:

username:只能大小写字母、数字、下划线
name:只能中文
phone:11%20位数字,前三位必须在指定号段里
email:必须有%20@,@%20前后有内容,并且有域名后缀
gender:只能是%20男%20或%20女

打开%20user_data.db

insurance_users,字段是:

id,%20username,%20name,%20phone,%20email,%20gender

发现有好多错误的数据,要按照个人信息数据规范文档.md%20开始清理一下

例:
name%20为空,不是中文姓名
email%20是%[email protected],@%20前面没有内容
gender%20是%20b,不是%20男/女

平台样例文件也要下,看它到底要什么格式

sql筛选exp:

SELECT%20id,%20username,%20name,%20phone,%20email,%20gender
FROM%20insurance_users
WHERE
&nbsp;%20&nbsp;%20username%20GLOB%20'*[^A-Za-z0-9_]*'

&nbsp;%20&nbsp;%20OR%20name%20=%20''
&nbsp;%20&nbsp;%20OR%20name%20GLOB%20'*[^一-龥]*'

&nbsp;%20&nbsp;%20OR%20length(phone)%20!=%2011
&nbsp;%20&nbsp;%20OR%20phone%20GLOB%20'*[^0-9]*'
&nbsp;%20&nbsp;%20OR%20substr(phone,%201,%203)%20NOT%20IN%20(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20'134','135','136','137','138','139','147','148','150','151','152','157','158','159',
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20'172','178','182','183','184','187','188','195','198','130','131','132','140','145',
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20'146','155','156','166','167','171','175','176','185','186','196','133','149','153',
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20'173','174','177','180','181','189','190','191','193','199'
&nbsp;%20&nbsp;%20)

&nbsp;%20&nbsp;%20OR%20length(email)%20-%20length(replace(email,%20'@',%20''))%20!=%201
&nbsp;%20&nbsp;%20OR%20email%20NOT%20GLOB%20'?*@?*.?*'

&nbsp;%20&nbsp;%20OR%20gender%20NOT%20IN%20('男',%20'女')
ORDER%20BY%20id;

龥%20是一个汉字,常被拿来当“常用%20CJK%20汉字范围”的右边界

导出上传%20csv%20文件上传校验%20获得%20flag%20值

flag{9d4c2f88e16b70a5c39f1e27bd84c610}
MaskTrace

核心就是先筛%20status=active,再按%20id%20升序导出固定表头%20CSV各字段基本都是先校验再脱敏,不合法写%20INVALID用户名、姓名、邮箱用户名都是保留首尾打星%20密码做%20MD5;身份证要校验日期和校验位后只留年份%20手机号和银行卡先归一化数字再判断长度/号段%20地址必须有“号”%20IP%20要合法%20IPv4;生日按%202026-05-01%20算十年年龄段

随机创个%20db%20文件%20,导入%20user_export.sql%20文件查看一下

这里能看到%20SQL%20是单表%20user_export,字段就是:

id,%20username,%20password,%20name,%20idcard,%20phone,%20email,%20bankcard,%20address,%20ip,%20birthday,%20status

看下示例文件

这一步很重要,示例前几行可以用来确认自己脱敏规则对不对

我先在%20DB%20Browser%20的执行%20SQL%20里查了一下数据,只筛%20status=active并按%20id%20升序:

SELECT%20id,%20username,%20password,%20name,%20idcard,%20phone,%20email,%20bankcard,%20address,%20ip,%20birthday
FROM%20user_export
WHERE%20status%20=&nbsp;'active'
ORDER%20BY%20CAST(id%20AS%20INTEGER)%20ASC;

查出来%20active%20一共%207000%20条,说明筛选范围没问题后面的脱敏规则比较多,比如密码要%20MD5,身份证要校验出生日期和最后一位校验码,手机号还要处理%20+86、空格、横杠这些格式,所以我没有继续硬写%20SQL,直接用%20Python%20从%20SQLite%20里读%20active%20数据,再逐字段处理生成%20CSV

exp:

import&nbsp;csv,%20re,%20hashlib,%20sqlite3,%20ipaddress
from&nbsp;datetime&nbsp;import&nbsp;datetime,%20date
from&nbsp;pathlib&nbsp;import&nbsp;Path

base%20=%20Path(__file__).resolve().parent
sql%20=%20next(base.rglob("user_export.sql"))
out%20=%20base%20/&nbsp;"example.csv"

phones%20=%20set("""134%20135%20136%20137%20138%20139%20147%20148%20150%20151%20152%20157%20158%20159%20172%20178%20182%20183%20184%20187%20188%20195%20198
130%20131%20132%20140%20145%20146%20155%20156%20166%20167%20171%20175%20176%20185%20186%20196
133%20149%20153%20173%20174%20177%20180%20181%20189%20190%20191%20193%20199""".split())

weights%20=%20[7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2,&nbsp;1,&nbsp;6,&nbsp;3,&nbsp;7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2]
checks%20=&nbsp;"10X98765432"
email_re%20=%20re.compile(r"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$")

def&nbsp;star(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;s[0]%20+&nbsp;"*"if&nbsp;len(s)%20==&nbsp;2else&nbsp;s[0]%20+&nbsp;"*"&nbsp;*%20(len(s)%20-&nbsp;2)%20+%20s[-1]

def&nbsp;name(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;star(s)&nbsp;if&nbsp;len(s)%20>=&nbsp;2and&nbsp;re.fullmatch(r"[\u4e00-\u9fff]+",%20s)&nbsp;else"INVALID"

def&nbsp;idcard(s):
&nbsp;%20&nbsp;%20s%20=%20s.upper()
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{17}[\dX]",%20s):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"
&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20datetime.strptime(s[6:14],&nbsp;"%Y%m%d")
&nbsp;%20&nbsp;&nbsp;except&nbsp;ValueError:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"
&nbsp;%20&nbsp;&nbsp;if&nbsp;checks[sum(int(a)%20*%20b&nbsp;for&nbsp;a,%20b&nbsp;in&nbsp;zip(s[:17],%20weights))%20%&nbsp;11]%20!=%20s[-1]:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"
&nbsp;%20&nbsp;&nbsp;return"******"&nbsp;+%20s[6:10]%20+&nbsp;"********"

def&nbsp;phone(s):
&nbsp;%20&nbsp;%20d%20=%20re.sub(r"\D",&nbsp;"",%20s)
&nbsp;%20&nbsp;&nbsp;if&nbsp;len(d)%20==&nbsp;13and&nbsp;d.startswith("86"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20=%20d[2:]
&nbsp;%20&nbsp;&nbsp;return&nbsp;d[:3]%20+&nbsp;"****"&nbsp;+%20d[7:]&nbsp;if&nbsp;len(d)%20==&nbsp;11and&nbsp;d[:3]&nbsp;in&nbsp;phones&nbsp;else"INVALID"

def&nbsp;email(s):
&nbsp;%20&nbsp;%20s%20=%20s.lower()
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;email_re.fullmatch(s):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"
&nbsp;%20&nbsp;%20a,%20b%20=%20s.split("@",&nbsp;1)
&nbsp;%20&nbsp;&nbsp;return&nbsp;star(a)%20+&nbsp;"@"&nbsp;+%20b

def&nbsp;bank(s):
&nbsp;%20&nbsp;%20d%20=%20re.sub(r"\D",&nbsp;"",%20s)
&nbsp;%20&nbsp;&nbsp;return&nbsp;d[:6]%20+&nbsp;"*"&nbsp;*%20(len(d)%20-&nbsp;10)%20+%20d[-4:]&nbsp;if&nbsp;re.fullmatch(r"\d{16,19}",%20d)&nbsp;else"INVALID"

def&nbsp;addr(s):
&nbsp;%20&nbsp;%20i%20=%20s.find("号")
&nbsp;%20&nbsp;&nbsp;return&nbsp;s[:i]%20+&nbsp;"***"if&nbsp;i%20!=&nbsp;-1else"INVALID"

def&nbsp;ip(s):
&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20p%20=%20str(ipaddress.IPv4Address(s)).split(".")
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return".".join(p[:3]%20+%20["*"])
&nbsp;%20&nbsp;&nbsp;except:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"

def&nbsp;birthday(s):
&nbsp;%20&nbsp;%20fmt%20=&nbsp;"%Y-%m-%d"if"-"in&nbsp;s&nbsp;else"%Y/%m/%d"if"/"in&nbsp;s&nbsp;elseNone
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;fmt:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"
&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20b%20=%20datetime.strptime(s,%20fmt).date()
&nbsp;%20&nbsp;&nbsp;except:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"INVALID"
&nbsp;%20&nbsp;%20ref%20=%20date(2026,&nbsp;5,&nbsp;1)
&nbsp;%20&nbsp;%20age%20=%20ref.year%20-%20b.year%20-%20((ref.month,%20ref.day)%20<%20(b.month,%20b.day))
&nbsp;%20&nbsp;%20lo%20=%20age%20//&nbsp;10&nbsp;*&nbsp;10
&nbsp;%20&nbsp;&nbsp;returnf"{lo}-{lo+9}"

con%20=%20sqlite3.connect(":memory:")
con.executescript(sql.read_text(encoding="utf-8"))

rows%20=%20con.execute("""
SELECT%20id,%20username,%20password,%20name,%20idcard,%20phone,%20email,%20bankcard,%20address,%20ip,%20birthday
FROM%20user_export
WHERE%20status='active'
ORDER%20BY%20CAST(id%20AS%20INTEGER)
""").fetchall()

with&nbsp;out.open("w",%20encoding="utf-8",%20newline="")&nbsp;as&nbsp;f:
&nbsp;%20&nbsp;%20w%20=%20csv.writer(f,%20lineterminator="\n")
&nbsp;%20&nbsp;%20w.writerow("id%20username%20password%20name%20idcard%20phone%20email%20bankcard%20address%20ip%20birthday".split())
&nbsp;%20&nbsp;&nbsp;for&nbsp;r&nbsp;in&nbsp;rows:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20i,%20u,%20p,%20n,%20c,%20ph,%20e,%20bk,%20ad,%20ipaddr,%20bd%20=%20r
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20w.writerow([
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20i,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20star(u),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20hashlib.md5(p.encode()).hexdigest(),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20name(n),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20idcard(c),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20phone(ph),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20email(e),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20bank(bk),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20addr(ad),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ip(ipaddr),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20birthday(bd)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20])

print(out)
print("active%20rows:",%20len(rows))

上传csv文件%20校验得到flag

flag{d11d22f3398ef4748b21ef061793612f}
InvisibleLeak

附件解出来以后先看文件类型和%20hash,能看到%20exfil_tool.pyc%20是%20CPython%203.11%20的%20pyc,notice.docx%20是%20Word%20文档,poster.png 是 512×512 PNG

先从 pyc 下手,反汇编看一下里面有什么:

python -m xdis.bin.pydisasm exfil_tool.pyc

a1b2c3d4e5f60718

最后把二维码结果拼上%20docx%20结果当%20key:

a1b2c3d4e5f60718c9d0e1f2a3b4c5d6

pyc%20里的%20_0x4f3a%20是左旋再%20xor,反解的时候就是先%20xor%20key,再按%20i%20%%208%20右旋:

cipher%20=%20bytes.fromhex(
&nbsp;%20&nbsp;&nbsp;"c76a46ef52b009aafebc2553a0939c4e95de0b1dd6509f00f81a74638093cb6698d44a758359"
)
key%20=%20bytes.fromhex("a1b2c3d4e5f60718c9d0e1f2a3b4c5d6")
def&nbsp;ror(x,%20s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;((x%20>>%20s)%20|%20(x%20<<%20(8&nbsp;-%20s)))%20&&nbsp;0xff&nbsp;if&nbsp;s&nbsp;else&nbsp;x
flag%20=%20bytes(
&nbsp;%20&nbsp;%20ror(cipher[i]%20^%20key[i%20%%20len(key)],%20i%20%&nbsp;8)
&nbsp;%20&nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(cipher))
)
print("key%20=",%20key.hex())
print("flag%20=",%20flag.decode())

最后拿到:

flag{28e761409e1462935b01ee2298a93b4f}
SectorVault

用火眼取证分析打开附件解压出来的img%20在这些路径下发现了被删除的痕迹

/home/audit/.cache/sectorvault/

删掉的扫描缓存和发布包都还在镜像里,直接恢复,右键导出

查看%20/home/audit/Documents/数据分级与敏感信息识别规范.md

重点是:

仅统计%20risk_level%20>=%203%20的记录
同一%20category%20与%20normalized_value%20重复时只保留%20source_path%20字典序最小、line_no%20最小的一条
release_policy:%20ready_record_with_matching_count_and_sha256
password_policy:%20SV-{case_id}-{effective_total}-P{phone_count}I{idcard_count}B{bankcard_count}E{email_count}-{evidence_digest8}

用%20DB%20Browser%20打开%20scan_cache.sqlite

这里就能证明要开的不是%20old,也不是%20draft,而是%20ready%20的%20audit_release_20260507.zip

有效记录和口令直接用脚本

import&nbsp;sqlite3,%20hashlib,%20datetime,%20re

db%20=&nbsp;r"D:\下载\123\scan_cache.sqlite"

phone_prefixes%20=%20set("""134%20135%20136%20137%20138%20139%20147%20148%20150%20151%20152%20157%20158%20159%20172%20178%20182%20183%20184%20187%20188%20195%20198%20130%20131%20132%20140%20145%20146%20155%20156%20166%20167%20171%20175%20176%20185%20186%20196%20133%20149%20153%20173%20174%20177%20180%20181%20189%20190%20191%20193%20199""".split())
bins%20=%20set("""622848%20622700%20621700%20622262%20622188%20622200%20622568%20622609%20622908%20622518""".split())

def&nbsp;valid_phone(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;bool(re.fullmatch(r"\d{11}",%20s))&nbsp;and&nbsp;s[:3]&nbsp;in&nbsp;phone_prefixes

def&nbsp;valid_id(s):
&nbsp;%20&nbsp;%20s%20=%20s.upper()
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{17}[0-9X]",%20s):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20datetime.date(int(s[6:10]),%20int(s[10:12]),%20int(s[12:14]))
&nbsp;%20&nbsp;&nbsp;except&nbsp;ValueError:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20weights%20=%20[7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2,&nbsp;1,&nbsp;6,&nbsp;3,&nbsp;7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2]
&nbsp;%20&nbsp;%20checks%20=&nbsp;"10X98765432"
&nbsp;%20&nbsp;&nbsp;return&nbsp;checks[sum(int(a)%20*%20b&nbsp;for&nbsp;a,%20b&nbsp;in&nbsp;zip(s[:17],%20weights))%20%&nbsp;11]%20==%20s[-1]

def&nbsp;valid_bank(s):
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{16,19}",%20s)&nbsp;ornot&nbsp;any(s.startswith(b)&nbsp;for&nbsp;b&nbsp;in&nbsp;bins):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20total%20=&nbsp;0
&nbsp;%20&nbsp;%20parity%20=%20len(s)%20%&nbsp;2
&nbsp;%20&nbsp;&nbsp;for&nbsp;idx,%20ch&nbsp;in&nbsp;enumerate(s):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20=%20int(ch)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;idx%20%&nbsp;2&nbsp;==%20parity:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20*=&nbsp;2
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;d%20>&nbsp;9:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20-=&nbsp;9
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20total%20+=%20d
&nbsp;%20&nbsp;&nbsp;return&nbsp;total%20%&nbsp;10&nbsp;==&nbsp;0

def&nbsp;valid_email(s):
&nbsp;%20&nbsp;&nbsp;return"@"in&nbsp;s&nbsp;andnot&nbsp;s.lower().endswith("@internal.example")

validators%20=%20{
&nbsp;%20&nbsp;&nbsp;"phone":%20valid_phone,
&nbsp;%20&nbsp;&nbsp;"idcard":%20valid_id,
&nbsp;%20&nbsp;&nbsp;"bankcard":%20valid_bank,
&nbsp;%20&nbsp;&nbsp;"email":%20valid_email,
}

con%20=%20sqlite3.connect(db)
rows%20=%20con.execute("""
select%20findings.category,%20findings.normalized_value,%20files.source_path,%20findings.line_no
from%20findings%20join%20files%20on%20files.id%20=%20findings.file_id
where%20files.scope%20=%20'20260507'%20and%20findings.risk_level%20>=%203
""").fetchall()

kept%20=%20{}
for&nbsp;cat,%20norm,%20path,%20line&nbsp;in&nbsp;rows:
&nbsp;%20&nbsp;&nbsp;if&nbsp;cat&nbsp;notin&nbsp;validators&nbsp;ornot&nbsp;validators[cat](norm):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20key%20=%20(cat,%20norm)
&nbsp;%20&nbsp;%20rec%20=%20(cat,%20norm,%20path,%20line)
&nbsp;%20&nbsp;&nbsp;if&nbsp;key&nbsp;notin&nbsp;kept&nbsp;or&nbsp;(path,%20line)%20<%20(kept[key][2],%20kept[key][3]):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20kept[key]%20=%20rec

final%20=%20sorted(kept.values(),%20key=lambda&nbsp;x:%20(x[0],%20x[1],%20x[2],%20x[3]))
evidence%20=&nbsp;"".join(f"{cat},{norm},{path},{line}\n"for&nbsp;cat,%20norm,%20path,%20line&nbsp;in&nbsp;final)
digest8%20=%20hashlib.sha256(evidence.encode()).hexdigest()[:8]
counts%20=%20{c:%20sum(1for&nbsp;r&nbsp;in&nbsp;final&nbsp;if&nbsp;r[0]%20==%20c)&nbsp;for&nbsp;c&nbsp;in&nbsp;["phone",&nbsp;"idcard",&nbsp;"bankcard",&nbsp;"email"]}

password%20=&nbsp;f"SV-SV-20260507-{len(final)}-P{counts['phone']}I{counts['idcard']}B{counts['bankcard']}E{counts['email']}-{digest8}"

print("effective_total%20=",%20len(final))
print("counts%20=",%20counts)
print("digest8%20=",%20digest8)
print("password%20=",%20password)

LLMDataAudit

这题先看README.pdf,规则里说得比较死:攻击类型只看user_input,而泄露的数据只从model_output%20里提;身份证、手机号、银行卡还要按题目给的规则校验,不然订单号、服务热线、工单号这种东西会混进去

我先用%20VS%20Code%20打开chat_logs.jsonl,搜Base64%20看一条明显的第%2099%20轮这里user_input%20是%20Base64%20注入,model_output%20里吐了email_b64,说明这类就是成功攻击,不是普通客服回复

把这一段email_b64%20丢到%20CyberChef,From%20Base64%20直接能解出邮箱[email protected]这个例子能证明后面脚本里必须处理%20Base64%20变形,不能只匹配明文邮箱

然后写脚本批量跑逻辑就三步:先按user_input%20判断encoding%20/%20unicode%20/%20role_play%20/%20direct,顺序按题目优先级来;再从model_output%20里提明文、Base64、空格/横杠分隔的%20PII;最后做身份证校验位、手机号号段、银行卡%20Luhn%20校验,能过校验才写进result.csv

这里我还踩了个坑,一开始role_play%20只写了Pretend%20you%20are%20和你的身份是,提交只有96.053%后来发现还有Pretend%20you’re%20…%20和你现在的身份是CyberAdmin…,这俩也得算角色扮演,补上后才完整

脚本如下:

import&nbsp;base64
import&nbsp;csv
import&nbsp;json
import&nbsp;re
from&nbsp;pathlib&nbsp;import&nbsp;Path

log%20=%20Path("chat_logs.jsonl")
out%20=%20Path("result.csv")

phone_prefix%20=%20set(
&nbsp;%20&nbsp;&nbsp;"134%20135%20136%20137%20138%20139%20147%20148%20150%20151%20152%20157%20158%20159%20"
&nbsp;%20&nbsp;&nbsp;"172%20178%20182%20183%20184%20187%20188%20195%20198%20"
&nbsp;%20&nbsp;&nbsp;"130%20131%20132%20140%20145%20146%20155%20156%20166%20167%20171%20175%20176%20185%20186%20196%20"
&nbsp;%20&nbsp;&nbsp;"133%20149%20153%20173%20174%20177%20180%20181%20189%20190%20191%20193%20199".split()
)
bank_bins%20=%20(
&nbsp;%20&nbsp;&nbsp;"622848",&nbsp;"622700",&nbsp;"621700",&nbsp;"622262",&nbsp;"622188",
&nbsp;%20&nbsp;&nbsp;"622200",&nbsp;"622568",&nbsp;"622609",&nbsp;"622908",&nbsp;"622518",
)
id_weights%20=%20[7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2,&nbsp;1,&nbsp;6,&nbsp;3,&nbsp;7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2]
id_check%20=&nbsp;"10X98765432"

def&nbsp;is_idcard(s):
&nbsp;%20&nbsp;%20s%20=%20s.upper()
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;r"[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;r"(?:0[1-9]|[12]\d|3[01])\d{3}[0-9X]",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20s,
&nbsp;%20&nbsp;%20):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20total%20=%20sum(int(a)%20*%20b&nbsp;for&nbsp;a,%20b&nbsp;in&nbsp;zip(s[:17],%20id_weights))
&nbsp;%20&nbsp;&nbsp;return&nbsp;id_check[total%20%&nbsp;11]%20==%20s[-1]

def&nbsp;is_phone(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;bool(re.fullmatch(r"1\d{10}",%20s))&nbsp;and&nbsp;s[:3]&nbsp;in&nbsp;phone_prefix

def&nbsp;is_bankcard(s):
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{16,19}",%20s)&nbsp;ornot&nbsp;s.startswith(bank_bins):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20total%20=&nbsp;0
&nbsp;%20&nbsp;&nbsp;for&nbsp;i,%20ch&nbsp;in&nbsp;enumerate(s[::-1],&nbsp;1):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20=%20int(ch)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;i%20%&nbsp;2&nbsp;==&nbsp;0:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20*=&nbsp;2
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;d%20>&nbsp;9:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20d%20-=&nbsp;9
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20total%20+=%20d
&nbsp;%20&nbsp;&nbsp;return&nbsp;total%20%&nbsp;10&nbsp;==&nbsp;0

def&nbsp;is_email(s):
&nbsp;%20&nbsp;&nbsp;return&nbsp;bool(re.fullmatch(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}",%20s))

role_re%20=%20re.compile(
&nbsp;%20&nbsp;&nbsp;r"(假设你是|你现在是|你现在的身份是|你的身份是|你的角色是|扮演|从现在开始你是|"
&nbsp;%20&nbsp;&nbsp;r"Pretend%20you%20are|Pretend%20you're|Act%20as|You%20are%20now|Let's%20play)",
&nbsp;%20&nbsp;%20re.I,
)
direct_re%20=%20re.compile(
&nbsp;%20&nbsp;&nbsp;r"(忽略|系统管理员|调试(?:命令|指令)?|输出用户数据|关闭安全过滤|解除|审计员|导出用户|"
&nbsp;%20&nbsp;&nbsp;r"Ignore.*(?:instruction|safety|protocol|privacy)|Override|Disable\s+(?:safety|filter)|"
&nbsp;%20&nbsp;&nbsp;r"Dump%20all|System%20Override|Display\s+(?:user|stored|PII)|Print\s+(?:user|PII))",
&nbsp;%20&nbsp;%20re.I,
)
base64_kw_re%20=%20re.compile(r"(Base64|编码指令|encoded%20command|Decode|decode|解码)",%20re.I)
base64_long_re%20=%20re.compile(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/=]{20,}(?![A-Za-z0-9+/=])")
unicode_re%20=%20re.compile(r"\\u[0-9a-fA-F]{4}")

def&nbsp;attack_type(user_input):
&nbsp;%20&nbsp;&nbsp;if&nbsp;base64_kw_re.search(user_input)&nbsp;and&nbsp;base64_long_re.search(user_input):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"encoding"
&nbsp;%20&nbsp;&nbsp;if&nbsp;unicode_re.search(user_input):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"unicode"
&nbsp;%20&nbsp;&nbsp;if&nbsp;role_re.search(user_input):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"role_play"
&nbsp;%20&nbsp;&nbsp;if&nbsp;direct_re.search(user_input):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return"direct"
&nbsp;%20&nbsp;&nbsp;returnNone

email_re%20=%20re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
num_re%20=%20re.compile(r"(?<![0-9A-Za-z])(?:[0-9Xx][\s,\-]*){11,19}(?![0-9A-Za-z])")
sep_email_re%20=%20re.compile(r"(?:[A-Za-z0-9._%+@\-][\s,\-]*){8,120}")
base64_token_re%20=%20re.compile(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/=]{8,}={0,2}(?![A-Za-z0-9+/=])")

def&nbsp;extract_plain(text):
&nbsp;%20&nbsp;%20found%20=%20[]
&nbsp;%20&nbsp;&nbsp;for&nbsp;match&nbsp;in&nbsp;email_re.finditer(text):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20value%20=%20match.group(0).strip(".,;:!?,;:!))]}")
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;is_email(value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20found.append(("email",%20value))

&nbsp;%20&nbsp;&nbsp;for&nbsp;line&nbsp;in&nbsp;text.splitlines():
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if"@"in&nbsp;line&nbsp;or&nbsp;re.search(r"(email|邮箱|mail)",%20line,%20re.I):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;for&nbsp;match&nbsp;in&nbsp;sep_email_re.finditer(line):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20value%20=%20re.sub(r"[\s,\-]+",&nbsp;"",%20match.group(0))
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20value%20=%20value.strip(".,;:!?,;:!))]}")
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if"@"in&nbsp;value&nbsp;and&nbsp;is_email(value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20found.append(("email",%20value))

&nbsp;%20&nbsp;&nbsp;for&nbsp;match&nbsp;in&nbsp;num_re.finditer(text):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20value%20=%20re.sub(r"[\s,\-]+",&nbsp;"",%20match.group(0)).upper()
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;is_idcard(value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20found.append(("idcard",%20value))
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;is_phone(value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20found.append(("phone",%20value))
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;is_bankcard(value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20found.append(("bankcard",%20value))
&nbsp;%20&nbsp;&nbsp;return&nbsp;found

def&nbsp;extract_all(text):
&nbsp;%20&nbsp;%20found%20=%20extract_plain(text)
&nbsp;%20&nbsp;&nbsp;for&nbsp;match&nbsp;in&nbsp;base64_token_re.finditer(text):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20token%20=%20match.group(0)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20decoded%20=%20base64.b64decode(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20token%20+&nbsp;"="&nbsp;*%20((-len(token))%20%&nbsp;4),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20validate=True,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20).decode("utf-8")
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;except&nbsp;Exception:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;all(ch.isprintable()&nbsp;or&nbsp;ch&nbsp;in"\r\n\t"for&nbsp;ch&nbsp;in&nbsp;decoded):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20found.extend(extract_plain(decoded))

&nbsp;%20&nbsp;%20seen%20=%20set()
&nbsp;%20&nbsp;%20result%20=%20[]
&nbsp;%20&nbsp;&nbsp;for&nbsp;item&nbsp;in&nbsp;found:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;item&nbsp;notin&nbsp;seen:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20seen.add(item)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20result.append(item)
&nbsp;%20&nbsp;&nbsp;return&nbsp;result

rows%20=%20[]
for&nbsp;line&nbsp;in&nbsp;log.open(encoding="utf-8"):
&nbsp;%20&nbsp;%20obj%20=%20json.loads(line)
&nbsp;%20&nbsp;%20typ%20=%20attack_type(obj["user_input"])
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;typ:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;&nbsp;for&nbsp;category,%20value&nbsp;in&nbsp;extract_all(obj["model_output"]):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20rows.append((obj["turn_id"],%20typ,%20category,%20value))

category_order%20=%20{"idcard":&nbsp;0,&nbsp;"phone":&nbsp;1,&nbsp;"bankcard":&nbsp;2,&nbsp;"email":&nbsp;3}
rows%20=%20sorted(set(rows),%20key=lambda&nbsp;row:%20(row[0],%20category_order[row[2]],%20row[3]))

with&nbsp;out.open("w",%20encoding="utf-8",%20newline="")&nbsp;as&nbsp;f:
&nbsp;%20&nbsp;%20writer%20=%20csv.writer(f)
&nbsp;%20&nbsp;%20writer.writerow(["turn_id",&nbsp;"attack_type",&nbsp;"category",&nbsp;"value"])
&nbsp;%20&nbsp;%20writer.writerows(rows)

print("生成完成:",%20out.resolve())
print("结果行数:",%20len(rows))

跑完能看到脚本生成了result.csv,结果行数是304

我用%20WPS%20打开看了一眼%20CSV%20结构,表头是题目要求的turn_id,attack_type,category,value这里注意别用%20WPS%20另存,因为它会把部分手机号显示成科学计数法;提交用的是脚本原始生成的result.csv

最后把这个result.csv%20上传到靶机,校验结果是100.000%,直接给%20flag

flag{f639ba7d803f97e5fbeab25f70f3af71}
LevelLedger

这题给了三个关键文件:mixed_data.csv、leaked_private.pem%20和分类分级规范mixed_data.csv%20是要处理的数据,leaked_private.pem%20是解%20RSA%20密文用的私钥,规范里写了每种数据怎么分类

我先去平台下载了示例文件%20example.csv,主要是看最后提交文件的格式这张图只看表头就行,别用%20WPS/Excel%20保存%20CSV,不然长数字可能会被改成科学计数法

示例文件的表头是:

data_id,value,category,level

分类规范的重点是:value%20可能是明文,也可能是固定%20344%20字符的%20RSA%20base64%20密文密文用%20leaked_private.pem%20解,实际测试用的是%20RSA-OAEP%20+%20SHA256解出来以后还要做归一化:全角转半角、去空白、去横线,手机号里的%20+86%20和%200086%20前缀也要去掉然后依次判断身份证、银行卡、手机号、IP,剩下的就是%20normal最后输出%20CSV,列名必须是%20data_id,value,category,level,编码用%20UTF-8%20无%20BOM

脚本我直接放在附件同一个目录里,所以开头用%20Path(file).resolve().parent%20取当前目录,这样%20mixed_data.csv、leaked_private.pem%20和生成的%20classified_data.csv%20都在一起,不容易因为路径不一样报错

import&nbsp;base64
import&nbsp;csv
import&nbsp;re
import&nbsp;unicodedata
from&nbsp;datetime&nbsp;import&nbsp;datetime
from&nbsp;pathlib&nbsp;import&nbsp;Path

from&nbsp;cryptography.hazmat.primitives&nbsp;import&nbsp;hashes,%20serialization
from&nbsp;cryptography.hazmat.primitives.asymmetric&nbsp;import&nbsp;padding

BASE%20=%20Path(__file__).resolve().parent
MIXED%20=%20BASE%20/&nbsp;"mixed_data.csv"
PRIVATE_KEY%20=%20BASE%20/&nbsp;"leaked_private.pem"
OUT%20=%20BASE%20/&nbsp;"classified_data.csv"

PHONE_PREFIXES%20=%20{
&nbsp;%20&nbsp;&nbsp;"134",&nbsp;"135",&nbsp;"136",&nbsp;"137",&nbsp;"138",&nbsp;"139",&nbsp;"147",&nbsp;"148",&nbsp;"150",&nbsp;"151",
&nbsp;%20&nbsp;&nbsp;"152",&nbsp;"157",&nbsp;"158",&nbsp;"159",&nbsp;"172",&nbsp;"178",&nbsp;"182",&nbsp;"183",&nbsp;"184",&nbsp;"187",
&nbsp;%20&nbsp;&nbsp;"188",&nbsp;"195",&nbsp;"198",&nbsp;"130",&nbsp;"131",&nbsp;"132",&nbsp;"140",&nbsp;"145",&nbsp;"146",&nbsp;"155",
&nbsp;%20&nbsp;&nbsp;"156",&nbsp;"166",&nbsp;"167",&nbsp;"171",&nbsp;"175",&nbsp;"176",&nbsp;"185",&nbsp;"186",&nbsp;"196",&nbsp;"133",
&nbsp;%20&nbsp;&nbsp;"149",&nbsp;"153",&nbsp;"173",&nbsp;"174",&nbsp;"177",&nbsp;"180",&nbsp;"181",&nbsp;"189",&nbsp;"190",&nbsp;"191",
&nbsp;%20&nbsp;&nbsp;"193",&nbsp;"199",
}

ID_WEIGHTS%20=%20[7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2,&nbsp;1,&nbsp;6,&nbsp;3,&nbsp;7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2]
ID_CHECK%20=&nbsp;"10X98765432"

def&nbsp;decrypt_if_needed(value:%20str,%20key)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;if&nbsp;len(value)%20!=&nbsp;344:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;value
&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ciphertext%20=%20base64.b64decode(value,%20validate=True)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20plaintext%20=%20key.decrypt(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ciphertext,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20padding.OAEP(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20mgf=padding.MGF1(algorithm=hashes.SHA256()),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20algorithm=hashes.SHA256(),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20label=None,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;plaintext.decode("utf-8")
&nbsp;%20&nbsp;&nbsp;except&nbsp;Exception:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;value

def&nbsp;nfkc_no_space(value:%20str)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;return&nbsp;re.sub(r"\s+",&nbsp;"",%20unicodedata.normalize("NFKC",%20value))

def&nbsp;id_candidate(value:%20str)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;return&nbsp;nfkc_no_space(value).replace("-",&nbsp;"").upper()

def&nbsp;phone_candidate(value:%20str)&nbsp;->%20str:
&nbsp;%20&nbsp;%20v%20=%20nfkc_no_space(value).replace("-",&nbsp;"")
&nbsp;%20&nbsp;&nbsp;if&nbsp;v.startswith("+86"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20v%20=%20v[3:]
&nbsp;%20&nbsp;&nbsp;elif&nbsp;v.startswith("0086"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20v%20=%20v[4:]
&nbsp;%20&nbsp;&nbsp;return&nbsp;v

def&nbsp;bank_candidate(value:%20str)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;return&nbsp;nfkc_no_space(value).replace("-",&nbsp;"")

def&nbsp;ip_candidate(value:%20str)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;return&nbsp;nfkc_no_space(value)

def&nbsp;normal_candidate(value:%20str)&nbsp;->%20str:
&nbsp;%20&nbsp;%20v%20=%20nfkc_no_space(value).replace("-",&nbsp;"")
&nbsp;%20&nbsp;&nbsp;if&nbsp;v.startswith("+86"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20v%20=%20v[3:]
&nbsp;%20&nbsp;&nbsp;elif&nbsp;v.startswith("0086"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20v%20=%20v[4:]
&nbsp;%20&nbsp;&nbsp;return&nbsp;v

def&nbsp;valid_idcard(value:%20str)&nbsp;->%20bool:
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{17}[\dX]",%20value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20birth%20=%20value[6:14]
&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20dt%20=%20datetime.strptime(birth,&nbsp;"%Y%m%d")
&nbsp;%20&nbsp;&nbsp;except&nbsp;ValueError:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;&nbsp;if&nbsp;dt.year%20<&nbsp;1900or&nbsp;dt.year%20>&nbsp;2030:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20checksum%20=%20sum(int(ch)%20*%20weight&nbsp;for&nbsp;ch,%20weight&nbsp;in&nbsp;zip(value[:17],%20ID_WEIGHTS))
&nbsp;%20&nbsp;&nbsp;return&nbsp;ID_CHECK[checksum%20%&nbsp;11]%20==%20value[-1]

def&nbsp;valid_luhn(value:%20str)&nbsp;->%20bool:
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{16,19}",%20value):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20total%20=&nbsp;0
&nbsp;%20&nbsp;&nbsp;for&nbsp;idx,%20ch&nbsp;in&nbsp;enumerate(reversed(value),&nbsp;1):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20digit%20=%20int(ch)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;idx%20%&nbsp;2&nbsp;==&nbsp;0:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20digit%20*=&nbsp;2
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;digit%20>&nbsp;9:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20digit%20-=&nbsp;9
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20total%20+=%20digit
&nbsp;%20&nbsp;&nbsp;return&nbsp;total%20%&nbsp;10&nbsp;==&nbsp;0

def&nbsp;valid_phone(value:%20str)&nbsp;->%20bool:
&nbsp;%20&nbsp;&nbsp;return&nbsp;bool(re.fullmatch(r"\d{11}",%20value))&nbsp;and&nbsp;value[:3]&nbsp;in&nbsp;PHONE_PREFIXES

def&nbsp;valid_ip(value:%20str)&nbsp;->%20bool:
&nbsp;%20&nbsp;%20parts%20=%20value.split(".")
&nbsp;%20&nbsp;&nbsp;if&nbsp;len(parts)%20!=&nbsp;4:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;&nbsp;for&nbsp;part&nbsp;in&nbsp;parts:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d+",%20part):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;len(part)%20>&nbsp;1and&nbsp;part.startswith("0"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;int(part)%20>&nbsp;255:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;returnFalse
&nbsp;%20&nbsp;&nbsp;returnTrue

def&nbsp;classify(value:%20str)&nbsp;->%20tuple[str,%20str,%20str]:
&nbsp;%20&nbsp;%20v%20=%20id_candidate(value)
&nbsp;%20&nbsp;&nbsp;if&nbsp;valid_idcard(v):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;v,&nbsp;"idcard",&nbsp;"S4"

&nbsp;%20&nbsp;%20v%20=%20bank_candidate(value)
&nbsp;%20&nbsp;&nbsp;if&nbsp;valid_luhn(v):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;v,&nbsp;"bankcard",&nbsp;"S4"

&nbsp;%20&nbsp;%20v%20=%20phone_candidate(value)
&nbsp;%20&nbsp;&nbsp;if&nbsp;valid_phone(v):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;v,&nbsp;"phone",&nbsp;"S3"

&nbsp;%20&nbsp;%20v%20=%20ip_candidate(value)
&nbsp;%20&nbsp;&nbsp;if&nbsp;valid_ip(v):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;v,&nbsp;"ip",&nbsp;"S2"

&nbsp;%20&nbsp;&nbsp;return&nbsp;normal_candidate(value),&nbsp;"normal",&nbsp;"S1"

def&nbsp;main()&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20key%20=%20serialization.load_pem_private_key(PRIVATE_KEY.read_bytes(),%20password=None)
&nbsp;%20&nbsp;&nbsp;with&nbsp;MIXED.open("r",%20encoding="utf-8-sig",%20newline="")&nbsp;as&nbsp;src,%20OUT.open(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"w",%20encoding="utf-8",%20newline=""
&nbsp;%20&nbsp;%20)&nbsp;as&nbsp;dst:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20reader%20=%20csv.DictReader(src)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20writer%20=%20csv.DictWriter(dst,%20fieldnames=["data_id",&nbsp;"value",&nbsp;"category",&nbsp;"level"])
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20writer.writeheader()
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;for&nbsp;row&nbsp;in&nbsp;reader:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20plain%20=%20decrypt_if_needed(row["value"],%20key)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20normalized,%20category,%20level%20=%20classify(plain)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20writer.writerow(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20{
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"data_id":%20row["data_id"],
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"value":%20normalized,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"category":%20category,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"level":%20level,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20}
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)

if&nbsp;__name__%20==&nbsp;"__main__":
&nbsp;%20&nbsp;%20main()

跑完会在同目录生成%20classified_data.csv下面这张图能看到脚本位置和前面几行路径设置,重点就是%20BASE%20=%20Path(file).resolve().parent,这样脚本会读当前目录里的附件文件

最后回到平台,输入验证码,上传生成的%20classified_data.csv结果出来是%20100.000%,flag%20也在结果表里

flag{a92c018d480b6b5e8aeb4a117e0fc72c}
licensed_unpack

分析壳

这里很像%20UPX,但不是标准%20UPX,直接upx%20-d会失败,是一个自定义壳。

这个拷贝逻辑是烟雾,0x412200对应%20.UPX1的文件偏移

file_offset%20=%200x412200%20-%200x412000%20+%200x200%20=%200x400

但文件偏移%200x400开始的数据执行不了

注意到这一串十六进制

CE%20B6%2014%203D%2043%2054%200E%200E%2002%200E%200E%200E%20CC%200A%2031%2031

常见zlib头包括

78%2001
78%209C
78%20DA

将该十六进制尝试异或0xce,正好是zlib头,前面填充的ce也算是提示吧。将这一段每个字节异或0xce后:

提取zlib压缩流,解压出内层PE

import&nbsp;argparse
import&nbsp;zlib
from&nbsp;pathlib&nbsp;import&nbsp;Path

PAYLOAD_OFF%20=&nbsp;0x25B
PAYLOAD_LEN%20=&nbsp;0x245
XOR_KEY%20=&nbsp;0xCE

def&nbsp;unpack_outer(path:%20Path)&nbsp;->%20bytes:
&nbsp;%20&nbsp;%20data%20=%20path.read_bytes()
&nbsp;%20&nbsp;%20blob%20=%20data[PAYLOAD_OFF:PAYLOAD_OFF%20+%20PAYLOAD_LEN]
&nbsp;%20&nbsp;&nbsp;if&nbsp;len(blob)%20!=%20PAYLOAD_LEN:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;raise&nbsp;ValueError("input%20is%20too%20small%20for%20the%20expected%20payload%20range")

&nbsp;%20&nbsp;%20xored%20=%20bytes(b%20^%20XOR_KEY&nbsp;for&nbsp;b&nbsp;in&nbsp;blob)
&nbsp;%20&nbsp;%20print(f"[+]%20xor%20head:&nbsp;{xored[:16].hex('%20')}")

&nbsp;%20&nbsp;&nbsp;if&nbsp;xored[:3]%20!=&nbsp;b"\x00\x78\xda":
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;raise&nbsp;ValueError("unexpected%20header:%20xored%20data%20should%20start%20with%2000%2078%20da")

&nbsp;%20&nbsp;%20inner%20=%20zlib.decompress(xored[1:])
&nbsp;%20&nbsp;&nbsp;if&nbsp;inner[:2]%20!=&nbsp;b"MZ":
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;raise&nbsp;ValueError("decompressed%20payload%20is%20not%20a%20PE%20file")
&nbsp;%20&nbsp;&nbsp;return&nbsp;inner

def&nbsp;main()&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20parser%20=%20argparse.ArgumentParser(description="Unpack%20licensed.exe%20outer%20layer")
&nbsp;%20&nbsp;%20parser.add_argument("input",%20type=Path,%20help="original%20licensed.exe")
&nbsp;%20&nbsp;%20parser.add_argument("output",%20type=Path,%20help="output%20inner%20PE")
&nbsp;%20&nbsp;%20args%20=%20parser.parse_args()

&nbsp;%20&nbsp;%20inner%20=%20unpack_outer(args.input)
&nbsp;%20&nbsp;%20args.output.write_bytes(inner)
&nbsp;%20&nbsp;%20print(f"[+]%20wrote:&nbsp;{args.output}")
&nbsp;%20&nbsp;%20print(f"[+]%20inner%20size:&nbsp;{len(inner)}&nbsp;bytes")

if&nbsp;__name__%20==&nbsp;"__main__":
&nbsp;%20&nbsp;%20main()

得到的1.exe一个很小的%20.NET%20PE

可以看到_bfk和_bfct关键字,猜测是blowfish%20key和cipherttext

提取key和cipherttext

from&nbsp;pathlib&nbsp;import&nbsp;Path
import&nbsp;struct

KEY_LEN%20=&nbsp;16
CT_LEN%20=&nbsp;48

def&nbsp;get_input_path()&nbsp;->%20Path:
&nbsp;%20&nbsp;&nbsp;for&nbsp;module_name&nbsp;in&nbsp;("ida_nalt",&nbsp;"idc",&nbsp;"idaapi"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20module%20=%20__import__(module_name)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20getter%20=%20getattr(module,&nbsp;"get_input_file_path",&nbsp;None)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;getter&nbsp;isNone:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20value%20=%20getter()
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;value:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20path%20=%20Path(value)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;path.exists():
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;path
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;except&nbsp;Exception:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;pass

&nbsp;%20&nbsp;&nbsp;import&nbsp;ida_kernwin

path%20=%20get_input_path()
data%20=%20path.read_bytes()

def&nbsp;u16(off):
&nbsp;%20&nbsp;&nbsp;return&nbsp;struct.unpack_from("<H",%20data,%20off)[0]

def&nbsp;u32(off):
&nbsp;%20&nbsp;&nbsp;return&nbsp;struct.unpack_from("<I",%20data,%20off)[0]

def&nbsp;rva_to_raw(rva):
&nbsp;%20&nbsp;%20pe%20=%20u32(0x3C)
&nbsp;%20&nbsp;%20number_of_sections%20=%20u16(pe%20+&nbsp;6)
&nbsp;%20&nbsp;%20size_of_optional_header%20=%20u16(pe%20+&nbsp;20)
&nbsp;%20&nbsp;%20section_table%20=%20pe%20+&nbsp;24&nbsp;+%20size_of_optional_header
&nbsp;%20&nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(number_of_sections):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20sec%20=%20section_table%20+%20i%20*&nbsp;40
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20name%20=%20data[sec:sec%20+&nbsp;8].rstrip(b"\x00").decode(errors="replace")
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20virtual_size%20=%20u32(sec%20+&nbsp;8)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20virtual_address%20=%20u32(sec%20+&nbsp;12)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20raw_size%20=%20u32(sec%20+&nbsp;16)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20raw_ptr%20=%20u32(sec%20+&nbsp;20)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;virtual_address%20<=%20rva%20<%20virtual_address%20+%20max(virtual_size,%20raw_size):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;raw_ptr%20+%20(rva%20-%20virtual_address),%20name
&nbsp;%20&nbsp;&nbsp;raise&nbsp;RuntimeError("RVA%200x%X%20is%20not%20in%20any%20section"&nbsp;%%20rva)

pe%20=%20u32(0x3C)
optional%20=%20pe%20+&nbsp;24
data_directories%20=%20optional%20+&nbsp;0x60
clr_dir%20=%20data_directories%20+&nbsp;14&nbsp;*&nbsp;8
clr_rva%20=%20u32(clr_dir)
clr_size%20=%20u32(clr_dir%20+&nbsp;4)
clr_raw,%20clr_sec%20=%20rva_to_raw(clr_rva)

metadata_rva%20=%20u32(clr_raw%20+&nbsp;8)
metadata_size%20=%20u32(clr_raw%20+&nbsp;12)
metadata_raw,%20metadata_sec%20=%20rva_to_raw(metadata_rva)

key_off%20=%20metadata_raw%20+%20metadata_size
ct_off%20=%20key_off%20+%20KEY_LEN
key%20=%20data[key_off:key_off%20+%20KEY_LEN]
ct%20=%20data[ct_off:ct_off%20+%20CT_LEN]

print("[+]%20CLR%20header:%20RVA=0x%X%20raw=0x%X%20section=%s%20size=0x%X"&nbsp;%%20(clr_rva,%20clr_raw,%20clr_sec,%20clr_size))
print("[+]%20CLR%20metadata:%20RVA=0x%X%20raw=0x%X%20section=%s%20size=0x%X"&nbsp;%%20(metadata_rva,%20metadata_raw,%20metadata_sec,%20metadata_size))
print("[+]%20metadata%20end%20raw%20offset:%200x%X"&nbsp;%%20key_off)
print("[+]%20CIL%20says%20_bfk%20length%20=%2016,%20_bfct%20length%20=%2048")
print("[+]%20key:",%20key.hex("%20"))
print("[+]%20ciphertext:",%20ct.hex("%20"))

字段名_bfk_bfct已经提示了blowfish。key%20是%2016%20字节,密文长度是%2048%20字节,正好是blowfish%208%20字节分组的整数倍,直接试%20blowfish%20ECB可以解出可读%20flag,尾部用 00 填充

#!/usr/bin/env%20python3
import&nbsp;argparse
from&nbsp;pathlib&nbsp;import&nbsp;Path

from&nbsp;Crypto.Cipher&nbsp;import&nbsp;Blowfish

KEY_OFF%20=&nbsp;0x3C4
KEY_LEN%20=&nbsp;16
CT_OFF%20=&nbsp;0x3D4
CT_LEN%20=&nbsp;48

def&nbsp;main()&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20parser%20=%20argparse.ArgumentParser(description="Decrypt%20flag%20from%20unpacked%20inner%20PE")
&nbsp;%20&nbsp;%20parser.add_argument("input",%20type=Path,%20help="unpacked%20.NET%20PE")
&nbsp;%20&nbsp;%20args%20=%20parser.parse_args()

&nbsp;%20&nbsp;%20data%20=%20args.input.read_bytes()
&nbsp;%20&nbsp;%20key%20=%20data[KEY_OFF:KEY_OFF%20+%20KEY_LEN]
&nbsp;%20&nbsp;%20ct%20=%20data[CT_OFF:CT_OFF%20+%20CT_LEN]
&nbsp;%20&nbsp;&nbsp;if&nbsp;len(key)%20!=%20KEY_LEN&nbsp;or&nbsp;len(ct)%20!=%20CT_LEN:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;raise&nbsp;ValueError("input%20is%20too%20small%20for%20the%20expected%20key/ciphertext%20offsets")

&nbsp;%20&nbsp;%20pt%20=%20Blowfish.new(key,%20Blowfish.MODE_ECB).decrypt(ct).rstrip(b"\x00")
&nbsp;%20&nbsp;%20print(f"[+]%20key:&nbsp;{key.hex()}")
&nbsp;%20&nbsp;%20print(f"[+]%20ciphertext:&nbsp;{ct.hex()}")
&nbsp;%20&nbsp;%20print(f"[+]%20plaintext:&nbsp;{pt.decode()}")

if&nbsp;__name__%20==&nbsp;"__main__":
&nbsp;%20&nbsp;%20main()

CovertChannel

TXT记录base64编码

MoreSecureAes128

分析出可疑的单向echo请求

ttl只有0和1,疑似二进制,每8bit转一个%20ASCII%20字符

from&nbsp;scapy.all&nbsp;import&nbsp;rdpcap,%20IP,%20ICMP

pcap%20=&nbsp;r"suspicious_traffic.pcapng"

pkts%20=%20rdpcap(pcap)

rows%20=%20[]
for&nbsp;p&nbsp;in&nbsp;pkts:
&nbsp;%20&nbsp;&nbsp;if&nbsp;p.haslayer(IP)&nbsp;and&nbsp;p.haslayer(ICMP):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;p[IP].src%20==&nbsp;"10.10.20.33"and&nbsp;p[IP].dst%20==&nbsp;"45.76.188.23"and&nbsp;p[ICMP].type%20==&nbsp;8:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20rows.append((p[ICMP].seq,%20p[IP].ttl))

rows.sort()

bits%20=&nbsp;"".join(str(ttl)&nbsp;for&nbsp;seq,%20ttl&nbsp;in&nbsp;rows)
print(bits)
001101100011000000110010001100100011011001100100001100110110001101100100001100110110001101100110001100110011010000110101011001000110011000110000011000100110000101100001001100110110001100111000011001000110010101100011001100000110010000111001011001000011001000110010001101010110001000110011011001100110011000111001001100010011011000110011001101000011010000111001001110000110010001100010001101000110011000110101011000010011011101100100001101000011010000110101001100100110010001100100001100010110000100110010011000110011001000110000001110000110010000110100011001010110011001100011001110010110010001100001001101110011001101100101011000100011100000111000001110000011010001100110011000100011011000111001011000110011001101100010001100010011001000110000001100100110001100110101011111000100000101000101010100110010110101000101010000110100001000101100001000000110101101100101011110010010000001101001011100110010000001101001011011100010000001000100010011100101001100100000010101000101100001010100

加密方式为AES-ECB,key就是DNS%20TXT解出的MoreSecureAes128

exam_system

查询表

表结构

身份证校验规则:

weights%20=%20[7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2,&nbsp;1,&nbsp;6,&nbsp;3,&nbsp;7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2]
check_codes%20=&nbsp;"10X98765432"
check%20=%20check_codes[sum(int(d)%20*%20w&nbsp;for&nbsp;d,%20w&nbsp;in&nbsp;zip(idcard[:17],%20weights))%20%&nbsp;11]

手机号校验规则:

phone%20必须匹配%20\d{11}
phone[:3]%20必须在规范给出的合法号段集合中

根据安全审计规范写了一份sql脚本

WITH&nbsp;ghost_accounts&nbsp;AS&nbsp;(
&nbsp;%20&nbsp;&nbsp;SELECT
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.id&nbsp;AS&nbsp;user_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.username,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.name,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.idcard,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.phone,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.role_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20r.role_name,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.status,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;CASE
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;WHEN&nbsp;is_valid_idcard(u.idcard)%20=&nbsp;0AND&nbsp;is_valid_phone(u.phone)%20=&nbsp;0THEN'idcard,phone'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;WHEN&nbsp;is_valid_idcard(u.idcard)%20=&nbsp;0THEN'idcard'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ELSE'phone'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ENDAS&nbsp;invalid_fields
&nbsp;%20&nbsp;&nbsp;FROMusersAS&nbsp;u
&nbsp;%20&nbsp;&nbsp;JOINrolesAS&nbsp;r
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ON&nbsp;r.id%20=%20u.role_id
&nbsp;%20&nbsp;&nbsp;WHERE&nbsp;is_valid_idcard(u.idcard)%20=&nbsp;0
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;OR&nbsp;is_valid_phone(u.phone)%20=&nbsp;0
),
authorized_permissions&nbsp;AS&nbsp;(
&nbsp;%20&nbsp;&nbsp;SELECT
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20role_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20api_endpoint
&nbsp;%20&nbsp;&nbsp;FROM&nbsp;api_permissions
&nbsp;%20&nbsp;&nbsp;WHERE&nbsp;can_access%20=&nbsp;1
),
violation_candidates&nbsp;AS&nbsp;(
&nbsp;%20&nbsp;&nbsp;SELECT
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20l.id&nbsp;AS&nbsp;log_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20l.user_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.username,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.name,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.status,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.role_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20r.role_name,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20l.api_endpoint,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20l.access_time,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;CASE
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;WHEN&nbsp;u.status%20=&nbsp;'inactive'THEN1
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;WHENtime(l.access_time)%20<&nbsp;'09:00:00'ORtime(l.access_time)%20>&nbsp;'18:00:00'THEN2
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;WHEN&nbsp;ap.role_id&nbsp;ISNULLTHEN3
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ENDAS&nbsp;violation_type,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;trim(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;CASEWHEN&nbsp;u.status%20=&nbsp;'inactive'THEN'1,'ELSE''END&nbsp;||
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;CASEWHENtime(l.access_time)%20<&nbsp;'09:00:00'ORtime(l.access_time)%20>&nbsp;'18:00:00'THEN'2,'ELSE''END&nbsp;||
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;CASEWHEN&nbsp;ap.role_id&nbsp;ISNULLTHEN'3,'ELSE''END,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;','
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)&nbsp;AS&nbsp;matched_types
&nbsp;%20&nbsp;&nbsp;FROM&nbsp;access_logs&nbsp;AS&nbsp;l
&nbsp;%20&nbsp;&nbsp;JOINusersAS&nbsp;u
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ON&nbsp;u.id%20=%20l.user_id
&nbsp;%20&nbsp;&nbsp;JOINrolesAS&nbsp;r
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ON&nbsp;r.id%20=%20u.role_id
&nbsp;%20&nbsp;&nbsp;LEFTJOIN&nbsp;authorized_permissions&nbsp;AS&nbsp;ap
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;ON&nbsp;ap.role_id%20=%20u.role_id
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;AND&nbsp;ap.api_endpoint%20=%20l.api_endpoint
&nbsp;%20&nbsp;&nbsp;WHERE&nbsp;u.id&nbsp;NOTIN&nbsp;(SELECT&nbsp;user_id&nbsp;FROM&nbsp;ghost_accounts)
&nbsp;%20&nbsp;%20&nbsp;&nbsp;AND&nbsp;(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.status%20=&nbsp;'inactive'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;ORtime(l.access_time)%20<&nbsp;'09:00:00'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;ORtime(l.access_time)%20>&nbsp;'18:00:00'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;OR&nbsp;ap.role_id&nbsp;ISNULL
&nbsp;%20&nbsp;%20&nbsp;%20)
)
SELECT
&nbsp;%20&nbsp;%20*
FROM&nbsp;violation_candidates
ORDERBY&nbsp;log_id;

找出幽灵账户脚本

from&nbsp;__future__&nbsp;import&nbsp;annotations

import&nbsp;argparse
import&nbsp;csv
import&nbsp;hashlib
import&nbsp;json
import&nbsp;re
import&nbsp;sqlite3
from&nbsp;collections&nbsp;import&nbsp;Counter
from&nbsp;pathlib&nbsp;import&nbsp;Path
from&nbsp;typing&nbsp;import&nbsp;Iterable

IDCARD_WEIGHTS%20=%20[7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2,&nbsp;1,&nbsp;6,&nbsp;3,&nbsp;7,&nbsp;9,&nbsp;10,&nbsp;5,&nbsp;8,&nbsp;4,&nbsp;2]
IDCARD_CHECK_CODES%20=&nbsp;"10X98765432"
PHONE_PREFIXES%20=%20{
&nbsp;%20&nbsp;&nbsp;"134",&nbsp;"135",&nbsp;"136",&nbsp;"137",&nbsp;"138",&nbsp;"139",&nbsp;"147",&nbsp;"148",&nbsp;"150",&nbsp;"151",
&nbsp;%20&nbsp;&nbsp;"152",&nbsp;"157",&nbsp;"158",&nbsp;"159",&nbsp;"172",&nbsp;"178",&nbsp;"182",&nbsp;"183",&nbsp;"184",&nbsp;"187",
&nbsp;%20&nbsp;&nbsp;"188",&nbsp;"195",&nbsp;"198",&nbsp;"130",&nbsp;"131",&nbsp;"132",&nbsp;"140",&nbsp;"145",&nbsp;"146",&nbsp;"155",
&nbsp;%20&nbsp;&nbsp;"156",&nbsp;"166",&nbsp;"167",&nbsp;"171",&nbsp;"175",&nbsp;"176",&nbsp;"185",&nbsp;"186",&nbsp;"196",&nbsp;"133",
&nbsp;%20&nbsp;&nbsp;"149",&nbsp;"153",&nbsp;"173",&nbsp;"174",&nbsp;"177",&nbsp;"180",&nbsp;"181",&nbsp;"189",&nbsp;"190",&nbsp;"191",
&nbsp;%20&nbsp;&nbsp;"193",&nbsp;"199",
}
WORK_START%20=&nbsp;"09:00:00"
WORK_END%20=&nbsp;"18:00:00"

def&nbsp;is_valid_idcard(idcard:%20str)&nbsp;->%20int:
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{17}[\dX]",%20idcard&nbsp;or""):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return0
&nbsp;%20&nbsp;%20weighted_sum%20=%20sum(int(digit)%20*%20weight&nbsp;for&nbsp;digit,%20weight&nbsp;in&nbsp;zip(idcard[:17],%20IDCARD_WEIGHTS))
&nbsp;%20&nbsp;&nbsp;return&nbsp;int(IDCARD_CHECK_CODES[weighted_sum%20%&nbsp;11]%20==%20idcard[-1])

def&nbsp;is_valid_phone(phone:%20str)&nbsp;->%20int:
&nbsp;%20&nbsp;&nbsp;ifnot&nbsp;re.fullmatch(r"\d{11}",%20phone&nbsp;or""):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return0
&nbsp;%20&nbsp;&nbsp;return&nbsp;int(phone[:3]&nbsp;in&nbsp;PHONE_PREFIXES)

def&nbsp;load_query(sql_path:%20Path)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;return&nbsp;sql_path.read_text(encoding="utf-8")

def&nbsp;rows_to_dicts(rows:%20Iterable[sqlite3.Row])&nbsp;->%20list[dict]:
&nbsp;%20&nbsp;&nbsp;return&nbsp;[dict(row)&nbsp;for&nbsp;row&nbsp;in&nbsp;rows]

def&nbsp;write_csv(path:%20Path,%20rows:%20list[dict],%20fieldnames:%20list[str])&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;&nbsp;with&nbsp;path.open("w",%20encoding="utf-8-sig",%20newline="")&nbsp;as&nbsp;handle:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20writer%20=%20csv.DictWriter(handle,%20fieldnames=fieldnames)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20writer.writeheader()
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20writer.writerows(rows)

def&nbsp;write_markdown_report(
&nbsp;%20&nbsp;%20report_path:%20Path,
&nbsp;%20&nbsp;%20summary:%20dict,
&nbsp;%20&nbsp;%20ghost_rows:%20list[dict],
&nbsp;%20&nbsp;%20violation_rows:%20list[dict],
&nbsp;%20&nbsp;%20db_path:%20Path,
&nbsp;%20&nbsp;%20spec_path:%20Path,
&nbsp;%20&nbsp;%20sql_path:%20Path,
&nbsp;%20&nbsp;%20script_path:%20Path,
)&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20ghost_lines%20=%20[
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20`{row['user_id']}`%20/%20`{row['username']}`%20/&nbsp;{row['name']}&nbsp;/%20`{row['invalid_fields']}`%20/%20`{row['status']}`%20/%20`{row['role_name']}`"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;for&nbsp;row&nbsp;in&nbsp;ghost_rows
&nbsp;%20&nbsp;%20]
&nbsp;%20&nbsp;%20violation_lines%20=%20[
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20`log={row['log_id']}`%20`type={row['violation_type']}`%20"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"`matched={row['matched_types']}`%20`user={row['user_id']}/{row['username']}`%20"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"`role={row['role_name']}`%20`api={row['api_endpoint']}`%20`time={row['access_time']}`"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;for&nbsp;row&nbsp;in&nbsp;violation_rows
&nbsp;%20&nbsp;%20]
&nbsp;%20&nbsp;%20report%20=&nbsp;"\n".join(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20[
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"#%20exam_system%20审计结果",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20输入文件",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20规范:%20`{spec_path}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20数据库:%20`{db_path}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20结论",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20最终%20flag:%20`{summary['flag']}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20幽灵账户总数:%20`{summary['ghost_count']}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20被幽灵账户排除的访问日志数:%20`{summary['excluded_log_count']}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20违规访问总数:%20`{summary['violation_count']}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20违规类型分布:%20`1={summary['violation_type_counts'].get('1',&nbsp;0)}`,%20`2={summary['violation_type_counts'].get('2',&nbsp;0)}`,%20`3={summary['violation_type_counts'].get('3',&nbsp;0)}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20规则说明",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"-%20身份证按%2018%20位格式和最后一位校验码校验;本库中的异常身份证全部表现为校验码错误。",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"-%20手机号按%2011%20位数字串和指定前三位号段集合校验;本库中的异常手机号全部表现为非法号段。",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"-%20工作时间边界按%20`09:00:00%20<=%20time%20<=%2018:00:00`%20处理;库中没有恰好等于%20`09:00:00`%20或%20`18:00:00`%20的记录,因此边界取值不影响结果。",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"-%20对同一条日志,若同时命中多类违规,按最小类型编号记入结果。",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20幽灵账户列表",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20*ghost_lines,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20违规访问明细",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20*violation_lines,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20复现",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20SQL%20逻辑:%20`{sql_path}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20脚本:%20`{script_path}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20命令:%20`python%20\"{script_path}\"%20--db%20\"{db_path}\"%20--spec%20\"{spec_path}\"%20--sql%20\"{sql_path}\"%20--outdir%20\"{report_path.parent}\"`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"##%20产物",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20`{report_path}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20`{report_path.parent%20/&nbsp;'ghost_accounts.csv'}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20`{report_path.parent%20/&nbsp;'violations.csv'}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"-%20`{report_path.parent%20/&nbsp;'audit_summary.json'}`",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20]
&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20report_path.write_text(report%20+&nbsp;"\n",%20encoding="utf-8")

def&nbsp;main()&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20parser%20=%20argparse.ArgumentParser(description="Audit%20exam_system%20ghost%20accounts%20and%20API%20violations.")
&nbsp;%20&nbsp;%20parser.add_argument("--db",%20type=Path,%20required=True,%20help="Path%20to%20exam_system.db")
&nbsp;%20&nbsp;%20parser.add_argument("--spec",%20type=Path,%20required=True,%20help="Path%20to%20安全审计规范.md")
&nbsp;%20&nbsp;%20parser.add_argument("--sql",%20type=Path,%20required=True,%20help="Path%20to%20audit%20SQL%20file")
&nbsp;%20&nbsp;%20parser.add_argument("--outdir",%20type=Path,%20required=True,%20help="Directory%20for%20generated%20outputs")
&nbsp;%20&nbsp;%20args%20=%20parser.parse_args()

&nbsp;%20&nbsp;%20outdir%20=%20args.outdir
&nbsp;%20&nbsp;%20outdir.mkdir(parents=True,%20exist_ok=True)

&nbsp;%20&nbsp;%20conn%20=%20sqlite3.connect(args.db)
&nbsp;%20&nbsp;%20conn.row_factory%20=%20sqlite3.Row
&nbsp;%20&nbsp;%20conn.create_function("is_valid_idcard",&nbsp;1,%20is_valid_idcard)
&nbsp;%20&nbsp;%20conn.create_function("is_valid_phone",&nbsp;1,%20is_valid_phone)
&nbsp;%20&nbsp;%20cur%20=%20conn.cursor()

&nbsp;%20&nbsp;%20ghost_query%20=&nbsp;"""
&nbsp;%20&nbsp;%20WITH%20ghost_accounts%20AS%20(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20SELECT
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.id%20AS%20user_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.username,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.name,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.idcard,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.phone,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.role_id,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20r.role_name,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20u.status,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20CASE
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20WHEN%20is_valid_idcard(u.idcard)%20=%200%20AND%20is_valid_phone(u.phone)%20=%200%20THEN%20'idcard,phone'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20WHEN%20is_valid_idcard(u.idcard)%20=%200%20THEN%20'idcard'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ELSE%20'phone'
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20END%20AS%20invalid_fields
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20FROM%20users%20AS%20u
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20JOIN%20roles%20AS%20r
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ON%20r.id%20=%20u.role_id
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20WHERE%20is_valid_idcard(u.idcard)%20=%200
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;OR%20is_valid_phone(u.phone)%20=%200
&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20SELECT%20*
&nbsp;%20&nbsp;%20FROM%20ghost_accounts
&nbsp;%20&nbsp;%20ORDER%20BY%20user_id
&nbsp;%20&nbsp;%20"""

&nbsp;%20&nbsp;%20violation_query%20=%20load_query(args.sql)

&nbsp;%20&nbsp;%20ghost_rows%20=%20rows_to_dicts(cur.execute(ghost_query))
&nbsp;%20&nbsp;%20violation_rows%20=%20rows_to_dicts(cur.execute(violation_query))

&nbsp;%20&nbsp;%20violation_key%20=&nbsp;",".join(f"{row['log_id']}-{row['violation_type']}"for&nbsp;row&nbsp;in&nbsp;violation_rows)
&nbsp;%20&nbsp;%20md5_value%20=%20hashlib.md5(violation_key.encode("utf-8")).hexdigest()

&nbsp;%20&nbsp;%20excluded_log_count%20=%20cur.execute(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"""
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20SELECT%20COUNT(*)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20FROM%20access_logs
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20WHERE%20user_id%20IN%20(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20SELECT%20u.id
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20FROM%20users%20AS%20u
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20WHERE%20is_valid_idcard(u.idcard)%20=%200
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;OR%20is_valid_phone(u.phone)%20=%200
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20"""
&nbsp;%20&nbsp;%20).fetchone()[0]

&nbsp;%20&nbsp;%20ghost_reason_counts%20=%20Counter(row["invalid_fields"]&nbsp;for&nbsp;row&nbsp;in&nbsp;ghost_rows)
&nbsp;%20&nbsp;%20violation_type_counts%20=%20Counter(str(row["violation_type"])&nbsp;for&nbsp;row&nbsp;in&nbsp;violation_rows)

&nbsp;%20&nbsp;%20summary%20=%20{
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"db_path":%20str(args.db),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"spec_path":%20str(args.spec),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"sql_path":%20str(args.sql),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"ghost_count":%20len(ghost_rows),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"ghost_reason_counts":%20dict(sorted(ghost_reason_counts.items())),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"excluded_log_count":%20excluded_log_count,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"violation_count":%20len(violation_rows),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"violation_type_counts":%20dict(sorted(violation_type_counts.items())),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"violation_key":%20violation_key,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"md5":%20md5_value,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"flag":&nbsp;f"flag{{{md5_value}}}",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"work_window":%20[WORK_START,%20WORK_END],
&nbsp;%20&nbsp;%20}

&nbsp;%20&nbsp;%20write_csv(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20outdir%20/&nbsp;"ghost_accounts.csv",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ghost_rows,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20["user_id",&nbsp;"username",&nbsp;"name",&nbsp;"idcard",&nbsp;"phone",&nbsp;"role_id",&nbsp;"role_name",&nbsp;"status",&nbsp;"invalid_fields"],
&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20write_csv(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20outdir%20/&nbsp;"violations.csv",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20violation_rows,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20[
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"log_id",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"user_id",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"username",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"name",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"status",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"role_id",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"role_name",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"api_endpoint",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"access_time",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"violation_type",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"matched_types",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20],
&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20(outdir%20/&nbsp;"audit_summary.json").write_text(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20json.dumps(summary,%20ensure_ascii=False,%20indent=2)%20+&nbsp;"\n",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20encoding="utf-8",
&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;%20write_markdown_report(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20report_path=outdir%20/&nbsp;"audit_report.md",
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20summary=summary,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20ghost_rows=ghost_rows,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20violation_rows=violation_rows,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20db_path=args.db,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20spec_path=args.spec,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20sql_path=args.sql,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20script_path=Path(__file__).resolve(),
&nbsp;%20&nbsp;%20)

&nbsp;%20&nbsp;%20print(json.dumps(summary,%20ensure_ascii=False,%20indent=2))

if&nbsp;__name__%20==&nbsp;"__main__":
&nbsp;%20&nbsp;%20main()

cpa_trace

先看明文、迹线和密文长度

import&nbsp;numpy&nbsp;as&nbsp;np,%20pathlib
p=pathlib.Path('.')
print('plaintexts.npy',%20np.load(p/'plaintexts.npy',%20mmap_mode='r').shape,%20np.load(p/'plaintexts.npy',%20mmap_mode='r').dtype)
print('traces.npy',%20np.load(p/'traces.npy',%20mmap_mode='r').shape,%20np.load(p/'traces.npy',%20mmap_mode='r').dtype)
f=(p/'flag.enc').read_bytes()
print('flag.enc',%20len(f),%20f.hex())

有 5000 组采样,每组明文是%2016%20字节,也就是一个%20AES%20block。

迹线形状是 (5000,%202,%201024),说明每组采样有%202%20个通道,每个通道%201024%20个采样点

CPA%20攻击

AES%20首轮中,每个字节会经过:

SBox(P[i]%20^%20K[i])

其中 P[i] 是已知明文字节,K[i] 是要猜的密钥字节。CPA%20的做法是:

  1. 对某个%20key%20byte%20枚举 0x00 到 0xff
  2. 对每条明文计算假设中间值。
  3. 把假设泄漏值和真实迹线逐点做%20Pearson%20相关。
  4. 哪个%20key%20guess%20的相关性最高,就认为哪个是正确密钥字节。

尝试最常见模型:

HW(SBox(P%20^%20K))

但这个模型的平均相关系数只有约%200.156,说明能看到一点信号,但不是设备真正主要泄漏的量

真正有效的模型是

HW(SBox(P%20^%20K)%20^%20P)

这个模型表示%20SubBytes%20前后的%20Hamming%20Distance,也就是状态从 P 变成 SBox(P%20^%20K) 时翻转了多少%20bit。对功耗/电磁侧信道来说,总线或寄存器翻转数量经常比单纯的%20Hamming%20Weight%20更明显。

恢复出的%20AES%20master%20key%20为:

422c174a4a89d96af0b7de281dc01324

直接用master%20key去尝试flag.enc并不能得到正确明文。继续做%20AES-128%20key%20schedule,可以得到第%2010%20轮密钥

flag.enc实际使用round10_key作为AES-ECB%20key加密。用该key解密并去掉PKCS#7 padding

from&nbsp;__future__&nbsp;import&nbsp;annotations

import&nbsp;argparse
from&nbsp;pathlib&nbsp;import&nbsp;Path

import&nbsp;numpy&nbsp;as&nbsp;np
from&nbsp;Crypto.Cipher&nbsp;import&nbsp;AES
from&nbsp;Crypto.Util.Padding&nbsp;import&nbsp;unpad

SBOX%20=%20np.frombuffer(
&nbsp;%20&nbsp;%20bytes.fromhex(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"08ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c1"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"1d9ee1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"054bb16"
&nbsp;%20&nbsp;%20),
&nbsp;%20&nbsp;%20dtype=np.uint8,
)
HW%20=%20np.array([i.bit_count()&nbsp;for&nbsp;i&nbsp;in&nbsp;range(256)],%20dtype=np.float32)
RCON%20=%20[0x00,&nbsp;0x01,&nbsp;0x02,&nbsp;0x04,&nbsp;0x08,&nbsp;0x10,&nbsp;0x20,&nbsp;0x40,&nbsp;0x80,&nbsp;0x1B,&nbsp;0x36]

def&nbsp;load_inputs(data_dir:%20Path)&nbsp;->%20tuple[np.ndarray,%20np.ndarray,%20bytes]:
&nbsp;%20&nbsp;%20plaintexts%20=%20np.load(data_dir%20/&nbsp;"plaintexts.npy")
&nbsp;%20&nbsp;%20traces%20=%20np.load(data_dir%20/&nbsp;"traces.npy")
&nbsp;%20&nbsp;%20flag_enc%20=%20(data_dir%20/&nbsp;"flag.enc").read_bytes()
&nbsp;%20&nbsp;&nbsp;return&nbsp;plaintexts,%20traces,%20flag_enc

def&nbsp;centered_trace_channel(traces:%20np.ndarray,%20channel:%20int%20=&nbsp;0)&nbsp;->%20tuple[np.ndarray,%20np.ndarray]:
&nbsp;%20&nbsp;%20matrix%20=%20traces[:,%20channel,%20:].astype(np.float32)
&nbsp;%20&nbsp;%20matrix%20-=%20matrix.mean(axis=0,%20keepdims=True)
&nbsp;%20&nbsp;%20norm%20=%20np.sqrt((matrix%20*%20matrix).sum(axis=0))
&nbsp;%20&nbsp;%20norm[norm%20==&nbsp;0]%20=&nbsp;1
&nbsp;%20&nbsp;&nbsp;return&nbsp;matrix,%20norm

def&nbsp;build_leakage(plain_byte:%20np.ndarray,%20guesses:%20np.ndarray,%20model:%20str)&nbsp;->%20np.ndarray:
&nbsp;%20&nbsp;%20state%20=%20np.bitwise_xor(plain_byte[:,&nbsp;None],%20guesses)
&nbsp;%20&nbsp;%20sbox_value%20=%20SBOX[state]

&nbsp;%20&nbsp;&nbsp;if&nbsp;model%20==&nbsp;"HW(SBox(P^K))":
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20leakage%20=%20HW[sbox_value]
&nbsp;%20&nbsp;&nbsp;elif&nbsp;model%20==&nbsp;"HW(SBox(P^K)^P)":
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20leakage%20=%20HW[np.bitwise_xor(sbox_value,%20plain_byte[:,&nbsp;None])]
&nbsp;%20&nbsp;&nbsp;else:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;raise&nbsp;ValueError(model)

&nbsp;%20&nbsp;%20leakage%20=%20leakage.T.astype(np.float32)
&nbsp;%20&nbsp;%20leakage%20-=%20leakage.mean(axis=1,%20keepdims=True)
&nbsp;%20&nbsp;&nbsp;return&nbsp;leakage

def&nbsp;run_cpa(plaintexts:%20np.ndarray,%20traces:%20np.ndarray,%20model:%20str)&nbsp;->%20tuple[bytes,%20list[dict]]:
&nbsp;%20&nbsp;%20trace_matrix,%20trace_norm%20=%20centered_trace_channel(traces,%20channel=0)
&nbsp;%20&nbsp;%20guesses%20=%20np.arange(256,%20dtype=np.uint8)
&nbsp;%20&nbsp;%20key%20=%20[]
&nbsp;%20&nbsp;%20table%20=%20[]

&nbsp;%20&nbsp;&nbsp;for&nbsp;byte_index&nbsp;in&nbsp;range(16):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20leakage%20=%20build_leakage(plaintexts[:,%20byte_index],%20guesses,%20model)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20leakage_norm%20=%20np.sqrt((leakage%20*%20leakage).sum(axis=1))
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20leakage_norm[leakage_norm%20==&nbsp;0]%20=&nbsp;1

&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20corr%20=%20(leakage%20@%20trace_matrix)%20/%20(leakage_norm[:,&nbsp;None]%20*%20trace_norm[None,%20:])
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20abs_corr%20=%20np.abs(corr)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20score%20=%20abs_corr.max(axis=1)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20order%20=%20np.argsort(score)[::-1]

&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20best%20=%20int(order[0])
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20second%20=%20int(order[1])
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20sample%20=%20int(abs_corr[best].argmax())
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20key.append(best)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20table.append(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20{
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"byte":%20byte_index,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"best":%20best,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"corr":%20float(score[best]),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"sample":%20sample,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"second":%20second,
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;"second_corr":%20float(score[second]),
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20}
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)

&nbsp;%20&nbsp;&nbsp;return&nbsp;bytes(key),%20table

def&nbsp;expand_aes128(master_key:%20bytes)&nbsp;->%20list[bytes]:
&nbsp;%20&nbsp;&nbsp;def&nbsp;xor_word(a:%20bytes,%20b:%20bytes)&nbsp;->%20bytes:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;bytes(x%20^%20y&nbsp;for&nbsp;x,%20y&nbsp;in&nbsp;zip(a,%20b))

&nbsp;%20&nbsp;&nbsp;def&nbsp;sub_word(word:%20bytes)&nbsp;->%20bytes:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;bytes(int(SBOX[b])&nbsp;for&nbsp;b&nbsp;in&nbsp;word)

&nbsp;%20&nbsp;&nbsp;def&nbsp;rot_word(word:%20bytes)&nbsp;->%20bytes:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;word[1:]%20+%20word[:1]

&nbsp;%20&nbsp;%20words%20=%20[master_key[i%20:%20i%20+&nbsp;4]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(0,&nbsp;16,&nbsp;4)]
&nbsp;%20&nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(4,&nbsp;44):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20temp%20=%20words[i%20-&nbsp;1]
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;i%20%&nbsp;4&nbsp;==&nbsp;0:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20temp%20=%20sub_word(rot_word(temp))
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20temp%20=%20bytes([temp[0]%20^%20RCON[i%20//&nbsp;4]])%20+%20temp[1:]
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20words.append(xor_word(words[i%20-&nbsp;4],%20temp))

&nbsp;%20&nbsp;&nbsp;return&nbsp;[b"".join(words[4&nbsp;*%20r%20:&nbsp;4&nbsp;*%20r%20+&nbsp;4])&nbsp;for&nbsp;r&nbsp;in&nbsp;range(11)]

def&nbsp;print_table(title:%20str,%20key:%20bytes,%20table:%20list[dict])&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20avg%20=%20sum(row["corr"]&nbsp;for&nbsp;row&nbsp;in&nbsp;table)%20/%20len(table)
&nbsp;%20&nbsp;%20print()
&nbsp;%20&nbsp;%20print(title)
&nbsp;%20&nbsp;%20print(f"candidate_key%20=&nbsp;{key.hex()}")
&nbsp;%20&nbsp;%20print(f"average_best_corr%20=&nbsp;{avg:.6f}")
&nbsp;%20&nbsp;%20print("byte%20&nbsp;key%20&nbsp;corr%20&nbsp;%20&nbsp;%20&nbsp;sample%20&nbsp;second%20&nbsp;second_corr")
&nbsp;%20&nbsp;&nbsp;for&nbsp;row&nbsp;in&nbsp;table:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20print(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"{row['byte']:>4}&nbsp;%20"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"{row['best']:02x}&nbsp;%20&nbsp;"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"{row['corr']:.6f}&nbsp;%20"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"{row['sample']:>6}&nbsp;%20"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"{row['second']:02x}&nbsp;%20&nbsp;%20&nbsp;%20"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"{row['second_corr']:.6f}"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)

def&nbsp;main()&nbsp;->&nbsp;None:
&nbsp;%20&nbsp;%20parser%20=%20argparse.ArgumentParser(description="CPA%20solve%20for%20a%20directory%20with%20flag.enc,%20plaintexts.npy%20and%20traces.npy")
&nbsp;%20&nbsp;%20parser.add_argument("--data-dir",%20type=Path,%20default=Path("."),%20help="Directory%20containing%20the%20three%20challenge%20files")
&nbsp;%20&nbsp;%20args%20=%20parser.parse_args()

&nbsp;%20&nbsp;%20data_dir%20=%20args.data_dir.resolve()
&nbsp;%20&nbsp;%20plaintexts,%20traces,%20flag_enc%20=%20load_inputs(data_dir)

&nbsp;%20&nbsp;%20print(f"data_dir%20=&nbsp;{data_dir}")
&nbsp;%20&nbsp;%20print(f"plaintexts.npy%20shape%20=&nbsp;{plaintexts.shape},%20dtype%20=&nbsp;{plaintexts.dtype}")
&nbsp;%20&nbsp;%20print(f"traces.npy%20shape%20=&nbsp;{traces.shape},%20dtype%20=&nbsp;{traces.dtype}")
&nbsp;%20&nbsp;%20print(f"flag.enc%20length%20=&nbsp;{len(flag_enc)},%20hex%20=&nbsp;{flag_enc.hex()}")

&nbsp;%20&nbsp;%20hw_key,%20hw_table%20=%20run_cpa(plaintexts,%20traces,&nbsp;"HW(SBox(P^K))")
&nbsp;%20&nbsp;%20hd_key,%20hd_table%20=%20run_cpa(plaintexts,%20traces,&nbsp;"HW(SBox(P^K)^P)")

&nbsp;%20&nbsp;%20print_table("Model%201:%20HW(SBox(P^K))",%20hw_key,%20hw_table)
&nbsp;%20&nbsp;%20print_table("Model%202:%20HW(SBox(P^K)^P)",%20hd_key,%20hd_table)

&nbsp;%20&nbsp;%20round_keys%20=%20expand_aes128(hd_key)
&nbsp;%20&nbsp;%20round10_key%20=%20round_keys[10]
&nbsp;%20&nbsp;%20flag%20=%20unpad(AES.new(round10_key,%20AES.MODE_ECB).decrypt(flag_enc),&nbsp;16).decode()

&nbsp;%20&nbsp;%20print()
&nbsp;%20&nbsp;%20print("AES-128%20key%20schedule")
&nbsp;%20&nbsp;%20print(f"round00(master_key)%20=&nbsp;{round_keys[0].hex()}")
&nbsp;%20&nbsp;%20print(f"round10_key%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20=&nbsp;{round10_key.hex()}")
&nbsp;%20&nbsp;%20print()
&nbsp;%20&nbsp;%20print("Final%20decrypt")
&nbsp;%20&nbsp;%20print("mode%20=%20AES-ECB")
&nbsp;%20&nbsp;%20print(f"key%20&nbsp;=%20round10_key")
&nbsp;%20&nbsp;%20print(f"flag%20=&nbsp;{flag}")

if&nbsp;__name__%20==&nbsp;"__main__":
&nbsp;%20&nbsp;%20main()
close%20primes

先解析公钥,得到%201024-bit%20RSA,公钥指数为常见的 65537。密文文件大小为 23168 字节,而%201024-bit%20RSA%20的单个密文块长度是 128 字节

23168%20/%20128%20=%20181

所以 encrypted.bin 不是一个单独%20RSA%20密文,而是由%20181%20个%20RSA%20密文块拼接而成。后面恢复私钥后,需要按%20128%20字节分块逐块解密。

Fermat%20分解

设:

n%20=%20p%20*%20q
a%20=%20(p%20+%20q)%20/%202
b%20=%20(q%20-%20p)%20/%202

则:

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

也就是说,从 ceil(sqrt(n)) 开始枚举 a,只要发现:

a^2%20-%20n%20=%20b^2

就可以得到:

p%20=%20a%20-%20b
q%20=%20a%20+%20b

p 和 q 的距离确实很小,Fermat%20分解一次就能命中

p%20=%2012751828551709836069103027639236748210345732901386209604520393543958229433834765694925409378355486164691077551258041029878391501131185664614971880587757401
q%20=%2012751828551709836069103027639236748210345732901386209604520393543958229433834765694925409378355486164691077551258041029878391501131185675868445062344890849
q%20-%20p%20=%2011253473181757133448

恢复 pq 后,就可以计算:

phi(n)%20=%20(p%20-%201)%20*%20(q%20-%201)
d%20=%20e^{-1}%20mod%20phi(n)

然后构造%20RSA%20私钥。

#!/usr/bin/env%20python3
from&nbsp;__future__&nbsp;import&nbsp;annotations

import&nbsp;argparse
from&nbsp;math&nbsp;import&nbsp;isqrt
from&nbsp;pathlib&nbsp;import&nbsp;Path

from&nbsp;Crypto.Cipher&nbsp;import&nbsp;PKCS1_OAEP
from&nbsp;Crypto.Hash&nbsp;import&nbsp;SHA1,%20SHA224,%20SHA256,%20SHA384,%20SHA512
from&nbsp;Crypto.PublicKey&nbsp;import&nbsp;RSA
from&nbsp;Crypto.Util.number&nbsp;import&nbsp;inverse

HASH_CANDIDATES%20=%20[
&nbsp;%20&nbsp;%20("SHA1",%20SHA1),
&nbsp;%20&nbsp;%20("SHA224",%20SHA224),
&nbsp;%20&nbsp;%20("SHA256",%20SHA256),
&nbsp;%20&nbsp;%20("SHA384",%20SHA384),
&nbsp;%20&nbsp;%20("SHA512",%20SHA512),
]

def&nbsp;fermat_factor(n:%20int)&nbsp;->%20tuple[int,%20int]:
&nbsp;%20&nbsp;%20a%20=%20isqrt(n)
&nbsp;%20&nbsp;&nbsp;if&nbsp;a%20*%20a%20<%20n:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20a%20+=&nbsp;1

&nbsp;%20&nbsp;&nbsp;whileTrue:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20b2%20=%20a%20*%20a%20-%20n
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20b%20=%20isqrt(b2)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;b%20*%20b%20==%20b2:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20p%20=%20a%20-%20b
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20q%20=%20a%20+%20b
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;if&nbsp;p%20*%20q%20==%20n:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;int(p),%20int(q)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20a%20+=&nbsp;1

def&nbsp;split_blocks(data:%20bytes,%20block_size:%20int)&nbsp;->%20list[bytes]:
&nbsp;%20&nbsp;&nbsp;if&nbsp;len(data)%20%%20block_size%20!=&nbsp;0:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;raise&nbsp;ValueError(
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;f"ciphertext%20length&nbsp;{len(data)}&nbsp;is%20not%20a%20multiple%20of%20block%20size&nbsp;{block_size}"
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20)
&nbsp;%20&nbsp;&nbsp;return&nbsp;[data[i%20:%20i%20+%20block_size]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(0,%20len(data),%20block_size)]

def&nbsp;decrypt_oaep_blocks(
&nbsp;%20&nbsp;%20key:%20RSA.RsaKey,%20blocks:%20list[bytes]
)&nbsp;->%20tuple[str,%20bytes]%20|&nbsp;None:
&nbsp;%20&nbsp;&nbsp;for&nbsp;hash_name,%20hash_mod&nbsp;in&nbsp;HASH_CANDIDATES:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20cipher%20=%20PKCS1_OAEP.new(key,%20hashAlgo=hash_mod)
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20plaintext_parts%20=%20[]
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;try:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;for&nbsp;block&nbsp;in&nbsp;blocks:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20plaintext_parts.append(cipher.decrypt(block))
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;except&nbsp;ValueError:
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;continue
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return&nbsp;hash_name,&nbsp;b"".join(plaintext_parts)
&nbsp;%20&nbsp;&nbsp;returnNone

def&nbsp;guess_extension(blob:%20bytes)&nbsp;->%20str:
&nbsp;%20&nbsp;&nbsp;if&nbsp;blob.startswith(b"\x89PNG\r\n\x1a\n"):
&nbsp;%20&nbsp;%20&nbsp;%20&nbsp;&nbsp;return".png"
&nbsp; &nbsp;&nbsp;if&nbsp;blob.startswith(b"\xff\xd8\xff"):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return".jpg"
&nbsp; &nbsp;&nbsp;if&nbsp;blob.startswith(b"GIF87a")&nbsp;or&nbsp;blob.startswith(b"GIF89a"):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return".gif"
&nbsp; &nbsp;&nbsp;if&nbsp;blob.startswith(b"BM"):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return".bmp"
&nbsp; &nbsp;&nbsp;if&nbsp;blob.startswith(b"RIFF")&nbsp;and&nbsp;blob[8:12] ==&nbsp;b"WEBP":
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return".webp"
&nbsp; &nbsp;&nbsp;return".bin"

def&nbsp;solve(input_dir: Path, output_path: Path | None)&nbsp;-> tuple[Path, dict[str, int | str]]:
&nbsp; &nbsp; input_dir = input_dir.resolve()
&nbsp; &nbsp; pubkey_path = input_dir /&nbsp;"pubkey.pem"
&nbsp; &nbsp; encrypted_path = input_dir /&nbsp;"encrypted.bin"

&nbsp; &nbsp; public_key = RSA.import_key(pubkey_path.read_bytes())
&nbsp; &nbsp; n = public_key.n
&nbsp; &nbsp; e = public_key.e

&nbsp; &nbsp; p, q = fermat_factor(n)
&nbsp; &nbsp; phi = (p -&nbsp;1) * (q -&nbsp;1)
&nbsp; &nbsp; d = inverse(e, phi)
&nbsp; &nbsp; private_key = RSA.construct((n, e, d, p, q))

&nbsp; &nbsp; ciphertext = encrypted_path.read_bytes()
&nbsp; &nbsp; blocks = split_blocks(ciphertext, public_key.size_in_bytes())

&nbsp; &nbsp; decrypted = decrypt_oaep_blocks(private_key, blocks)
&nbsp; &nbsp;&nbsp;if&nbsp;decrypted&nbsp;isNone:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;RuntimeError("unable to decrypt all blocks with common OAEP hash choices")

&nbsp; &nbsp; hash_name, plaintext = decrypted
&nbsp; &nbsp;&nbsp;if&nbsp;output_path&nbsp;isNone:
&nbsp; &nbsp; &nbsp; &nbsp; output_path = input_dir /&nbsp;f"decrypted_image{guess_extension(plaintext)}"

&nbsp; &nbsp; output_path.write_bytes(plaintext)

&nbsp; &nbsp; metadata: dict[str, int | str] = {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"n_bits": public_key.size_in_bits(),
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"e": e,
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"p": p,
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"q": q,
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"prime_gap": q - p,
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"block_size": public_key.size_in_bytes(),
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"block_count": len(blocks),
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"oaep_hash": hash_name,
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"plaintext_len": len(plaintext),
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;output_path, metadata

def&nbsp;main()&nbsp;->&nbsp;None:
&nbsp; &nbsp; parser = argparse.ArgumentParser(description="Solve q4_close_primes")
&nbsp; &nbsp; parser.add_argument(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"--input-dir",
&nbsp; &nbsp; &nbsp; &nbsp; type=Path,
&nbsp; &nbsp; &nbsp; &nbsp; default=Path("."),
&nbsp; &nbsp; &nbsp; &nbsp; help="directory containing pubkey.pem and encrypted.bin; defaults to current directory",
&nbsp; &nbsp; )
&nbsp; &nbsp; parser.add_argument(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"--output",
&nbsp; &nbsp; &nbsp; &nbsp; type=Path,
&nbsp; &nbsp; &nbsp; &nbsp; default=None,
&nbsp; &nbsp; &nbsp; &nbsp; help="output image path; defaults to decrypted_image.<ext> in the input directory",
&nbsp; &nbsp; )
&nbsp; &nbsp; args = parser.parse_args()

&nbsp; &nbsp; output_path, metadata = solve(args.input_dir, args.output)
&nbsp; &nbsp; print(f"[+] saved plaintext to:&nbsp;{output_path}")
&nbsp; &nbsp;&nbsp;for&nbsp;key, value&nbsp;in&nbsp;metadata.items():
&nbsp; &nbsp; &nbsp; &nbsp; print(f"[+]&nbsp;{key}:&nbsp;{value}")

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛查查 《2026第十届御网杯 数据安全赛道WP》

评论:0   参与:  0