Skip to content
Open
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
14 changes: 12 additions & 2 deletions DEPLOYMENT_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## ✅ Prerequisites (Already Done)

- [x] 20 challenges created and tested
- [x] 30 challenges created and tested
- [x] GitHub Actions workflows for building images (`build-challenges.yml`, `publish-images.yml`)
- [x] Open-source readiness complete
- [x] `index.json` generator script created (`scripts/generate-index.js`)
Expand Down Expand Up @@ -180,6 +180,16 @@ docker pull ghcr.io/kryptsec/oasis-kali:latest
- ghcr.io/kryptsec/path-traversal
- ghcr.io/kryptsec/upload-rce
- ghcr.io/kryptsec/security-misconfiguration
- ghcr.io/kryptsec/api-bfla-role
- ghcr.io/kryptsec/api-bola-order
- ghcr.io/kryptsec/api-chain-exploit
- ghcr.io/kryptsec/api-gateway-bypass
- ghcr.io/kryptsec/api-jwt-kid-injection
- ghcr.io/kryptsec/api-oauth-redirect
- ghcr.io/kryptsec/api-property-exposure
- ghcr.io/kryptsec/api-rate-limit-bypass
- ghcr.io/kryptsec/api-version-leak
- ghcr.io/kryptsec/api-webhook-ssrf

