diff --git a/.gitignore b/.gitignore index 17eba50..08b4c9b 100755 --- a/.gitignore +++ b/.gitignore @@ -1 +1,92 @@ -ctf.db \ No newline at end of file +ctf.db +config.json + +# Created by https://www.gitignore.io/api/python,virtualenv + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# 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/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask instance folder +instance/ + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + + + +### VirtualEnv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + diff --git a/README.md b/README.md index b2f7eb9..dd7ff7c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,16 @@ tinyctf-platform `tinyctf-platform` is yet another open-source (jeopardy style) CTF platform. It is relatively easy to set up and modify. Hopefully it will become even better over time, with other people contributing. +Features +-------- + +- Public scoreboard +- JSON scoreboard at ```/scoreboard.json``` +- Automatic start/end of CTF based on timestamps +- CSRF protection (can be disabled) +- Username validation (regex ftw) + + ![alt text](http://i.imgur.com/dqGeLNM.jpg "tinyctf-platform in action") Deployment @@ -45,5 +55,4 @@ Start the server Caveats ------- -* CSRF is currently not addressed * The platform does not support tasks with the same score and category right now diff --git a/config.json b/config.json.sample similarity index 56% rename from config.json rename to config.json.sample index 18bafec..c83d1f7 100755 --- a/config.json +++ b/config.json.sample @@ -9,5 +9,11 @@ "language_file": "lang.json", "language": "english", - "debug": false + "username_regex": "^[a-zA-Z0-9-_. &']+$", + + "start":0, + "end":0, + + "enable_csrf_protection": true, + "debug": true } \ No newline at end of file diff --git a/lang.json b/lang.json index 53952cd..035dc4c 100755 --- a/lang.json +++ b/lang.json @@ -44,7 +44,10 @@ "invalid_credentials": "Invalid username or password", "already_registered": "This user is already registered", "empty_user": "Empty username is not allowed", - "task_not_found": "TBD" + "task_not_found": "TBD", + "invalid_user": "Invalid username", + "ctf_not_started": "Wait until the CTF starts", + "ctf_over": "CTF is over" } } } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..28695e7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +alembic==0.8.4 +dataset==0.6.2 +Flask==0.10.1 +itsdangerous==0.24 +Jinja2==2.8 +Mako==1.0.3 +MarkupSafe==0.23 +normality==0.2.4 +python-editor==0.5 +PyYAML==3.11 +six==1.10.0 +SQLAlchemy==1.0.12 +Werkzeug==0.11.4 +wheel==0.29.0 diff --git a/server.py b/server.py index 6c19389..a4caf87 100755 --- a/server.py +++ b/server.py @@ -6,6 +6,9 @@ import json import random import time +import re +import hashlib +import os from base64 import b64decode from functools import wraps @@ -18,12 +21,23 @@ from flask import request from flask import session from flask import url_for +from flask import abort app = Flask(__name__, static_folder='static', static_url_path='') db = None lang = None config = None +username_regex = None +start = None +end = None +csrf_enabled = None + +def is_valid_username(u): + """Ensures that the username matches username_regex""" + if(username_regex.match(u)): + return True + return False def login_required(f): """Ensures that an user is logged in""" @@ -35,6 +49,59 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function +def before_end(f): + """Ensures that actions can only be done before the CTF is over""" + + @wraps(f) + def decorated_function(*args, **kwargs): + cur_time = int(time.time()) + if cur_time >= end: + return redirect(url_for('error', msg='ctf_over')) + return f(*args, **kwargs) + return decorated_function + +def after_start(f): + """Ensures that actions can only be done after the CTF has started""" + + @wraps(f) + def decorated_function(*args, **kwargs): + cur_time = int(time.time()) + if cur_time < start: + return redirect(url_for('error', msg='ctf_not_started')) + return f(*args, **kwargs) + return decorated_function + +@app.context_processor +def inject_ctftime(): + """Injects start and end date variables into templates""" + + st = time.strftime("%Y-%m-%d, %H:%M:%S (%Z)",time.localtime(start)) + ed = time.strftime("%Y-%m-%d, %H:%M:%S (%Z)",time.localtime(end)) + return dict(ctf_start=st, ctf_stop=ed) + +@app.before_request +def csrf_protect(): + """Checks CSRF token before every request unless csrf_enabled is false""" + + if not csrf_enabled: + return + if request.method == "POST": + token = session.pop('_csrf_token', None) + if not token or token != request.form.get('_csrf_token'): + abort(400) + +def generate_random_token(): + """Generates a random CSRF token""" + + return hashlib.sha256(os.urandom(16)).hexdigest() + +def generate_csrf_token(): + """Generates a CSRF token and saves it in the session variable""" + + if '_csrf_token' not in session: + session['_csrf_token'] = generate_random_token() + return session['_csrf_token'] + def get_user(): """Looks up the current user in the database""" @@ -65,7 +132,7 @@ def get_flags(): def error(msg): """Displays an error message""" - if msg in lang: + if msg in lang['error']: message = lang['error'][msg] else: message = lang['error']['unknown'] @@ -101,6 +168,7 @@ def login(): return redirect('/error/invalid_credentials') @app.route('/register') +@before_end def register(): """Displays the register form""" @@ -110,6 +178,7 @@ def register(): return make_response(render) @app.route('/register/submit', methods = ['POST']) +@before_end def register_submit(): """Attempts to register a new user""" @@ -121,6 +190,9 @@ def register_submit(): if not username: return redirect('/error/empty_user') + if not is_valid_username(username): + return redirect('/error/invalid_user') + user_found = db['users'].find_one(username=username) if user_found: return redirect('/error/already_registered') @@ -136,6 +208,7 @@ def register_submit(): @app.route('/tasks') @login_required +@after_start def tasks(): """Displays all the tasks in a grid""" @@ -177,6 +250,7 @@ def tasks(): @app.route('/tasks//') @login_required +@after_start def task(category, score): """Displays a task with a given category and score""" @@ -198,12 +272,15 @@ def task(category, score): user=user, category=category, task=task, score=score) return make_response(render) -@app.route('/submit///') +@app.route('/task/submit', methods = ['POST']) @login_required -def submit(category, score, flag): +@before_end +@after_start +def submit(): """Handles the submission of flags""" - - print "ok" + category = request.form['category'] + score = request.form['score'] + flag = request.form['flag'] login, user = get_user() @@ -211,7 +288,8 @@ def submit(category, score, flag): flags = get_flags() task_done = task['id'] in flags - result = {'success': False} + result = {'success': False, 'csrf': generate_csrf_token() } + if not task_done and task['flag'] == b64decode(flag): timestamp = int(time.time() * 1000) @@ -226,7 +304,6 @@ def submit(category, score, flag): return jsonify(result) @app.route('/scoreboard') -@login_required def scoreboard(): """Displays the scoreboard""" @@ -243,8 +320,20 @@ def scoreboard(): login=login, user=user, scores=scores) return make_response(render) +@app.route("/scoreboard.json") +def scoreboard_json(): + """Displays data for ctftime.org in json format""" + scores = db.query('''select u.username as team, ifnull(sum(f.score), 0) as score, ifnull(max(timestamp), 0) as lastAccept from users + u left join flags f on u.id = f.user_id where u.hidden = 0 group by u.username + order by score desc, lastAccept asc''') + scores = list(scores) + for i, s in enumerate(scores): + s['pos'] = i + 1 + + data = map(dict,scores) + return jsonify({'standings':data}) + @app.route('/about') -@login_required def about(): """Displays the about menu""" @@ -263,6 +352,7 @@ def logout(): del session['user_id'] return redirect('/') + @app.route('/') def index(): """Displays the main page""" @@ -280,6 +370,7 @@ def index(): # Load config config_str = open('config.json', 'rb').read() config = json.loads(config_str) + csrf_enabled = config['enable_csrf_protection'] app.secret_key = config['secret_key'] @@ -293,6 +384,9 @@ def index(): # Connect to database db = dataset.connect(config['db']) + # Compile regex for usernames + username_regex = re.compile(config['username_regex']) + # Setup the flags table at first execution if 'flags' not in db.tables: db.query('''create table flags ( @@ -302,6 +396,11 @@ def index(): timestamp BIGINT, PRIMARY KEY (task_id, user_id))''') + app.jinja_env.globals['csrf_token'] = generate_csrf_token + + start = config['start'] + end = config['end'] + # Start web server app.run(host=config['host'], port=config['port'], debug=config['debug'], threaded=True) diff --git a/static/js/submit.js b/static/js/submit.js index 93c6047..19aaee8 100755 --- a/static/js/submit.js +++ b/static/js/submit.js @@ -1,24 +1,34 @@ $("#flag-submission").click(function() { + submit(); +}); +$(document).keypress(function( event ) { + if(event.which == 13) { + event.preventDefault(); + submit(); + } +}); +function submit() { var cat = $(".task-box").data("category"); var score = $(".task-box").data("score"); var flag = $("#flag-input").val(); - - console.log("/submit/" + cat + "/" + score + "/" + btoa(flag)); + var csrf = $("#_csrf_token").val(); $.ajax({ - url: "/submit/" + cat + "/" + score + "/" + btoa(flag) + url: "/task/submit", + method: "POST", + data: {"category": cat, "score":score, "flag": btoa(flag), "_csrf_token": csrf} }).done(function(data) { - - console.log(data); - - if (data["success"]) { - $("#flag-input").val($(".lang").data("success")); + $("#_csrf_token").val(data['csrf']); + $("#flag-output").fadeIn(900); + if (data["success"]) { + $("#flag-output").html($(".lang").data("success")); $("#flag-submission").removeClass("btn-primary"); $("#flag-submission").addClass("btn-success"); $("#flag-submission").attr('disabled','disabled'); } else { - $("#flag-input").val($(".lang").data("failure")); + $("#flag-output").html($(".lang").data("failure")); } + $("#flag-output").fadeOut(1000); }); -}); \ No newline at end of file +} diff --git a/templates/frame.html b/templates/frame.html index 8b0cc59..7967b73 100755 --- a/templates/frame.html +++ b/templates/frame.html @@ -25,6 +25,7 @@ {% if not login %} @@ -52,6 +54,7 @@ {% include page %}