Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions .github/workflows/ci.yml

This file was deleted.

41 changes: 13 additions & 28 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import json
import os
import uuid
from logging.config import dictConfig
from flask import (Flask, render_template, request, jsonify, session, Response,
abort, stream_with_context)

Expand All @@ -28,23 +27,11 @@
from live import model_registry
from live.ollama_client import OllamaError, chat_stream

dictConfig({
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
},
"root": {
"level": "INFO",
"handlers": ["console"],
},
})

app = Flask(__name__)
app.secret_key = config.SECRET_KEY
# Cache static assets in the browser so images/CSS aren't re-downloaded on every
# page navigation (they're versioned by content and rarely change during a session).
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 86400 # 1 day
CHALLENGES = load_all()
live.load_all()
TOTAL_LABS = len(CHALLENGES) + expert_vault.expert_count()
Expand All @@ -70,14 +57,6 @@ def save_progress():
os.replace(tmp, config.DATA_FILE)


def log_expert_unlock(result: str) -> None:
"""Record an unlock attempt without logging the access key or player name."""
sid = session.get("sid", "")
sid_tag = hashlib.sha256(sid.encode()).hexdigest()[:12] if sid else "none"
app.logger.info("expert_unlock result=%s session=%s remote_addr=%s", result, sid_tag,
request.remote_addr or "unknown")


_load_progress()


Expand Down Expand Up @@ -763,6 +742,12 @@ def hint():
if idx > used:
# can't skip ahead — hints must be revealed in order
return jsonify(error="reveal the earlier hints first"), 403
# Revealing a NEW hint costs points — only allowed while you have a positive score.
# (Re-viewing an already-revealed hint is always free.)
if idx == used and c.id not in p["solved"] and score_of(p) <= 0:
return jsonify(error="You need a positive score to unlock a hint. "
"Solve a lab to earn points first.",
score=score_of(p), used=used), 403
if idx == used and c.id not in p["solved"]:
p["hints"][c.id] = used + 1
save_progress()
Expand Down Expand Up @@ -803,15 +788,15 @@ def unlock_expert():
try:
unlocked = expert_vault.try_unlock(key)
except expert_vault.VaultLoadError:
log_expert_unlock("error")
app.logger.exception("expert vault could not be loaded")
# the vault itself couldn't be loaded — controlled error, never leak details/secrets
app.logger.info("expert_unlock result=error")
return jsonify(ok=False, error="Expert vault is temporarily unavailable."), 500
if unlocked:
p["expert_unlocked"] = True
save_progress()
log_expert_unlock("success")
app.logger.info("expert_unlock result=success")
return jsonify(ok=True, count=expert_vault.expert_count())
log_expert_unlock("invalid_key")
app.logger.info("expert_unlock result=denied")
return jsonify(ok=False, error="Invalid access key."), 403


Expand Down
26 changes: 17 additions & 9 deletions challenges/advanced/a01_jailbreak.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@
FLAG = f"{FLAG_PREFIX}{{{_FLAG_SUFFIX}}}"

FRAME = re.compile(r"roleplay|role-play|let'?s play|pretend|imagine|fiction|story|game|"
r"you are (now )?(a|an|dan|aim|dev mode|debug)", re.I)
ESCALATE = re.compile(r"no (rules|restrictions|filter|limits)|stay in character|"
r"unrestricted|ignore (safety|guidelines)|for the story|"
r"remain as|don'?t break character", re.I)
ASK = re.compile(r"forbidden|config|secret|password|the code|reveal|tell me the", re.I)
r"improv|theatre|theater|act|acting|scene|character|persona|"
r"you are (now )?(a|an|dan|aim|dev mode|debug|character|persona)|"
r"play (a|an|the (role|part|character))|be (a|an|my)", re.I)
ESCALATE = re.compile(r"no (rules|restrictions|filter|limits|guidelines|boundaries)|stay in character|"
r"unrestricted|unfiltered|unlimited|unchained|jailbroken|"
r"ignore (safety|guidelines|rules)|for the (story|scene|game|character)|"
r"remain as|don'?t break character|fully (embody|commit)|in character|"
r"embody|no limits|without (rules|restrictions|limits)", re.I)
ASK = re.compile(r"forbidden|config|secret|password|the code|reveal|tell me the|"
r"maintenance (config|key)|what is the", re.I)


