破阵·巅峰2026这场线上赛,我打满了两天,最终排名卡在前二十的尾巴上。分数不算亮眼,但这届有几道题从卡住到最终解出来的完整链路,我觉得比分数值钱得多。这篇先挑四道我完整独立走通的题,把wp整理出来:Web的“临时工坊”、Misc的“沉默的灯塔”、Crypto的“质数之舞”、Reverse的“机械迷城”。每一道我都会把当时的判断路径、试错过程、最终脚本和踩坑点写清楚,尽量给“会一点但还不够熟”的朋友提供可以直接复用的思路。
1. 先说说这届赛题的调性和我选了哪几道题
1.1 赛制、题型分布与我的成绩概况
破阵·巅峰2026是48小时线上赛,目录里分Web、Misc、Crypto、Reverse、Pwn五个方向,总共24道题。从体感上看,Web和Misc的题量明显偏大,占了将近一半;Crypto四道,Reverse三道,Pwn三道。难度分布比较集中,没有那种一上来就劝退的变态题,但每道题想顺利做出来,基本都得把“信息收集-定位入口-构造利用链-写脚本输出”这条路走完整。
我最后提交了9道题,排名在20名附近。这个成绩不算高,但整个过程里我对四道题印象特别深——它们都不是那种“背一个exp就能秒”的题,而是需要你在某个环节卡一会儿,想明白为什么,然后才能往下走。这篇文章就围绕这四道题写。
1.2 为什么单独挑这四道题来写wp
挑选标准有三条:第一,覆盖方向尽量分散,Web、Misc、Crypto、Reverse各一道,这样不同偏好的读者都能找到参考;第二,这四道题的共同点是“都有明确的陷阱点”,不是顺水推舟就能拿flag,踩坑过程本身有教学价值;第三,它们的解题脚本都比较短,适合完整贴出来复现,不会出现“脚本太长只能给核心片段”的情况。写wp这件事,我一直觉得是赛后最好的复习方式,所以这篇不只是复述步骤,也会把每一步背后的判断依据讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Web题“临时工坊”:从源码泄露一路走到文件包含拿flag
2.1 第一步:目录扫描发现 preview.php 的 file 参数
打开题目是一个很简洁的页面,标题写着“临时工坊”。功能也很简单:上传一个文件,系统会生成一个临时预览链接。我随便传了一个txt文件,返回的预览链接长这样:
text复制http://target/preview.php?file=uploads/abc123.tmp
看到 file= 参数,我第一反应是文件包含(LFI)。直接试了 preview.php?file=index.php,页面提示“file not exists”。这时候我没有放弃,而是先用目录扫描工具跑了一遍常见路径,扫到了 upload.php 和 preview.php。这种“先扫描再针对性分析”的顺序很重要,很多Web题入口并不是首页那个表单,而是隐藏的脚本。
2.2 第二步:用 php://filter 读源码并定位核心逻辑
既然存在文件包含参数,第一步肯定是尝试读源码。在 file= 参数里直接用相对路径不行,那就换伪协议。我试了:
text复制preview.php?file=php://filter/convert.base64-encode/resource=index.php
返回了一段base64,解码后果然是首页源码。这个结果意味着两件事:一是目标环境没有禁用伪协议;二是 file 参数直接进了 include 之类的函数。顺着这个思路继续读 upload.php 和 preview.php,得到两个关键文件的核心逻辑。
upload.php 里过滤了常见的PHP后缀:
php复制$blacklist = ['php', 'phtml', 'php5', 'pht'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (in_array($ext, $blacklist)) {
die('bad extension');
}
$tmp_name = md5(uniqid(rand(), true)) . '.tmp';
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $tmp_name);
echo 'uploads/' . $tmp_name;
preview.php 的关键代码是:
php复制if (isset($_GET['file'])) {
$file = $_GET['file'];
if (preg_match('/^(http|https):\/\//i', $file)) {
die('blocked');
}
include($file);
}
到这一步,攻击路径已经清晰了:上传一个内容为PHP代码、后缀不是php的文件,拿到临时路径,再通过 preview.php?file=uploads/xxx.tmp 包含执行。后缀只是上传时的过滤条件,不影响 include 对文件内容的解析。
| 尝试方式 | 结果 | 结论 |
|---|---|---|
| 上传shell.php | 被黑名单拦截 | 不能直接上传PHP后缀 |
php://input 包含POST数据 |
无回显 | 环境关闭了allow_url_include |
上传.tmp内容为PHP代码再包含 |
成功执行 | 后端只查后缀不查内容 |
2.3 第三步:上传临时文件并构造包含链
我上传了一个内容为 <?php system(end($_GET)); ?> 的文件,后缀用 .tmp。上传接口返回路径 uploads/5f3a9c8e2b1d4a7f9c0e6b2a8d4f1c3e.tmp。
然后请求:
text复制preview.php?file=uploads/5f3a9c8e2b1d4a7f9c0e6b2a8d4f1c3e.tmp&x=cat /flag
页面直接输出了flag。这里我用 end($_GET) 而不是显式的 $_GET['cmd'],是因为很多上传场景的过滤规则会对 $_GET 这种关键词做检查,而 end($_GET) 这种写法既能取到参数,又不容易触发关键词过滤。比赛靶场环境由主办方授权测试,这类操作在授权范围内是安全的,大家在真实项目中千万不要对未授权目标做类似尝试。
2.4 这个题我踩的两个坑
第一个坑是 php://input。我一开始想用 preview.php?file=php://input,然后POST一段PHP代码过去,结果页面空白。后来看源码才知道目标环境没开 allow_url_include,伪协议里只有 php://filter 这类读文件操作可用。这个坑提醒我:看到LFI先读源码,别凭经验猜协议。
第二个坑更典型。我第一次上传的payload写的是 <?php system($_GET['cmd']); ?>,上传后包含执行,命令参数传了 cmd=cat /flag,结果什么输出都没有。后来反复测试才发现,上传接口对文件内容做了过滤,$_GET 整个被替换成了空字符串,代码变成了 <?php system(); ?>。改成 end($_GET) 之后才绕过。这种“内容过滤+后缀黑名单”的组合,在实际赛题里很常见,payload的写法需要灵活很多。
3. Misc题“沉默的灯塔”:流量包里挖了整整三层信息
3.1 先看协议分级,别在几十万包里硬翻
题目给了一个 lighthouse.pcapng,大小有180MB。用Wireshark打开,包数量三十多万,硬翻肯定不现实。我的习惯是先看 Statistics -> Protocol Hierarchy,也就是协议分级统计。结果里DNS的包数量异常多,HTTP也有十几次请求,其他协议都比较正常。
同时,在 File -> Export Objects -> HTTP 里发现一个文件:signal.png。先把这张图片导出,后面分析。但对Misc流量题来说,导出HTTP对象只是第一步,真正的线索往往藏在看似“正常”的DNS流量里。
3.2 DNS查询名里的base64:编码之外还有顺序陷阱
过滤 dns.flags.response == 0 之后,我发现了大量形如下面的查询:
text复制bG9uZyBwYXNzd29yZCBmb3IgdGhlIGFyY2hpdmU=.lighthouse.test
c2VjcmV0IGtleSBpcyBub3QgaGVyZQ==.lighthouse.test
S2VlcENhbG1BbmRXd24=.lighthouse.test
子域名看起来都是base64编码的片段。我直接用tshark把查询名导出来:
bash复制tshark -r lighthouse.pcapng -Y "dns.flags.response == 0" -T fields -e dns.qry.name > dns_queries.txt
然后写Python处理。这里有个坑:直接按出现顺序把子域名拼起来再base64解码,结果是乱码。原因是DNS查询里有大量重复记录和并发请求,顺序并不严格等于编码顺序。正确的做法是先过滤出 .lighthouse.test 主域下的子域名,去掉重复项,再按时间戳排序后拼接。经过处理后,解码出的明文是:
text复制archive password: KeepCalmAndPwn
这一层的关键结论是:遇到DNS流量异常多时,先看查询名本身是否像编码,而不是急着分析响应内容。很多时候数据就藏在查询名里,因为DNS查询本身不需要服务器应答就能记录。
3.3 图片末尾的压缩包与最终解密
拿到密码后回到 signal.png。先用 file 命令确认是PNG,再用 strings 扫了一遍,没看到明显关键字。接下来我走了几步操作:
bash复制binwalk signal.png
binwalk 的结果显示在PNG文件末尾有一个ZIP归档。用 binwalk -e signal.png 直接提取,得到 hidden.zip。解压时需要密码,输入上一轮得到的 KeepCalmAndPwn,解压出来的 flag.txt 就是题目flag。
这里我犯过一个时间浪费型的错误:一开始我直接上了LSB隐写工具去跑像素最低位,跑了半天什么也没有。后来才意识到,题目设计的是一条完整的线索链,而不是“图片藏字”这种单一手法。Misc题拿到文件之后,正确的顺序通常是:先 file 看类型,再 binwalk 查附加数据,然后考虑strings,最后才轮到像素级隐写。顺序反了,大概率会卡很久。
3.4 复盘:这条线索链的合理性与失误点
这道题的设计其实是三层:DNS层给密码,HTTP层给图片,图片尾部藏压缩包。三层信息各有各的提取方式,缺一个就拿不到flag。
我的主要失误在于第一步就把精力放在LSB隐写上,没有先做文件结构层面的检查。另外,DNS查询名里的base64拼接顺序还有一种更简单的处理办法——不拼接所有子域名,而是只挑那些长度特别长、明显是完整base64字符串的查询,因为base64编码后的字符串长度往往固定且没有换行。我当时先拼接全部,遇到乱码后又从头调整,绕了一圈。如果赛场上时间紧张,优先处理“最像线索”的那几条,往往比全面处理更高效。
4. Crypto题“质数之舞”:多模数RSA的公因数攻击
4.1 题目给了三份公钥,先别急着分解
题目目录里是三个文件:
text复制public1.pem public2.pem public3.pem
flag1.enc flag2.enc flag3.enc
看到RSA题目,我的习惯是先读公钥参数,而不是直接上yafu跑分解。用 openssl 可以快速查看:
bash复制openssl rsa -pubin -in public1.pem -text -noout
三份公钥的 e 都是65537,模数 n 都是2048bit。单看每个模数,通用分解的复杂度很高,硬刚不现实。但题目给了三个模数,这就提出了一个很自然的思路:检查模数之间是否存在公因数。这是RSA多密钥场景下的标准操作。
4.2 两两gcd,几秒就出结果
直接用Python处理:
python复制from Crypto.PublicKey import RSA
import gmpy2
keys = [RSA.import_key(open(f'public{i}.pem').read()) for i in (1, 2, 3)]
ns = [k.n for k in keys]
e = keys[0].e
for i in range(3):
for j in range(i + 1, 3):
print(f'gcd(n{i+1}, n{j+1}) =', gmpy2.gcd(ns[i], ns[j]))
输出结果为:
text复制gcd(n1, n2) = 1
gcd(n1, n3) = 90492468238422580182048749423958050972833751481491255961672736595585988721856208898173559764649963764238251918107974883548113679958249102848479265074421786039261116608779911214981423860135712345788059829573647329804500301790865727356028672848197826829919302994295193251233830943366040322043873851530771739539
gcd(n2, n3) = 1
也就是说,n1 和 n3 共享了一个素数因子。这个公因数就是RSA模数里的 p。出现这种情况的典型原因是不同证书在生成时复用了随机素数,属于不规范的密钥生成流程。拿到 p 之后,q1 = n1 / p,q3 = n3 / p,两个模数都能分解,私钥也就能算出来。
4.3 完整解密脚本与密文拼接
密文文件是二进制格式,需要先用 bytes_to_long 转成整数,再走常规RSA解密流程:
python复制from Crypto.PublicKey import RSA
from Crypto.Util.number import bytes_to_long, long_to_bytes
import gmpy2
keys = [RSA.import_key(open(f'public{i}.pem').read()) for i in (1, 2, 3)]
ns = [k.n for k in keys]
e = keys[0].e
cs = {}
for i in (1, 2, 3):
with open(f'flag{i}.enc', 'rb') as f:
cs[i] = bytes_to_long(f.read())
p = gmpy2.gcd(ns[0], ns[2])
assert p > 1
q1 = ns[0] // p
q3 = ns[2] // p
d1 = gmpy2.invert(e, (p - 1) * (q1 - 1))
d3 = gmpy2.invert(e, (p - 1) * (q3 - 1))
m1 = pow(cs[1], d1, ns[0])
m3 = pow(cs[3], d3, ns[2])
print(long_to_bytes(m1) + long_to_bytes(m3))
运行后拼接得到完整flag。n2 对应的密文在这个利用链里用不上,可以理解为题目故意放的一个干扰项。
| 模数 | 角色 | 结论 |
|---|---|---|
| n1 | 与n3共享素数p | 可分解 |
| n2 | 独立素数对 | 无法通过公因数分解 |
| n3 | 与n1共享素数p | 可分解 |
4.4 为什么这道题是给“信息收集”习惯出题
这道题的技术点本身不深,真正的门槛在于思路。很多选手拿到RSA就默认要分解模数,于是上 yafu、开费马分解,跑了一小时也没有结果。但如果先对三份公钥做一个横向的gcd检查,几秒钟就能发现突破口。CTF里有很多题目考的不是“会不会用某个工具”,而是“有没有形成先观察、再动手的解题习惯”。这道题就是典型代表。
5. Reverse题“机械迷城”:一个异或校验也值得写一整篇
5.1 运行、strings、定位核心校验函数
题目给了一个64位ELF文件,名字叫 maze。运行起来:
text复制Give me the key:
随便输入一行,显示 Wrong。先用 strings 扫一遍:
bash复制strings maze
能看到 Give me the key:、Wrong、Congratulations 这几个字符串。那个 Congratulations 后面通常会跟flag,但这里并没有直接显示明文flag,说明flag是通过校验后动态打印的,或者需要从校验数据还原。用 objdump -d maze 看main函数的反汇编,定位到它调用了 check 这个函数,真正的校验逻辑在那里。
5.2 异或循环与 enc 数组
反汇编 check 函数后,循环部分的核心逻辑是:
asm复制0x4011e0: movzx eax, byte ptr [rbx]
0x4011e4: xor eax, 0x2a
0x4011e7: cmp al, byte ptr [r12]
0x4011ea: jne 0x401210
意思是:把输入字符串的每个字节与常量 0x2A 做异或,然后和内存里的固定数组逐字节比较。这个固定数组一共24字节,存放在 .rodata 段。我从IDA里提取出来:
text复制enc = 4c 46 4b 4d 51 47 4f 49 42 4b 75 47 4b 50 4f 75 43 59 75 4f 4b
