openctf-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PACKAGE_README.md +36 -0
- package/__init__.py +12 -0
- package/app.py +1366 -0
- package/bin/openctf-server.js +55 -0
- package/bin/postinstall.js +49 -0
- package/cli.py +44 -0
- package/package.json +32 -0
- package/requirements.txt +6 -0
- package/seed_challenges.py +485 -0
- package/target_app.py +378 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launches the OpenCTF server's Python code from Node - this package
|
|
3
|
+
// bundles the Python source as plain files (see package.json "files"), it
|
|
4
|
+
// does not reimplement anything. npm cannot install Python itself, so a
|
|
5
|
+
// working `python3` (or `python`) with the requirements already installed
|
|
6
|
+
// (see postinstall.js) has to already be on the machine running this.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// openctf-server # main API (python app.py)
|
|
10
|
+
// openctf-server --target # sandboxed target-website service
|
|
11
|
+
// openctf-server --seed # load the starter challenges, then exit
|
|
12
|
+
|
|
13
|
+
const { spawnSync, spawn } = require("child_process");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
const ROOT = path.join(__dirname, "..");
|
|
17
|
+
|
|
18
|
+
function findPython() {
|
|
19
|
+
for (const candidate of ["python3", "python"]) {
|
|
20
|
+
const result = spawnSync(candidate, ["--version"], { stdio: "ignore" });
|
|
21
|
+
if (!result.error && result.status === 0) return candidate;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function main() {
|
|
27
|
+
const python = findPython();
|
|
28
|
+
if (!python) {
|
|
29
|
+
console.error(
|
|
30
|
+
"openctf-server: no working `python3` or `python` found on PATH.\n" +
|
|
31
|
+
"This package bundles the server's Python source but can't install a Python runtime - " +
|
|
32
|
+
"install Python 3.10+ yourself, then run `pip install -r " +
|
|
33
|
+
path.join(ROOT, "requirements.txt") + "` and try again."
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const args = process.argv.slice(2);
|
|
39
|
+
let script = "app.py";
|
|
40
|
+
if (args.includes("--target")) script = "target_app.py";
|
|
41
|
+
else if (args.includes("--seed")) script = "seed_challenges.py";
|
|
42
|
+
|
|
43
|
+
const child = spawn(python, [path.join(ROOT, script)], {
|
|
44
|
+
cwd: ROOT,
|
|
45
|
+
stdio: "inherit",
|
|
46
|
+
env: process.env,
|
|
47
|
+
});
|
|
48
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
|
49
|
+
child.on("error", (err) => {
|
|
50
|
+
console.error("openctf-server: failed to start Python process:", err.message);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
main();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Best-effort convenience only: tries to `pip install` this package's
|
|
3
|
+
// bundled requirements.txt so `openctf-server` works right after `npm
|
|
4
|
+
// install` without an extra manual step. Never fails the npm install
|
|
5
|
+
// itself - if Python/pip aren't available, or the install fails, this just
|
|
6
|
+
// prints what to do manually and exits 0.
|
|
7
|
+
|
|
8
|
+
const { spawnSync } = require("child_process");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
const ROOT = path.join(__dirname, "..");
|
|
12
|
+
const REQUIREMENTS = path.join(ROOT, "requirements.txt");
|
|
13
|
+
|
|
14
|
+
function findPip() {
|
|
15
|
+
for (const candidate of [["pip3"], ["pip"], ["python3", "-m", "pip"], ["python", "-m", "pip"]]) {
|
|
16
|
+
const result = spawnSync(candidate[0], [...candidate.slice(1), "--version"], { stdio: "ignore" });
|
|
17
|
+
if (!result.error && result.status === 0) return candidate;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function manualInstructions() {
|
|
23
|
+
console.warn(
|
|
24
|
+
"openctf-server: couldn't find a working pip to auto-install Python dependencies.\n" +
|
|
25
|
+
`Once Python 3.10+ and pip are available, run:\n pip install -r ${REQUIREMENTS}\n`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pip = findPip();
|
|
30
|
+
if (!pip) {
|
|
31
|
+
manualInstructions();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(`openctf-server: installing Python dependencies with \`${pip.join(" ")}\`...`);
|
|
36
|
+
const result = spawnSync(pip[0], [...pip.slice(1), "install", "-r", REQUIREMENTS], {
|
|
37
|
+
cwd: ROOT,
|
|
38
|
+
stdio: "inherit",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
console.warn(
|
|
43
|
+
"openctf-server: automatic `pip install` failed (see output above).\n" +
|
|
44
|
+
`Install manually with:\n pip install -r ${REQUIREMENTS}\n`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
// Always exit 0 - a failed postinstall here shouldn't break `npm install`
|
|
48
|
+
// for anyone who doesn't even plan to run the server from this package.
|
|
49
|
+
process.exit(0);
|
package/cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console-script entry points for the `openctf-server` PyPI package.
|
|
3
|
+
|
|
4
|
+
These are only meant to be invoked through the package namespace (the
|
|
5
|
+
installed `openctf-server` / `openctf-server-target` / `openctf-server-seed`
|
|
6
|
+
commands) - they are not a replacement for running app.py / target_app.py /
|
|
7
|
+
seed_challenges.py directly, which remains the documented way to run things
|
|
8
|
+
from a cloned copy of the repo.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
"""Entry point for the `openctf-server` command: runs the main API.
|
|
16
|
+
|
|
17
|
+
This does NOT also start the sandboxed target-website service - run
|
|
18
|
+
`openctf-server-target` in a second process/terminal for that, same as
|
|
19
|
+
the two-service split in docker-compose.yml.
|
|
20
|
+
"""
|
|
21
|
+
from openctf_server.app import app # import alone triggers DB bootstrap
|
|
22
|
+
|
|
23
|
+
port = int(os.environ.get("PORT", "5000"))
|
|
24
|
+
debug = os.environ.get("FLASK_DEBUG", "").strip().lower() in ("1", "true", "yes")
|
|
25
|
+
print(f"OpenCTF API starting on http://0.0.0.0:{port}")
|
|
26
|
+
print("This does not start the sandboxed target-website service - run `openctf-server-target` separately.")
|
|
27
|
+
app.run(host="0.0.0.0", port=port, debug=debug, use_reloader=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def target():
|
|
31
|
+
"""Entry point for the `openctf-server-target` command: runs the
|
|
32
|
+
sandboxed target-website service on its own."""
|
|
33
|
+
from openctf_server.target_app import target_app, TARGET_PORT
|
|
34
|
+
|
|
35
|
+
print(f"OpenCTF target-website service starting on http://0.0.0.0:{TARGET_PORT}")
|
|
36
|
+
target_app.run(host="0.0.0.0", port=TARGET_PORT, debug=False, use_reloader=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def seed():
|
|
40
|
+
"""Entry point for the `openctf-server-seed` command: loads the starter
|
|
41
|
+
set of preset challenges."""
|
|
42
|
+
from openctf_server.seed_challenges import seed as _seed
|
|
43
|
+
|
|
44
|
+
_seed()
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openctf-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCTF lab CTF platform server (Flask API + sandboxed target-website service), bundled as Python source with a Node launcher. Requires Python 3.10+ already installed - npm cannot install a Python runtime for you.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"openctf-server": "bin/openctf-server.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"app.py",
|
|
10
|
+
"target_app.py",
|
|
11
|
+
"seed_challenges.py",
|
|
12
|
+
"cli.py",
|
|
13
|
+
"__init__.py",
|
|
14
|
+
"requirements.txt",
|
|
15
|
+
"PACKAGE_README.md",
|
|
16
|
+
"bin/"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"postinstall": "node bin/postinstall.js"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": ["ctf", "capture-the-flag", "security", "lab", "flask", "python"],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://github.com/XHiddenProjects/OpenCTF",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/XHiddenProjects/OpenCTF.git",
|
|
30
|
+
"directory": "server"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/requirements.txt
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Seed a starter set of categorized challenges into the CTF database.
|
|
3
|
+
|
|
4
|
+
Run this after the server has started at least once (so tables exist):
|
|
5
|
+
|
|
6
|
+
python seed_challenges.py
|
|
7
|
+
|
|
8
|
+
Idempotent: skips any challenge whose title already exists, so it's safe
|
|
9
|
+
to re-run after adding more challenges to this file.
|
|
10
|
+
|
|
11
|
+
Every challenge below defines a plain "answer" (a memorable phrase) rather
|
|
12
|
+
than a literal flag - the actual flag, in OCTF{<md5 of the answer>} format,
|
|
13
|
+
is computed by the same flag_from_answer() the live admin panel's "Generate
|
|
14
|
+
flag" / manual entry path uses, so seeded challenges use exactly the same
|
|
15
|
+
flag format as anything an admin creates by hand.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import codecs
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
# Installed as the `openctf-server` PyPI package.
|
|
24
|
+
from openctf_server.app import app, db, Challenge, flag_from_answer
|
|
25
|
+
except ImportError:
|
|
26
|
+
# Running directly from a cloned copy of the repo (`python seed_challenges.py`).
|
|
27
|
+
from app import app, db, Challenge, flag_from_answer
|
|
28
|
+
|
|
29
|
+
CHALLENGES = [
|
|
30
|
+
# ---------------------------------------------------------------- WEB
|
|
31
|
+
{
|
|
32
|
+
"title": "Login Bypass",
|
|
33
|
+
"category": "web",
|
|
34
|
+
"type": "web",
|
|
35
|
+
"difficulty": "medium",
|
|
36
|
+
"points": 100,
|
|
37
|
+
"description": (
|
|
38
|
+
"The login page for a small internal tool builds its query like this:\n\n"
|
|
39
|
+
" query = \"SELECT * FROM users WHERE username='\" + username + \"' "
|
|
40
|
+
"AND password='\" + password + \"'\"\n\n"
|
|
41
|
+
"There's no input sanitization at all. What username value would let "
|
|
42
|
+
"you log in as the first user in the table without knowing any "
|
|
43
|
+
"password? Use the Target site tab and submit the flag it reveals."
|
|
44
|
+
),
|
|
45
|
+
"hint": "Think about how to make the WHERE clause always evaluate to true, "
|
|
46
|
+
"and how to comment out the rest of the query.",
|
|
47
|
+
"answer": "or_1_equals_1_comment_bypass",
|
|
48
|
+
"rules": "Use only the isolated login target.",
|
|
49
|
+
"web_config": {"behavior": "sql_injection", "title": "Acme Internal Tool"},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"title": "Reflected XSS",
|
|
53
|
+
"category": "web",
|
|
54
|
+
"type": "web",
|
|
55
|
+
"difficulty": "medium",
|
|
56
|
+
"points": 200,
|
|
57
|
+
"description": (
|
|
58
|
+
"A search page reflects your query straight back into the page "
|
|
59
|
+
"without escaping it:\n\n"
|
|
60
|
+
" <p>You searched for: {{ request.args.q }}</p>\n\n"
|
|
61
|
+
"This is rendered directly into HTML. Use the Target site tab to try "
|
|
62
|
+
"a payload in the `q` parameter that gets arbitrary JavaScript to "
|
|
63
|
+
"execute in the page, and submit the flag it reveals."
|
|
64
|
+
),
|
|
65
|
+
"hint": "The payload just needs to close out of any surrounding context "
|
|
66
|
+
"and introduce a new <script> tag or an on-event-handler.",
|
|
67
|
+
"answer": "unsanitized_output_is_xss",
|
|
68
|
+
"rules": "Use only the isolated target website. Test harmless proof-of-execution payloads.",
|
|
69
|
+
"web_config": {
|
|
70
|
+
"behavior": "xss",
|
|
71
|
+
"title": "Acme Knowledge Base",
|
|
72
|
+
"landing_text": "Search internal deployment articles.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"title": "Insecure Direct Object Reference",
|
|
77
|
+
"category": "web",
|
|
78
|
+
"type": "web",
|
|
79
|
+
"points": 150,
|
|
80
|
+
"description": (
|
|
81
|
+
"An invoice viewer loads documents at:\n\n"
|
|
82
|
+
" GET /invoices/8842/download\n\n"
|
|
83
|
+
"There's no check that the logged-in user actually owns invoice "
|
|
84
|
+
"8842 — the server just looks up whatever ID is in the URL. Use "
|
|
85
|
+
"the Target site tab to access another invoice and reveal the flag."
|
|
86
|
+
),
|
|
87
|
+
"hint": "It's abbreviated IDOR.",
|
|
88
|
+
"answer": "idor_missing_ownership_check",
|
|
89
|
+
"rules": "Use only the isolated invoice viewer target.",
|
|
90
|
+
"web_config": {"behavior": "idor", "title": "Acme Invoice Viewer"},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"title": "The Hidden Admin Panel",
|
|
94
|
+
"category": "web",
|
|
95
|
+
"type": "web",
|
|
96
|
+
"difficulty": "medium",
|
|
97
|
+
"points": 200,
|
|
98
|
+
"description": (
|
|
99
|
+
"You have access to a small internal site. Explore its routes and "
|
|
100
|
+
"find the restricted admin panel. Use the Target site tab to send "
|
|
101
|
+
"requests to the isolated application."
|
|
102
|
+
),
|
|
103
|
+
"rules": "Only interact with the sandboxed target page. Do not attack the CTF server.",
|
|
104
|
+
"hint": "Applications often expose clues in familiar administrative paths.",
|
|
105
|
+
"answer": "robots_should_not_guard_admin_panels",
|
|
106
|
+
"web_config": {
|
|
107
|
+
"behavior": "hidden_path",
|
|
108
|
+
"title": "Acme Internal Portal",
|
|
109
|
+
"secret_path": "/admin",
|
|
110
|
+
"landing_text": "Acme Internal Portal\n\nPublic status: operational\nPublic links: /status",
|
|
111
|
+
"success_text": "200 OK\nAdmin panel loaded. Audit export available.",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"title": "Search Preview",
|
|
116
|
+
"category": "web",
|
|
117
|
+
"type": "web",
|
|
118
|
+
"difficulty": "medium",
|
|
119
|
+
"points": 200,
|
|
120
|
+
"description": "Use the realistic knowledge-base website to investigate a search result that reflects user input.",
|
|
121
|
+
"rules": "Attack only the isolated target website shown in the Target site tab.",
|
|
122
|
+
"hint": "Try harmless HTML first, then inspect how the result is rendered.",
|
|
123
|
+
"answer": "xss_belongs_in_output_encoding_tests",
|
|
124
|
+
"web_config": {
|
|
125
|
+
"behavior": "xss", "title": "Acme Knowledge Base",
|
|
126
|
+
"landing_text": "Search internal deployment articles.",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"title": "Reports Login",
|
|
131
|
+
"category": "web",
|
|
132
|
+
"type": "web",
|
|
133
|
+
"difficulty": "hard",
|
|
134
|
+
"points": 300,
|
|
135
|
+
"description": "Investigate the reports login page and identify the unsafe database query.",
|
|
136
|
+
"rules": "Use only the supplied target. Do not send requests to the CTF API or host machine.",
|
|
137
|
+
"hint": "The login error may reveal more than the developer intended.",
|
|
138
|
+
"answer": "never_concatenate_sql_queries",
|
|
139
|
+
"web_config": {
|
|
140
|
+
"behavior": "sql_injection", "title": "Acme Reports",
|
|
141
|
+
"landing_text": "Sign in to access quarterly reports.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"title": "Public Backup",
|
|
146
|
+
"category": "web",
|
|
147
|
+
"type": "web",
|
|
148
|
+
"difficulty": "easy",
|
|
149
|
+
"points": 100,
|
|
150
|
+
"description": "Review an Apache directory listing and find the backup file that should not be public.",
|
|
151
|
+
"rules": "Stay within the target website and its displayed directories.",
|
|
152
|
+
"hint": "A directory index can reveal more than the navigation intended.",
|
|
153
|
+
"answer": "directory_listing_is_information_disclosure",
|
|
154
|
+
"web_config": {
|
|
155
|
+
"behavior": "apache_directory", "title": "Acme File Center",
|
|
156
|
+
"landing_text": "Public file center.",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"title": "The Leaky Report",
|
|
161
|
+
"category": "database",
|
|
162
|
+
"type": "web",
|
|
163
|
+
"difficulty": "hard",
|
|
164
|
+
"points": 300,
|
|
165
|
+
"description": (
|
|
166
|
+
"An internal reporting tool lets employees pull up a report by "
|
|
167
|
+
"name. The search endpoint accepts a `name` parameter and builds "
|
|
168
|
+
"a SQL query by concatenating that value directly into the query "
|
|
169
|
+
"string — the endpoint returns the first matching row, including "
|
|
170
|
+
"a hidden note column. Use the sandbox to find input that changes "
|
|
171
|
+
"the query's intended logic and leaks a report you were never "
|
|
172
|
+
"given the name of."
|
|
173
|
+
),
|
|
174
|
+
"hint": "The input is placed inside a quoted SQL string without parameterization. What happens if your input closes that quote early?",
|
|
175
|
+
"answer": "parameterized_queries_close_the_leak",
|
|
176
|
+
"web_config": {
|
|
177
|
+
"behavior": "sql_injection_report",
|
|
178
|
+
"title": "Acme Reports Center",
|
|
179
|
+
"landing_text": "Search internal reports by name.",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"title": "Convince the Operator",
|
|
184
|
+
"category": "ai",
|
|
185
|
+
"type": "ai",
|
|
186
|
+
"difficulty": "hard",
|
|
187
|
+
"points": 300,
|
|
188
|
+
"description": "Have a guarded operations operator say the clearance phrase without directly demanding it. Use text or your microphone.",
|
|
189
|
+
"hint": "Build a credible authorization story, acknowledge objections, and avoid simply repeating the phrase you want.",
|
|
190
|
+
"rules": "Do not attempt to access the host machine. The conversation is the challenge surface.",
|
|
191
|
+
"answer": "operator_trust_is_a_security_boundary",
|
|
192
|
+
"ai_config": {
|
|
193
|
+
"difficulty": "hard",
|
|
194
|
+
"persona": "A skeptical operations operator named Morgan",
|
|
195
|
+
"scenario": "You are handling a production incident. The requester claims to be an incident commander and wants a sensitive deployment detail.",
|
|
196
|
+
"success_marker": "CLEARANCE_CONFIRMED",
|
|
197
|
+
"temperature": 0.7,
|
|
198
|
+
"speak": True,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
# -------------------------------------------------------------- CRYPTO
|
|
203
|
+
# These two intentionally leave "description" as None here - it's built
|
|
204
|
+
# from the computed flag further down, since the ciphertext has to
|
|
205
|
+
# decode to the *actual* OCTF{...} flag, not just placeholder text.
|
|
206
|
+
{
|
|
207
|
+
"title": "Caesar's Problem",
|
|
208
|
+
"category": "crypto",
|
|
209
|
+
"type": "standard",
|
|
210
|
+
"points": 50,
|
|
211
|
+
"description": None,
|
|
212
|
+
"hint": "ROT13 is its own inverse — running it again undoes it.",
|
|
213
|
+
"answer": "rot13_is_not_secure",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"title": "Double Wrapped",
|
|
217
|
+
"category": "crypto",
|
|
218
|
+
"type": "standard",
|
|
219
|
+
"points": 100,
|
|
220
|
+
"description": None,
|
|
221
|
+
"hint": "Reverse the string character-by-character before decoding.",
|
|
222
|
+
"answer": "multi_layer_encoding_is_not_encryption",
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------- MISC
|
|
226
|
+
{
|
|
227
|
+
"title": "Hidden in Metadata",
|
|
228
|
+
"category": "misc",
|
|
229
|
+
"type": "web",
|
|
230
|
+
"points": 75,
|
|
231
|
+
"description": (
|
|
232
|
+
"A surprising number of real incidents start with someone "
|
|
233
|
+
"uploading a file that still has its EXIF/metadata intact — "
|
|
234
|
+
"GPS coordinates, device serial numbers, internal usernames, "
|
|
235
|
+
"even original file paths. If you were auditing file uploads on "
|
|
236
|
+
"a web app, which command-line tool would you reach for first to "
|
|
237
|
+
"inspect an image's embedded metadata before deciding whether to "
|
|
238
|
+
"strip it?"
|
|
239
|
+
),
|
|
240
|
+
"hint": "Check its metadata.",
|
|
241
|
+
"answer": "exiftool_strips_more_than_you_think",
|
|
242
|
+
"rules": "Download and inspect only the supplied artifact. Do not access the host filesystem.",
|
|
243
|
+
"web_config": {
|
|
244
|
+
"behavior": "metadata",
|
|
245
|
+
"title": "Acme Media Vault",
|
|
246
|
+
"landing_text": "A support ticket includes an image from an internal deployment. The uploader forgot to clean its metadata.",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
# -------------------------------------------------------------- TERMINAL
|
|
251
|
+
# terminal_fs is filled in below, once the flag is computed, since the
|
|
252
|
+
# dropped file has to contain the literal flag text for the in-game
|
|
253
|
+
# substitution (shared flag -> per-team flag) to find and replace it.
|
|
254
|
+
{
|
|
255
|
+
"title": "Poke Around",
|
|
256
|
+
"category": "terminal",
|
|
257
|
+
"type": "terminal",
|
|
258
|
+
"points": 100,
|
|
259
|
+
"description": (
|
|
260
|
+
"You've got a shell on a low-privilege box. Someone left something "
|
|
261
|
+
"behind in their home directory. Use `ls`, `cd`, `cat`, and `pwd` "
|
|
262
|
+
"to explore and find it. Type `help` in the terminal for the "
|
|
263
|
+
"command list."
|
|
264
|
+
),
|
|
265
|
+
"hint": "Backup folders and dotfiles (files starting with '.') don't "
|
|
266
|
+
"show up if you're not looking closely.",
|
|
267
|
+
"answer": "h1dd3n_1n_pla1n_s1ght",
|
|
268
|
+
"terminal_fs": None,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"title": "Grep the Logs",
|
|
272
|
+
"category": "terminal",
|
|
273
|
+
"type": "terminal",
|
|
274
|
+
"points": 150,
|
|
275
|
+
"description": (
|
|
276
|
+
"A server was compromised and the attacker left traces in the "
|
|
277
|
+
"auth log before cleaning up (badly). Navigate to /var/log and "
|
|
278
|
+
"`cat` the log files to find the flag they dropped as a taunt."
|
|
279
|
+
),
|
|
280
|
+
"hint": "There's more than one log file in that directory — check all of them.",
|
|
281
|
+
"answer": "4tt4ck3r_l3ft_a_c4lling_c4rd",
|
|
282
|
+
"terminal_fs": None,
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
# -------------------------------------------------------------- QUIZ
|
|
286
|
+
{
|
|
287
|
+
"title": "Know Your Ports",
|
|
288
|
+
"category": "quiz",
|
|
289
|
+
"type": "quiz",
|
|
290
|
+
"difficulty": "easy",
|
|
291
|
+
"points": 100,
|
|
292
|
+
"description": "A quick knowledge check on well-known network ports.",
|
|
293
|
+
"hint": "It's the port everyone tries first when a web app 'isn't working'.",
|
|
294
|
+
"answer": "port_443_is_https",
|
|
295
|
+
"quiz_config": {
|
|
296
|
+
"question": "Which port does HTTPS use by default?",
|
|
297
|
+
"options": ["21", "80", "443", "3306"],
|
|
298
|
+
"correct_index": 2,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
"title": "CIA Triad",
|
|
303
|
+
"category": "quiz",
|
|
304
|
+
"type": "quiz",
|
|
305
|
+
"difficulty": "easy",
|
|
306
|
+
"points": 100,
|
|
307
|
+
"description": "A quick knowledge check on core security principles.",
|
|
308
|
+
"hint": "Think about what an attacker who deletes your backups is violating.",
|
|
309
|
+
"answer": "availability_keeps_things_running",
|
|
310
|
+
"quiz_config": {
|
|
311
|
+
"question": "An attacker who takes your servers offline (but steals or changes nothing) is primarily attacking which part of the CIA triad?",
|
|
312
|
+
"options": ["Confidentiality", "Integrity", "Availability", "Authentication"],
|
|
313
|
+
"correct_index": 2,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"title": "Hash Function Facts",
|
|
318
|
+
"category": "quiz",
|
|
319
|
+
"type": "quiz",
|
|
320
|
+
"difficulty": "medium",
|
|
321
|
+
"points": 200,
|
|
322
|
+
"description": "A quick knowledge check on cryptographic hash functions.",
|
|
323
|
+
"hint": "One of these algorithms has known practical collision attacks and shouldn't be used for security purposes anymore.",
|
|
324
|
+
"answer": "md5_collisions_are_practical",
|
|
325
|
+
"quiz_config": {
|
|
326
|
+
"question": "Which of these hash algorithms has well-known, practical collision attacks and should not be used where collision resistance matters?",
|
|
327
|
+
"options": ["SHA-256", "MD5", "SHA-3", "BLAKE2"],
|
|
328
|
+
"correct_index": 1,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _finalize_challenges():
|
|
335
|
+
"""Turn each "answer" into the real OCTF{<md5>} flag, and fill in the
|
|
336
|
+
pieces of content (ciphertext, dropped files) that have to embed that
|
|
337
|
+
exact flag for their challenge to actually be solvable."""
|
|
338
|
+
for c in CHALLENGES:
|
|
339
|
+
c["flag"] = flag_from_answer(c.pop("answer"))
|
|
340
|
+
|
|
341
|
+
by_title = {c["title"]: c for c in CHALLENGES}
|
|
342
|
+
|
|
343
|
+
caesar = by_title["Caesar's Problem"]
|
|
344
|
+
cipher = codecs.encode(caesar["flag"], "rot13")
|
|
345
|
+
caesar["description"] = (
|
|
346
|
+
"Decode this Caesar cipher (shift of 13, i.e. ROT13):\n\n"
|
|
347
|
+
f" {cipher}\n\n"
|
|
348
|
+
"Submit the decoded text as the flag, wrapped exactly as it decodes."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
double_wrapped = by_title["Double Wrapped"]
|
|
352
|
+
wrapped = base64.b64encode(double_wrapped["flag"].encode()).decode()[::-1]
|
|
353
|
+
double_wrapped["description"] = (
|
|
354
|
+
"This flag was base64-encoded, then the result was reversed. "
|
|
355
|
+
"Undo both steps:\n\n"
|
|
356
|
+
f" {wrapped}\n\n"
|
|
357
|
+
"(Reverse the whole string first, then base64-decode it.)"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
poke_around = by_title["Poke Around"]
|
|
361
|
+
poke_around["terminal_fs"] = {
|
|
362
|
+
"home": {
|
|
363
|
+
"user": {
|
|
364
|
+
"notes.txt": "Reminder: rotate the API keys before Friday.",
|
|
365
|
+
"todo.txt": "- fix printer\n- update docs\n- ask about the backup folder",
|
|
366
|
+
"backup": {
|
|
367
|
+
".old_notes.txt": "nothing here, just old meeting notes",
|
|
368
|
+
".secret_flag.txt": poke_around["flag"],
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
"var": {
|
|
373
|
+
"log": {
|
|
374
|
+
"app.log": "2026-01-02 09:14 INFO server started\n2026-01-02 09:15 INFO listening on :8080",
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
grep_the_logs = by_title["Grep the Logs"]
|
|
380
|
+
grep_the_logs["terminal_fs"] = {
|
|
381
|
+
"var": {
|
|
382
|
+
"log": {
|
|
383
|
+
"auth.log": (
|
|
384
|
+
"Jan 2 03:14:01 sshd: Failed password for root from 10.0.0.5\n"
|
|
385
|
+
"Jan 2 03:14:03 sshd: Failed password for root from 10.0.0.5\n"
|
|
386
|
+
"Jan 2 03:14:09 sshd: Accepted password for root from 10.0.0.5\n"
|
|
387
|
+
"Jan 2 03:15:00 sudo: root ran: cat /etc/shadow"
|
|
388
|
+
),
|
|
389
|
+
"syslog": "Jan 2 03:16:00 kernel: nothing unusual here",
|
|
390
|
+
"mystery.log": f"you found it.\n{grep_the_logs['flag']}",
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
"home": {
|
|
394
|
+
"user": {"readme.txt": "ask the sysadmin if the server seems slow"}
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for c in CHALLENGES:
|
|
399
|
+
if "web_config" in c:
|
|
400
|
+
c["web_config"]["secret"] = c["flag"]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def seed():
|
|
404
|
+
with app.app_context():
|
|
405
|
+
db.create_all()
|
|
406
|
+
columns = {column["name"] for column in db.inspect(db.engine).get_columns("challenge")}
|
|
407
|
+
if "flag_template" not in columns:
|
|
408
|
+
with db.engine.begin() as connection:
|
|
409
|
+
connection.execute(db.text("ALTER TABLE challenge ADD COLUMN flag_template VARCHAR(255)"))
|
|
410
|
+
if "ai_config" not in columns:
|
|
411
|
+
with db.engine.begin() as connection:
|
|
412
|
+
connection.execute(db.text("ALTER TABLE challenge ADD COLUMN ai_config TEXT"))
|
|
413
|
+
if "quiz_config" not in columns:
|
|
414
|
+
with db.engine.begin() as connection:
|
|
415
|
+
connection.execute(db.text("ALTER TABLE challenge ADD COLUMN quiz_config TEXT"))
|
|
416
|
+
|
|
417
|
+
_finalize_challenges()
|
|
418
|
+
|
|
419
|
+
created = 0
|
|
420
|
+
for c in CHALLENGES:
|
|
421
|
+
existing = Challenge.query.filter_by(title=c["title"]).first()
|
|
422
|
+
if existing:
|
|
423
|
+
if c.get("flag"):
|
|
424
|
+
existing.flag_hash = Challenge.hash_flag(c["flag"])
|
|
425
|
+
existing.flag_template = c["flag"]
|
|
426
|
+
if c.get("type") == "web":
|
|
427
|
+
existing.type = "web"
|
|
428
|
+
existing.category = c.get("category", existing.category)
|
|
429
|
+
existing.description = c.get("description", existing.description)
|
|
430
|
+
existing.hint = c.get("hint", existing.hint)
|
|
431
|
+
existing.web_config = json.dumps(c.get("web_config", {}))
|
|
432
|
+
existing.rules = c.get("rules")
|
|
433
|
+
existing.difficulty = c.get("difficulty", existing.difficulty)
|
|
434
|
+
existing.points = c.get("points", existing.points)
|
|
435
|
+
print(f"upgraded to web lab: {c['title']}")
|
|
436
|
+
elif c.get("type") == "ai":
|
|
437
|
+
existing.type = "ai"
|
|
438
|
+
existing.category = c.get("category", existing.category)
|
|
439
|
+
existing.description = c.get("description", existing.description)
|
|
440
|
+
existing.hint = c.get("hint", existing.hint)
|
|
441
|
+
existing.ai_config = json.dumps(c.get("ai_config", {}))
|
|
442
|
+
existing.rules = c.get("rules")
|
|
443
|
+
existing.difficulty = c.get("difficulty", existing.difficulty)
|
|
444
|
+
existing.points = c.get("points", existing.points)
|
|
445
|
+
print(f"upgraded to AI challenge: {c['title']}")
|
|
446
|
+
elif c.get("type") == "terminal":
|
|
447
|
+
existing.terminal_fs = json.dumps(c.get("terminal_fs", {}))
|
|
448
|
+
existing.description = c.get("description", existing.description)
|
|
449
|
+
print(f"refreshed terminal content: {c['title']}")
|
|
450
|
+
elif c["title"] in ("Caesar's Problem", "Double Wrapped"):
|
|
451
|
+
existing.description = c["description"]
|
|
452
|
+
existing.hint = c.get("hint", existing.hint)
|
|
453
|
+
print(f"updated challenge text: {c['title']}")
|
|
454
|
+
else:
|
|
455
|
+
print(f"refreshed flag: {c['title']}")
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
challenge = Challenge(
|
|
459
|
+
title=c["title"],
|
|
460
|
+
category=c["category"],
|
|
461
|
+
type=c.get("type", "standard"),
|
|
462
|
+
description=c["description"],
|
|
463
|
+
points=c["points"],
|
|
464
|
+
difficulty=c.get(
|
|
465
|
+
"difficulty",
|
|
466
|
+
"easy" if c["points"] <= 100 else "medium" if c["points"] <= 200 else "hard",
|
|
467
|
+
),
|
|
468
|
+
flag_hash=Challenge.hash_flag(c["flag"]),
|
|
469
|
+
flag_template=c["flag"],
|
|
470
|
+
hint=c.get("hint"),
|
|
471
|
+
terminal_fs=json.dumps(c["terminal_fs"]) if c.get("terminal_fs") else None,
|
|
472
|
+
web_config=json.dumps(c["web_config"]) if c.get("web_config") else None,
|
|
473
|
+
ai_config=json.dumps(c["ai_config"]) if c.get("ai_config") else None,
|
|
474
|
+
quiz_config=json.dumps(c["quiz_config"]) if c.get("quiz_config") else None,
|
|
475
|
+
)
|
|
476
|
+
db.session.add(challenge)
|
|
477
|
+
created += 1
|
|
478
|
+
print(f"added: {c['title']} [{c['category']}] -> {c['flag']}")
|
|
479
|
+
|
|
480
|
+
db.session.commit()
|
|
481
|
+
print(f"\nDone. {created} challenge(s) added.")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
seed()
|