From 82751b6186d7f368c941511d5a64a9afa8d98553 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sun, 2 Aug 2026 18:34:10 -0400 Subject: [PATCH 1/5] 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 From 08f53599318dafd89fc5b8e61649a0ef67d77c7b Mon Sep 17 00:00:00 2001 From: Xusheng Date: Mon, 21 Sep 2026 21:45:53 -0400 Subject: [PATCH 4/5] Complete auto-validation review and submission workflow --- app/controllers/comment.py | 22 +- app/controllers/crackme.py | 88 ++++- app/controllers/rating.py | 47 ++- app/controllers/solution.py | 90 ++++- app/models/crackme.py | 17 +- app/models/flag_submission.py | 52 +++ app/models/solve.py | 14 +- app/models/user.py | 21 + app/services/archive.py | 5 + app/services/discord.py | 73 ++++ app/services/limiter.py | 19 +- app/services/points.py | 4 +- config/config.json.example | 5 +- review/routes.py | 165 ++++++-- .../templates/reviewer/_deletion_preview.html | 4 + review/templates/reviewer/dashboard.html | 2 + review/templates/reviewer/editcrackme.html | 16 +- .../templates/reviewer/flagvalidations.html | 53 +++ review/templates/reviewer/viewcrackme.html | 228 +++++------ templates/crackme/create.html | 41 +- templates/crackme/read.html | 65 +++- templates/faq/faq.html | 27 +- templates/solution/editor.html | 51 ++- tests/test_onsite_writeups.py | 36 ++ tests/test_remaining_utils.py | 10 + tests/test_reviewer_remaining.py | 26 ++ tests/test_routes.py | 8 + tests/test_services_and_failures.py | 29 ++ tests/test_services_extended.py | 2 + tests/test_solves.py | 363 +++++++++++++++++- 30 files changed, 1328 insertions(+), 255 deletions(-) create mode 100644 app/models/flag_submission.py create mode 100644 review/templates/reviewer/flagvalidations.html diff --git a/app/controllers/comment.py b/app/controllers/comment.py index bd34700..0e93576 100644 --- a/app/controllers/comment.py +++ b/app/controllers/comment.py @@ -9,7 +9,9 @@ import bleach from app.models.comment import comment_create, comment_by_id, comment_set_spoiler, get_thread_participants from app.models.solution import get_solution_authors -from app.models.crackme import crackme_by_hexid, crackme_increment_comments +from app.models.crackme import ( + crackme_by_hexid, crackme_increment_comments, crackme_is_auto_validated +) from app.models.notification import notification_add from app.models.errors import ErrNoResult from app.services.recaptcha import verify as verify_recaptcha @@ -51,6 +53,23 @@ def leave_comment(hexid): """Post a comment on a crackme.""" username = session.get('name') + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + flash('Crackme not found.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + except Exception as e: + print(f"Error getting crackme: {e}") + flash('Comment creation failed. Please try again later.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + if crackme_is_auto_validated(crackme): + flash( + 'Comments are disabled for auto-validated crackmes.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexid}') + # Validate required fields is_valid, missing = validate_required(request.form, ['comment']) if not is_valid: @@ -92,7 +111,6 @@ def leave_comment(hexid): # Send notification to crackme author and handle @mentions try: - crackme = crackme_by_hexid(hexid) crackme_author = crackme.get('author') crackme_name = crackme['name'] diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index f8f4665..f87b4d4 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -21,16 +21,25 @@ 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 + solve_by_user_and_crackme, solve_create, count_solves_by_crackme, + solves_by_crackme ) -from app.models.user import user_by_name +from app.models.flag_submission import ( + flag_submission_create, count_flag_submissions_by_crackme +) +from app.models.user import user_by_name, users_by_hexids from app.models.errors import ErrNoResult from app.services.recaptcha import verify as verify_recaptcha -from app.services.limiter import limit +from app.services.limiter import configured_limit, limit 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.archive import ( + is_archive_password_protected, is_single_file_archive, + is_unsupported_archive, is_zip_archive, +) +from app.services.discord import ( + notify_flag_solved, notify_flag_submission, notify_new_crackme +) from app.services.flag import ( FLAG_FORMAT_HINT, flags_match, is_valid_flag_format, normalize_flag ) @@ -72,10 +81,25 @@ def crackme_view(hexid): # queries; everything else renders exactly as before. auto_validation = crackme_is_auto_validated(crackme) nbsolves = 0 + nbattempts = 0 + solves = [] user_solve = None if auto_validation: try: nbsolves = count_solves_by_crackme(hexid) + nbattempts = count_flag_submissions_by_crackme(hexid) + solve_records = solves_by_crackme(hexid) + solvers = users_by_hexids( + solve['user_hexid'] for solve in solve_records + ) + solves = [ + { + 'solve': solve, + 'username': solvers.get(solve['user_hexid'], {}) + .get('name', 'Deleted user'), + } + for solve in solve_records + ] viewer_hexid = _user_hexid(usersess) if usersess else None if viewer_hexid: user_solve = solve_by_user_and_crackme(viewer_hexid, hexid) @@ -114,6 +138,8 @@ def crackme_view(hexid): labels_dataset_url=get_dataset_url(), auto_validation=auto_validation, nbsolves=nbsolves, + nbattempts=nbattempts, + solves=solves, user_solve=user_solve, solve_points=points_for_solve(crackme) if auto_validation else 0, flag_format_hint=FLAG_FORMAT_HINT, @@ -225,6 +251,7 @@ def _submitted_form_values(): 'labels': normalize_labels(request.form.getlist('labels')), 'auto_validation': bool(request.form.get('auto_validation')), 'flag': request.form.get('flag', ''), + 'points': request.form.get('points', ''), } @@ -295,11 +322,21 @@ def upload_crackme_post(): flag = None source_data = None source_filename = None + official_difficulty = 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}') + points_value = request.form.get('points') or str(diff_int * 100) + try: + points = int(points_value) + except (TypeError, ValueError): + return _upload_rejected('Points must be a whole number from 100 to 600.') + if points < 100 or points > 600: + return _upload_rejected('Points must be a whole number from 100 to 600.') + official_difficulty = points / 100 + 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.') @@ -309,6 +346,8 @@ def upload_crackme_post(): 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 not is_zip_archive(source_data): + return _upload_rejected('The reviewer verification file must be a valid ZIP archive.') 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.') @@ -331,7 +370,8 @@ def upload_crackme_post(): try: crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, labels=labels, flag=flag, - source_original_filename=source_filename) + source_original_filename=source_filename, + official_difficulty=official_difficulty) except Exception as e: print(f"Error preparing crackme: {e}") abort(500) @@ -434,10 +474,28 @@ def upload_crackme_submitted(): @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')) +@limit(configured_limit('FlagSubmissions'), key_func=lambda: session.get('name')) def submit_flag(hexid): """Validate a submitted flag and, if correct, record the solve.""" username = session.get('name') + flag = normalize_flag(request.form.get('flag', '')) + + def log_result(result, user_hexid=None): + """Persist the submitted flag and its validation outcome.""" + try: + flag_submission_create( + user_hexid, username, hexid, flag, result + ) + except Exception as e: + # Audit logging must not turn a validation response into a 500. + print(f"Error logging flag submission: {e}") + try: + notify_flag_submission( + username, crackme.get('name', ''), hexid, flag, result + ) + except Exception as e: + # Discord must never affect validation or its database audit trail. + print(f"Discord flag-audit notification error: {e}") try: crackme = crackme_by_hexid(hexid) @@ -448,34 +506,39 @@ def submit_flag(hexid): abort(500) if not crackme_is_auto_validated(crackme): + log_result('not_enabled') 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: + log_result('own_crackme') 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: + log_result('account_unavailable') 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): + log_result('already_solved', user_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): + log_result('invalid_format', user_hexid) 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): + log_result('incorrect', user_hexid) flash('Wrong flag. Keep trying!', FLASH_ERROR) return redirect(f'/crackme/{hexid}') @@ -486,6 +549,15 @@ def submit_flag(hexid): print(f"Error recording solve: {e}") abort(500) + log_result('correct', user_hexid) + + try: + notify_flag_solved( + username, crackme.get('name', ''), hexid, points + ) + except Exception as e: + print(f"Discord solve notification error: {e}") + try: notification_add( username, diff --git a/app/controllers/rating.py b/app/controllers/rating.py index 2136f79..0b8b942 100644 --- a/app/controllers/rating.py +++ b/app/controllers/rating.py @@ -3,12 +3,18 @@ """ from flask import Blueprint, request, redirect, flash, session -from app.models.crackme import crackme_update_difficulty, crackme_update_quality +from app.models.crackme import ( + crackme_by_hexid, crackme_is_auto_validated, + crackme_update_difficulty, crackme_update_quality +) +from app.models.errors import ErrNoResult from app.models.rating import ( is_already_rated_difficulty, is_already_rated_quality, rating_difficulty_create, rating_difficulty_set_rating, rating_quality_create, rating_quality_set_rating ) +from app.models.solve import solve_by_user_and_crackme +from app.models.user import user_by_name from app.services.view import FLASH_ERROR, FLASH_SUCCESS, validate_required from app.controllers.decorators import login_required @@ -21,6 +27,21 @@ def rate_difficulty(hexid): """Rate a crackme's difficulty.""" username = session.get('name') + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + return redirect(f'/crackme/{hexid}'), 404 + except Exception as e: + print(f"Rating error: {e}") + return redirect(f'/crackme/{hexid}'), 500 + + if crackme_is_auto_validated(crackme): + flash( + 'Difficulty ratings are disabled for auto-validated crackmes.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexid}') + # Validate required fields is_valid, missing = validate_required(request.form, ['difficulty']) if not is_valid: @@ -61,6 +82,30 @@ def rate_quality(hexid): """Rate a crackme's quality.""" username = session.get('name') + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + return redirect(f'/crackme/{hexid}'), 404 + except Exception as e: + print(f"Rating error: {e}") + return redirect(f'/crackme/{hexid}'), 500 + + if crackme_is_auto_validated(crackme): + try: + user = user_by_name(username) + user_hexid = user.get('hexid') or str(user['_id']) + has_solved = solve_by_user_and_crackme(user_hexid, hexid) + except Exception as e: + print(f"Rating error: {e}") + return redirect(f'/crackme/{hexid}'), 500 + + if not has_solved: + flash( + 'Solve this crackme before rating it.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexid}') + # Validate required fields is_valid, missing = validate_required(request.form, ['quality']) if not is_valid: diff --git a/app/controllers/solution.py b/app/controllers/solution.py index 96ec93f..1c382fe 100644 --- a/app/controllers/solution.py +++ b/app/controllers/solution.py @@ -9,10 +9,10 @@ import os from html import escape as html_escape -from flask import Blueprint, render_template, request, redirect, flash, session, abort, current_app, Response +from flask import Blueprint, render_template, request, redirect, flash, session, abort, current_app, Response, jsonify from werkzeug.utils import secure_filename import bleach -from app.models.crackme import crackme_by_hexid +from app.models.crackme import crackme_by_hexid, crackme_is_auto_validated from app.models.solution import solution_create, solution_exists, solution_by_hexid from app.models.notification import notification_add from app.models.errors import ErrNoResult @@ -68,12 +68,32 @@ def _send_notifications_and_render_success(username, crackme): except Exception as e: print(f"Notification error: {e}") + if _wants_json(): + session['submitted_writeup'] = crackme['name'] + return jsonify({ + 'ok': True, + 'redirect': '/upload/solution/submitted', + }) + return render_template('submission/success.html', submission_type='Writeup', name=crackme['name'], username=username) +def _wants_json(): + """True when the editor submitted in the background.""" + return request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + +def _submission_rejected(message, redirect_url): + """Reject without navigating away from a background-submitted editor.""" + if _wants_json(): + return jsonify({'ok': False, 'error': message}), 400 + flash(message, FLASH_ERROR) + return redirect(redirect_url) + + def _validate_attachment(file): """Validate an uploaded attachment. Returns (data, error_message). @@ -110,6 +130,12 @@ def _validate_attachment(file): def upload_solution_get(hexidcrackme): """Display the solution submission form (markdown editor + optional attachment).""" crackme = _get_crackme_or_abort(hexidcrackme) + if crackme_is_auto_validated(crackme): + flash( + 'Writeups are disabled for auto-validated crackmes.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexidcrackme}') return render_template('solution/editor.html', hexidcrackme=hexidcrackme, username=crackme.get('author', ''), @@ -135,30 +161,42 @@ def upload_solution_post(hexidcrackme): username = session.get('name') redirect_url = f'/upload/solution/{hexidcrackme}' + if crackme_is_auto_validated(crackme): + return _submission_rejected( + 'Writeups are disabled for auto-validated crackmes.', + f'/crackme/{hexidcrackme}', + ) + # Check if user already submitted a solution if solution_exists(username, crackme['_id']): - flash("You've already submitted a solution to this crackme", FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + "You've already submitted a solution to this crackme", redirect_url + ) if not verify_recaptcha(request): - flash('reCAPTCHA invalid!', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected('reCAPTCHA invalid!', redirect_url) # Summary info = bleach.clean(request.form.get('info', '')) if len(info) > MAX_INFO_LENGTH: - flash(f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', + redirect_url, + ) # Inline markdown content (optional) content = request.form.get('content', '').strip() if content: if len(content) < MIN_CONTENT_LENGTH: - flash(f'Your writeup is too short. Please write at least {MIN_CONTENT_LENGTH} characters.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + f'Your writeup is too short. Please write at least {MIN_CONTENT_LENGTH} characters.', + redirect_url, + ) if len(content) > MAX_CONTENT_LENGTH: - flash(f'Your writeup exceeds the maximum length of {MAX_CONTENT_LENGTH:,} characters.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + f'Your writeup exceeds the maximum length of {MAX_CONTENT_LENGTH:,} characters.', + redirect_url, + ) else: content = None @@ -170,14 +208,15 @@ def upload_solution_post(hexidcrackme): if has_file: data, error = _validate_attachment(file) if error: - flash(error, FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected(error, redirect_url) original_filename = secure_filename(file.filename) or "unnamed" # A solution must have a writeup body: markdown content, an attachment, or both. if content is None and not has_file: - flash('Please write a markdown writeup or attach a file (or both).', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + 'Please write a markdown writeup or attach a file (or both).', + redirect_url, + ) try: solution = solution_create(info, username, crackme, content=content, original_filename=original_filename) @@ -198,12 +237,27 @@ def upload_solution_post(hexidcrackme): get_collection('solution').delete_one({'hexid': solution['hexid']}) except Exception as cleanup_error: print(f"Failed to roll back solution record: {cleanup_error}") - flash('An error occurred on the server. Please try again later.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + 'An error occurred on the server. Please try again later.', + redirect_url, + ) return _send_notifications_and_render_success(username, crackme) +@solution_bp.route('/upload/solution/submitted', methods=['GET']) +@login_required +def upload_solution_submitted(): + """Confirm a writeup submitted by the background editor.""" + name = session.pop('submitted_writeup', None) + if not name: + return redirect('/') + return render_template( + 'submission/success.html', submission_type='Writeup', + name=name, username=session.get('name'), + ) + + @solution_bp.route('/solution/', methods=['GET']) def view_solution(hexid): """Display a solution's writeup page (public).""" diff --git a/app/models/crackme.py b/app/models/crackme.py index ae09d22..d72a91a 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -307,7 +307,8 @@ def crackme_by_user_and_name(username, name, visible=True): def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, - labels=None, flag=None, source_original_filename=None): + labels=None, flag=None, source_original_filename=None, + official_difficulty=None): """Prepare a crackme object without inserting it. Args: @@ -345,8 +346,9 @@ def crackme_create_prepare(name, info, username, lang, arch, platform, size, ori '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, + # For auto-validation, the author chooses an integer point value and we + # store points / 100 here so the existing scoring field stays canonical. + 'official_difficulty': official_difficulty, } @@ -461,7 +463,7 @@ def crackme_set_official_difficulty(hexid, difficulty): Args: hexid: The hex ID of the crackme - difficulty: Difficulty level 1-6 + difficulty: Difficulty value 1-6, with up to two decimal places Returns: True if the crackme was updated, False if it was not found or the @@ -471,13 +473,18 @@ def crackme_set_official_difficulty(hexid, difficulty): raise ErrUnavailable("Database is unavailable") try: - difficulty = int(difficulty) + difficulty = float(difficulty) except (TypeError, ValueError): return False if difficulty < 1 or difficulty > 6: return False + # Point values are whole numbers, so stored difficulty has at most two + # decimal places (points / 100). + if abs(round(difficulty * 100) - difficulty * 100) > 1e-9: + return False + result = get_collection('crackme').update_one( {'hexid': hexid}, {'$set': {'official_difficulty': difficulty}} diff --git a/app/models/flag_submission.py b/app/models/flag_submission.py new file mode 100644 index 0000000..a70eb59 --- /dev/null +++ b/app/models/flag_submission.py @@ -0,0 +1,52 @@ +"""Persistent audit records for user flag-submission attempts.""" + +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 flag_submission_create( + user_hexid, username, crackme_hexid, submitted_flag, result): + """Persist one flag-validation attempt and return the inserted document.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + obj_id = ObjectId() + submission = { + '_id': obj_id, + 'hexid': str(obj_id), + 'user_hexid': user_hexid, + 'username': username, + 'crackme_hexid': crackme_hexid, + 'submitted_flag': submitted_flag, + 'result': result, + 'created_at': datetime.now(timezone.utc), + } + get_collection('flag_submission').insert_one(submission) + return submission + + +def flag_submissions_by_crackme(crackme_hexid, limit=100): + """Return the newest audit entries for a crackme.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return list( + get_collection('flag_submission') + .find({'crackme_hexid': crackme_hexid}) + .sort('created_at', -1) + .limit(limit) + ) + + +def count_flag_submissions_by_crackme(crackme_hexid): + """Return the number of attempted flag submissions for a crackme.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return get_collection('flag_submission').count_documents({ + 'crackme_hexid': crackme_hexid, + }) diff --git a/app/models/solve.py b/app/models/solve.py index 78e7c81..51f7773 100644 --- a/app/models/solve.py +++ b/app/models/solve.py @@ -61,7 +61,7 @@ def solve_create(user_hexid, crackme_hexid, points, difficulty): 'crackme_hexid': crackme_hexid, 'created_at': datetime.now(timezone.utc), 'points': int(points), - 'difficulty': int(difficulty), + 'difficulty': float(difficulty), } collection.insert_one(solve) return solve @@ -101,3 +101,15 @@ def count_solves_by_crackme(crackme_hexid): return get_collection('solve').count_documents( {'crackme_hexid': crackme_hexid} ) + + +def solves_by_crackme(crackme_hexid): + """Return a crackme's solves, oldest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return list( + get_collection('solve') + .find({'crackme_hexid': crackme_hexid}) + .sort('created_at', 1) + ) diff --git a/app/models/user.py b/app/models/user.py index e84350f..c502e80 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -109,6 +109,27 @@ def all_users_visible(): return list(collection.find({'visible': True})) +def users_by_hexids(hexids): + """Return visible users keyed by immutable hexid.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + hexids = list(hexids) + if not hexids: + return {} + return { + user.get('hexid') or str(user['_id']): user + for user in get_collection('user').find({ + '$or': [ + {'hexid': {'$in': hexids}}, + {'_id': {'$in': [ObjectId(value) for value in hexids + if ObjectId.is_valid(value)]}}, + ], + 'visible': True, + }) + } + + def user_create(name, email, password): """Create a new user. diff --git a/app/services/archive.py b/app/services/archive.py index 8caadee..ad7d784 100644 --- a/app/services/archive.py +++ b/app/services/archive.py @@ -169,6 +169,11 @@ def is_single_file_archive(file_data: bytes) -> bool: return file_count == 1 +def is_zip_archive(file_data: bytes) -> bool: + """Return whether ``file_data`` is a readable ZIP archive.""" + return get_archive_file_count(file_data) is not None + + def is_archive_password_protected(file_data: bytes) -> bool: """Check if an archive file is password-protected. diff --git a/app/services/discord.py b/app/services/discord.py index acb4ead..f6cd15f 100644 --- a/app/services/discord.py +++ b/app/services/discord.py @@ -192,6 +192,79 @@ def notify_new_solution(username: str, crackme_name: str) -> bool: return send_private_notification(embed=embed) +def notify_flag_solved(username: str, crackme_name: str, + crackme_hexid: str, points: int) -> bool: + """Announce a successful auto-validated solve in the public channel.""" + timestamp = ( + datetime.datetime.utcnow() + .replace(tzinfo=timezone.utc) + .isoformat(timespec='milliseconds') + .replace('+00:00', 'Z') + ) + base_url = get_base_url() + embed = { + "title": "Crackme Solved", + "description": "A player submitted the correct flag", + "color": 65280, + "fields": [ + { + "name": "Challenge", + "value": f"[{crackme_name}]({base_url}/crackme/{crackme_hexid})", + "inline": True, + }, + { + "name": "Player", + "value": f"[{username}]({base_url}/user/{username})", + "inline": True, + }, + {"name": "Points", "value": str(points), "inline": True}, + ], + "footer": {"text": "CrackMes.One"}, + "timestamp": timestamp, + } + if not is_enabled(): + return True + return send_to_webhook(get_public_webhook(), embed=embed) + + +def notify_flag_submission(username: str, crackme_name: str, + crackme_hexid: str, submitted_flag: str, + result: str) -> bool: + """Send every flag attempt, including its value, to the audit channel.""" + timestamp = ( + datetime.datetime.utcnow() + .replace(tzinfo=timezone.utc) + .isoformat(timespec='milliseconds') + .replace('+00:00', 'Z') + ) + base_url = get_base_url() + # Discord limits an embed field to 1,024 characters. The database remains + # the canonical, untruncated audit record. + displayed_flag = submitted_flag[:1021] + "..." if len(submitted_flag) > 1024 else submitted_flag + embed = { + "title": "Flag Submitted", + "description": "Auto-validation attempt recorded", + "color": 65280 if result == 'correct' else 16744448, + "fields": [ + { + "name": "Challenge", + "value": f"[{crackme_name}]({base_url}/crackme/{crackme_hexid})", + "inline": True, + }, + { + "name": "User", + "value": f"[{username}]({base_url}/user/{username})", + "inline": True, + }, + {"name": "Result", "value": result, "inline": True}, + {"name": "Submitted flag", "value": displayed_flag or "(empty)", "inline": False}, + ], + "footer": {"text": "CrackMes.One Internal Audit"}, + "timestamp": timestamp, + } + return send_private_notification(embed=embed) + + def send_moderation_notification(embed: dict) -> bool: """Send a notification to the moderation Discord channel. diff --git a/app/services/limiter.py b/app/services/limiter.py index 6e8c448..d7b536a 100644 --- a/app/services/limiter.py +++ b/app/services/limiter.py @@ -15,7 +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 | +| POST /crackme/../solve | 5/min, 20/hr | Username | Prevent flag brute-forcing | +-------------------------+----------------+-------------+----------------------------------+ Configuration @@ -41,6 +41,10 @@ limiter = None limiter_config = {} +DEFAULT_LIMITS = { + 'FlagSubmissions': '5 per minute; 20 per hour', +} + def init_limiter(app, config): """Initialize rate limiter with configuration. @@ -52,7 +56,13 @@ def init_limiter(app, config): - StorageUri: str - Storage backend URI (default: memory://) """ global limiter, limiter_config - limiter_config = config + # Abuse protection is on by default. Deployments and tests can explicitly + # disable it, select another storage backend, or override named limits. + limiter_config = { + 'Enabled': True, + 'StorageUri': 'memory://', + **(config or {}), + } if not is_enabled(): # Create a no-op limiter that doesn't actually limit @@ -84,6 +94,11 @@ def get_limiter(): return limiter +def configured_limit(name): + """Return a named route limit, falling back to its built-in default.""" + return limiter_config.get('Limits', {}).get(name, DEFAULT_LIMITS[name]) + + def limit(*args, **kwargs): """Decorator for rate limiting routes. diff --git a/app/services/points.py b/app/services/points.py index a700f92..668fa37 100644 --- a/app/services/points.py +++ b/app/services/points.py @@ -29,14 +29,14 @@ def solve_difficulty(crackme): """ official = crackme.get('official_difficulty') if official: - return _clamp(int(official)) + return _clamp(float(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 + return round(solve_difficulty(crackme) * POINTS_PER_DIFFICULTY) def _clamp(difficulty): diff --git a/config/config.json.example b/config/config.json.example index 85fa7bb..dd3c487 100644 --- a/config/config.json.example +++ b/config/config.json.example @@ -18,7 +18,10 @@ }, "RateLimiter": { "Enabled": true, - "StorageUri": "memory://" + "StorageUri": "memory://", + "Limits": { + "FlagSubmissions": "5 per minute; 20 per hour" + } }, "Discord": { "Enabled": false, diff --git a/review/routes.py b/review/routes.py index 4219b8b..c0784b5 100644 --- a/review/routes.py +++ b/review/routes.py @@ -46,13 +46,15 @@ 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.crackme_fields import ( + ARCH_CHOICES, LANG_CHOICES, PLATFORM_CHOICES, +) 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, @@ -1004,7 +1006,8 @@ def _cascade_delete_crackme_data(crackme_id, crackme_hexid): 'comments': 0, 'difficulty_ratings': 0, 'quality_ratings': 0, - 'solves': 0 + 'solves': 0, + 'flag_submissions': 0 } # Delete solutions @@ -1039,6 +1042,11 @@ def _cascade_delete_crackme_data(crackme_id, crackme_hexid): }) deleted['solves'] = result.deleted_count + result = g_crackmesone_db.flag_submission.delete_many({ + 'crackme_hexid': crackme_hexid + }) + deleted['flag_submissions'] = result.deleted_count + return deleted @@ -1109,6 +1117,7 @@ def preview_user_deletion(user_email): 'notifications': 0, 'solutions': 0, 'solves': 0, + 'flag_submissions': 0, 'crackmes': 0, 'crackme_details': [], 'user_comments': 0, @@ -1130,6 +1139,9 @@ def preview_user_deletion(user_email): preview['solves'] = db.solve.count_documents({ "user_hexid": user.get("hexid") or str(user["_id"]) }) + preview['flag_submissions'] = db.flag_submission.count_documents({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) # Count data for each crackme for crackme in db.crackme.find({"author": username}): @@ -1226,6 +1238,13 @@ def delete_user_account(user_email, admin_username=None): }) deletion_log.append(f"Deleted {result.deleted_count} solves by user") + result = db.flag_submission.delete_many({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) + deletion_log.append( + f"Deleted {result.deleted_count} flag submissions by user" + ) + # 2b. Delete user's solutions solution_count = 0 for solution in db.solution.find({"author": username}): @@ -1461,6 +1480,40 @@ def dashboard(current_user): ) +@reviewer_bp.route('/flagvalidations') +@admin_required +def flagvalidations(current_user): + """Show recent flag submissions and validation results to admins.""" + submissions = list( + g_crackmesone_db.flag_submission + .find({}) + .sort('created_at', -1) + .limit(250) + ) + crackme_ids = { + item.get('crackme_hexid') for item in submissions + if item.get('crackme_hexid') + } + crackme_names = { + item['hexid']: item.get('name', 'Unnamed crackme') + for item in g_crackmesone_db.crackme.find( + {'hexid': {'$in': list(crackme_ids)}}, + {'hexid': 1, 'name': 1} + ) + } + for item in submissions: + item['crackme_name'] = crackme_names.get( + item.get('crackme_hexid'), 'Deleted crackme' + ) + + return render_template( + 'reviewer/flagvalidations.html', + user=current_user['username'], + is_admin=True, + submissions=submissions, + ) + + # ============================================================================= # Route Handlers - Review Pending Submissions # ============================================================================= @@ -1725,22 +1778,80 @@ 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: + crackme_obj = g_crackmesone_db.crackme.find_one({ + 'hexid': crackme_uuid.lower(), 'visible': False, + }) + if not crackme_obj: + return redirect(url_for( + 'reviewer.reviewcrackme', message="Pending crackme not found" + )) + + info = request.form.get('info', crackme_obj.get('info', '')).strip() + lang = request.form.get('lang', crackme_obj.get('lang', '')) + arch = request.form.get('arch', crackme_obj.get('arch', '')) + platform = request.form.get('platform', crackme_obj.get('platform', '')) + if not info: + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message="Description is required" + )) + valid_classification = ( + (lang in LANG_CHOICES or lang == crackme_obj.get('lang')) and + (arch in ARCH_CHOICES or arch == crackme_obj.get('arch')) and + (platform in PLATFORM_CHOICES or platform == crackme_obj.get('platform')) + ) + if not valid_classification: + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message="Invalid language, architecture, or platform" + )) + + updates = { + 'info': info, + 'lang': lang, + 'arch': arch, + 'platform': platform, + 'labels': ( + normalize_labels(request.form.getlist('labels')) + if request.form.get('labels_submitted') == '1' + else crackme_obj.get('labels', []) + ), + } + + if crackme_obj.get('flag'): + flag = normalize_flag(request.form.get('flag', crackme_obj.get('flag', ''))) + if not is_valid_flag_format(flag): + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message=f"Invalid flag format. {FLAG_FORMAT_HINT}" + )) + updates['flag'] = flag + + # Reviewers may adjust the author's proposed reward before approval. The + # database keeps this as points / 100 in the official difficulty field. + official_points = request.form.get('official_points', '').strip() + if (crackme_obj.get('flag') and 'official_points' not in request.form + and crackme_obj.get('official_difficulty')): + official_points = str(round(crackme_obj['official_difficulty'] * 100)) + if crackme_obj.get('flag') or official_points: + try: + official_points = int(official_points) + except ValueError: + official_points = 0 + if not 100 <= official_points <= 600: return redirect(url_for( - 'reviewer.viewcrackme', - crackme_uuid=crackme_uuid, - message="Invalid official difficulty" + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message="Points must be a whole number from 100 to 600" )) + updates['official_difficulty'] = official_points / 100 + + g_crackmesone_db.crackme.update_one( + {'_id': crackme_obj['_id']}, {'$set': updates} + ) + log_reviewer_operation( + "update_pending_crackme", current_user['username'], + {"crackme_uuid": crackme_uuid, "fields": sorted(updates)}, True + ) success, message = approve_pending_crackme(crackme_file) @@ -2221,17 +2332,17 @@ def _apply_crackme_edit(current_user, crackme_obj, request): } 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: + # Official points are stored as points / 100 in official_difficulty. An + # empty value clears the override and falls back to the community rating. + official_points = request.form.get('official_points', '').strip() + if official_points: try: - official_difficulty = int(official_difficulty) + official_points = int(official_points) except ValueError: - return "Invalid official difficulty", None - if not 1 <= official_difficulty <= 6: - return "Invalid official difficulty", None - updates['official_difficulty'] = official_difficulty + return "Points must be a whole number from 100 to 600", None + if not 100 <= official_points <= 600: + return "Points must be a whole number from 100 to 600", None + updates['official_difficulty'] = official_points / 100 else: updates['official_difficulty'] = None @@ -3021,6 +3132,10 @@ def create_site_archive_background(requesting_user): set_archive_status('running', step='Exporting crackmes database...') crackmes = list(db.crackme.find({})) for c in crackmes: + # Flags are reviewer-only secrets and are not part of the public + # site archive. Omit the key entirely rather than exporting a + # placeholder value. + c.pop('flag', None) c['_id'] = str(c['_id']) with open(os.path.join(archive_folder, 'database', 'crackmes.json'), 'w') as f: json.dump(crackmes, f, indent=2, default=str) diff --git a/review/templates/reviewer/_deletion_preview.html b/review/templates/reviewer/_deletion_preview.html index 0036e1b..5c0f57a 100644 --- a/review/templates/reviewer/_deletion_preview.html +++ b/review/templates/reviewer/_deletion_preview.html @@ -63,6 +63,10 @@ {{ preview.solves }} Solves by user (the points they earned go too)
        +
        + {{ preview.flag_submissions }} Flag Submissions by user +
        +
        {{ preview.user_comments }} Comments by user on other crackmes
        diff --git a/review/templates/reviewer/dashboard.html b/review/templates/reviewer/dashboard.html index a3ce053..07eecab 100644 --- a/review/templates/reviewer/dashboard.html +++ b/review/templates/reviewer/dashboard.html @@ -31,6 +31,7 @@

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

        Review label requests Account deletion requests {% if is_admin %} + Review flag validations Delete solution Delete crackme Edit crackme @@ -74,6 +75,7 @@

        {{ acctdel_cnt }}


    + diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 08d57d9..88b71f9 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -81,17 +81,13 @@

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

    - - + +

    - 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. + Whole numbers from 100 to 600. Stored internally as points ÷ 100; already-earned + points are not re-priced.

    diff --git a/review/templates/reviewer/flagvalidations.html b/review/templates/reviewer/flagvalidations.html new file mode 100644 index 0000000..f5fc125 --- /dev/null +++ b/review/templates/reviewer/flagvalidations.html @@ -0,0 +1,53 @@ + + + + Review flag validations + + + + + + + + +
    +
    + {% if submissions %} + + + + + + {% for submission in submissions %} + + + + + + + + {% endfor %} + +
    UserCrackmeSubmitted flagResultSubmitted
    {{ submission.username }} + {% if submission.crackme_name != 'Deleted crackme' %} + {{ submission.crackme_name }} + {% else %} + {{ submission.crackme_name }} + {% endif %} + {{ submission.submitted_flag }}{{ submission.result|replace('_', ' ')|title }}{{ submission.created_at }}
    + {% else %} +

    No flag submissions have been recorded.

    + {% endif %} +
    +
    + + diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 3e00678..7bb416a 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -1,22 +1,16 @@ - - View crackme + Review crackme -
    -

    -

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

    -

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

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

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

    -

    Crackme info

    -

    -

    Language: {{ crackme.lang }}

    -

    Arch: {{ crackme.arch }}

    -

    Platform: {{ crackme.platform }}

    -
    -

    -

    Description

    -

    -

    {{ crackme.info }}

    -
    -

    -

    Download

    -

    +
    + + - Download -
    +

    Crackme info

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    -

    -

    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

    -

    -

    Author-suggested / current labels. Adjust and save before accepting.

    - - - - +
    +

    Download

    +

    Download crackme

    + +
    +

    Auto-validation

    + {% if crackme.auto_validation %} +

    This crackme will accept a flag and award the point value shown above.

    + {% if crackme.has_source_archive %} +

    Download private verification ZIP

    + {% else %} +

    No verification ZIP was uploaded.

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

    The author did not enable auto-validation.

    + {% endif %} + +
    +

    Labels

    +

    Adjust the author-suggested labels before approval.

    +
    {% set label_input_name = 'labels' %} {% set checked_labels = crackme.labels %} {% include 'partial/labels_checkboxes.html' %}
    -
    - - -
    - -

    -

    Review

    -

    -
    +
    +

    Review

    + - - +
    - @@ -119,54 +112,41 @@

    - +

    - -
    - -
    - -
    - -
    +
    - - -
    - - - -
    - - -

    - 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 f3c2108..b7b6e1a 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -113,15 +113,15 @@

    Quick Rules

    @@ -133,10 +133,9 @@

    Quick Rules

    - Select the anti-analysis / obfuscation techniques your crackme uses. Please label every technique - that applies; if none apply, it's fine to leave these empty. Pick a broad category and, where it - applies, the specific technique underneath — ticking a technique ticks its category - automatically. Reviewers may adjust these later. + Select all anti-analysis and obfuscation techniques used. Specific techniques automatically select + their category, and reviewers may adjust labels. Labels on active auto-validated crackmes remain + hidden until scoring ends.

    {% set label_input_name = 'labels' %} @@ -162,7 +161,25 @@

    Quick Rules

    var toggle = document.getElementById('auto_validation'); var fields = document.getElementById('auto-validation-fields'); if (!toggle || !fields) return; - function sync() { fields.style.display = toggle.checked ? 'block' : 'none'; } + var points = document.getElementById('points'); + var difficultyInputs = document.querySelectorAll('input[name="difficulty"]'); + var pointsOverridden = !!(points && points.value); + function selectedDifficulty() { + var selected = document.querySelector('input[name="difficulty"]:checked'); + return selected ? parseInt(selected.value, 10) : null; + } + function setDefaultPoints() { + var difficulty = selectedDifficulty(); + if (points && difficulty && !pointsOverridden) points.value = difficulty * 100; + } + function sync() { + fields.style.display = toggle.checked ? 'block' : 'none'; + if (toggle.checked) setDefaultPoints(); + } + if (points) points.addEventListener('input', function() { pointsOverridden = true; }); + difficultyInputs.forEach(function(input) { + input.addEventListener('change', setDefaultPoints); + }); toggle.addEventListener('change', sync); sync(); })(); diff --git a/templates/crackme/read.html b/templates/crackme/read.html index b1f8ee5..0464f7b 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -159,14 +159,18 @@

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

    {{ platform }}

    + {% if auto_validation %} +

    Points:
    {{ solve_points }}

    + {% else %}

    Difficulty:
    {{ difficulty }} {% if AuthLevel == "auth" %} Rate!

    {% endif %} + {% endif %}

    Quality:
    {{ quality }} - {% if AuthLevel == "auth" %} + {% if AuthLevel == "auth" and (not auto_validation or user_solve) %} Rate!

    {% endif %}
    @@ -181,12 +185,19 @@

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

    Size:
    {{ size|FILESIZE }}

    + {% if auto_validation %} +

    Solves:
    {{ nbsolves }}

    + {% else %}

    Writeups:
    {{ nbsolutions }}

    + {% endif %}
    + {% if auto_validation %} +

    Attempts:
    {{ nbattempts }}

    + {% else %}

    Comments:
    {{ nbcomments }}

    + {% endif %}
    -
    @@ -200,11 +211,6 @@

    {{ 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. @@ -226,6 +232,7 @@

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

    {% endif %} + {% if not auto_validation %}

    Labels @@ -258,18 +265,53 @@

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

    + {% endif %}
    + {% if auto_validation %} +
    + {% if solves %} + + + + + + {% for item in solves %} + + + + + + {% endfor %} + +
    SolverSolvedPoints
    + {% if item.username != 'Deleted user' %} + {{ item.username }} + {% else %} + {{ item.username }} + {% endif %} + {{ item.solve.created_at|PRETTYTIME }}{{ item.solve.points }}
    + {% else %} +

    No one has solved this crackme yet.

    + {% endif %} +
    + {% else %} + {% endif %} + {% if not auto_validation %} + {% endif %} + {% if not auto_validation %} + {% endif %} +{% if not auto_validation or user_solve %} +{% endif %} -{% if AuthLevel == "auth" %} +{% if AuthLevel == "auth" and not auto_validation %}