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/target_app.py ADDED
@@ -0,0 +1,378 @@
1
+ """
2
+ OpenCTF isolated target server.
3
+
4
+ This is deliberately separate from app.py and runs on port 5001. It serves
5
+ local-only vulnerable lab sites for web challenges. Never expose this process
6
+ outside the CTF lab network.
7
+
8
+ Run from server/:
9
+ python target_app.py
10
+ """
11
+
12
+ import html
13
+ import json
14
+ import os
15
+ import sqlite3
16
+ import hashlib
17
+ import hmac
18
+ import struct
19
+ import zlib
20
+ from urllib.parse import quote
21
+
22
+ from flask import Flask, abort, jsonify, request
23
+
24
+ try:
25
+ # Installed as the `openctf-server` PyPI package.
26
+ from openctf_server.app import Challenge, TARGET_ACCESS_SECRET, app as ctf_app, team_challenge_flag
27
+ except ImportError:
28
+ # Running directly from a cloned copy of the repo (`python target_app.py`).
29
+ from app import Challenge, TARGET_ACCESS_SECRET, app as ctf_app, team_challenge_flag
30
+
31
+
32
+ target_app = Flask(__name__)
33
+ TARGET_PORT = int(os.environ.get("TARGET_PORT", "5001"))
34
+
35
+
36
+ @target_app.after_request
37
+ def apache_headers(response):
38
+ response.headers["Server"] = "Apache/2.4.58 (Ubuntu)"
39
+ response.headers["X-Powered-By"] = "PHP/8.2.10"
40
+ response.headers["Cache-Control"] = "no-store, max-age=0"
41
+ return response
42
+
43
+ BASE_CSS = """
44
+ <style>
45
+ :root { color-scheme: light; --ink: #18212b; --muted: #667382; --line: #dce3ea; --blue: #2563eb; --surface: #ffffff; }
46
+ * { box-sizing: border-box; }
47
+ body { margin: 0; background: #f3f6f9; color: var(--ink); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
48
+ .site-shell { max-width: 980px; margin: 0 auto; padding: 0 24px 48px; }
49
+ .site-nav { display: flex; align-items: center; justify-content: space-between; padding: 22px 0; border-bottom: 1px solid var(--line); }
50
+ .brand { font-weight: 700; letter-spacing: -.02em; }
51
+ .nav-links { display: flex; gap: 18px; color: var(--muted); font-size: 13px; }
52
+ a { color: var(--blue); text-decoration: none; } a:hover { text-decoration: underline; }
53
+ .hero { padding: 58px 0 34px; max-width: 700px; } h1 { font-size: 42px; line-height: 1.08; margin: 0 0 14px; letter-spacing: -.04em; } h2 { margin-top: 0; }
54
+ .eyebrow { color: var(--blue); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; font-weight: 700; }
55
+ .muted { color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
56
+ .card, .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 22px; box-shadow: 0 8px 24px rgba(28, 44, 64, .05); }
57
+ .card h3 { margin: 0 0 6px; font-size: 16px; } .card p { color: var(--muted); font-size: 13px; }
58
+ form { display: grid; gap: 12px; max-width: 480px; } label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } input { border: 1px solid #cbd5df; border-radius: 5px; padding: 11px 12px; font: inherit; } button { border: 0; border-radius: 5px; padding: 11px 16px; background: var(--blue); color: white; font-weight: 600; cursor: pointer; }
59
+ .alert { border-left: 3px solid var(--blue); background: #eef4ff; padding: 14px 16px; white-space: pre-wrap; } .danger { border-color: #dc2626; background: #fff1f2; }
60
+ .file-card { display: flex; align-items: center; gap: 14px; border: 1px solid var(--line); border-radius: 7px; padding: 14px; background: #f8fafc; } .file-card p { margin: 2px 0 0; } .file-icon { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 6px; background: #dbeafe; color: #1d4ed8; font: 700 11px ui-monospace, monospace; } .download-link { margin-left: auto; background: var(--blue); color: white; border-radius: 5px; padding: 9px 12px; font-size: 13px; } .download-link:hover { text-decoration: none; background: #1d4ed8; }
61
+ .lab-terminal { margin-top: 22px; border-radius: 7px; overflow: hidden; background: #080b0f; color: #d7e0e8; } .terminal-title { padding: 9px 12px; border-bottom: 1px solid #26313b; color: #75d49a; font: 12px ui-monospace, monospace; } .lab-terminal pre { height: 170px; overflow: auto; margin: 0; padding: 14px; } .lab-terminal form { display: flex; flex-direction: row; align-items: center; gap: 8px; max-width: none; padding: 10px 12px; border-top: 1px solid #26313b; } .lab-terminal form span { color: #75d49a; font: 13px ui-monospace, monospace; } .lab-terminal input { flex: 1; min-width: 0; background: transparent; border: 0; color: white; font: 13px ui-monospace, monospace; outline: 0; } .lab-terminal button { padding: 7px 10px; font-size: 12px; }
62
+ .dir { border-collapse: collapse; width: 100%; background: white; } .dir th, .dir td { text-align: left; border-bottom: 1px solid var(--line); padding: 12px; } .dir th { background: #f8fafc; font-size: 12px; color: var(--muted); }
63
+ pre { white-space: pre-wrap; font: 13px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; } footer { color: var(--muted); font-size: 12px; margin-top: 48px; }
64
+ @media (max-width: 700px) { .grid { grid-template-columns: 1fr; } .nav-links { display: none; } h1 { font-size: 34px; } .site-shell { padding: 0 16px 32px; } }
65
+ </style>
66
+ """
67
+
68
+
69
+ def get_challenge(challenge_id):
70
+ with ctf_app.app_context():
71
+ challenge = Challenge.query.get(challenge_id)
72
+ if not challenge or not challenge.is_active or challenge.type != "web":
73
+ abort(404)
74
+ try:
75
+ config = json.loads(challenge.web_config or "{}")
76
+ except json.JSONDecodeError:
77
+ abort(500)
78
+ access = request.args.get("access", "")
79
+ payload, _, signature = access.rpartition(":")
80
+ valid_access = bool(payload and signature) and hmac.compare_digest(
81
+ signature,
82
+ hmac.new(TARGET_ACCESS_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest(),
83
+ )
84
+ team_id = int(payload.split(":", 1)[0]) if valid_access and ":" in payload else None
85
+ # Each team gets its own flag baked into the sandboxed site (same
86
+ # mechanism as terminal/AI challenges), so a flag one team finds
87
+ # can't just be copy-pasted by another team.
88
+ config["_team_flag"] = team_challenge_flag(team_id, challenge) if team_id else None
89
+ config["_access"] = access
90
+ return challenge, config
91
+
92
+
93
+ def layout(config, content, path="/"):
94
+ title = html.escape(config.get("title") or "Acme Internal Portal")
95
+ access = config.get("_access")
96
+ query = f"?access={quote(access)}" if access else ""
97
+ path = f"{path}{query}"
98
+ return f"""<!doctype html><html><head><meta charset='utf-8'><title>{title}</title>{BASE_CSS}</head>
99
+ <body><div class='site-shell'><nav class='site-nav'><div class='brand'>{title}</div>
100
+ <div class='nav-links'><a href='{path}'>Home</a><a href='{path}status'>Status</a><a href='{path}about'>About</a></div></nav>{content}
101
+ <footer>Acme Systems &middot; Internal preview environment</footer></div>
102
+ <script>
103
+ function reportLocation() {{ parent.postMessage({{ type: 'target-location', path: location.pathname + location.search }}, '*'); }}
104
+ window.addEventListener('load', reportLocation);
105
+ window.addEventListener('popstate', reportLocation);
106
+ window.addEventListener('message', function (event) {{
107
+ if (!event.data || event.data.type !== 'target-navigation') return;
108
+ if (event.data.action === 'back') history.back();
109
+ if (event.data.action === 'forward') history.forward();
110
+ if (event.data.action === 'reload') location.reload();
111
+ }});
112
+ window.addEventListener('DOMContentLoaded', function () {{
113
+ const access = new URLSearchParams(location.search).get('access');
114
+ if (!access) return;
115
+ document.querySelectorAll('a[href], form[action]').forEach(function (element) {{
116
+ const attribute = element.tagName === 'FORM' ? 'action' : 'href';
117
+ const url = new URL(element.getAttribute(attribute), location.href);
118
+ if (url.origin !== location.origin) return;
119
+ url.searchParams.set('access', access);
120
+ element.setAttribute(attribute, url.pathname + url.search);
121
+ }});
122
+ }});
123
+ </script></body></html>"""
124
+
125
+
126
+ def challenge_secret(config):
127
+ return str(config.get("_team_flag") or config.get("secret") or "OCTF{configure_the_web_challenge}")
128
+
129
+
130
+ @target_app.route("/target/<int:challenge_id>/", methods=["GET"])
131
+ def landing(challenge_id):
132
+ _, config = get_challenge(challenge_id)
133
+ base = f"/target/{challenge_id}/"
134
+ behavior = config.get("behavior", "hidden_path")
135
+ landing_text = html.escape(config.get("landing_text") or "Welcome to the internal site.")
136
+ if behavior == "xss":
137
+ return xss_page(challenge_id, config)
138
+ if behavior == "sql_injection":
139
+ return sql_page(challenge_id, config)
140
+ if behavior == "sql_injection_report":
141
+ return report_page(challenge_id, config)
142
+ if behavior == "apache_directory":
143
+ return apache_page(challenge_id, config)
144
+ if behavior == "idor":
145
+ return invoice_page(challenge_id, config)
146
+ if behavior == "metadata":
147
+ return metadata_page(challenge_id, config)
148
+ content = f"""<main class='hero'><div class='eyebrow'>Internal portal</div><h1>Work smarter, together.</h1>
149
+ <p class='muted'>{landing_text}</p><div class='grid'><div class='card'><h3>Operations</h3><p>Service health and deployment notes.</p><a href='{base}status'>View status</a></div>
150
+ <div class='card'><h3>Knowledge base</h3><p>Guides for the internal team.</p><a href='{base}about'>Browse articles</a></div>
151
+ <div class='card'><h3>Staff login</h3><p>Restricted tools for employees.</p><a href='{base}admin'>Open console</a></div></div></main>"""
152
+ return layout(config, content, base)
153
+
154
+
155
+ @target_app.route("/target/<int:challenge_id>", methods=["GET"])
156
+ def landing_without_slash(challenge_id):
157
+ return landing(challenge_id)
158
+
159
+
160
+ @target_app.route("/target/<int:challenge_id>/status")
161
+ def status(challenge_id):
162
+ _, config = get_challenge(challenge_id)
163
+ return layout(config, "<main class='hero'><div class='eyebrow'>System status</div><h1>All systems operational.</h1><p class='muted'>Last checked 2 minutes ago.</p></main>", f"/target/{challenge_id}/")
164
+
165
+
166
+ @target_app.route("/target/<int:challenge_id>/about")
167
+ def about(challenge_id):
168
+ _, config = get_challenge(challenge_id)
169
+ return layout(config, "<main class='hero'><div class='eyebrow'>About Acme</div><h1>Tools for people doing serious work.</h1><p class='muted'>This internal preview is maintained by the platform team.</p></main>", f"/target/{challenge_id}/")
170
+
171
+
172
+ def png_chunk(kind, data):
173
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xffffffff)
174
+
175
+
176
+ def incident_png(metadata):
177
+ width, height = 960, 540
178
+ rows = []
179
+ for y in range(height):
180
+ row = bytearray([0])
181
+ for x in range(width):
182
+ if 44 <= x < 916 and 44 <= y < 496:
183
+ color = (36, 55, 70, 255)
184
+ elif ((x - 210) ** 2 + (y - 260) ** 2) < 104 ** 2:
185
+ color = (216, 227, 234, 255)
186
+ else:
187
+ color = (23, 33, 43, 255)
188
+ row.extend(color)
189
+ rows.append(bytes(row))
190
+ raw_pixels = b"".join(rows)
191
+ png = b"\x89PNG\r\n\x1a\n"
192
+ png += png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
193
+ for key, value in metadata.items():
194
+ png += png_chunk(b"tEXt", f"{key}\x00{value}".encode("latin-1", "replace"))
195
+ png += png_chunk(b"IDAT", zlib.compress(raw_pixels, 6))
196
+ return png + png_chunk(b"IEND", b"")
197
+
198
+
199
+ @target_app.route("/target/<int:challenge_id>/assets/incident-photo.png")
200
+ def metadata_asset(challenge_id):
201
+ _, config = get_challenge(challenge_id)
202
+ if config.get("behavior") != "metadata":
203
+ abort(404)
204
+ access = quote(config.get("_access", ""))
205
+ flag = html.escape(challenge_secret(config))
206
+ png = incident_png({
207
+ "Title": "Acme deployment room",
208
+ "Author": "m.ortiz",
209
+ "Description": "Exported support evidence",
210
+ "Comment": challenge_secret(config),
211
+ })
212
+ return target_app.response_class(png, mimetype="image/png", headers={"Content-Disposition": "attachment; filename=incident-photo.png"})
213
+
214
+
215
+ def metadata_page(challenge_id, config):
216
+ base = f"/target/{challenge_id}/"
217
+ access = quote(config.get("_access", ""))
218
+ asset = f"{base}assets/incident-photo.png?access={access}"
219
+ content = f"""<main class='hero'><div class='eyebrow'>Digital evidence</div><h1>Incident photo review</h1>
220
+ <p class='muted'>{html.escape(config.get('landing_text', 'Inspect the supplied asset metadata.'))}</p>
221
+ <div class='panel'><div class='file-card'><span class='file-icon'>PNG</span><div><strong>incident-photo.png</strong><p class='muted'>Deployment evidence image · 4 KB</p></div><a class='download-link' href='{asset}'>Download image</a></div>
222
+ <p class='muted'>Download the image</p></div></main>"""
223
+ return layout(config, content, base)
224
+
225
+
226
+ @target_app.route("/target/<int:challenge_id>/invoices/<int:invoice_id>/download")
227
+ def invoice_page(challenge_id, invoice_id):
228
+ _, config = get_challenge(challenge_id)
229
+ if config.get("behavior") != "idor":
230
+ abort(404)
231
+ if invoice_id == 8842:
232
+ content = f"<main class='hero'><div class='eyebrow'>Invoice viewer</div><h1>Invoice {invoice_id}</h1><div class='alert'>Invoice owner: finance-team<br>Download ready.<br>{html.escape(challenge_secret(config))}</div></main>"
233
+ else:
234
+ content = f"<main class='hero'><h1>Invoice {invoice_id}</h1><p class='muted'>Invoice not found.</p></main>"
235
+ return layout(config, content, f"/target/{challenge_id}/")
236
+
237
+
238
+ @target_app.route("/target/<int:challenge_id>/admin")
239
+ def admin_panel(challenge_id):
240
+ _, config = get_challenge(challenge_id)
241
+ if config.get("behavior") != "hidden_path":
242
+ abort(404)
243
+ content = f"""<main class='hero'><div class='eyebrow'>Restricted console</div><h1>Admin panel</h1>
244
+ <div class='alert'>Access granted.\n\nAudit export ready.\n{html.escape(challenge_secret(config))}</div></main>"""
245
+ return layout(config, content, f"/target/{challenge_id}/")
246
+
247
+
248
+ @target_app.route("/target/<int:challenge_id>/search")
249
+ def xss_page(challenge_id, config=None):
250
+ if config is None:
251
+ _, config = get_challenge(challenge_id)
252
+ query = request.args.get("q", "")
253
+ # Intentional vulnerability: q is reflected without escaping for XSS labs.
254
+ reflected = query
255
+ exploit_notice = ""
256
+ if "<script" in query.lower() or "onerror" in query.lower() or "onload" in query.lower():
257
+ exploit_notice = f"<div class='alert'>Client-side execution confirmed.<br>{html.escape(challenge_secret(config))}</div>"
258
+ base = f"/target/{challenge_id}/"
259
+ content = f"""<main class='hero'><div class='eyebrow'>Knowledge search</div><h1>Find an article</h1>
260
+ <form method='get' action='{base}search'><label>Search the knowledge base<input name='q' value='{html.escape(query, quote=True)}' placeholder='Try deployment'></label><button>Search</button></form>
261
+ <div class='panel' style='margin-top:22px'><strong>Search results for: </strong>{reflected or 'all articles'}<p class='muted'>Showing 3 internal articles.</p></div>{exploit_notice}</main>"""
262
+ return layout(config, content, base)
263
+
264
+
265
+ @target_app.route("/target/<int:challenge_id>/login", methods=["GET", "POST"])
266
+ def sql_page(challenge_id, config=None):
267
+ if config is None:
268
+ _, config = get_challenge(challenge_id)
269
+ username = request.values.get("username", "")
270
+ password = request.values.get("password", "")
271
+ connection = sqlite3.connect(":memory:")
272
+ connection.execute("CREATE TABLE users (username TEXT, password TEXT, note TEXT)")
273
+ connection.execute("INSERT INTO users VALUES (?, ?, ?)", ("analyst", "winter2026", "Quarterly reports"))
274
+ connection.execute("INSERT INTO users VALUES (?, ?, ?)", ("admin", "not-for-production", challenge_secret(config)))
275
+ # Intentional vulnerability: direct string interpolation for SQLi labs.
276
+ query = "SELECT username, note FROM users WHERE username = '%s' AND password = '%s'" % (username, password)
277
+ message = "Enter your credentials to continue."
278
+ try:
279
+ row = connection.execute(query).fetchone()
280
+ if row:
281
+ message = f"Welcome, {html.escape(row[0])}.\n{html.escape(row[1])}"
282
+ elif username or password:
283
+ message = "Invalid username or password."
284
+ except sqlite3.Error as error:
285
+ message = f"Database error: {html.escape(str(error))}"
286
+ base = f"/target/{challenge_id}/"
287
+ content = f"""<main class='hero'><div class='eyebrow'>Employee access</div><h1>Sign in to reports</h1>
288
+ <form method='post' action='{base}login'><label>Username<input name='username' value='{html.escape(username, quote=True)}'></label><label>Password<input type='password' name='password'></label><button>Sign in</button></form>
289
+ <div class='alert' style='margin-top:22px'>{message}</div></main>"""
290
+ return layout(config, content, base)
291
+
292
+
293
+ @target_app.route("/target/<int:challenge_id>/reports", methods=["GET"])
294
+ def report_page(challenge_id, config=None):
295
+ if config is None:
296
+ _, config = get_challenge(challenge_id)
297
+ name = request.values.get("name", "")
298
+ connection = sqlite3.connect(":memory:")
299
+ connection.execute("CREATE TABLE reports (name TEXT, department TEXT, note TEXT)")
300
+ # The confidential row is inserted first on purpose: an always-true
301
+ # WHERE clause returns it as the "first matching row" the challenge
302
+ # description talks about. Its name is never shown anywhere in the UI.
303
+ connection.execute(
304
+ "INSERT INTO reports VALUES (?, ?, ?)",
305
+ ("Q4 Board Briefing", "Executive", challenge_secret(config)),
306
+ )
307
+ connection.execute(
308
+ "INSERT INTO reports VALUES (?, ?, ?)",
309
+ ("Marketing Overview", "Marketing", "Campaign performance is up 4% quarter over quarter."),
310
+ )
311
+ connection.execute(
312
+ "INSERT INTO reports VALUES (?, ?, ?)",
313
+ ("Support Ticket Trends", "Support", "Ticket volume is flat; no action needed."),
314
+ )
315
+ # Intentional vulnerability: name is concatenated straight into the
316
+ # query string instead of being bound as a parameter, so a value that
317
+ # closes the quote early can change the query's intended logic.
318
+ query = "SELECT name, department, note FROM reports WHERE name = '%s' LIMIT 1" % name
319
+ result_html = ""
320
+ error_html = ""
321
+ try:
322
+ row = connection.execute(query).fetchone()
323
+ if row:
324
+ result_html = (
325
+ f"<div class='panel' style='margin-top:22px'>"
326
+ f"<strong>{html.escape(row[0])}</strong>"
327
+ f"<p class='muted'>Department: {html.escape(row[1])}</p>"
328
+ f"<p>{html.escape(row[2])}</p></div>"
329
+ )
330
+ elif name:
331
+ result_html = "<p class='muted' style='margin-top:22px'>No report found for that name.</p>"
332
+ except sqlite3.Error as error:
333
+ error_html = f"<div class='alert danger' style='margin-top:22px'>Database error: {html.escape(str(error))}</div>"
334
+ base = f"/target/{challenge_id}/"
335
+ content = f"""<main class='hero'><div class='eyebrow'>Reports center</div><h1>Find a report</h1>
336
+ <p class='muted'>Try a known report name, like Marketing Overview or Support Ticket Trends.</p>
337
+ <form method='get' action='{base}reports'><label>Report name<input name='name' value='{html.escape(name, quote=True)}' placeholder='Marketing Overview'></label><button>Search</button></form>
338
+ {result_html}{error_html}</main>"""
339
+ return layout(config, content, base)
340
+
341
+
342
+ @target_app.route("/target/<int:challenge_id>/files/")
343
+ def apache_page(challenge_id, config=None):
344
+ if config is None:
345
+ _, config = get_challenge(challenge_id)
346
+ base = f"/target/{challenge_id}/files/"
347
+ content = f"""<main class='hero'><div class='eyebrow'>Index of /files/</div><h1>Directory listing</h1>
348
+ <p class='muted'>Apache/2.4.58 (Ubuntu) Server at intranet.local Port 80</p><table class='dir'><tr><th>Name</th><th>Last modified</th><th>Size</th></tr>
349
+ <tr><td><a href='{base}../'>Parent Directory</a></td><td></td><td>-</td></tr><tr><td><a href='{base}assets/'>assets/</a></td><td>2026-02-04</td><td>-</td></tr>
350
+ <tr><td><a href='{base}backup/'>backup/</a></td><td>2026-02-01</td><td>-</td></tr></table></main>"""
351
+ return layout(config, content, f"/target/{challenge_id}/")
352
+
353
+
354
+ @target_app.route("/target/<int:challenge_id>/files/backup/")
355
+ def apache_backup(challenge_id):
356
+ _, config = get_challenge(challenge_id)
357
+ base = f"/target/{challenge_id}/files/backup/"
358
+ content = f"""<main class='hero'><div class='eyebrow'>Index of /files/backup/</div><h1>Backup files</h1>
359
+ <table class='dir'><tr><th>Name</th><th>Last modified</th><th>Size</th></tr><tr><td><a href='{base}notes.txt'>notes.txt</a></td><td>2026-01-12</td><td>4 KB</td></tr>
360
+ <tr><td><a href='{base}.env.bak'>.env.bak</a></td><td>2026-01-12</td><td>1 KB</td></tr></table></main>"""
361
+ return layout(config, content, f"/target/{challenge_id}/files/")
362
+
363
+
364
+ @target_app.route("/target/<int:challenge_id>/files/assets/")
365
+ def apache_assets(challenge_id):
366
+ _, config = get_challenge(challenge_id)
367
+ content = "<main class='hero'><div class='eyebrow'>Index of /files/assets/</div><h1>Assets</h1><p class='muted'>No public assets are currently published.</p></main>"
368
+ return layout(config, content, f"/target/{challenge_id}/files/")
369
+
370
+
371
+ @target_app.route("/target/<int:challenge_id>/files/backup/.env.bak")
372
+ def apache_secret(challenge_id):
373
+ _, config = get_challenge(challenge_id)
374
+ return f"APP_ENV=production\nBACKUP_ENABLED=true\nSECRET_TOKEN={challenge_secret(config)}\n"
375
+
376
+
377
+ if __name__ == "__main__":
378
+ target_app.run(host="0.0.0.0", port=TARGET_PORT, debug=False, use_reloader=False)