diff --git a/.gitignore b/.gitignore index 1dc4bb2..bc7c8f4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ static/crackme/* static/solution/* !static/crackme/.gitkeep !static/solution/.gitkeep +# Private source archives for auto-validated crackmes (reviewers only) +private/ config/config.json users.json .env diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 4675007..f8f4665 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -4,14 +4,14 @@ import os from html import escape as html_escape -from flask import Blueprint, render_template, request, redirect, flash, session, abort +from flask import Blueprint, render_template, request, redirect, flash, jsonify, session, abort from werkzeug.utils import secure_filename import bleach from app.models.crackme import ( crackme_by_hexid, last_crackmes, crackme_create_prepare, crackme_insert, crackme_delete_by_hexid, crackme_by_user_and_name, crackme_update_difficulty, crackme_update_quality, crackme_increment_downloads, - crackme_update + crackme_update, crackme_is_auto_validated ) from app.models.solution import solutions_by_crackme from app.models.comment import comments_by_crackme @@ -20,19 +20,30 @@ from app.models.label_request import ( label_request_create, pending_label_requests_by_user_and_crackme ) +from app.models.solve import ( + solve_by_user_and_crackme, solve_create, count_solves_by_crackme +) +from app.models.user import user_by_name from app.models.errors import ErrNoResult from app.services.recaptcha import verify as verify_recaptcha from app.services.limiter import limit -from app.services.view import FLASH_ERROR, FLASH_SUCCESS, validate_required +from app.services.view import FLASH_ERROR, FLASH_SUCCESS, FLASH_NOTICE, validate_required from app.services.labels import get_label_groups, get_dataset_url, normalize_labels from app.services.archive import is_archive_password_protected, is_single_file_archive, is_unsupported_archive from app.services.discord import notify_new_crackme +from app.services.flag import ( + FLAG_FORMAT_HINT, flags_match, is_valid_flag_format, normalize_flag +) +from app.services.points import points_for_solve, solve_difficulty from app.controllers.decorators import login_required crackme_bp = Blueprint('crackme', __name__) # Upload folder for crackmes UPLOAD_FOLDER = 'tmp/crackme' +# Source archives for auto-validated crackmes. Never served: this directory sits +# outside static/ so the only way to read one is the reviewer download route. +SOURCE_UPLOAD_FOLDER = 'private/crackme_source' MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB @@ -57,6 +68,20 @@ def crackme_view(hexid): # Get current user for edit permission check usersess = session.get('name') + # Flag submission panel. Only auto-validated crackmes pay for these extra + # queries; everything else renders exactly as before. + auto_validation = crackme_is_auto_validated(crackme) + nbsolves = 0 + user_solve = None + if auto_validation: + try: + nbsolves = count_solves_by_crackme(hexid) + viewer_hexid = _user_hexid(usersess) if usersess else None + if viewer_hexid: + user_solve = solve_by_user_and_crackme(viewer_hexid, hexid) + except Exception as e: + print(f"Error getting solve data: {e}") + # Build mention targets for @mention autocomplete (author + commenters + solution authors) mention_targets = {crackme.get('author', '')} for comment in comments: @@ -87,9 +112,27 @@ def crackme_view(hexid): labels=crackme.get('labels', []), label_groups=get_label_groups(), labels_dataset_url=get_dataset_url(), + auto_validation=auto_validation, + nbsolves=nbsolves, + user_solve=user_solve, + solve_points=points_for_solve(crackme) if auto_validation else 0, + flag_format_hint=FLAG_FORMAT_HINT, usersess=usersess) +def _user_hexid(username): + """Resolve a username to the immutable id solves are keyed by. + + Returns None when the user can't be resolved, which callers treat as "no + solve" rather than an error. + """ + try: + user = user_by_name(username) + except Exception: + return None + return user.get('hexid') or str(user['_id']) + + @crackme_bp.route('/lasts') def last_crackmes_redirect(): """Redirect /lasts to /lasts/1.""" @@ -144,6 +187,47 @@ def upload_crackme_get(): return render_template('crackme/create.html', label_groups=get_label_groups()) +def _upload_rejected(message): + """Reject an upload without throwing away what the user typed. + + An AJAX submission gets the message as JSON and the browser keeps the page + (and the files the user picked) untouched; a plain form post falls back to + re-rendering the form with the submitted values filled back in. Either way a + single missing field no longer costs someone the whole form. + """ + if _wants_json(): + return jsonify({'ok': False, 'error': message}), 400 + + flash(message, FLASH_ERROR) + return render_template('crackme/create.html', + label_groups=get_label_groups(), + form=_submitted_form_values()) + + +def _wants_json(): + """True when the upload form posted in the background rather than navigating.""" + return request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + +def _submitted_form_values(): + """The submitted values, shaped for re-rendering the upload form. + + File inputs are deliberately absent: browsers won't let a server refill them, + which is exactly why the form posts over fetch when it can. + """ + return { + 'name': request.form.get('name', ''), + 'info': request.form.get('info', ''), + 'lang': request.form.get('lang', ''), + 'arch': request.form.get('arch', ''), + 'platform': request.form.get('platform', ''), + 'difficulty': request.form.get('difficulty', ''), + 'labels': normalize_labels(request.form.getlist('labels')), + 'auto_validation': bool(request.form.get('auto_validation')), + 'flag': request.form.get('flag', ''), + } + + @crackme_bp.route('/upload/crackme', methods=['POST']) @login_required @limit("10 per day", key_func=lambda: session.get('name')) @@ -155,8 +239,7 @@ def upload_crackme_post(): required = ['name', 'info', 'lang', 'difficulty', 'platform', 'arch'] is_valid, missing = validate_required(request.form, required) if not is_valid: - flash(f'Field missing: {missing}', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected(f'Field missing: {missing}') name = bleach.clean(request.form.get('name', '')) info = bleach.clean(request.form.get('info', '')) @@ -174,46 +257,62 @@ def upload_crackme_post(): if diff_int < 1 or diff_int > 6: raise ValueError() except (ValueError, TypeError): - flash('Wrong difficulty', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Wrong difficulty') # Validate reCAPTCHA if not verify_recaptcha(request): - flash('reCAPTCHA invalid!', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('reCAPTCHA invalid!') # Check for file if 'file' not in request.files: - flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Field missing: file') file = request.files['file'] if file.filename == '': - flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Field missing: file') # Read file data file_data = file.read() # Check file size if len(file_data) > MAX_FILE_SIZE: - flash('This file is too large!', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('This file is too large!') # Check for unsupported archive formats (RAR, tar, etc.) if is_unsupported_archive(file_data): - flash('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.') # Check for password protection if is_archive_password_protected(file_data): - flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.') # Check for single-file archives if is_single_file_archive(file_data): - flash('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.') + + # Auto-validation opt-in: the flag users will submit, plus the private source + # archive a reviewer needs to confirm that flag is actually the right one. + flag = None + source_data = None + source_filename = None + if request.form.get('auto_validation'): + flag = normalize_flag(request.form.get('flag', '')) + if not is_valid_flag_format(flag): + return _upload_rejected(f'Invalid flag format. {FLAG_FORMAT_HINT}') + + source = request.files.get('source') + if source is None or source.filename == '': + return _upload_rejected('Auto-validation needs a source archive so reviewers can verify the flag.') + + source_data = source.read() + if len(source_data) > MAX_FILE_SIZE: + return _upload_rejected('The source archive is too large!') + if is_unsupported_archive(source_data): + return _upload_rejected('RAR and tar source archives are not supported. Please upload a ZIP file.') + if is_archive_password_protected(source_data): + return _upload_rejected('Password-protected source archives are not allowed - reviewers need to be able to open it.') + + source_filename = secure_filename(source.filename) or "source" # Store the uploaded file size size = len(file_data) @@ -221,8 +320,7 @@ def upload_crackme_post(): # Check for duplicate pending submission try: crackme_by_user_and_name(username, name, visible=False) - flash('You already have a pending crackme with this name. Please wait for review or choose a different name.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('You already have a pending crackme with this name. Please wait for review or choose a different name.') except ErrNoResult: pass # No duplicate, continue @@ -231,13 +329,16 @@ def upload_crackme_post(): # Prepare crackme try: - crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, labels=labels) + crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, + labels=labels, flag=flag, + source_original_filename=source_filename) except Exception as e: print(f"Error preparing crackme: {e}") abort(500) # Create path using hexid only safe_path = os.path.join(UPLOAD_FOLDER, crackme['hexid']) + source_path = os.path.join(SOURCE_UPLOAD_FOLDER, crackme['hexid']) # Ensure upload directory exists os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -248,15 +349,32 @@ def upload_crackme_post(): f.write(file_data) except Exception as e: print(f"File write error: {e}") - flash('Failed to save file. Please try again.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Failed to save file. Please try again.') + + if source_data is not None: + try: + os.makedirs(SOURCE_UPLOAD_FOLDER, exist_ok=True) + with open(source_path, 'wb') as f: + f.write(source_data) + except Exception as e: + print(f"Source file write error: {e}") + os.remove(safe_path) + return _upload_rejected('Failed to save the source archive. Please try again.') + + def _cleanup_files(): + for path in (safe_path, source_path if source_data is not None else None): + if path: + try: + os.remove(path) + except OSError: + pass # Insert crackme into database try: crackme_insert(crackme) except Exception as e: print(f"Database insert error: {e}") - os.remove(safe_path) # Cleanup + _cleanup_files() # Cleanup abort(500) # Create ratings @@ -265,7 +383,7 @@ def upload_crackme_post(): rating_quality_create(username, crackme['hexid'], 4) except Exception as e: print(f"Rating creation error: {e}") - os.remove(safe_path) + _cleanup_files() crackme_delete_by_hexid(crackme['hexid']) rating_difficulty_delete_by_crackme(crackme['hexid']) abort(500) @@ -289,10 +407,95 @@ def upload_crackme_post(): except Exception as e: print(f"Discord notification error: {e}") + # Post/redirect/get: the confirmation lives at its own URL, so the browser + # (and the background submit above, which just follows the redirect) can't + # re-post the upload by refreshing. + session['submitted_crackme'] = crackme['name'] + if _wants_json(): + return jsonify({'ok': True, 'redirect': '/upload/crackme/submitted'}) + return redirect('/upload/crackme/submitted') + + +@crackme_bp.route('/upload/crackme/submitted', methods=['GET']) +@login_required +def upload_crackme_submitted(): + """Confirm a crackme upload that just went through.""" + name = session.pop('submitted_crackme', None) + if not name: + return redirect('/upload/crackme') + return render_template('submission/success.html', submission_type='Crackme', - name=crackme['name'], - username=username) + name=name, + username=session.get('name')) + + +@crackme_bp.route('/crackme//solve', methods=['POST']) +@login_required +# Guessing a flag is meant to be impossible, but a slow attempt rate makes that +# true even for a badly chosen flag. +@limit("20 per hour", key_func=lambda: session.get('name')) +def submit_flag(hexid): + """Validate a submitted flag and, if correct, record the solve.""" + username = session.get('name') + + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + abort(404) + except Exception as e: + print(f"Error getting crackme: {e}") + abort(500) + + if not crackme_is_auto_validated(crackme): + flash('This crackme does not accept flag submissions.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + # Authors already know their own flag; awarding them points for it would + # make the scoreboard meaningless. + if crackme.get('author') == username: + flash("You can't submit a flag for your own crackme.", FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + user_hexid = _user_hexid(username) + if not user_hexid: + flash('Could not verify your account. Please log in again.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + try: + if solve_by_user_and_crackme(user_hexid, hexid): + flash('You have already solved this crackme.', FLASH_NOTICE) + return redirect(f'/crackme/{hexid}') + except Exception as e: + print(f"Error checking existing solve: {e}") + abort(500) + + flag = normalize_flag(request.form.get('flag', '')) + if not is_valid_flag_format(flag): + flash(f'That is not a valid flag. {FLAG_FORMAT_HINT}', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + if not flags_match(crackme.get('flag'), flag): + flash('Wrong flag. Keep trying!', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + points = points_for_solve(crackme) + try: + solve_create(user_hexid, hexid, points, solve_difficulty(crackme)) + except Exception as e: + print(f"Error recording solve: {e}") + abort(500) + + try: + notification_add( + username, + f"Correct flag for '{html_escape(crackme.get('name', ''))}' - {points} points earned!" + ) + except Exception as e: + print(f"Notification error: {e}") + + flash(f'Correct! You earned {points} points.', FLASH_SUCCESS) + return redirect(f'/crackme/{hexid}') @crackme_bp.route('/crackme//edit', methods=['GET']) diff --git a/app/controllers/user.py b/app/controllers/user.py index 84bb8d7..3c82fd2 100644 --- a/app/controllers/user.py +++ b/app/controllers/user.py @@ -4,9 +4,10 @@ from flask import Blueprint, render_template, session, abort from app.models.user import user_by_name -from app.models.crackme import crackmes_by_user +from app.models.crackme import crackmes_by_user, crackmes_by_hexids from app.models.solution import solutions_by_user from app.models.comment import comments_by_user +from app.models.solve import solves_by_user from app.models.errors import ErrNoResult user_bp = Blueprint('user', __name__) @@ -46,6 +47,22 @@ def user_profile(name): 'crackmename': solution.get('crackmename', '') }) + # Solved crackmes and the score they add up to. Names are looked up in + # one batch rather than stored on the solve, so a renamed or deleted + # crackme can't leave a stale title behind on the profile. + solves = solves_by_user(user.get('hexid') or str(user['_id'])) + solved_crackmes = crackmes_by_hexids([s['crackme_hexid'] for s in solves]) + solves_extended = [ + { + 'solve': solve, + 'crackmehexid': solve['crackme_hexid'], + 'crackmename': solved_crackmes.get(solve['crackme_hexid'], {}) + .get('name', 'Unknown crackme'), + } + for solve in solves + ] + score = sum(solve.get('points', 0) for solve in solves) + # Check if viewing own profile session_username = session.get('name', '') viewing_own_page = session_username and session_username == actual_username @@ -55,9 +72,11 @@ def user_profile(name): NbCrackmes=nb_crackmes, NbSolutions=nb_solutions, NbComments=nb_comments, + Score=score, crackmes=crackmes, solutions=solutions_extended, comments=comments, + solves=solves_extended, viewingOwnPage=viewing_own_page) except Exception as e: diff --git a/app/models/crackme.py b/app/models/crackme.py index 3780db0..ae09d22 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -263,6 +263,31 @@ def crackmes_by_user(username): .sort('created_at', DESCENDING)) +def crackmes_by_hexids(hexids): + """Look up several crackmes at once. + + Args: + hexids: An iterable of crackme hex IDs. + + Returns: + A dict of hexid -> crackme document, holding only the ids that exist and + are visible. Callers listing references to crackmes (a user's solves, + say) use this to resolve names in one query instead of one per row. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + hexids = list(hexids) + if not hexids: + return {} + + collection = get_collection('crackme') + return { + crackme['hexid']: crackme + for crackme in collection.find({'hexid': {'$in': hexids}, 'visible': True}) + } + + def crackme_by_user_and_name(username, name, visible=True): """Get crackme by user and name.""" if not check_connection(): @@ -281,8 +306,18 @@ def crackme_by_user_and_name(username, name, visible=True): return result -def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, labels=None): - """Prepare a crackme object without inserting it.""" +def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, + labels=None, flag=None, source_original_filename=None): + """Prepare a crackme object without inserting it. + + Args: + flag: The author's flag when they opted into auto-validation, else None. + Its presence is what marks a crackme as auto-validated -- there is + no separate flag to keep in sync. Stored in cleartext for reviewers; + never rendered on the public site. + source_original_filename: Filename of the private source archive that + accompanies an auto-validated submission (reviewers only). + """ if not check_connection(): raise ErrUnavailable("Database is unavailable") @@ -307,10 +342,19 @@ def crackme_create_prepare(name, info, username, lang, arch, platform, size, ori 'platform': platform, 'size': size, 'original_filename': original_filename, - 'labels': labels or [] + 'labels': labels or [], + 'flag': flag, + 'source_original_filename': source_original_filename, + # Assigned by a reviewer when approving or editing; see app.services.points. + 'official_difficulty': None, } +def crackme_is_auto_validated(crackme): + """Return True if a crackme accepts flag submissions.""" + return bool(crackme.get('flag')) + + def crackme_insert(crackme): """Insert a prepared crackme into the database.""" if not check_connection(): @@ -408,6 +452,39 @@ def crackme_set_labels(hexid, labels): return old_labels +def crackme_set_official_difficulty(hexid, difficulty): + """Store the difficulty level a reviewer assigned to a crackme. + + This is the number solves are priced at (see :mod:`app.services.points`). + It is deliberately separate from the ``difficulty`` field, which is the + community rating average and keeps moving as people rate the crackme. + + Args: + hexid: The hex ID of the crackme + difficulty: Difficulty level 1-6 + + Returns: + True if the crackme was updated, False if it was not found or the + difficulty was out of range. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + try: + difficulty = int(difficulty) + except (TypeError, ValueError): + return False + + if difficulty < 1 or difficulty > 6: + return False + + result = get_collection('crackme').update_one( + {'hexid': hexid}, + {'$set': {'official_difficulty': difficulty}} + ) + return result.matched_count == 1 + + def crackme_by_hexid_any(hexid): """Get crackme by hex ID regardless of visibility status. diff --git a/app/models/solve.py b/app/models/solve.py new file mode 100644 index 0000000..78e7c81 --- /dev/null +++ b/app/models/solve.py @@ -0,0 +1,103 @@ +"""Solve model - records of users who submitted a crackme's correct flag. + +A solve is the unit the point system is built on: one record per (user, +crackme) pair, carrying the points awarded at the moment it was earned. + +Solves are keyed by the user's immutable hexid rather than their username. +Usernames are display data that can change; a score that silently zeroed out +when someone renamed themselves would be worse than no score at all. +""" + +from datetime import datetime, timezone + +from bson import ObjectId + +from app.models.errors import ErrUnavailable +from app.services.database import get_collection, check_connection + + +def solve_by_user_and_crackme(user_hexid, crackme_hexid): + """Return the user's solve of a crackme, or None if they haven't solved it.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return get_collection('solve').find_one({ + 'user_hexid': user_hexid, + 'crackme_hexid': crackme_hexid, + }) + + +def solve_create(user_hexid, crackme_hexid, points, difficulty): + """Record a solve and return it. + + Args: + user_hexid: The solver's immutable hexid. + crackme_hexid: The solved crackme's hexid. + points: Points awarded, snapshotted so a later change to the scoring + formula doesn't retroactively re-price solves already earned. + difficulty: The difficulty level those points were priced at. + + Returns: + The inserted solve document, or the existing one if the user had + already solved this crackme (double-submits are a no-op, never a + second award). + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('solve') + existing = collection.find_one({ + 'user_hexid': user_hexid, + 'crackme_hexid': crackme_hexid, + }) + if existing: + return existing + + obj_id = ObjectId() + solve = { + '_id': obj_id, + 'hexid': str(obj_id), + 'user_hexid': user_hexid, + 'crackme_hexid': crackme_hexid, + 'created_at': datetime.now(timezone.utc), + 'points': int(points), + 'difficulty': int(difficulty), + } + collection.insert_one(solve) + return solve + + +def solves_by_user(user_hexid): + """Get a user's solves, newest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + solves = list(get_collection('solve').find({'user_hexid': user_hexid})) + solves.sort(key=lambda s: s.get('created_at') or s['_id'].generation_time, + reverse=True) + return solves + + +def user_score(user_hexid): + """Return a user's total score: the sum of the points on their solves. + + Summed from the solve records rather than kept as a counter on the user + document, so the score can never drift out of step with the solves it is + supposed to represent (a deleted crackme takes its points with it). + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + solves = get_collection('solve').find({'user_hexid': user_hexid}, + {'points': 1}) + return sum(solve.get('points', 0) for solve in solves) + + +def count_solves_by_crackme(crackme_hexid): + """Count how many users have solved a crackme.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return get_collection('solve').count_documents( + {'crackme_hexid': crackme_hexid} + ) diff --git a/app/services/flag.py b/app/services/flag.py new file mode 100644 index 0000000..7d9e60d --- /dev/null +++ b/app/services/flag.py @@ -0,0 +1,55 @@ +"""Flag format and comparison for auto-validated crackmes. + +Authors of an auto-validated crackme give us the correct flag once, at upload +time. It is stored in cleartext so reviewers can read it: verifying that a +submission really is solvable, and fixing a mistyped flag afterwards, both need +the actual value, and a hash would leave a wrong flag undetectable until users +started failing on it. + +Cleartext storage means the flag must never leave the reviewer tool: the public +crackme page renders a fixed set of fields and the flag is not among them (see +``crackme_view``), and submissions are only ever compared against it here. +""" + +import hmac +import re + +# Standardised flag format, per issue #127: a CMO prefix and a brace-delimited +# body, so a flag is always a single unambiguous token that authors can embed in +# a binary and users can copy-paste. +FLAG_PREFIX = 'CMO' +FLAG_BODY_MAX = 56 +# Printable ASCII (0x21-0x7e) minus the braces, which keeps the closing brace +# unambiguous. Keeping a flag a single whitespace-free ASCII token means neither +# copy-pasting it out of a terminal nor re-encoding it can silently change it. +FLAG_PATTERN = re.compile( + r'^%s\{[\x21-\x7a\x7c\x7e]{1,%d}\}$' % (FLAG_PREFIX, FLAG_BODY_MAX) +) + +FLAG_FORMAT_HINT = f'Flags look like {FLAG_PREFIX}{{...}}' + + +def normalize_flag(flag): + """Return a submitted flag with surrounding whitespace removed. + + Users copy flags out of terminals, so leading/trailing whitespace is noise + rather than a wrong answer. Inner characters are left untouched -- they are + part of the flag. + """ + return (flag or '').strip() + + +def is_valid_flag_format(flag): + """Return True if the flag matches the standardised CMO{...} format.""" + return bool(FLAG_PATTERN.match(flag or '')) + + +def flags_match(stored_flag, submitted_flag): + """Return True if a submitted flag matches the crackme's stored one. + + Compared in constant time so the response can't be used to recover the flag + character by character. + """ + if not stored_flag or not submitted_flag: + return False + return hmac.compare_digest(stored_flag, submitted_flag) diff --git a/app/services/limiter.py b/app/services/limiter.py index 2f2a340..6e8c448 100644 --- a/app/services/limiter.py +++ b/app/services/limiter.py @@ -15,6 +15,7 @@ | POST /upload/crackme | 10 per day | Username | Prevent submission spam | | POST /upload/solution | 20 per day | Username | Prevent submission spam | | POST /comment | 30 per hour | Username | Prevent comment spam | +| POST /crackme/../solve | 20 per hour | Username | Prevent flag brute-forcing | +-------------------------+----------------+-------------+----------------------------------+ Configuration diff --git a/app/services/points.py b/app/services/points.py new file mode 100644 index 0000000..a700f92 --- /dev/null +++ b/app/services/points.py @@ -0,0 +1,43 @@ +"""Scoring rules for solved crackmes. + +PROVISIONAL: the point system is still being designed (issue #127 lists first +blood on old crackmes, writeup points, author-funded bounties and decay-by-solve- +count as candidates). Only the base "solve an auto-validated crackme" award is +implemented so far, and the numbers here are expected to change. + +Everything about the formula lives in this module so a later change is one edit. +Awards are snapshotted onto the solve record at solve time (see +:mod:`app.models.solve`), so tuning the formula re-prices future solves without +silently rewriting everyone's score history. +""" + +# Points per difficulty level: a level 3 crackme is worth 300. +POINTS_PER_DIFFICULTY = 100 + +MIN_DIFFICULTY = 1 +MAX_DIFFICULTY = 6 + + +def solve_difficulty(crackme): + """Return the difficulty level a solve of ``crackme`` is priced at. + + Prefers the ``official_difficulty`` a reviewer assigned when approving the + crackme -- issue #127 wants that number fixed, immune to the community + difficulty rating drifting after the fact. Crackmes approved before the + reviewer form existed have no official difficulty, so those fall back to the + community rating, rounded and clamped into the 1-6 scale. + """ + official = crackme.get('official_difficulty') + if official: + return _clamp(int(official)) + + return _clamp(round(crackme.get('difficulty') or 0)) + + +def points_for_solve(crackme): + """Return the points awarded for solving ``crackme``.""" + return solve_difficulty(crackme) * POINTS_PER_DIFFICULTY + + +def _clamp(difficulty): + return max(MIN_DIFFICULTY, min(MAX_DIFFICULTY, difficulty)) diff --git a/review/routes.py b/review/routes.py index eab0686..4219b8b 100644 --- a/review/routes.py +++ b/review/routes.py @@ -27,6 +27,7 @@ import requests from rustyzipper import compress_file, EncryptionMethod from bson.objectid import ObjectId +from werkzeug.utils import secure_filename from review.logger import log_reviewer_operation from review.auth import ( @@ -45,6 +46,13 @@ from app.services.crypto import get_obfuscation_salt from app.services.view import is_valid_hexid from app.services.labels import get_label_groups, normalize_labels +from app.services.flag import ( + FLAG_FORMAT_HINT, is_valid_flag_format, normalize_flag +) +from app.services.archive import ( + is_archive_password_protected, is_unsupported_archive +) +from app.models.crackme import crackme_set_official_difficulty from app.models.label_request import ( label_requests_pending, count_pending_label_requests, label_request_by_hexid, label_request_set_status, STATUS_APPROVED, STATUS_REJECTED, @@ -171,6 +179,27 @@ def get_static_dir(item_type): return os.path.join(CRACKMESONE_DIR, 'static', item_type) +def get_source_dir(): + """ + Get the directory holding private source archives. + + Auto-validated crackmes ship with a source archive that only reviewers may + read, so it lives outside static/ and is never linked from the public site. + + Returns: + Absolute path to the private source archive directory + """ + return os.path.join(CRACKMESONE_DIR, 'private', 'crackme_source') + + +def delete_source_archive(hexid): + """Remove a crackme's private source archive, if it has one.""" + try: + os.remove(os.path.join(get_source_dir(), hexid)) + except OSError: + pass + + def find_pending_file(item_type, hexid): """ Find a pending submission file by its hexid. @@ -605,7 +634,14 @@ def get_crackme_details(uuid): "lang": crackme_obj["lang"], "arch": crackme_obj["arch"], "platform": crackme_obj["platform"], - "labels": crackme_obj.get("labels", []) + "labels": crackme_obj.get("labels", []), + # Auto-validation. The flag is reviewer-only data: it reaches this + # template and nowhere else. + "flag": crackme_obj.get("flag"), + "auto_validation": bool(crackme_obj.get("flag")), + "has_source_archive": bool(crackme_obj.get("source_original_filename")), + "difficulty": crackme_obj.get("difficulty", 0), + "official_difficulty": crackme_obj.get("official_difficulty") }, None @@ -641,6 +677,10 @@ def reject_pending_crackme(hexid, reject_reason=None): if os.path.exists(file_path): os.remove(file_path) + # A rejected submission's private source archive has no reason to stay + # on disk. + delete_source_archive(hexid) + # Notify author notif_text = f"Your crackme '{html_escape(crackme['name'])}' has been rejected!" if reject_reason: @@ -933,6 +973,8 @@ def delete_approved_crackme(crackme_uuid): except Exception: pass + delete_source_archive(crackme_uuid) + # Delete crackme document g_crackmesone_db.crackme.delete_one({"_id": ObjectId(crackme_uuid)}) @@ -940,7 +982,8 @@ def delete_approved_crackme(crackme_uuid): f"Cascade deleted: {deleted['solutions']} solutions, " f"{deleted['comments']} comments, " f"{deleted['difficulty_ratings']} difficulty ratings, " - f"{deleted['quality_ratings']} quality ratings\n" + f"{deleted['quality_ratings']} quality ratings, " + f"{deleted['solves']} solves\n" "Crackme deleted" ) @@ -960,7 +1003,8 @@ def _cascade_delete_crackme_data(crackme_id, crackme_hexid): 'solutions': 0, 'comments': 0, 'difficulty_ratings': 0, - 'quality_ratings': 0 + 'quality_ratings': 0, + 'solves': 0 } # Delete solutions @@ -988,6 +1032,13 @@ def _cascade_delete_crackme_data(crackme_id, crackme_hexid): }) deleted['quality_ratings'] = result.deleted_count + # Solve records, and with them the points their solvers earned: the crackme + # they were awarded for no longer exists to back them up. + result = g_crackmesone_db.solve.delete_many({ + 'crackme_hexid': crackme_hexid + }) + deleted['solves'] = result.deleted_count + return deleted @@ -1057,6 +1108,7 @@ def preview_user_deletion(user_email): 'email': user_email, 'notifications': 0, 'solutions': 0, + 'solves': 0, 'crackmes': 0, 'crackme_details': [], 'user_comments': 0, @@ -1075,6 +1127,9 @@ def preview_user_deletion(user_email): preview['solutions'] = db.solution.count_documents({ "author": username }) + preview['solves'] = db.solve.count_documents({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) # Count data for each crackme for crackme in db.crackme.find({"author": username}): @@ -1087,6 +1142,7 @@ def preview_user_deletion(user_email): 'hexid': hexid, 'solutions': db.solution.count_documents({"crackmeid": cid}), 'comments': db.comment.count_documents({"crackmehexid": hexid}), + 'solves': db.solve.count_documents({"crackme_hexid": hexid}), 'difficulty_ratings': db.rating_difficulty.count_documents({ "crackmehexid": hexid }), @@ -1164,7 +1220,13 @@ def delete_user_account(user_email, admin_username=None): result = db.notifications.delete_many({"user": username}) deletion_log.append(f"Deleted {result.deleted_count} notifications") - # 2. Delete user's solutions + # 2. Delete the user's solve records (their score goes with the account) + result = db.solve.delete_many({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) + deletion_log.append(f"Deleted {result.deleted_count} solves by user") + + # 2b. Delete user's solutions solution_count = 0 for solution in db.solution.find({"author": username}): delete_approved_solution(str(solution["_id"])) @@ -1470,40 +1532,46 @@ def viewcrackme(current_user): user=current_user['username'], is_admin=current_user['is_admin'], crackme=crackme, - label_groups=get_label_groups() + label_groups=get_label_groups(), + message=request.args.get('message') ) @reviewer_bp.route('/downloadreview') @token_required def downloadreview(current_user): - """Download a pending submission file for review.""" + """Download a pending submission file, or a crackme's private source, for review.""" download_type = request.args.get("type") uuid = request.args.get("uuid") - if download_type not in ('solution', 'crackme'): + if download_type not in ('solution', 'crackme', 'source'): abort(404) if not is_valid_hexid(uuid): abort(404) uuid = uuid.lower() - tmp_dir = get_tmp_dir(download_type) - file_path = os.path.join(tmp_dir, uuid) + if download_type == 'source': + # Source archives stay reviewer-only for the crackme's whole life, so + # they live in their own private directory rather than tmp/. + file_path = os.path.join(get_source_dir(), uuid) + else: + file_path = os.path.join(get_tmp_dir(download_type), uuid) if not os.path.exists(file_path): abort(404) # Get original filename from database - if download_type == 'crackme': - doc = g_crackmesone_db.crackme.find_one({'hexid': uuid}) - else: + if download_type == 'solution': doc = g_crackmesone_db.solution.find_one({'hexid': uuid}) + else: + doc = g_crackmesone_db.crackme.find_one({'hexid': uuid}) if not doc: print(f"Warning: Orphaned {download_type} file {uuid} exists on disk but not in database") - original_filename = (doc.get('original_filename') if doc else None) or uuid + filename_field = 'source_original_filename' if download_type == 'source' else 'original_filename' + original_filename = (doc.get(filename_field) if doc else None) or uuid return send_file( file_path, @@ -1657,6 +1725,23 @@ def approvecrackme(current_user): message="Crackme file not found" )) + # The official difficulty is what solves of this crackme are worth. It is + # set here, at approval, because issue #127 wants it fixed from then on. + official_difficulty = request.form.get('official_difficulty') + if official_difficulty: + if crackme_set_official_difficulty(crackme_file, official_difficulty): + log_reviewer_operation( + "set_official_difficulty", current_user['username'], + {"crackme_uuid": crackme_uuid, "official_difficulty": official_difficulty}, + True + ) + else: + return redirect(url_for( + 'reviewer.viewcrackme', + crackme_uuid=crackme_uuid, + message="Invalid official difficulty" + )) + success, message = approve_pending_crackme(crackme_file) log_reviewer_operation( @@ -2058,9 +2143,13 @@ def deletecrackme(current_user): @reviewer_bp.route('/editcrackme', methods=['GET', 'POST']) @admin_required def editcrackme(current_user): - """Edit an approved crackme (admin only). + """Edit every field of a crackme, approved or still pending (admin only). - Allows editing crackme metadata and optionally replacing the file. + This is the one place a crackme can be corrected after the fact: metadata, + labels, the binary, and everything auto-validation depends on -- the flag, + the private source archive and the official difficulty that fixes what a + solve is worth. Difficulty in particular used to be settable only while + approving, which left a typo unfixable once the crackme was live. """ message = None error = None @@ -2068,118 +2157,14 @@ def editcrackme(current_user): crackme_uuid = request.args.get('crackme_uuid') or request.form.get('crackme_uuid') - if request.method == 'POST' and crackme_uuid: + if request.method == 'POST' and crackme_uuid and ObjectId.is_valid(crackme_uuid): validate_csrf_token() + crackme_obj = g_crackmesone_db.crackme.find_one({"_id": ObjectId(crackme_uuid)}) - # Get form data (name is not editable) - info = request.form.get('info', '').strip() - lang = request.form.get('lang', '') - arch = request.form.get('arch', '') - platform = request.form.get('platform', '') - labels = normalize_labels(request.form.getlist('labels')) - notify_author = request.form.get('notify_author') == 'on' - - if True: - # Get current crackme - crackme_obj = g_crackmesone_db.crackme.find_one({ - "_id": ObjectId(crackme_uuid) - }) - - if not crackme_obj: - error = "Crackme not found" - else: - # Track changes (name is not editable) - changes = [] - if crackme_obj.get('info') != info: - changes.append("description updated") - if crackme_obj.get('lang') != lang: - changes.append(f"language: '{crackme_obj.get('lang')}' -> '{lang}'") - if crackme_obj.get('arch') != arch: - changes.append(f"arch: '{crackme_obj.get('arch')}' -> '{arch}'") - if crackme_obj.get('platform') != platform: - changes.append(f"platform: '{crackme_obj.get('platform')}' -> '{platform}'") - if sorted(crackme_obj.get('labels', [])) != sorted(labels): - changes.append(f"labels: {crackme_obj.get('labels', [])} -> {labels}") - - # Update the crackme (name excluded) - g_crackmesone_db.crackme.update_one( - {"_id": ObjectId(crackme_uuid)}, - {"$set": { - "info": info, - "lang": lang, - "arch": arch, - "platform": platform, - "labels": labels - }} - ) - - # Handle file replacement - file_replaced = False - if 'file' in request.files: - file = request.files['file'] - if file.filename: - file_data = file.read() - if len(file_data) > 0: - # Save to temp location - temp_path = os.path.join( - CRACKMESONE_DIR, 'tmp', - f"replace_{crackme_uuid}_{file.filename}" - ) - os.makedirs(os.path.dirname(temp_path), exist_ok=True) - - with open(temp_path, 'wb') as f: - f.write(file_data) - - # Create password-protected zip - dest_path = os.path.join( - get_static_dir('crackme'), - crackme_obj['hexid'] - ) - - # Remove old zip first - old_zip = dest_path + ".zip" - if os.path.exists(old_zip): - os.remove(old_zip) - - success, zip_error = create_password_protected_zip( - temp_path, dest_path, file.filename - ) - - if success: - file_replaced = True - changes.append("file replaced") - else: - error = f"Failed to replace file: {zip_error}" - - if changes: - # Log the operation - log_reviewer_operation( - "edit_crackme_admin", current_user['username'], - { - "crackme_uuid": crackme_uuid, - "crackme_name": crackme_obj.get('name'), - "changes": changes - }, - True - ) - - # Notify author if requested - if notify_author and not error: - try: - change_summary = ", ".join(changes[:3]) - if len(changes) > 3: - change_summary += f" and {len(changes) - 3} more" - send_user_notification( - crackme_obj['author'], - f"Your crackme '{html_escape(crackme_obj.get('name'))}' has been updated by an admin: {html_escape(change_summary)}" - ) - except Exception as e: - print(f"Notification error: {e}") - - if not error: - message = f"Crackme '{crackme_obj.get('name')}' updated successfully" - else: - message = "No changes were made" + if not crackme_obj: + error = "Crackme not found" + else: + error, message = _apply_crackme_edit(current_user, crackme_obj, request) # Load crackme for display if crackme_uuid and ObjectId.is_valid(crackme_uuid): @@ -2195,9 +2180,15 @@ def editcrackme(current_user): "arch": crackme_obj.get('arch', ''), "platform": crackme_obj.get('platform', ''), "author": crackme_obj.get('author', ''), - "labels": crackme_obj.get('labels', []) + "labels": crackme_obj.get('labels', []), + "visible": crackme_obj.get('visible', False), + "difficulty": crackme_obj.get('difficulty', 0), + "official_difficulty": crackme_obj.get('official_difficulty'), + # Reviewer-only fields; no public view renders either of these. + "flag": crackme_obj.get('flag') or '', + "source_original_filename": crackme_obj.get('source_original_filename') or '' } - else: + elif not error: error = "Crackme not found" return render_template( @@ -2207,10 +2198,186 @@ def editcrackme(current_user): crackme=crackme, message=message, error=error, + flag_format_hint=FLAG_FORMAT_HINT, label_groups=get_label_groups() ) +def _apply_crackme_edit(current_user, crackme_obj, request): + """Apply a submitted crackme edit. + + Returns: + Tuple of (error, message), either of which may be None. + """ + crackme_uuid = crackme_obj['hexid'] + + # Metadata (the crackme's name is not edited here) + updates = { + 'info': request.form.get('info', '').strip(), + 'lang': request.form.get('lang', ''), + 'arch': request.form.get('arch', ''), + 'platform': request.form.get('platform', ''), + 'labels': normalize_labels(request.form.getlist('labels')), + } + notify_author = request.form.get('notify_author') == 'on' + + # Official difficulty: what a solve of this crackme is worth. An empty + # selection clears it, dropping the crackme back to its community rating. + official_difficulty = request.form.get('official_difficulty', '').strip() + if official_difficulty: + try: + official_difficulty = int(official_difficulty) + except ValueError: + return "Invalid official difficulty", None + if not 1 <= official_difficulty <= 6: + return "Invalid official difficulty", None + updates['official_difficulty'] = official_difficulty + else: + updates['official_difficulty'] = None + + # Flag. Clearing it turns auto-validation off; existing solves and the + # points they carry are left alone, since they were earned fairly. + if request.form.get('remove_flag') == 'on': + updates['flag'] = None + else: + flag = normalize_flag(request.form.get('flag', '')) + if flag and not is_valid_flag_format(flag): + return f"Invalid flag format. {FLAG_FORMAT_HINT}", None + updates['flag'] = flag or None + + # Replacement source archive (reviewer-only, never published) + source_data = None + source_file = request.files.get('source_file') + if source_file and source_file.filename: + source_data = source_file.read() + if is_unsupported_archive(source_data): + return "Source archive must be a ZIP (RAR/tar are not supported)", None + if is_archive_password_protected(source_data): + return "Source archive must not be password-protected", None + updates['source_original_filename'] = secure_filename(source_file.filename) or "source" + + changes = _describe_crackme_changes(crackme_obj, updates) + if source_data is not None: + changes.append("source archive replaced") + + g_crackmesone_db.crackme.update_one( + {"_id": crackme_obj['_id']}, {"$set": updates} + ) + + if source_data is not None: + try: + os.makedirs(get_source_dir(), exist_ok=True) + with open(os.path.join(get_source_dir(), crackme_uuid), 'wb') as f: + f.write(source_data) + except OSError as e: + return f"Failed to save source archive: {e}", None + + error = None + replaced, replace_error = _replace_crackme_file(crackme_obj, request) + if replaced: + changes.append("file replaced") + if replace_error: + error = replace_error + + if not changes: + return error, "No changes were made" + + log_reviewer_operation( + "edit_crackme_admin", current_user['username'], + { + "crackme_uuid": crackme_uuid, + "crackme_name": crackme_obj.get('name'), + "changes": changes + }, + True + ) + + if notify_author and not error: + try: + change_summary = ", ".join(changes[:3]) + if len(changes) > 3: + change_summary += f" and {len(changes) - 3} more" + send_user_notification( + crackme_obj['author'], + f"Your crackme '{html_escape(crackme_obj.get('name'))}' has been updated by an admin: {html_escape(change_summary)}" + ) + except Exception as e: + print(f"Notification error: {e}") + + if error: + return error, None + return None, f"Crackme '{crackme_obj.get('name')}' updated successfully" + + +def _describe_crackme_changes(crackme_obj, updates): + """Summarise an edit for the operation log and the author's notification. + + The flag is reported as changed but never quoted: the log is mirrored to a + Discord channel, which is one more place a live flag doesn't need to be. + """ + labels = ('description', 'language', 'arch', 'platform', 'labels', + 'official difficulty') + fields = ('info', 'lang', 'arch', 'platform', 'labels', 'official_difficulty') + + changes = [] + for label, field in zip(labels, fields): + old, new = crackme_obj.get(field), updates[field] + if field == 'labels': + if sorted(old or []) != sorted(new): + changes.append(f"labels: {old or []} -> {new}") + elif old != new: + if field == 'info': + changes.append("description updated") + else: + changes.append(f"{label}: '{old}' -> '{new}'") + + old_flag, new_flag = crackme_obj.get('flag'), updates['flag'] + if old_flag != new_flag: + if not new_flag: + changes.append("flag removed (auto-validation off)") + elif not old_flag: + changes.append("flag set (auto-validation on)") + else: + changes.append("flag changed") + + return changes + + +def _replace_crackme_file(crackme_obj, request): + """Replace the downloadable crackme archive, if a new file was uploaded. + + Returns: + Tuple of (replaced: bool, error: str or None). + """ + file = request.files.get('file') + if not file or not file.filename: + return False, None + + file_data = file.read() + if not file_data: + return False, None + + temp_path = os.path.join( + CRACKMESONE_DIR, 'tmp', + f"replace_{crackme_obj['hexid']}_{file.filename}" + ) + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + with open(temp_path, 'wb') as f: + f.write(file_data) + + dest_path = os.path.join(get_static_dir('crackme'), crackme_obj['hexid']) + old_zip = dest_path + ".zip" + if os.path.exists(old_zip): + os.remove(old_zip) + + success, zip_error = create_password_protected_zip( + temp_path, dest_path, file.filename + ) + if not success: + return False, f"Failed to replace file: {zip_error}" + return True, None + + @reviewer_bp.route('/delcomment', methods=['GET', 'POST']) @admin_required def delcomment(current_user): diff --git a/review/templates/reviewer/_deletion_preview.html b/review/templates/reviewer/_deletion_preview.html index 8e4e67c..0036e1b 100644 --- a/review/templates/reviewer/_deletion_preview.html +++ b/review/templates/reviewer/_deletion_preview.html @@ -59,6 +59,10 @@ {{ preview.solutions }} Solutions posted by user +
+ {{ preview.solves }} Solves by user (the points they earned go too) +
+
{{ preview.user_comments }} Comments by user on other crackmes
@@ -82,6 +86,7 @@
-> {{ crackme.solutions }} solutions will be cascade deleted
-> {{ crackme.comments }} comments will be cascade deleted
+ -> {{ crackme.solves }} solves by other users will be cascade deleted (they lose those points)
-> {{ crackme.difficulty_ratings }} difficulty ratings will be cascade deleted
-> {{ crackme.quality_ratings }} quality ratings will be cascade deleted
@@ -99,6 +104,7 @@
  • {{ preview.solutions + preview.total_solutions_on_user_crackmes }} solutions ({{ preview.solutions }} by user + {{ preview.total_solutions_on_user_crackmes }} on user's crackmes)
  • {{ preview.total_comments }} comments ({{ preview.user_comments }} by user + {{ preview.total_comments - preview.user_comments }} on user's crackmes)
  • {{ preview.crackmes }} crackmes
  • +
  • {{ preview.solves }} solves by user
  • Difficulty/quality ratings will be recalculated for affected crackmes
  • diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 0919b9e..08d57d9 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -33,7 +33,12 @@

    Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}

    {% if crackme %}

    Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

    + {% if crackme.visible %}

    View on site: {{ crackme.name }}

    + {% else %} +

    Still pending review — not visible on the site yet. + Review it.

    + {% endif %}
    @@ -46,51 +51,50 @@

    Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

    + {# Option lists come from app/services/crackme_fields.py (injected app-wide), + so the reviewer form can't drift from the upload form. #}
    +
    + + +

    + Fixes what a solve of this crackme is worth (difficulty × 100 points), independently of + the community rating, which keeps moving as people rate it (currently + {{ "%.1f"|format(crackme.difficulty or 0) }}). Already-earned points are not re-priced. +

    +
    +
    @@ -105,6 +109,35 @@

    Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

    +
    + + +

    + The flag solvers submit for points. Leave empty (or tick below) and this crackme accepts no + flag submissions at all. Setting one on a crackme that had none turns auto-validation on. + Existing solves keep the points they earned either way. +

    + +
    + +
    + + {% if crackme.source_original_filename %} +

    + Current: {{ crackme.source_original_filename }} + — reviewers only, never published. +

    + {% else %} +

    No source archive on file.

    + {% endif %} + +

    Upload a zip to replace it. Leave empty to keep the current one.

    +
    +
    diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 02e047a..3e00678 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -27,6 +27,9 @@

    Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}

    "{{ crackme.name }}" by {{ crackme.author }}

    + {% if message %} +
    {{ message }}
    + {% endif %}

    @@ -48,6 +51,36 @@

    Download
    +

    +

    Auto-validation

    +

    + {% if crackme.auto_validation %} +

    + The author opted in: solvers will be able to submit a flag on this crackme and earn points for it. +

    + {% if crackme.has_source_archive %} +

    + Download source archive + — reviewers only, never published. +

    + {% else %} +

    No source archive was uploaded. Reject unless you can verify the flag another way.

    + {% endif %} +

    + Author's flag: {{ crackme.flag }} +

    +

    + Build the crackme from its source and check that this really is the flag it prints. If it isn't, the + crackme is unsolvable for points — + {% if is_admin %}fix the flag on the + edit page or + reject it.{% else %}reject it, or ask an admin to fix the flag on the edit page.{% endif %} +

    + {% else %} +

    The author did not opt into auto-validation. This crackme accepts no flag submissions and awards no points.

    + {% endif %} +
    +

    Labels

    @@ -115,6 +148,22 @@

    + +
    + + +

    + What a solve of this crackme is worth (difficulty × 100 points), fixed from now on. + The author suggested {{ "%.0f"|format(crackme.difficulty or 0) }}. + {% if not crackme.auto_validation %}Only matters if the crackme ever starts awarding points.{% endif %} +

    +
    +

    diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 9bfd1c3..f3c2108 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -23,6 +23,11 @@

    Quick Rules

    Read the full crackme submission rules for detailed guidelines.

    + {# ``form`` carries back what the user typed when a submission is rejected, so a + missing field never costs them the rest of the form. Defaults double as the + first-visit state. #} + {% set form = form|default({}) %} +
    @@ -30,7 +35,7 @@

    Quick Rules

    - +
    @@ -41,7 +46,7 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'difficulty' %} {% set choice_options = DIFFICULTY_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.difficulty|default('') %} {% include 'partial/choice_inputs.html' %}
    @@ -53,7 +58,7 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'lang' %} {% set choice_options = LANG_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.lang|default('') %} {% include 'partial/choice_inputs.html' %}
    @@ -65,7 +70,7 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'arch' %} {% set choice_options = ARCH_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.arch|default('') %} {% include 'partial/choice_inputs.html' %} @@ -77,10 +82,51 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'platform' %} {% set choice_options = PLATFORM_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.platform|default('') %} {% include 'partial/choice_inputs.html' %} +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + + +
    +
    @@ -94,37 +140,92 @@

    Quick Rules

    {% set label_input_name = 'labels' %} - {% set checked_labels = [] %} + {% set checked_labels = form.labels|default([]) %} {% include 'partial/labels_checkboxes.html' %}
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    {% if RECAPTCHA_SITEKEY %}




    {% endif %} - + + {% include 'partial/footer.html' %} {% endblock %} diff --git a/templates/crackme/read.html b/templates/crackme/read.html index a976217..b1f8ee5 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -197,6 +197,35 @@

    {{ username }}'s {{ name }}

    + {% if auto_validation %} +
    +

    Flag

    +

    + This crackme is auto-validated: submit the flag it prints when you beat it and you'll earn + {{ solve_points }} points. Solved by {{ nbsolves }} {{ 'person' if nbsolves == 1 else 'people' }} so far. + Scoring is new and still being tuned, so what a solve is worth may change. +

    + {% if user_solve %} +

    + Solved! You cracked this on {{ user_solve.created_at|PRETTYTIME }} for {{ user_solve.points }} points. +

    + {% elif usersess == username %} +

    This is your crackme — you can't submit its flag.

    + {% elif AuthLevel == "auth" %} +
    + +
    + + +
    +
    + {% else %} +

    Log in to submit the flag.

    + {% endif %} +
    +
    + {% endif %} +

    Labels diff --git a/templates/faq/faq.html b/templates/faq/faq.html index 515e740..5c20c38 100644 --- a/templates/faq/faq.html +++ b/templates/faq/faq.html @@ -100,6 +100,24 @@

    Some crackmes do not have any information. #

    Indeed, but in most cases there is a README or instructions file within the archive.

    +

    How do points work? #

    +

    Some crackmes are auto-validated: their author gave us the flag the crackme prints when you beat it. + On those, a "Flag" section appears on the crackme page — submit the flag you found and, if it's + correct, the solve is recorded on your profile and you earn difficulty × 100 points. Flags look + like CMO{...}. You can't earn points on your own crackmes, and each crackme can only be + solved once per account.

    +

    Your score is shown on your profile, along with everything you've solved.

    +

    This is new and still being designed. The scoring rules will change as more of the system lands + (first blood on older crackmes, points for writeups, author-funded bounties), so treat today's numbers as + provisional.

    +
    +

    How do I add a flag to my own crackme? #

    +

    Tick Auto-validation when you upload it, give us the flag, and attach a zip with your source code and + build scripts. That archive is for reviewers only — it is never published or downloadable — and + it's how a reviewer confirms your flag is really the right one before your crackme goes live. Reviewers can + read your flag; nobody else can, and it is never rendered anywhere on the site. If it turns out to be wrong, + a reviewer can correct it.

    +

    How do I submit a writeup? #

    First, you must login or register for an account. Then, navigate to the crackme page and upload your writeup there.

    diff --git a/templates/rules/crackmerules.html b/templates/rules/crackmerules.html index 2c30448..6879512 100644 --- a/templates/rules/crackmerules.html +++ b/templates/rules/crackmerules.html @@ -67,6 +67,21 @@

    Submission Details

  • Platform: Windows, Linux, macOS, etc.
  • +

    Auto-validation (optional)

    +

    Tick Auto-validation if you want solvers to prove they beat your crackme and earn points for it. Two + things are needed:

    +
      +
    • The flag your crackme prints when it is solved, in the format CMO{...} — no + spaces or braces inside. Reviewers can read it, because they need it to confirm your crackme is + solvable; it is never shown anywhere on the site.
    • +
    • A source archive (zip) with your source code, build scripts and anything else needed to rebuild + the crackme. It is visible to reviewers only, never published or downloadable, and it is how a + reviewer confirms the flag you gave is really the right one.
    • +
    +

    A reviewer assigns the crackme an official difficulty when approving it, and that fixes what a solve of it is + worth. Both it and the flag can be corrected later by a reviewer. Scoring is new and the exact numbers may + still change.

    +

    Difficulty Rating Guide

    • 1 - Very Easy: Plaintext strings, no obfuscation, simple comparisons
    • diff --git a/templates/user/read.html b/templates/user/read.html index ebb31cd..c23f11e 100644 --- a/templates/user/read.html +++ b/templates/user/read.html @@ -4,10 +4,13 @@ {% block head %}{% endblock %} {% block content %}