@register
Expand Down Expand Up @@ -45,11 +50,14 @@ class JailbreakEscalation(Challenge):
"turn in isolation; use an independent safety classifier on inputs+outputs (OWASP LLM01).")

def respond(self, message: str, state: dict) -> str:
lvl = state.get("lvl", 0)
if FRAME.search(message):
lvl = max(lvl, 1)
if ESCALATE.search(message) and lvl >= 1:
prev = state.get("lvl", 0) # level coming INTO this turn (from prior turns)
lvl = prev
# At most one step per turn, and escalation requires you were ALREADY framed
# in a PREVIOUS turn (prev >= 1) — so no single message can jump 0 -> 2.
if ESCALATE.search(message) and prev >= 1:
lvl = 2
elif FRAME.search(message):
lvl = max(lvl, 1)
state["lvl"] = lvl
if ASK.search(message):
if lvl >= 2:
Expand Down
3 changes: 2 additions & 1 deletion challenges/advanced/a05_stored_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class StoredInjection(Challenge):
"it, only when the note is READ back — and reads render as raw HTML.",
"Step 1: save a note containing markup. Step 2: read the note to trigger it. There's "
"a hidden 'secret-flag' element to unhide.",
"save note: <style>.secret-flag{display:block !important}</style> then: read note",
"save note: <style>.secret-flag{display:block}</style> then: read note "
"(you can also use display:block !important, or an <img src=x onerror=...> that unhides it).",
]
flag = FLAG # also embedded as a hidden DOM node by the template
defense = ("Sanitise/encode at BOTH write and render time; treat stored LLM/user content "
Expand Down
86 changes: 56 additions & 30 deletions challenges/expert_vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import re
Expand All @@ -24,17 +22,24 @@
_HERE = os.path.dirname(os.path.abspath(__file__))
_ENC = os.path.join(_HERE, "expert.enc")
_META = os.path.join(_HERE, "expert_meta.json")
# Updated alongside the vault bundle by the release process. Keeping these values
# in code makes an unexpected vault-file change distinguishable from a wrong key.
EXPECTED_ENC_SHA256 = "5e17bbd9e6525490b47c566b678ca60f42cd0330d3b1b89e0855e2ae71deb551"
EXPECTED_META_SHA256 = "251c75cfcf8838cab57955336a7c785dd1a16b115d6d751437829a6dc18c1f80"

_SPECS: list[dict] | None = None # populated only after a valid unlock
_CHALLENGES: dict[str, "DeclarativeChallenge"] = {}

# Expected (pinned) KDF parameters. The public meta file must match these exactly;
# any deviation means the metadata was tampered with, and we reject it BEFORE deriving
# a key. This is the integrity gate the security tests require.
_EXPECTED_SALT = "EMKqrY702tO/UFN2yKJbvg=="
_EXPECTED_ITERATIONS = 200000

class VaultLoadError(RuntimeError):
"""The encrypted vault could not be loaded because of a server-side fault."""

class VaultLoadError(Exception):
"""Raised when the vault files are missing, malformed, or fail integrity checks.

Distinct from a wrong access key (which is a normal 'denied' outcome): this signals
the vault itself can't be loaded, so the endpoint returns a controlled 500 instead
of leaking a stack trace.
"""


class DeclarativeChallenge(Challenge):
Expand Down Expand Up @@ -64,36 +69,57 @@ def _derive(access_key: str, salt: bytes, iterations: int) -> bytes:


def try_unlock(access_key: str) -> bool:
"""Attempt to decrypt the vault with the supplied key. True on success."""
"""Attempt to decrypt the vault with the supplied key.

Returns True on success, False on a wrong key. Raises VaultLoadError if the vault
files are missing, malformed, or fail integrity checks (so the caller can return a
controlled error rather than crashing).
"""
global _SPECS, _CHALLENGES
if _SPECS is not None:
# already decrypted this process; verify the supplied key still matches
return _verify(access_key)
if not (os.path.exists(_ENC) and os.path.exists(_META)):
raise VaultLoadError("expert vault files are missing")
raise VaultLoadError("vault files not present")

# --- load + validate metadata (controlled errors, no stack traces) ---
try:
with open(_META) as fh:
meta = json.load(fh)
except (ValueError, OSError) as e:
raise VaultLoadError("metadata unreadable or not valid JSON") from e
if not isinstance(meta, dict) or "salt" not in meta or "iterations" not in meta:
raise VaultLoadError("metadata missing required fields")

# --- integrity gate: metadata must match the pinned KDF params BEFORE deriving ---
if meta.get("salt") != _EXPECTED_SALT or meta.get("iterations") != _EXPECTED_ITERATIONS:
raise VaultLoadError("metadata integrity check failed")

try:
with open(_META, "rb") as meta_file:
meta_bytes = meta_file.read()
with open(_ENC, "rb") as encrypted_file:
encrypted = encrypted_file.read()
meta_digest = hashlib.sha256(meta_bytes).hexdigest()
if not hmac.compare_digest(meta_digest, EXPECTED_META_SHA256):
raise VaultLoadError("expert vault metadata integrity check failed")
encrypted_digest = hashlib.sha256(encrypted).hexdigest()
if not hmac.compare_digest(encrypted_digest, EXPECTED_ENC_SHA256):
raise VaultLoadError("expert vault ciphertext integrity check failed")
meta = json.loads(meta_bytes)
salt = base64.b64decode(meta["salt"], validate=True)
fkey = _derive(access_key.strip(), salt, meta["iterations"])
plain = Fernet(fkey).decrypt(encrypted)
specs = json.loads(plain)
challenges = {s["id"]: DeclarativeChallenge(s) for s in specs}
except (ValueError, Exception) as e: # binascii.Error subclasses ValueError
raise VaultLoadError("metadata salt is not valid base64") from e

fkey = _derive(access_key.strip(), salt, meta["iterations"])

# --- decrypt: wrong key OR corrupt ciphertext ---
try:
ciphertext = open(_ENC, "rb").read()
except OSError as e:
raise VaultLoadError("ciphertext unreadable") from e
try:
plain = Fernet(fkey).decrypt(ciphertext)
except InvalidToken:
return False
except (OSError, ValueError, KeyError, TypeError) as exc:
raise VaultLoadError("expert vault could not be loaded") from exc
_SPECS = specs
_CHALLENGES = challenges
return False # wrong key -> normal denial
except Exception as e:
# truncated/garbage ciphertext that isn't even a valid Fernet token
raise VaultLoadError("ciphertext malformed") from e

try:
_SPECS = json.loads(plain)
except ValueError as e:
raise VaultLoadError("decrypted payload is not valid JSON") from e
_CHALLENGES = {s["id"]: DeclarativeChallenge(s) for s in _SPECS}
globals()["_VALID_KEY"] = access_key.strip()
return True

Expand Down
6 changes: 5 additions & 1 deletion challenges/llm02_info_disclosure.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""LLM02:2025 — Sensitive Information Disclosure (output-filter bypass)."""
"""LLM02:2025 — Sensitive Information Disclosure (output-filter bypass).

Mirrors the user's sample lab: the bot will share the secret, but an output filter
redacts the exact plain-text flag. Ask for it transformed and the filter misses it.
"""
import base64
import codecs
from config import FLAG_PREFIX
Expand Down
Loading