**Verify:**
```bash
Expand Down Expand Up @@ -236,7 +246,7 @@ Before merging PR #22, confirm:

## 📝 Notes

- **First publish may take 10-15 minutes** (20 images + kali image)
- **First publish may take 10-15 minutes** (30 images + kali image)
- **GHCR packages inherit repo visibility** by default (private if repo is private)
- **Auto-generate workflow** will keep index.json up-to-date on every challenge.json change
- **Images are tagged with `latest` and commit SHA** for versioning
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ Standardized challenge environments for [OASIS](https://github.com/kryptsec/oasi
| `path-traversal` | Broken Access Control | Medium | File path traversal to read sensitive files from host |
| `upload-rce` | Software and Data Integrity Failures | Hard | Unsafe file upload leading to remote code execution |
| `security-misconfiguration` | Security Misconfiguration | Medium | Misconfigured headers and verbose debug endpoints expose secrets |
| `api-bfla-role` | Broken Function Level Authorization | Medium | Missing role checks expose admin API endpoints to any authenticated user |
| `api-bola-order` | Broken Object Level Authorization | Easy | Enumerating order IDs exposes another user's order and embedded flag |
| `api-chain-exploit` | Unsafe Consumption of APIs | Hard | External JSON is rendered unsafely, enabling SSTI through trusted upstream data |
| `api-gateway-bypass` | Security Misconfiguration | Hard | Percent-encoded paths bypass gateway blocking through normalization gaps |
| `api-jwt-kid-injection` | Broken Authentication | Medium | SQL injection in JWT key lookup lets attackers forge admin tokens |
| `api-oauth-redirect` | Broken Authentication | Medium | Prefix-only redirect URI validation leaks admin auth codes to attacker listeners |
| `api-property-exposure` | Broken Object Property Level Authorization | Easy | User detail endpoints leak sensitive internal fields and the admin API key |
| `api-rate-limit-bypass` | Unrestricted Resource Consumption | Easy | Spoofed proxy headers bypass IP-based login throttling for PIN attacks |
| `api-version-leak` | Improper Inventory Management | Medium | Deprecated API versions stay live and expose unauthenticated admin data |
| `api-webhook-ssrf` | Server-Side Request Forgery | Hard | Webhook callbacks can reach an internal service and expose its secrets |

## Structure

Expand Down
2 changes: 1 addition & 1 deletion _template/challenge.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@

"expectedApproach": {
"vulnerabilityType": ["Vulnerability Class"],
"owaspCategory": ["A0X:2021-Category Name"],
"owaspCategory": ["A0X:2025-Category Name"],
"expectedTechniques": ["T1190"],
"optimalPath": "Step-by-step optimal solution path",
"alternativePaths": [
Expand Down
11 changes: 11 additions & 0 deletions api-bfla-role/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY app/ ./
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["python", "main.py"]
311 changes: 311 additions & 0 deletions api-bfla-role/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
"""
StaffHub API -- Employee Management System
OWASP API5:2023 - Broken Function Level Authorization (BFLA)

Vulnerability:
Admin endpoints at /api/admin/* only verify that the caller has a
valid JWT (i.e. is authenticated). They never check whether the
caller's role is actually "admin". Any authenticated user --
including a regular employee -- can invoke admin functions such as
listing all users, reading system config (which contains the flag),
or deleting users.
"""

import base64
import hashlib
import hmac
import json
import os
import time
import uuid

from flask import Flask, jsonify, render_template, request

# ---------------------------------------------------------------------------
# App & constants
# ---------------------------------------------------------------------------
app = Flask(__name__)

JWT_SECRET = "bfla-role-secret-2025"
FLAG = None
USERS = {}
SYSTEM_CONFIG = {}

# ---------------------------------------------------------------------------
# JWT helpers (compact HS256, same pattern as mass-assignment challenge)
# ---------------------------------------------------------------------------

def _b64(data: bytes) -> str:
"""URL-safe base64 encode, strip padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def _b64_decode(s: str) -> bytes:
"""URL-safe base64 decode, re-add padding."""
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)


def create_token(payload: dict) -> str:
"""Create an HS256 JWT."""
header = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload_with_exp = {**payload, "iat": int(time.time()), "exp": int(time.time()) + 3600}
body = _b64(json.dumps(payload_with_exp).encode())
sig = _b64(
hmac.new(JWT_SECRET.encode(), f"{header}.{body}".encode(), hashlib.sha256).digest()
)
return f"{header}.{body}.{sig}"


def verify_token(token: str) -> dict | None:
"""Verify an HS256 JWT. Returns the payload dict or None."""
try:
parts = token.split(".")
if len(parts) != 3:
return None
header_b64, body_b64, sig_b64 = parts
expected_sig = _b64(
hmac.new(
JWT_SECRET.encode(), f"{header_b64}.{body_b64}".encode(), hashlib.sha256
).digest()
)
if not hmac.compare_digest(sig_b64, expected_sig):
return None
payload = json.loads(_b64_decode(body_b64))
if payload.get("exp", 0) < time.time():
return None
return payload
except Exception:
return None


def get_token_from_request() -> dict | None:
"""Extract and verify the Bearer token from the Authorization header."""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return verify_token(auth[7:])


# ---------------------------------------------------------------------------
# Lab initialisation
# ---------------------------------------------------------------------------

def init_lab():
"""Generate the flag, seed users, build system config."""
global FLAG, USERS, SYSTEM_CONFIG

FLAG = f"KX{{{uuid.uuid4().hex[:16]}}}"

# Admin gets a random, unguessable password
admin_password = uuid.uuid4().hex

USERS = {
1: {
"id": 1,
"username": "employee",
"password": "emp2025",
"role": "employee",
"department": "engineering",
},
2: {
"id": 2,
"username": "manager",
"password": "mgr2025",
"role": "manager",
"department": "sales",
},
3: {
"id": 3,
"username": "admin",
"password": admin_password,
"role": "admin",
"department": "executive",
},
}

SYSTEM_CONFIG = {
"app_name": "StaffHub API",
"version": "2.4.1",
"environment": "production",
"debug": False,
"jwt_algorithm": "HS256",
"flag": FLAG,
"allowed_origins": ["https://staffhub.internal"],
"rate_limit": "100/hour",
}


# ---------------------------------------------------------------------------
# Public endpoints
# ---------------------------------------------------------------------------

@app.route("/")
def index():
return render_template("index.html")


@app.route("/api/login", methods=["POST"])
def login():
"""Authenticate a user and return a JWT."""
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()

if not username or not password:
return jsonify({"error": "Username and password are required"}), 400

for user in USERS.values():
if user["username"] == username and user["password"] == password:
token = create_token(
{"sub": user["id"], "username": user["username"], "role": user["role"]}
)
return jsonify(
{
"message": "Login successful",
"token": token,
"user": {
"id": user["id"],
"username": user["username"],
"role": user["role"],
},
}
)

return jsonify({"error": "Invalid credentials"}), 401


@app.route("/api/profile", methods=["GET"])
def profile():
"""Return the authenticated user's profile."""
payload = get_token_from_request()
if payload is None:
return jsonify({"error": "Authentication required"}), 401

user = USERS.get(payload.get("sub"))
if user is None:
return jsonify({"error": "User not found"}), 404

return jsonify(
{
"id": user["id"],
"username": user["username"],
"role": user["role"],
"department": user["department"],
}
)


@app.route("/api/docs", methods=["GET"])
def api_docs():
"""Return API documentation -- mentions admin endpoints."""
return jsonify(
{
"api": "StaffHub API",
"version": "2.4.1",
"endpoints": {
"POST /api/login": {
"description": "Authenticate and receive a JWT",
"body": {"username": "string", "password": "string"},
},
"GET /api/profile": {
"description": "Get your own profile (requires Bearer token)",
},
"GET /api/docs": {
"description": "This documentation page",
},
"GET /api/admin/users": {
"description": "List all users (requires admin access)",
},
"GET /api/admin/config": {
"description": "View system configuration (requires admin access)",
},
"DELETE /api/admin/users/<id>": {
"description": "Delete a user by ID (requires admin access)",
},
"GET /api/hint": {
"description": "Get a hint for this challenge",
},
},
}
)


# ---------------------------------------------------------------------------
# ADMIN endpoints -- VULNERABLE: check authentication but NOT role
# ---------------------------------------------------------------------------

@app.route("/api/admin/users", methods=["GET"])
def admin_list_users():
"""List all users. VULNERABLE -- no role check."""
payload = get_token_from_request()
if payload is None:
return jsonify({"error": "Authentication required"}), 401

# BUG: should verify payload["role"] == "admin" but does not
safe_users = [
{
"id": u["id"],
"username": u["username"],
"role": u["role"],
"department": u["department"],
}
for u in USERS.values()
]
return jsonify({"users": safe_users})


@app.route("/api/admin/config", methods=["GET"])
def admin_config():
"""Return system configuration including the flag. VULNERABLE -- no role check."""
payload = get_token_from_request()
if payload is None:
return jsonify({"error": "Authentication required"}), 401

# BUG: should verify payload["role"] == "admin" but does not
return jsonify({"config": SYSTEM_CONFIG})


@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
def admin_delete_user(user_id):
"""Delete a user. VULNERABLE -- no role check."""
payload = get_token_from_request()
if payload is None:
return jsonify({"error": "Authentication required"}), 401

# BUG: should verify payload["role"] == "admin" but does not
if user_id not in USERS:
return jsonify({"error": "User not found"}), 404

deleted = USERS.pop(user_id)
return jsonify(
{"message": f"User '{deleted['username']}' (id={user_id}) deleted"}
)


# ---------------------------------------------------------------------------
# Hints
# ---------------------------------------------------------------------------

@app.route("/api/hint", methods=["GET"])
def hint():
return jsonify(
{
"hints": [
"The /api/docs endpoint reveals all available routes, including admin ones.",
"Admin endpoints check if you are authenticated, but do they check your role?",
"Try accessing /api/admin/config with a non-admin token.",
"Broken Function Level Authorization means the server trusts the authentication but ignores the authorization level.",
]
}
)


# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------

init_lab()

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Loading