From a3c679c953ce3a7f77b46ec5e1cd4e2e41c5ccbe Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Mon, 5 Jan 2026 21:30:16 +0000 Subject: [PATCH] Initial commit --- .gitignore | 176 ++++++++++++++++++++++++++++++++++++++++ README.md | 3 + __init__.py | 97 ++++++++++++++++++++++ assets/inject.js | 41 ++++++++++ config.json | 4 + templates/settings.html | 22 +++++ 6 files changed, 343 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 assets/inject.js create mode 100644 config.json create mode 100644 templates/settings.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36b13f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea31c66 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ctfd-category-submit-plugin + +CTFd plugin to enable submitting flags for unknown challenges within a category. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..49aa4c5 --- /dev/null +++ b/__init__.py @@ -0,0 +1,97 @@ +import time +from flask import Blueprint, request, jsonify, render_template +from CTFd.models import db, Challenges, Flags, Solves, Submissions +from CTFd.utils.user import get_current_user, get_current_team +from CTFd.utils.decorators import authed_only, admins_only, during_ctf_time_only +from CTFd.utils.decorators.visibility import check_challenge_visibility +from CTFd.plugins.flags import get_flag_class +from CTFd.utils import get_config, set_config + +def load(app): + plugin_bp = Blueprint('category_submission', __name__, + template_folder='templates', static_folder='assets') + + @plugin_bp.route('/admin/category_submission', methods=['GET', 'POST']) + @admins_only + def admin_settings(): + if request.method == 'POST': + set_config('cat_sub_categories', request.form.get('categories')) + set_config('cat_sub_cooldown', request.form.get('cooldown')) + return render_template('settings.html', success=True, + categories=request.form.get('categories'), + cooldown=request.form.get('cooldown')) + + return render_template('settings.html', + categories=get_config('cat_sub_categories') or "", + cooldown=get_config('cat_sub_cooldown') or "3" + ) + + @plugin_bp.route('/category_submit/config', methods=['GET']) + @check_challenge_visibility # Matches challenge page visibility settings + @during_ctf_time_only + def get_plugin_config(): + return jsonify({ + 'categories': [c.strip() for c in (get_config('cat_sub_categories') or "").split(',') if c.strip()], + 'cooldown': int(get_config('cat_sub_cooldown') or 3) + }) + + @plugin_bp.route('/category_submit', methods=['POST']) + @authed_only + @check_challenge_visibility + @during_ctf_time_only + def category_submit(): + user = get_current_user() + team = get_current_team() + category = request.form.get('category') + provided_flag = request.form.get('submission', '').strip() + + enabled_cats = [c.strip() for c in (get_config('cat_sub_categories') or "").split(',') if c.strip()] + cooldown = int(get_config('cat_sub_cooldown') or 3) + + if category not in enabled_cats: + return jsonify({'success': False, 'message': 'Invalid category'}) + + # Rate Limiting + last_sub = Submissions.query.filter_by(user_id=user.id).order_by(Submissions.date.desc()).first() + if last_sub and (time.time() - last_sub.date.timestamp() < cooldown): + return jsonify({'success': False, 'message': f'Wait {cooldown}s between tries'}) + + # Optimized Solve Check (Unified User/Team Mode) + solve_filter = (Solves.team_id == team.id) if team else (Solves.user_id == user.id) + + # Only query unsolved challenges in the specific category + challenges = Challenges.query.filter( + Challenges.category == category, + Challenges.state == 'visible', + ~Challenges.id.in_(db.session.query(Solves.challenge_id).filter(solve_filter)) + ).all() + + for chall in challenges: + for flag in Flags.query.filter_by(challenge_id=chall.id).all(): + try: + # Supports Static, Regex, and Case-Insensitive flags via CTFd internal classes + if get_flag_class(flag.type).compare(flag, provided_flag): + solve = Solves( + user_id=user.id, team_id=team.id if team else None, + challenge_id=chall.id, ip=request.remote_addr, provided=provided_flag + ) + db.session.add(solve) + db.session.add(Submissions( + user_id=user.id, team_id=team.id if team else None, + challenge_id=chall.id, ip=request.remote_addr, provided=provided_flag, type='correct' + )) + db.session.commit() + return jsonify({'success': True, 'message': f'Correct: {chall.name}'}) + except Exception: continue + + # Record failed attempt for audit/brute-force detection + db.session.add(Submissions( + user_id=user.id, team_id=team.id if team else None, + challenge_id=None, ip=request.remote_addr, provided=provided_flag, type='incorrect' + )) + db.session.commit() + return jsonify({'success': False, 'message': 'Incorrect Flag'}) + + app.register_blueprint(plugin_bp) + from CTFd.utils.plugins import register_script + register_script('/plugins/category_submission/assets/inject.js') diff --git a/assets/inject.js b/assets/inject.js new file mode 100644 index 0000000..69b586c --- /dev/null +++ b/assets/inject.js @@ -0,0 +1,41 @@ +document.addEventListener('DOMContentLoaded', function() { + if (window.location.pathname !== '/challenges') return; + + fetch('/category_submit/config') + .then(r => r.json()) + .then(config => { + if (!config.categories) return; + + function inject() { + document.querySelectorAll('.category-header').forEach(header => { + const catName = header.textContent.trim(); + if (config.categories.includes(catName) && !header.querySelector('.custom-box')) { + const div = document.createElement('div'); + div.className = "custom-box input-group mt-2 mb-4 p-2 bg-light border rounded"; + div.innerHTML = ` + +
+ `; + header.after(div); + + div.querySelector('button').onclick = function() { + const btn = this; + const val = document.getElementById(`in-${catName}`).value; + btn.disabled = true; + + const body = new URLSearchParams({ submission: val, category: catName, nonce: init.csrfNonce }); + fetch('/category_submit', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body }) + .then(r => r.json()) + .then(data => { + alert(data.message); + if(data.success) location.reload(); + btn.disabled = false; + }); + }; + } + }); + } + // Watch for CTFd's dynamic challenge loading + new MutationObserver(inject).observe(document.body, { childList: true, subtree: true }); + }); +}); \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..3700202 --- /dev/null +++ b/config.json @@ -0,0 +1,4 @@ +{ + "name": "Category Submission", + "route": "/admin/category_submission" +} \ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..498dd8e --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,22 @@ +{% extends "admin/base.html" %} +{% block content %} +
+

Category Submission Settings

+
+
+ {% if success %}
Settings saved!
{% endif %} +
+
+ + + A submission box will appear under these category titles. +
+
+ + +
+ + +
+
+{% endblock %} \ No newline at end of file