From 82751b6186d7f368c941511d5a64a9afa8d98553 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sun, 2 Aug 2026 18:34:10 -0400 Subject: [PATCH 1/3] Award points for flag submissions on auto-validated crackmes (#127) Starts the point system from issue #127 with the parts everything else depends on: a flag that can be submitted, a solve that gets recorded, and a score to show for it. Authors opt in when uploading. Opting in requires the flag their crackme prints when beaten, plus a private zip of the source and build scripts. The flag is stored as a bcrypt hash and never in cleartext - a leak of every crackme's flag would quietly retire the whole system - so nobody, author or reviewer, can read one back out. That is what the source archive is for: a reviewer rebuilds the crackme, solves it, and tests the flag they derive against the hash from the review page. A crackme whose flag doesn't match is unsolvable for points and should be rejected. The archive lives outside static/ and is only reachable through the reviewer download route. Reviewers assign an official difficulty when approving, which fixes what a solve of that crackme is worth (difficulty x 100) independently of the community rating, which keeps drifting. Crackmes approved before this existed fall back to their rounded community rating. Solves are keyed by the user's immutable id rather than their username, so a rename can't zero out a score, and the score is summed from the solve records rather than counted on the user document, so it can't drift out of step with them. Points are snapshotted onto each solve at the moment it is earned: the scoring rules are explicitly provisional (issue #127 still has first blood on old crackmes, writeup points, bounties and decay to settle), and re-pricing future solves shouldn't silently rewrite everyone's history. Deleting a crackme takes its solves and source archive with it, and deleting a user takes their solves; both show up in the deletion preview. Follow-ups from the issue, deliberately not here: first blood on old crackmes, writeup points, bounties, decay, and six-month retirement. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 + app/controllers/crackme.py | 173 ++++++- app/controllers/user.py | 21 +- app/models/crackme.py | 82 +++- app/models/solve.py | 103 +++++ app/services/flag.py | 68 +++ app/services/limiter.py | 1 + app/services/points.py | 43 ++ review/routes.py | 148 +++++- .../templates/reviewer/_deletion_preview.html | 6 + review/templates/reviewer/viewcrackme.html | 52 +++ templates/crackme/create.html | 37 ++ templates/crackme/read.html | 29 ++ templates/faq/faq.html | 17 + templates/rules/crackmerules.html | 14 + templates/user/read.html | 64 ++- tests/test_solves.py | 422 ++++++++++++++++++ 17 files changed, 1249 insertions(+), 33 deletions(-) create mode 100644 app/models/solve.py create mode 100644 app/services/flag.py create mode 100644 app/services/points.py create mode 100644 tests/test_solves.py 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..c8995ee 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -11,7 +11,7 @@ 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, hash_flag, is_valid_flag_format, normalize_flag, verify_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.""" @@ -215,6 +258,36 @@ def upload_crackme_post(): 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()) + # 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_hash = 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): + flash(f'Invalid flag format. {FLAG_FORMAT_HINT}', FLASH_ERROR) + return render_template('crackme/create.html', label_groups=get_label_groups()) + + source = request.files.get('source') + if source is None or source.filename == '': + flash('Auto-validation needs a source archive so reviewers can verify the flag.', FLASH_ERROR) + return render_template('crackme/create.html', label_groups=get_label_groups()) + + source_data = source.read() + if len(source_data) > MAX_FILE_SIZE: + flash('The source archive is too large!', FLASH_ERROR) + return render_template('crackme/create.html', label_groups=get_label_groups()) + if is_unsupported_archive(source_data): + flash('RAR and tar source archives are not supported. Please upload a ZIP file.', FLASH_ERROR) + return render_template('crackme/create.html', label_groups=get_label_groups()) + if is_archive_password_protected(source_data): + flash('Password-protected source archives are not allowed - reviewers need to be able to open it.', FLASH_ERROR) + return render_template('crackme/create.html', label_groups=get_label_groups()) + + flag_hash = hash_flag(flag) + source_filename = secure_filename(source.filename) or "source" + # Store the uploaded file size size = len(file_data) @@ -231,13 +304,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_hash=flag_hash, + 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) @@ -251,12 +327,31 @@ def upload_crackme_post(): flash('Failed to save file. Please try again.', FLASH_ERROR) return render_template('crackme/create.html', label_groups=get_label_groups()) + 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) + flash('Failed to save the source archive. Please try again.', FLASH_ERROR) + return render_template('crackme/create.html', label_groups=get_label_groups()) + + 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 +360,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) @@ -295,6 +390,74 @@ def upload_crackme_post(): username=username) +@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 verify_flag(crackme.get('flag_hash'), 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']) @login_required def edit_crackme_get(hexid): 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..80700c6 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,17 @@ 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_hash=None, source_original_filename=None): + """Prepare a crackme object without inserting it. + + Args: + flag_hash: bcrypt hash of 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. + 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 +341,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_hash': flag_hash, + 'source_original_filename': source_original_filename, + # Assigned by a reviewer at approval time; 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_hash')) + + def crackme_insert(crackme): """Insert a prepared crackme into the database.""" if not check_connection(): @@ -408,6 +451,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..c17e482 --- /dev/null +++ b/app/services/flag.py @@ -0,0 +1,68 @@ +"""Flag format and verification for auto-validated crackmes. + +Authors of an auto-validated crackme submit the correct flag once, at upload +time. It is stored as a bcrypt hash and never in cleartext: the site only ever +needs to answer "does this submission match?", and a database leak of every +crackme's flag would quietly retire the whole point system. + +That means nobody -- author, reviewer or admin -- can read a flag back out of +the site. Reviewers verify a submission by building it from the private source +archive and testing the flag they derive against the hash (see the check-flag +tool on the review page); if the author fat-fingered the flag, the test fails +and the crackme gets rejected rather than shipping unsolvable. +""" + +import re + +from app.services.passhash import hash_string, match_string + +# Standardised flag format, per issue #127: a CM1 prefix and a brace-delimited +# body. The body is printable ASCII without braces, so a flag is always a single +# unambiguous token that authors can embed in a binary and users can copy-paste. +FLAG_PREFIX = 'CM1' +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 -- +# and it bounds a flag's byte length, which matters below. +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}{{...}}' + +# bcrypt silently truncates at 72 bytes, which would make two flags sharing a +# long prefix interchangeable. FLAG_BODY_MAX keeps every valid flag well under +# that, so the truncation can never be reached. +assert len(FLAG_PREFIX) + 2 + FLAG_BODY_MAX < 72 + + +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 CM1{...} format.""" + return bool(FLAG_PATTERN.match(flag or '')) + + +def hash_flag(flag): + """Hash a flag for storage. The cleartext is never persisted.""" + return hash_string(flag) + + +def verify_flag(flag_hash, flag): + """Return True if ``flag`` matches the stored hash. + + Comparison happens inside bcrypt, so it is constant-time with respect to the + hash contents. + """ + if not flag_hash or not flag: + return False + return match_string(flag_hash, 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..870fa6c 100644 --- a/review/routes.py +++ b/review/routes.py @@ -45,6 +45,10 @@ 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, verify_flag +) +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 +175,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 +630,13 @@ 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 itself is only stored hashed, so the review + # page offers a "does this flag match?" test instead of showing it. + "auto_validation": bool(crackme_obj.get("flag_hash")), + "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 +672,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 +968,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 +977,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 +998,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 +1027,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 +1103,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 +1122,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 +1137,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 +1215,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 +1527,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 +1720,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( @@ -1677,6 +1757,48 @@ def approvecrackme(current_user): return redirect(url_for('reviewer.reviewcrackme', message=message)) +@reviewer_bp.route('/checkflag', methods=['POST']) +@token_required +def checkflag(current_user): + """Test a flag against an auto-validated crackme's stored hash. + + Flags are only ever stored hashed, so this is how a reviewer confirms the + author submitted the right one: build the crackme from its private source + archive, solve it, and check the flag you get back here. A crackme whose + flag doesn't match is unsolvable for points and should be rejected. + """ + validate_csrf_token() + crackme_uuid = request.form.get('uuid') + + if not is_valid_hexid(crackme_uuid): + return redirect(url_for('reviewer.reviewcrackme', message="Invalid crackme id")) + + crackme = g_crackmesone_db.crackme.find_one({'hexid': crackme_uuid.lower()}) + if not crackme: + return redirect(url_for('reviewer.reviewcrackme', message="Crackme not found")) + + flag = normalize_flag(request.form.get('flag', '')) + if not crackme.get('flag_hash'): + message = "This crackme has no flag (auto-validation was not requested)" + elif not is_valid_flag_format(flag): + message = f"Not a valid flag format. {FLAG_FORMAT_HINT}" + elif verify_flag(crackme.get('flag_hash'), flag): + message = "Match: this is the author's flag" + else: + message = "No match: this is NOT the author's flag" + + # The tested flag is deliberately left out of the log -- writing it there + # would undo the point of storing only a hash. + log_reviewer_operation( + "check_crackme_flag", current_user['username'], + {"crackme_uuid": crackme_uuid, "result": message}, + True + ) + + return redirect(url_for('reviewer.viewcrackme', + crackme_uuid=crackme_uuid, message=message)) + + # ============================================================================= # Route Handlers - Labels # ============================================================================= 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/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 02e047a..46546ba 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,39 @@

    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 %} +

    + The flag is stored hashed and can't be displayed. Build the crackme from its source, solve it, and + check the flag you get here — if it doesn't match, the author submitted the wrong flag and the + crackme would be unsolvable for points. +

    +
    + + +
    + + +
    +
    + {% else %} +

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

    + {% endif %} +
    +

    Labels

    @@ -115,6 +151,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..6085eb1 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -115,6 +115,31 @@

    Quick Rules

    +
    +
    + +
    +
    + +

    + Optional. Give us the flag your crackme prints when it is beaten, and solvers can prove they + cracked it by submitting that flag — a correct one earns them points. The flag is stored + hashed and is never shown to anyone, so keep your own copy. +

    + +
    +
    {% if RECAPTCHA_SITEKEY %}




    @@ -125,6 +150,18 @@

    Quick Rules

    + {% 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..3a0b2ad 100644 --- a/templates/faq/faq.html +++ b/templates/faq/faq.html @@ -100,6 +100,23 @@

    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 CM1{...}. 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. The flag + itself is stored hashed and can't be read back by anyone, so keep your own copy.

    +

    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..5086ced 100644 --- a/templates/rules/crackmerules.html +++ b/templates/rules/crackmerules.html @@ -67,6 +67,20 @@

    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 CM1{...} — no + spaces or braces inside. It is stored hashed and never shown to anyone, including you, so keep your own + copy.
    • +
    • 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. 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..1120e72 100644 --- a/templates/user/read.html +++ b/templates/user/read.html @@ -4,10 +4,13 @@ {% block head %}{% endblock %} {% block content %} {% include 'partial/footer.html' %} diff --git a/templates/faq/faq.html b/templates/faq/faq.html index 3bb2ca1..5c20c38 100644 --- a/templates/faq/faq.html +++ b/templates/faq/faq.html @@ -104,7 +104,7 @@

      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 CM1{...}. You can't earn points on your own crackmes, and each crackme can only be + 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 diff --git a/templates/rules/crackmerules.html b/templates/rules/crackmerules.html index 81832c1..6879512 100644 --- a/templates/rules/crackmerules.html +++ b/templates/rules/crackmerules.html @@ -71,7 +71,7 @@

      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 CM1{...} — no +
      • 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 diff --git a/templates/user/read.html b/templates/user/read.html index 1120e72..c23f11e 100644 --- a/templates/user/read.html +++ b/templates/user/read.html @@ -42,7 +42,7 @@

        {{ username }}'s profile

        -

        Score: (?)

        +

        Score:

        {{ Score }}


        diff --git a/tests/test_comments_and_crackmes.py b/tests/test_comments_and_crackmes.py index 61d8348..710edf3 100644 --- a/tests/test_comments_and_crackmes.py +++ b/tests/test_comments_and_crackmes.py @@ -75,7 +75,8 @@ def test_crackme_upload_creates_pending_record_file_and_ratings( 'file': (BytesIO(b'not-an-archive-binary'), '../../challenge.bin'), }, content_type='multipart/form-data') - assert response.status_code == 200 + assert response.status_code == 302 + assert response.headers['Location'] == '/upload/crackme/submitted' crackme = db.crackme.find_one({'name': 'Uploaded Challenge'}) assert crackme['visible'] is False assert crackme['original_filename'] == 'challenge.bin' diff --git a/tests/test_labels.py b/tests/test_labels.py index f2907e3..5e4c326 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -203,7 +203,7 @@ def test_upload_stores_sublabel_labels(alice_client, db, monkeypatch): "labels": ["Packer", "UPX", "not-real"], "file": (buf, "sample.zip"), }, content_type="multipart/form-data") - assert resp.status_code == 200 + assert resp.status_code == 302 stored = db.crackme.find_one({"name": "Labeled CM"}) assert stored is not None assert stored["labels"] == ["Packer", "UPX"] # invalid dropped, ordered @@ -228,7 +228,7 @@ def test_upload_allows_no_labels(alice_client, db, monkeypatch): # no labels "file": (buf, "sample.zip"), }, content_type="multipart/form-data") - assert resp.status_code == 200 + assert resp.status_code == 302 stored = db.crackme.find_one({"name": "No Label CM"}) assert stored is not None assert stored["labels"] == [] diff --git a/tests/test_solves.py b/tests/test_solves.py index 16c2292..a58906f 100644 --- a/tests/test_solves.py +++ b/tests/test_solves.py @@ -8,7 +8,7 @@ from app.services.flag import flags_match, is_valid_flag_format, normalize_flag from app.services.points import points_for_solve, solve_difficulty -FLAG = 'CM1{a_perfectly_good_flag}' +FLAG = 'CMO{a_perfectly_good_flag}' def _hexid(user): @@ -43,8 +43,8 @@ def flagged_crackme(db, sample_crackme): # ---------------------------------------------------------------- flag format @pytest.mark.parametrize('flag', [ - 'CM1{ok}', - 'CM1{' + 'x' * 56 + '}', + 'CMO{ok}', + 'CMO{' + 'x' * 56 + '}', ]) def test_valid_flags_are_accepted(flag): assert is_valid_flag_format(flag) @@ -54,13 +54,13 @@ def test_valid_flags_are_accepted(flag): '', 'ok', 'CTF{ok}', - 'CM1{}', - 'CM1{nested{braces}}', - 'CM1{has space}', - 'CM1{' + 'x' * 57 + '}', # longer than the body limit - 'prefix CM1{ok}', - 'CM1{\u00fcnicode}', # ASCII only, so a flag's bytes stay bounded - 'CM1{tab\tinside}', + 'CMO{}', + 'CMO{nested{braces}}', + 'CMO{has space}', + 'CMO{' + 'x' * 57 + '}', # longer than the body limit + 'prefix CMO{ok}', + 'CMO{\u00fcnicode}', # ASCII only, so a flag's bytes stay bounded + 'CMO{tab\tinside}', ]) def test_invalid_flags_are_rejected(flag): assert not is_valid_flag_format(flag) @@ -72,7 +72,7 @@ def test_surrounding_whitespace_is_not_a_wrong_answer(): def test_flags_match_only_the_exact_flag(): assert flags_match(FLAG, FLAG) - assert not flags_match(FLAG, 'CM1{wrong}') + assert not flags_match(FLAG, 'CMO{wrong}') assert not flags_match(FLAG, FLAG.upper()) assert not flags_match(None, FLAG) assert not flags_match(FLAG, '') @@ -112,7 +112,8 @@ def _upload(client, monkeypatch, tmp_path, **extra): } data.update(extra) return client.post('/upload/crackme', data=data, - content_type='multipart/form-data') + content_type='multipart/form-data', + follow_redirects=True) def test_opting_into_auto_validation_stores_flag_hash_and_private_source( @@ -122,6 +123,7 @@ def test_opting_into_auto_validation_stores_flag_hash_and_private_source( source=(_zip_bytes(), 'source.zip')) assert response.status_code == 200 + assert b'has been submitted' in response.data or b'Flagged Challenge' in response.data crackme = db.crackme.find_one({'name': 'Flagged Challenge'}) assert crackme['flag'] == FLAG assert crackme['source_original_filename'] == 'source.zip' @@ -169,7 +171,7 @@ def test_correct_flag_records_a_solve_and_awards_points( def test_wrong_flag_records_nothing(bob_client, db, bob, flagged_crackme): response = bob_client.post(f"/crackme/{flagged_crackme['hexid']}/solve", - data={'flag': 'CM1{nope}'}, + data={'flag': 'CMO{nope}'}, follow_redirects=True) assert b'Wrong flag' in response.data @@ -461,7 +463,7 @@ def test_admin_edits_every_crackme_field_including_flag_and_difficulty( response = _edit(admin_client, flagged_crackme, info='Rewritten description', lang='Rust', arch='ARM', - platform='Windows', flag='CM1{corrected}', + platform='Windows', flag='CMO{corrected}', official_difficulty='6', notify_author='on') assert response.status_code == 200 @@ -470,13 +472,13 @@ def test_admin_edits_every_crackme_field_including_flag_and_difficulty( assert stored['lang'] == 'Rust' assert stored['arch'] == 'ARM' assert stored['platform'] == 'Windows' - assert stored['flag'] == 'CM1{corrected}' + assert stored['flag'] == 'CMO{corrected}' assert stored['official_difficulty'] == 6 # The flag change is recorded, but neither the log nor the author's # notification quotes the flag itself. assert 'flag changed' in logged[0]['changes'] - assert 'CM1{corrected}' not in str(logged[0]) - assert 'CM1{corrected}' not in db.notifications.find_one({'user': 'alice'})['text'] + assert 'CMO{corrected}' not in str(logged[0]) + assert 'CMO{corrected}' not in db.notifications.find_one({'user': 'alice'})['text'] def test_a_corrected_flag_is_the_one_that_now_scores( @@ -484,11 +486,11 @@ def test_a_corrected_flag_is_the_one_that_now_scores( from review import routes monkeypatch.setattr(routes, 'log_reviewer_operation', lambda *a, **kw: None) - _edit(admin_client, flagged_crackme, flag='CM1{corrected}', official_difficulty='6') + _edit(admin_client, flagged_crackme, flag='CMO{corrected}', official_difficulty='6') path = f"/crackme/{flagged_crackme['hexid']}/solve" stale = bob_client.post(path, data={'flag': FLAG}, follow_redirects=True) - corrected = bob_client.post(path, data={'flag': 'CM1{corrected}'}, + corrected = bob_client.post(path, data={'flag': 'CMO{corrected}'}, follow_redirects=True) assert b'Wrong flag' in stale.data @@ -561,3 +563,97 @@ def test_non_admin_reviewers_cannot_reach_the_editor(reviewer_client, flagged_cr ) assert response.status_code == 403 + + +# ------------------------------------------------- upload failure recovery + +def test_a_rejected_upload_keeps_what_the_user_typed( + alice_client, db, alice, tmp_path, monkeypatch): + from app.controllers import crackme as crackme_controller + + monkeypatch.setattr(crackme_controller, 'UPLOAD_FOLDER', str(tmp_path / 'pending')) + response = alice_client.post('/upload/crackme', data={ + 'name': 'Half Filled Challenge', + 'info': 'A long description nobody wants to retype.', + 'lang': 'Rust', + 'difficulty': '5', + 'platform': 'Windows', + 'arch': 'ARM', + 'labels': ['Packer'], + 'auto_validation': 'on', + 'flag': 'CMO{typed_but_not_lost}', + # ... and no file, so the submission is rejected. + }, content_type='multipart/form-data') + + assert response.status_code == 200 + body = response.data.decode() + assert 'Field missing: file' in body + assert 'value="Half Filled Challenge"' in body + assert 'A long description nobody wants to retype.' in body + assert 'value="CMO{typed_but_not_lost}"' in body + # Radios and label checkboxes come back ticked too. + assert 'value="Rust" checked' in body + assert 'value="5" checked' in body + assert 'value="Windows" checked' in body + assert 'value="ARM" checked' in body + assert 'value="Packer" data-label-class="1" checked' in body + + +def test_background_submits_report_errors_as_json( + alice_client, db, alice, tmp_path, monkeypatch): + from app.controllers import crackme as crackme_controller + + monkeypatch.setattr(crackme_controller, 'UPLOAD_FOLDER', str(tmp_path / 'pending')) + response = alice_client.post('/upload/crackme', data={ + 'name': 'No File Challenge', 'info': 'info', 'lang': 'C/C++', + 'difficulty': '3', 'platform': 'Linux', 'arch': 'x86-64', + }, content_type='multipart/form-data', + headers={'X-Requested-With': 'XMLHttpRequest'}) + + assert response.status_code == 400 + assert response.json == {'ok': False, 'error': 'Field missing: file'} + + +def test_background_submits_get_the_confirmation_url_on_success( + alice_client, db, alice, tmp_path, monkeypatch): + from app.controllers import crackme as crackme_controller + + monkeypatch.setattr(crackme_controller, 'UPLOAD_FOLDER', str(tmp_path / 'pending')) + response = alice_client.post('/upload/crackme', data={ + 'name': 'Ajax Challenge', 'info': 'info', 'lang': 'C/C++', + 'difficulty': '3', 'platform': 'Linux', 'arch': 'x86-64', + 'file': (BytesIO(b'binary'), 'challenge.bin'), + }, content_type='multipart/form-data', + headers={'X-Requested-With': 'XMLHttpRequest'}) + + assert response.status_code == 200 + assert response.json == {'ok': True, 'redirect': '/upload/crackme/submitted'} + assert db.crackme.find_one({'name': 'Ajax Challenge'}) is not None + + confirmation = alice_client.get('/upload/crackme/submitted') + assert b'Ajax Challenge' in confirmation.data + # One-shot: refreshing the confirmation doesn't re-announce the submission. + assert alice_client.get('/upload/crackme/submitted').status_code == 302 + + +def test_auto_validation_is_ticked_by_default_on_a_fresh_form(alice_client, alice): + body = alice_client.get('/upload/crackme').data.decode() + + assert 'id="auto_validation" name="auto_validation" checked' in body + # Labels sit at the end of the form, after the auto-validation block. + assert body.index('Auto-validation') < body.index('Select the anti-analysis') + + +def test_unticking_auto_validation_survives_a_rejected_upload( + alice_client, db, alice, tmp_path, monkeypatch): + from app.controllers import crackme as crackme_controller + + monkeypatch.setattr(crackme_controller, 'UPLOAD_FOLDER', str(tmp_path / 'pending')) + response = alice_client.post('/upload/crackme', data={ + 'name': 'No Flag Here', 'info': 'info', 'lang': 'C/C++', + 'difficulty': '3', 'platform': 'Linux', 'arch': 'x86-64', + # auto_validation deliberately absent: the user unticked it. + }, content_type='multipart/form-data') + + body = response.data.decode() + assert 'id="auto_validation" name="auto_validation" checked' not in body