OOPArtDB
29th May 2024 / Document No. D24.102.88
Prepared By: clubby789
Challenge Author(s): Strellic
Difficulty: Insane
Classification: Official
Synopsis
Exploit weird behavior in browsers and JavaScript to steal the flag from a website. Use XS-Leaks to
leak the database id of the flag entry and then use DNS rebinding and CSRF to make the admin
create an account for you to view it.
Skills Required
XSS basics
Knowledge of XS-Leaks
Skills Learned
DNS rebinding
CSRF
Solution
OOPArtDB is a simple site that stores info about "OOPArts", or "out-of-place artifacts". Before
going over the exploit, I'll go over the functionality of the site.
The app is written as a NodeJS + Express application, served by NGINX. You can search for specific
OOPArts using the website's search functionality, and it will return any entries that have a name or
database id that start with that entry.
From auditing the source code, you can see that the flag is stored in /flag.txt , but is also placed
into the database in challenge/src/db.js .
let flag;
try {
flag = (await fsp.readFile("/flag.txt")).toString();
}
catch(err) {
flag = "HTB{test_flag}";
}
await OOPArt.create({
name: "???",
desc: crypto.randomBytes(512).toString("hex")
+ flag
+ crypto.randomBytes(512).toString("hex"),
accessLevel: "overseer"
});
So, rather than get RCE, the goal is to read the flag from the website. The site implements a simple
access level system, with three roles in this order: "guest", "researcher", and "overseer".
You can only search for OOPArts that you have access to view (guest can only view guest,
researcher can only view guest or researcher, overseer can see everything). When you search for
an OOPArt, there is a view button that lets you see its full information.
For some reason, guests who have access to the "guest" OOPArts cannot view more information
about them. Examining the source code, we see that viewing OOPArts is restricted to users logged
in.
// routes/view.js
router.get("/:id", util.isLoggedIn, async (req, res) => {
let id = req.params.id;
let entry = await OOPArt.findByPk(id);
if(!entry) {
return util.flash(req, res, "error", "No OOPArt was found with that
id.", "/");
}
res.render("view", { entry });
});
// src/util.js
const isLoggedIn = (req, res, next) => {
if(!req.user) {
return flash(req, res, "error", "You must be logged in to access this
page.", "/");
}
next();
};
But weirdly enough, it doesn't check your access level, only that you are logged in. So, if we could
find some way to leak the database id of the flag entry, we could view it if we had a researcher
account, even though it has an access level of overseer.
So the goal is two-parts:
1. Get an account
2. Leak the flag database id
Of course, if we could just achieve XSS on the site, we could probably skip all these steps all
together since the admin bot is logged in as an overseer fairly easily. But, there's a strict CSP.
default-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src
https://fonts.gstatic.com; object-src 'none'; base-uri 'none'; frame-ancestors
'none';
The site has both frame-ancestors 'none' and X-FRAME-OPTIONS: DENY , so we can't iframe
the site. But, the site IS missing the "Cross-Origin-Opener-Policy" header.
From the MDN Web Docs:
If a cross-origin document with COOP is opened in a new window, the opening document will
not have a reference to it, and the window.opener property of the new window will be null.
This allows you to have more control over references to a window than rel=noopener, which
only affects outgoing navigations.
Since this header is missing, we can open the site with window.open , and get a reference to it
which we can use. But that doesn't help us as of yet.
There's also a weird /debug endpoint that is only accessible via localhost.
// index.js
app.get("/debug", util.isLocalhost, (req, res) => {
let utils = require("util");
res.end(
Object.getOwnPropertyNames(global)
.map(n => `${n}:\n${utils.inspect(global[n])}`)
.join("\n\n")
);
});
// src/util.js
const isLocalhost = (req, res, next) => {
let ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
if(ip !== "127.0.0.1") {
return flash(req, res, "error", "You cannot access this page.", "/");
}
next();
};
The website uses this code below to flash messages to the user.
// src/util.js
const flash = (req, res, type, msg, url) => {
let endpoint = url || req.originalUrl;
let delim = endpoint.includes("?") ? "&" : "?";
return
res.redirect(`${endpoint}${delim}${type}=${encodeURIComponent(msg)}`);
};
It seems weird, but it works™. You give it a type, message, and optional URL. It then checks
whether the URL contains url params already, appends a new one correctly to the URL, then
redirects the user.
There is a markup injection endpoint, however. Looking at /assets/js/main.js on the site, we
see the code used for flashing information:
/* We are watching. */
const $ = document.querySelector.bind(document); // imagine using jQuery
const sanitize = (dirty) => {
// There is no escape.
return DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true },
FORBID_ATTR: ["id"] });
};
window.onload = () => {
// flash messages
let params = new URLSearchParams(location.search);
if(location.search.includes("?error=") ||
location.search.includes("&error=")) {
$("#flash-error").style.display = "block";
$("#flash-error").innerHTML = sanitize(params.get("error"));
}
if(location.search.includes("?info=") ||
location.search.includes("&info=")) {
$("#flash-info").style.display = "block";
$("#flash-info").innerHTML = sanitize(params.get("info"));
}
// wipe flash
history.replaceState({}, document.title, location.pathname);
};
If the URL contains "error" or "info" query parameters, it will set their contents to innerHTML after
being sanitized by DOMPurify. It will also clear all URL parameters using history.replaceState .
So while we can do stuff like ?error=<h1>blah</h1> , DOMPurify basically restricts most things
we can do for fun.
I don't believe there is a bypass (and if there was, CSP would probably not allow anything cool
anyway), so I think that two step goal from above is the only solution path.
Solution
The two step goal can be done in any order, but let's just start with getting access to an account.
The session cookie is HTTP-only and SameSite Lax by default, and the admin's default password is
randomly generated, so there seems to be no way to use their account.
Luckily, the site has a /register endpoint. But, it's restricted to users who are logged in.
However, the site seems to lack any CSRF protection, so we could abuse the LAX + POST
functionality to get the admin to make us an account. But, it asks for a referral token...
// routes/register.js
const express = require("express");
const bcrypt = require("bcrypt");
const REFERRAL_TOKEN = process.env.REFERRAL_TOKEN ||
require("crypto").randomBytes(32).toString("hex");
const router = express.Router();
const { User } = require("../src/db.js");
const util = require("../src/util.js");
router.get("/", util.isLoggedIn, (req, res) => res.render("register"));
router.post("/", util.isLoggedIn, async (req, res) => {
let { user, pass, token } = req.body;
if(!user || !pass) {
return util.flash(req, res, "error", "Missing username or password.");
}
if(!token) {
return util.flash(req, res, "error", "Missing referral token.");
}
if(token !== REFERRAL_TOKEN) {
return util.flash(req, res, "error", "Incorrect referral token.");
}
let entry = await User.findByPk(user);
if(entry) {
return util.flash(req, res, "error", "A user already exists with that
username.");
}
pass = await bcrypt.hash(pass, 12);
await User.create({ user, pass, accessLevel: "researcher" });
util.flash(req, res, "info", `A researcher has been created under the
username <b>${user}</b>.`, "/");
});
module.exports = router;
So we provide a user, password, and the referral token, and it creates a user for us. The referral
token comes from process.env.REFERRAL_TOKEN , which we can see from the Dockerfile is:
ENV REFERRAL_TOKEN CONTROL_THE_ΩΩPARTS
However, this should be redacted and not shown to the player. So, we need to leak this referral
token somehow, which is stored in an environment variable. Thinking about it, another way to
access the referral token would be through global.process.env.REFERRAL_TOKEN , and the
/debug route shows us information related to global .
app.get("/debug", util.isLocalhost, (req, res) => {
let utils = require("util");
res.end(
Object.getOwnPropertyNames(global)
.map(n => `${n}:\n${utils.inspect(global[n])}`)
.join("\n\n")
);
});
Object.getOwnPropertyNames(global) returns in an array all of global 's attributes, of which
"process" is one. Then the map function will run
require("util").inspect(global["process"]) , which if you run locally you see will actually
dump all of the environment variables. So, if we could get the data from /debug , we could get the
referral token.
However, we have to request this endpoint from localhost, which only the admin can do. And as
we said above, there's no XSS which allows us to exfiltrate this data bypassing SOP, so we need to
find another way.
The answer is DNS rebinding. Read this article if you want an in-depth view on how this works, but
basically, we'll be abusing DNS failover with multiple A records.
This is different from TTL-based DNS rebinding. Since the admin bot only stays on the server for
~10s, we can't use normal TTL-based DNS rebinding (which takes around 60+ seconds because
Chrome's DNS cache has a minimum time).
Basically, the process is:
1. Setup a custom NS entry for your domain name that sets the DNS server to an IP you control
2. Run a DNS server that responds with the following A records:
;; ANSWER SECTION:
example.com. 0 IN A IPADDRESS
example.com. 0 IN A 0.0.0.0
3. Now, send the admin to your site hosted on port 80, and while they are on your site, kill your
server.
4. Then send a fetch request to /debug , and send the contents to a webhook.
This should exfiltrate the contents of /debug . The reason this works is that the fetch will attempt
to resolve the domain name to IPADDRESS from before, but since the server is down, it'll go to the
next entry 0.0.0.0 , which will fetch /debug from 0.0.0.0 which will get it from localhost. And
since running fetch('/debug') is fetching something on the same domain, SOP is satisfied and
you can read the output.
I recommend that you read BookGin's blog post linked above, or the singularity exploit framework
to learn more.
Once you do this, you should get the environment variable token needed to create a user, which
you can then create using LAX + POST CSRF.
Now, we need to leak the flag id.
Since we have a search bar and we don't have XSS, it's not that far-fetched to assume that leaking
the flag id requires some sort of XS-Leaks attack.
For an XS-Leak attack to work, you usually need to build an oracle - we need to somehow
determine whether the search returned results or not. So, let's look at the search functionality very
closely.
If the search returns results, an info parameter is added to the URL. If it returns no results, an
error parameter is added.
Sadly, since the server appends the URL parameter to the end, we can't have a custom info or
error message on a search. Or can we?
Funnily enough, URLSearchParams takes the first instance of the query parameter in a URL!
At least, sometimes. Reading the spec it says that it should, but sometimes it doesn't if you give it a
weird input.
But thankfully, location.search doesn't begin with a / , so it will take the first instance in our
web app (and I'm not sure why the above behavior happens).
However, there's still a problem: the page will display both info and error flash messages if
they exist.
So for example, if we search with the URL parameter ?error=a , it will display the error message
"a" on both successful or unsuccessful searches.
It detects whether to display the parameter or not by using location.search.includes :
let params = new URLSearchParams(location.search);
if(location.search.includes("?error=") ||
location.search.includes("&error=")) {
$("#flash-error").style.display = "block";
$("#flash-error").innerHTML = sanitize(params.get("error"));
}
if(location.search.includes("?info=") ||
location.search.includes("&info=")) {
$("#flash-info").style.display = "block";
$("#flash-info").innerHTML = sanitize(params.get("info"));
}
So theoretically, if we could create a parser differential between location.search.includes and
params.get , we could get it to display our custom flash only if the server intends to show that
type of flash.
Assume that there is some special string ?UNKNOWN=a , that if we append to the URL, doesn't
trigger the info flash. But, when we search and no results are returned, &info=Search found 5
results: is appended to the URL, so location.search.includes("&info=") returns true. But,
when params.get("info") is run, instead of the info message, we got our custom message since
it comes first. Is there a way we can do this?
It turns out, yes! If we URL-encode one of the letters in info , for example "i", to "%69", it doesn't
display the info flash since location.search doesn't exactly contain the target string. However, if
we do this and run a successful search, a new &info= attribute is added, returning our custom
flash message since URLSearchParams decodes our location string and uses the first one!
Okay, now we can inject custom markup on the page on successful / unsuccessful searches. Can
we use this to create some sort of detectable difference?
This is where a lot of people will get stuck, since this part is very hard to figure out. But, since the
admin bot is running headless Chrome, Cache Isolation isn't turned on. So, if a resource is loaded,
we can check whether it is cached by running a command like:
let x = performance.now();
await fetch(url, {'mode': 'no-cors', 'cache': 'force-cache'});
console.log(performance.now() - x);
If the time delta is less than 30ms, we can assume it's cached. So, the easy thing would be to just
check whether /search?info=1 results were found: url was cached. But sadly, there is a
Cache-Control: no-cache header on the search page.
So, can we do something else?
Here's an idea: can we get the page to load a resource on either a successful or unsuccessful
search? If so, then we can just check whether the resource is cached!
Sadly, the only thing we can really load in DOMPurify are images, and there are no images to cache
on the website...
Well, we can embed an image. So we can embed any resource on the site accessible by a GET
request. What would happen if we used the /logout URL?
We can use the user's logged in status to link with our oracle! Remember, none of the endpoints
on this page are protected with CSRF. So, we could embed an image tag in a custom info flash that
logs out the user, and is only displayed on searches that return results.
So, the user will be logged out only if the search returns true. Then, we can open the /register
page in a new tab! If they're logged in, the website will load this page successfully, caching the
resource. If not, then it will redirect and not be cached. Then, we can just check whether the page
is cached.
So, we use this logout CSRF to help us determine the results of the search! Now with the power to
tell whether a search returned results or not, we can search for the flag database id character by
character, until we get the whole id.
Once we have that, we can use our created account to view the flag's entry on the site and get the
flag!