From 509c19f487167e06a6052c435d4ccdf405fb3137 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sat, 11 Jul 2026 22:33:18 -0400 Subject: [PATCH 01/19] Add crackme obfuscation tags: display, search, submission, review (#17) Introduces a controlled vocabulary of 21 anti-analysis / obfuscation tags (anti-debugging, string encryption, packers, ...) seeded from the AI-labeled crackmes-RE dataset, and wires them through the whole crackme lifecycle. - app/services/tags.py: 21-class vocabulary, normalize_tags(), dataset URL - Crackmes carry a `tags` field: authors pick tags at submission, tags show on the crackme page (each links to a tag search) with a "?" explaining the tags are AI-generated from write-ups/comments and may be inaccurate - Search: filter by tags (matches crackmes carrying all selected tags) - Reviewers can override tags when reviewing a pending crackme and when editing an approved one - Tag change requests: any logged-in user can request tag adds/removes; a new reviewer queue (dashboard link + count) approves (applies) or rejects them, notifying the requester - FAQ #tags entry; script/import_tags.py to backfill tags from the dataset - Tests for vocabulary, model, submission, search, requests, reviewer overrides Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/crackme.py | 83 +++++- app/controllers/search.py | 14 +- app/models/crackme.py | 37 ++- app/models/tag_request.py | 128 +++++++++ app/services/tags.py | 70 +++++ review/routes.py | 198 +++++++++++++- review/templates/reviewer/dashboard.html | 10 + review/templates/reviewer/editcrackme.html | 10 + review/templates/reviewer/tagrequests.html | 74 ++++++ review/templates/reviewer/viewcrackme.html | 20 ++ script/import_tags.py | 133 ++++++++++ templates/crackme/create.html | 15 ++ templates/crackme/read.html | 88 ++++++ templates/faq/faq.html | 12 + templates/search/search.html | 17 ++ tests/test_tags.py | 295 +++++++++++++++++++++ 16 files changed, 1179 insertions(+), 25 deletions(-) create mode 100644 app/models/tag_request.py create mode 100644 app/services/tags.py create mode 100644 review/templates/reviewer/tagrequests.html create mode 100644 script/import_tags.py create mode 100644 tests/test_tags.py diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 3d447eb..ffc332b 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -17,10 +17,14 @@ from app.models.comment import comments_by_crackme from app.models.rating import rating_difficulty_create, rating_quality_create, rating_difficulty_delete_by_crackme from app.models.notification import notification_add +from app.models.tag_request import ( + tag_request_create, pending_tag_requests_by_user_and_crackme +) 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.tags import OBFUSCATION_TAGS, DATASET_URL, normalize_tags 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.controllers.decorators import login_required @@ -80,6 +84,9 @@ def crackme_view(hexid): difficulty=f"{crackme.get('difficulty', 0):.1f}", quality=f"{crackme.get('quality', 0):.1f}", size=crackme.get('size', 0), + tags=crackme.get('tags', []), + all_tags=OBFUSCATION_TAGS, + tags_dataset_url=DATASET_URL, usersess=usersess) @@ -134,7 +141,7 @@ def download_crackme(hexid): @login_required def upload_crackme_get(): """Display the crackme upload form.""" - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) @crackme_bp.route('/upload/crackme', methods=['POST']) @@ -149,7 +156,7 @@ def upload_crackme_post(): is_valid, missing = validate_required(request.form, required) if not is_valid: flash(f'Field missing: {missing}', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) name = bleach.clean(request.form.get('name', '')) info = bleach.clean(request.form.get('info', '')) @@ -157,6 +164,8 @@ def upload_crackme_post(): arch = bleach.clean(request.form.get('arch', '')) platform = request.form.get('platform', '') difficulty = request.form.get('difficulty', '') + # Tags are optional; keep only values from the controlled vocabulary. + tags = normalize_tags(request.form.getlist('tags')) # Validate difficulty try: @@ -165,22 +174,22 @@ def upload_crackme_post(): raise ValueError() except (ValueError, TypeError): flash('Wrong difficulty', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Validate reCAPTCHA if not verify_recaptcha(request): flash('reCAPTCHA invalid!', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Check for file if 'file' not in request.files: flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) file = request.files['file'] if file.filename == '': flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Read file data file_data = file.read() @@ -188,22 +197,22 @@ def upload_crackme_post(): # Check file size if len(file_data) > MAX_FILE_SIZE: flash('This file is too large!', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Check for unsupported archive formats (RAR, tar, etc.) if is_unsupported_archive(file_data): flash('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Check for password protection if is_archive_password_protected(file_data): flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Check for single-file archives if is_single_file_archive(file_data): flash('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Store the uploaded file size size = len(file_data) @@ -212,7 +221,7 @@ def upload_crackme_post(): try: crackme_by_user_and_name(username, name, visible=False) flash('You already have a pending crackme with this name. Please wait for review or choose a different name.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) except ErrNoResult: pass # No duplicate, continue @@ -221,7 +230,7 @@ def upload_crackme_post(): # Prepare crackme try: - crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename) + crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, tags=tags) except Exception as e: print(f"Error preparing crackme: {e}") abort(500) @@ -239,7 +248,7 @@ def upload_crackme_post(): except Exception as e: print(f"File write error: {e}") flash('Failed to save file. Please try again.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', all_tags=OBFUSCATION_TAGS) # Insert crackme into database try: @@ -361,3 +370,51 @@ def edit_crackme_post(hexid): flash('No changes were made.', FLASH_SUCCESS) return redirect(f'/crackme/{hexid}') + + +@crackme_bp.route('/crackme//tags/request', methods=['POST']) +@login_required +@limit("20 per day", key_func=lambda: session.get('name')) +def request_tag_change(hexid): + """Submit a request to add and/or remove tags on a crackme. + + Requests are queued for reviewers; nothing changes on the crackme until a + reviewer approves. + """ + 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) + + current = crackme.get('tags', []) + # Only propose adds for tags not already present, and removes for tags that are. + add = [t for t in normalize_tags(request.form.getlist('add')) if t not in current] + remove = [t for t in normalize_tags(request.form.getlist('remove')) if t in current] + note = bleach.clean(request.form.get('note', ''))[:500] + + if not add and not remove: + flash('Select at least one tag to add or remove.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + # Prevent a user from piling up duplicate pending requests on one crackme. + try: + if pending_tag_requests_by_user_and_crackme(username, hexid) > 0: + flash('You already have a pending tag change request for this crackme.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + except Exception as e: + print(f"Error checking pending tag requests: {e}") + + try: + tag_request_create(hexid, crackme.get('name', ''), username, + add=add, remove=remove, note=note) + except Exception as e: + print(f"Error creating tag request: {e}") + abort(500) + + flash('Tag change request submitted for review. Thank you!', FLASH_SUCCESS) + return redirect(f'/crackme/{hexid}') diff --git a/app/controllers/search.py b/app/controllers/search.py index 14f4f8a..8255fda 100644 --- a/app/controllers/search.py +++ b/app/controllers/search.py @@ -5,6 +5,7 @@ from flask import Blueprint, render_template, request, redirect, url_for from app.models.crackme import search_crackme, random_crackmes from app.models.errors import ErrUnavailable +from app.services.tags import OBFUSCATION_TAGS, normalize_tags search_bp = Blueprint('search', __name__) @@ -12,7 +13,8 @@ @search_bp.route('/search', methods=['GET']) def search_get(): """Display the search page.""" - return render_template('search/search.html', crackmes=[], page=1, has_more=False, show_all=False, search_params={}) + return render_template('search/search.html', crackmes=[], page=1, has_more=False, + show_all=False, search_params={}, all_tags=OBFUSCATION_TAGS) @search_bp.route('/search', methods=['POST']) @@ -24,6 +26,7 @@ def search_post(): lang = request.form.getlist('lang') arch = request.form.getlist('arch') platform = request.form.getlist('platform') + tags = normalize_tags(request.form.getlist('tags')) # Get difficulty range try: @@ -161,6 +164,7 @@ def parse_size_with_unit(value_str, unit_str): 'lang': lang, # list 'arch': arch, # list 'platform': platform, # list + 'tags': tags, # list 'difficulty-min': difficulty_min, 'difficulty-max': difficulty_max, 'quality-min': quality_min, @@ -187,6 +191,7 @@ def parse_size_with_unit(value_str, unit_str): lang=lang, arch=arch, platform=platform, + tags=tags, difficulty_min=difficulty_min, difficulty_max=difficulty_max, quality_min=quality_min, @@ -212,6 +217,7 @@ def parse_size_with_unit(value_str, unit_str): lang=lang, arch=arch, platform=platform, + tags=tags, difficulty_min=difficulty_min, difficulty_max=difficulty_max, quality_min=quality_min, @@ -238,7 +244,8 @@ def parse_size_with_unit(value_str, unit_str): page=page, has_more=has_more, show_all=show_all, - search_params=search_params) + search_params=search_params, + all_tags=OBFUSCATION_TAGS) @search_bp.route('/random', methods=['GET']) @@ -276,4 +283,5 @@ def random_get(): page=1, has_more=False, show_all=True, - search_params=search_params) + search_params=search_params, + all_tags=OBFUSCATION_TAGS) diff --git a/app/models/crackme.py b/app/models/crackme.py index b15fa31..51b6b5a 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -123,6 +123,7 @@ def crackme_increment_downloads(crackme_hexid): def search_crackme(name='', author='', lang='', arch='', platform='', + tags=None, difficulty_min=0, difficulty_max=6, quality_min=0, quality_max=6, downloads_min=0, downloads_max=None, @@ -190,6 +191,12 @@ def search_crackme(name='', author='', lang='', arch='', platform='', query['platform'] = {'$in': platform} else: query['platform'] = platform + # tags: a crackme must carry *all* selected tags ($all) + if tags: + if isinstance(tags, str): + tags = [tags] + if tags: + query['tags'] = {'$all': tags} skip = (page - 1) * per_page # Determine sort field and direction @@ -274,7 +281,7 @@ 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): +def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, tags=None): """Prepare a crackme object without inserting it.""" if not check_connection(): raise ErrUnavailable("Database is unavailable") @@ -299,7 +306,8 @@ def crackme_create_prepare(name, info, username, lang, arch, platform, size, ori 'nbdownloads': 0, 'platform': platform, 'size': size, - 'original_filename': original_filename + 'original_filename': original_filename, + 'tags': tags or [] } @@ -348,7 +356,7 @@ def crackme_update(hexid, updates): raise ErrUnavailable("Database is unavailable") # Only allow updating these fields (name is excluded to avoid database issues) - allowed_fields = {'info', 'lang', 'arch', 'platform'} + allowed_fields = {'info', 'lang', 'arch', 'platform', 'tags'} filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields} if not filtered_updates: @@ -377,6 +385,29 @@ def crackme_update(hexid, updates): return changes +def crackme_set_tags(hexid, tags): + """Overwrite the obfuscation tags on a crackme. + + Args: + hexid: The hex ID of the crackme + tags: The full (already validated) list of tags to store + + Returns: + The previous list of tags, or None if the crackme was not found. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('crackme') + crackme = collection.find_one({'hexid': hexid}, {'tags': 1}) + if not crackme: + return None + + old_tags = crackme.get('tags', []) + collection.update_one({'hexid': hexid}, {'$set': {'tags': list(tags)}}) + return old_tags + + def crackme_by_hexid_any(hexid): """Get crackme by hex ID regardless of visibility status. diff --git a/app/models/tag_request.py b/app/models/tag_request.py new file mode 100644 index 0000000..62cc39c --- /dev/null +++ b/app/models/tag_request.py @@ -0,0 +1,128 @@ +""" +Tag change request model. + +Any logged-in user can request that tags be added to or removed from a crackme. +Requests land in a pending queue that reviewers approve or reject; approving a +request applies the add/remove sets to the crackme's tags. +""" + +from datetime import datetime +from bson import ObjectId +from pymongo import DESCENDING +from app.services.database import get_collection, check_connection +from app.models.errors import ErrNoResult, ErrUnavailable + +# Request lifecycle states. +STATUS_PENDING = 'pending' +STATUS_APPROVED = 'approved' +STATUS_REJECTED = 'rejected' + + +def tag_request_create(hexid, crackme_name, requester, add=None, remove=None, note=''): + """Create a pending tag change request. + + Args: + hexid: Hex ID of the target crackme + crackme_name: Name of the crackme (denormalized for the review queue) + requester: Username of the requesting user + add: List of (already validated) tags to add + remove: List of (already validated) tags to remove + note: Optional free-text justification from the requester + + Returns: + The inserted request document. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + obj_id = ObjectId() + + request = { + '_id': obj_id, + 'hexid': str(obj_id), + 'crackme_hexid': hexid, + 'crackme_name': crackme_name, + 'requester': requester, + 'add': list(add or []), + 'remove': list(remove or []), + 'note': note or '', + 'status': STATUS_PENDING, + 'created_at': datetime.utcnow(), + 'reviewed_by': None, + 'reviewed_at': None, + } + + collection.insert_one(request) + return request + + +def tag_requests_pending(): + """Return all pending tag change requests, newest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + return list(collection.find({'status': STATUS_PENDING}) + .sort('created_at', DESCENDING)) + + +def count_pending_tag_requests(): + """Count pending tag change requests.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + return collection.count_documents({'status': STATUS_PENDING}) + + +def tag_request_by_hexid(hexid): + """Get a tag change request by its hex ID. + + Raises: + ErrNoResult: If no request matches. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + request = collection.find_one({'hexid': hexid}) + if not request: + raise ErrNoResult("Tag request not found") + return request + + +def tag_request_set_status(hexid, status, reviewer): + """Mark a request approved/rejected and stamp the reviewer. + + Returns: + The updated request document, or None if not found. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + from pymongo import ReturnDocument + + collection = get_collection('tag_request') + return collection.find_one_and_update( + {'hexid': hexid}, + {'$set': { + 'status': status, + 'reviewed_by': reviewer, + 'reviewed_at': datetime.utcnow(), + }}, + return_document=ReturnDocument.AFTER + ) + + +def pending_tag_requests_by_user_and_crackme(requester, crackme_hexid): + """Count a user's already-pending requests for one crackme (anti-spam).""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + return collection.count_documents({ + 'requester': requester, + 'crackme_hexid': crackme_hexid, + 'status': STATUS_PENDING, + }) diff --git a/app/services/tags.py b/app/services/tags.py new file mode 100644 index 0000000..12a0e29 --- /dev/null +++ b/app/services/tags.py @@ -0,0 +1,70 @@ +""" +Obfuscation tag vocabulary and helpers. + +Crackmes can be labeled with high-level anti-analysis / obfuscation tags +(anti-debugging, string encryption, packers, ...). The controlled vocabulary +below mirrors the 21 "obfuscation classes" of the crackmes-RE dataset, whose +labels were produced by an AI reading public solution writeups and comments and +are therefore **not guaranteed to be accurate**. The dataset is what seeds the +initial tags (see ``script/import_tags.py``); afterwards authors pick tags at +submission time, reviewers can override them, and any user can request changes. +""" + +# Canonical, ordered list of obfuscation tags (the 21 dataset classes). +# Order is roughly by how common the class is so the UI reads sensibly. +OBFUSCATION_TAGS = [ + "Anti-debugging", + "Packer", + "String / data encryption", + "Self-modifying / runtime decrypt", + "Code virtualization / VM", + "Crypto / hash algorithm", + "Anti-tamper / integrity", + "Control-flow obfuscation", + "Anti-disassembly", + "Timing checks", + "Exception-based", + "Import / API obfuscation", + "Custom / generic obfuscation", + "Encoding (base64/hex)", + "Commercial protector", + "Stripped / no symbols", + "Anti-attach / thread tricks", + "Binary hardening (ASLR/PIE/canary)", + "Anti-VM / sandbox", + "Nag / trial", + "Anti-static analysis", +] + +# Fast membership set and canonical ordering index. +TAG_SET = set(OBFUSCATION_TAGS) +_TAG_ORDER = {tag: i for i, tag in enumerate(OBFUSCATION_TAGS)} + +# Where the "?" next to the tags links to. The tags originate from this +# AI-generated dataset, so the explanation of their (im)precision lives there. +DATASET_URL = "https://github.com/crackmesone/crackmes-re-dataset" + + +def normalize_tags(raw_tags): + """Validate, de-duplicate, and canonically order a list of tags. + + Accepts any iterable of strings (e.g. ``request.form.getlist('tags')``), + drops anything not in the controlled vocabulary, removes duplicates, and + returns the survivors in the canonical :data:`OBFUSCATION_TAGS` order. + """ + if not raw_tags: + return [] + + seen = set() + for tag in raw_tags: + if isinstance(tag, str): + tag = tag.strip() + if tag in TAG_SET: + seen.add(tag) + + return sorted(seen, key=lambda t: _TAG_ORDER[t]) + + +def is_valid_tag(tag): + """Return True if ``tag`` is part of the controlled vocabulary.""" + return tag in TAG_SET diff --git a/review/routes.py b/review/routes.py index f189040..19f79a5 100644 --- a/review/routes.py +++ b/review/routes.py @@ -44,6 +44,12 @@ ) from app.services.crypto import get_obfuscation_salt from app.services.view import is_valid_hexid +from app.services.tags import OBFUSCATION_TAGS, normalize_tags +from app.models.tag_request import ( + tag_requests_pending, count_pending_tag_requests, tag_request_by_hexid, + tag_request_set_status, STATUS_APPROVED, STATUS_REJECTED, +) +from app.models.errors import ErrNoResult # ============================================================================= @@ -593,7 +599,8 @@ def get_crackme_details(uuid): "author": crackme_obj["author"], "lang": crackme_obj["lang"], "arch": crackme_obj["arch"], - "platform": crackme_obj["platform"] + "platform": crackme_obj["platform"], + "tags": crackme_obj.get("tags", []) }, None @@ -1364,12 +1371,19 @@ def logout(): @token_required def dashboard(current_user): """Display dashboard with pending submission counts.""" + try: + tagreq_cnt = count_pending_tag_requests() + except Exception as e: + print(f"Error counting tag requests: {e}") + tagreq_cnt = 0 + return render_template( 'reviewer/dashboard.html', user=current_user['username'], is_admin=current_user['is_admin'], solution_cnt=count_pending_solutions(), - crackme_cnt=count_pending_items('crackme') + crackme_cnt=count_pending_items('crackme'), + tagreq_cnt=tagreq_cnt ) @@ -1443,7 +1457,8 @@ def viewcrackme(current_user): 'reviewer/viewcrackme.html', user=current_user['username'], is_admin=current_user['is_admin'], - crackme=crackme + crackme=crackme, + all_tags=OBFUSCATION_TAGS ) @@ -1650,6 +1665,171 @@ def approvecrackme(current_user): return redirect(url_for('reviewer.reviewcrackme', message=message)) +# ============================================================================= +# Route Handlers - Tags +# ============================================================================= + +@reviewer_bp.route('/settags', methods=['POST']) +@token_required +def settags(current_user): + """Override the obfuscation tags on a crackme (reviewer). + + Used both when reviewing a pending crackme and from the edit page. The + posted ``tags`` values are filtered against the controlled vocabulary. + """ + validate_csrf_token() + crackme_uuid = request.form.get('uuid') + redirect_to = request.form.get('redirect_to') or 'view' + + if not is_valid_hexid(crackme_uuid): + return redirect(url_for('reviewer.reviewcrackme', message="Invalid crackme id")) + + tags = normalize_tags(request.form.getlist('tags')) + + crackme = g_crackmesone_db.crackme.find_one({'hexid': crackme_uuid.lower()}) + if not crackme: + return redirect(url_for('reviewer.reviewcrackme', message="Crackme not found")) + + old_tags = crackme.get('tags', []) + g_crackmesone_db.crackme.update_one( + {'hexid': crackme_uuid.lower()}, + {'$set': {'tags': tags}} + ) + + log_reviewer_operation( + "set_crackme_tags", current_user['username'], + {"crackme_uuid": crackme_uuid, "old_tags": old_tags, "new_tags": tags}, + True + ) + + if redirect_to == 'edit': + return redirect(url_for('reviewer.editcrackme', crackme_uuid=crackme_uuid)) + return redirect(url_for('reviewer.viewcrackme', crackme_uuid=crackme_uuid)) + + +def _apply_tag_request(tag_request): + """Apply an approved tag request's add/remove sets to the crackme. + + Returns the crackme's new tag list, or None if the crackme is gone. + """ + crackme = g_crackmesone_db.crackme.find_one( + {'hexid': tag_request['crackme_hexid']} + ) + if not crackme: + return None + + current = list(crackme.get('tags', [])) + for tag in tag_request.get('add', []): + if tag not in current: + current.append(tag) + for tag in tag_request.get('remove', []): + if tag in current: + current.remove(tag) + + new_tags = normalize_tags(current) + g_crackmesone_db.crackme.update_one( + {'hexid': tag_request['crackme_hexid']}, + {'$set': {'tags': new_tags}} + ) + return new_tags + + +@reviewer_bp.route('/tagrequests') +@token_required +def tagrequests(current_user): + """List pending tag change requests for review.""" + try: + requests_list = tag_requests_pending() + except Exception as e: + print(f"Error loading tag requests: {e}") + requests_list = [] + + return render_template( + 'reviewer/tagrequests.html', + user=current_user['username'], + is_admin=current_user['is_admin'], + requests=requests_list, + message=request.args.get('message') + ) + + +@reviewer_bp.route('/approvetagrequest', methods=['POST']) +@token_required +def approvetagrequest(current_user): + """Approve a tag change request and apply it to the crackme.""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + + try: + tag_request = tag_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.tagrequests', message="Request not found")) + + if tag_request.get('status') != 'pending': + return redirect(url_for('reviewer.tagrequests', message="Request already handled")) + + new_tags = _apply_tag_request(tag_request) + if new_tags is None: + # Crackme no longer exists; close the request as rejected. + tag_request_set_status(req_hexid, STATUS_REJECTED, current_user['username']) + return redirect(url_for('reviewer.tagrequests', message="Crackme not found; request closed")) + + tag_request_set_status(req_hexid, STATUS_APPROVED, current_user['username']) + + log_reviewer_operation( + "approve_tag_request", current_user['username'], + {"request": req_hexid, "crackme": tag_request['crackme_hexid'], "new_tags": new_tags}, + True + ) + + try: + send_user_notification( + tag_request['requester'], + f"Your tag change request for '" + f"{html_escape(tag_request['crackme_name'])}' has been approved." + ) + except Exception as e: + print(f"Notification error: {e}") + + return redirect(url_for('reviewer.tagrequests', message="Request approved")) + + +@reviewer_bp.route('/rejecttagrequest', methods=['POST']) +@token_required +def rejecttagrequest(current_user): + """Reject a tag change request.""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + reject_reason = request.form.get('reject_reason') + + try: + tag_request = tag_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.tagrequests', message="Request not found")) + + if tag_request.get('status') != 'pending': + return redirect(url_for('reviewer.tagrequests', message="Request already handled")) + + tag_request_set_status(req_hexid, STATUS_REJECTED, current_user['username']) + + log_reviewer_operation( + "reject_tag_request", current_user['username'], + {"request": req_hexid, "crackme": tag_request['crackme_hexid'], "reason": reject_reason}, + True + ) + + try: + notif = (f"Your tag change request for '" + f"{html_escape(tag_request['crackme_name'])}' has been rejected.") + if reject_reason: + notif += f" Reason: {html_escape(reject_reason)}" + send_user_notification(tag_request['requester'], notif) + except Exception as e: + print(f"Notification error: {e}") + + return redirect(url_for('reviewer.tagrequests', message="Request rejected")) + + # ============================================================================= # Route Handlers - Admin: Delete Approved Content # ============================================================================= @@ -1723,6 +1903,7 @@ def editcrackme(current_user): lang = request.form.get('lang', '') arch = request.form.get('arch', '') platform = request.form.get('platform', '') + tags = normalize_tags(request.form.getlist('tags')) notify_author = request.form.get('notify_author') == 'on' if True: @@ -1744,6 +1925,8 @@ def editcrackme(current_user): changes.append(f"arch: '{crackme_obj.get('arch')}' -> '{arch}'") if crackme_obj.get('platform') != platform: changes.append(f"platform: '{crackme_obj.get('platform')}' -> '{platform}'") + if sorted(crackme_obj.get('tags', [])) != sorted(tags): + changes.append(f"tags: {crackme_obj.get('tags', [])} -> {tags}") # Update the crackme (name excluded) g_crackmesone_db.crackme.update_one( @@ -1752,7 +1935,8 @@ def editcrackme(current_user): "info": info, "lang": lang, "arch": arch, - "platform": platform + "platform": platform, + "tags": tags }} ) @@ -1837,7 +2021,8 @@ def editcrackme(current_user): "lang": crackme_obj.get('lang', ''), "arch": crackme_obj.get('arch', ''), "platform": crackme_obj.get('platform', ''), - "author": crackme_obj.get('author', '') + "author": crackme_obj.get('author', ''), + "tags": crackme_obj.get('tags', []) } else: error = "Crackme not found" @@ -1848,7 +2033,8 @@ def editcrackme(current_user): is_admin=current_user['is_admin'], crackme=crackme, message=message, - error=error + error=error, + all_tags=OBFUSCATION_TAGS ) diff --git a/review/templates/reviewer/dashboard.html b/review/templates/reviewer/dashboard.html index bdaa3e2..fa2a4a6 100644 --- a/review/templates/reviewer/dashboard.html +++ b/review/templates/reviewer/dashboard.html @@ -27,6 +27,7 @@

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

Review solutions Review crackmes + Review tag requests {% if is_admin %} Delete solution Delete crackme @@ -56,6 +57,15 @@

{{ crackme_cnt }}


+ +
+
+
+

The number of tag change requests to review:

+

{{ tagreq_cnt }}


+
+
+
diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 8f9ad7f..b20feac 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -93,6 +93,16 @@

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

+
+ + {% for tag in all_tags %} + + {% endfor %} +
+
diff --git a/review/templates/reviewer/tagrequests.html b/review/templates/reviewer/tagrequests.html new file mode 100644 index 0000000..7cabd3a --- /dev/null +++ b/review/templates/reviewer/tagrequests.html @@ -0,0 +1,74 @@ + + + + + Tag change requests + + + + + + + + + +
+ {% if message %} +
{{ message }}
+ {% endif %} + +

Pending tag change requests

+ + {% if not requests %} +

No pending tag change requests.

+ {% else %} + {% for req in requests %} +
+
+

+ "{{ req.crackme_name }}" + — view crackme +
+ Requested by {{ req.requester }} + on {{ req.created_at }} +

+ {% if req.add %} +

Add: {{ req.add|join(', ') }}

+ {% endif %} + {% if req.remove %} +

Remove: {{ req.remove|join(', ') }}

+ {% endif %} + {% if req.note %} +

Note: {{ req.note }}

+ {% endif %} + +
+ + + +
+ +
+ + + + +
+
+
+ {% endfor %} + {% endif %} +
+ + + diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 5df6468..4dd96f3 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -47,6 +47,26 @@

Download
+

+

Tags

+

+

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

+
+ + + +
+ {% for tag in all_tags %} + + {% endfor %} +
+ +
+
+

Review

diff --git a/script/import_tags.py b/script/import_tags.py new file mode 100644 index 0000000..e83ad2a --- /dev/null +++ b/script/import_tags.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Import obfuscation tags from the crackmes-RE dataset into the database. + +The dataset (https://github.com/crackmesone/crackmes-re-dataset) ships one JSONL +record per crackme, keyed by `hexid`, with an `obfuscation_classes` list of +high-level tags produced by an AI reading public solutions and comments. This +script joins those records to crackme documents by hexid and writes the +normalized tags into the `tags` field. + +Only tags in the controlled vocabulary (app/services/tags.py) are kept, so the +site and the dataset never drift apart. + +Usage: + cd /path/to/crackmesone_python/script + python import_tags.py --dataset /path/to/crackmes_dataset.jsonl # Dry run + python import_tags.py --dataset /path/to/crackmes_dataset.jsonl --apply # Apply + + --overwrite Replace existing tags even if a crackme already has some + (default: skip crackmes that already have a non-empty tags list) +""" + +import argparse +import json +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from pymongo import MongoClient + +from app.services.tags import normalize_tags + + +def load_dataset_tags(dataset_path): + """Return {hexid: [normalized tags]} from a dataset JSONL file.""" + mapping = {} + with open(dataset_path, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError as e: + print(f" Skipping malformed line: {e}") + continue + hexid = record.get("hexid") + if not hexid: + continue + tags = normalize_tags(record.get("obfuscation_classes", [])) + mapping[hexid] = tags + return mapping + + +def main(): + parser = argparse.ArgumentParser(description="Import obfuscation tags from the crackmes-RE dataset") + parser.add_argument("--dataset", required=True, help="Path to crackmes_dataset.jsonl") + parser.add_argument("--apply", action="store_true", help="Actually write changes") + parser.add_argument("--overwrite", action="store_true", + help="Replace tags even on crackmes that already have some") + args = parser.parse_args() + + if not os.path.exists(args.dataset): + print(f"Error: dataset file not found: {args.dataset}") + return 1 + + config_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "config", "config.json" + ) + with open(config_path, "r") as f: + config = json.load(f) + + mongo_url = config["Database"].get("URL", "mongodb://127.0.0.1:27017") + db_name = config["Database"].get("Name", "crackmesone") + client = MongoClient(mongo_url) + collection = client[db_name]["crackme"] + print(f"Connected to MongoDB: {db_name}") + + dry_run = not args.apply + if dry_run: + print("\n[DRY RUN MODE - No changes will be made; use --apply to write]\n") + + print(f"Loading dataset from {args.dataset} ...") + dataset_tags = load_dataset_tags(args.dataset) + print(f"Loaded tags for {len(dataset_tags)} dataset records\n") + + updated = 0 + skipped_existing = 0 + skipped_no_tags = 0 + not_in_db = 0 + + for hexid, tags in dataset_tags.items(): + crackme = collection.find_one({"hexid": hexid}, {"tags": 1}) + if not crackme: + not_in_db += 1 + continue + + if not tags: + skipped_no_tags += 1 + continue + + existing = crackme.get("tags") or [] + if existing and not args.overwrite: + skipped_existing += 1 + continue + + if sorted(existing) == sorted(tags): + continue + + if dry_run: + print(f" {hexid}: {existing} -> {tags}") + else: + collection.update_one({"hexid": hexid}, {"$set": {"tags": tags}}) + updated += 1 + + print(f"\n{'=' * 70}") + print("Summary:") + print(f" {'Would update' if dry_run else 'Updated'}: {updated}") + print(f" Skipped (already tagged, no --overwrite): {skipped_existing}") + print(f" Skipped (dataset had no valid tags): {skipped_no_tags}") + print(f" Dataset records not in DB: {not_in_db}") + print(f"{'=' * 70}") + + if dry_run and updated > 0: + print("\nRun with --apply to actually update the database") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 7afdbbf..f8fae6a 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -100,6 +100,21 @@

Quick Rules

+
+
+ +
+
+

+ Select the anti-analysis / obfuscation techniques your crackme uses (optional). Reviewers may adjust these. +

+ {% for tag in all_tags %} + + {% endfor %} +
+
diff --git a/templates/crackme/read.html b/templates/crackme/read.html index f505743..945b544 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -103,6 +103,24 @@ .modal.active { display: flex; } + .tag-help { + display: inline-block; + width: 1.1rem; + height: 1.1rem; + line-height: 1.1rem; + text-align: center; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 0.7rem; + font-weight: bold; + margin-left: 4px; + text-decoration: none; + } + .tags-checkboxes label { + display: inline-block; + margin-right: 12px; + margin-bottom: 4px; + }

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

@@ -166,6 +184,34 @@

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

+
+

+ Tags + ? +

+

+ {% for tag in tags %} + {{ tag }} +

+ {% else %} + No tags yet. + {% endfor %} +

+

+ Tags are AI-generated from write-ups & comments and may be inaccurate. + {% if AuthLevel == "auth" %} + Request a tag change + {% endif %} +

+
+
+
+{% if AuthLevel == "auth" %} + +{% endif %} + {% include 'partial/footer.html' %}
diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 63ebf38..4f2b6b4 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -59,14 +59,14 @@

{% for group in tag_groups %}
{% if group.sublabels %} {% for sub in group.sublabels %} {% endfor %} @@ -77,6 +77,24 @@

+

diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 7f53cd4..3698ce5 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -23,7 +23,7 @@

Quick Rules

Read the full crackme submission rules for detailed guidelines.

-
+
@@ -106,19 +106,21 @@

Quick Rules

- Select the anti-analysis / obfuscation techniques your crackme uses (optional). Pick a broad - category and, where it applies, the specific technique underneath. Reviewers may adjust these. + Select the anti-analysis / obfuscation techniques your crackme uses. Please tag 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.

{% for group in tag_groups %}
{% if group.sublabels %}
{% for sub in group.sublabels %} {% endfor %}
@@ -151,6 +153,37 @@

Quick Rules

+ + {% include 'partial/footer.html' %} {% endblock %} {% block foot %}{% endblock %} diff --git a/templates/crackme/read.html b/templates/crackme/read.html index 76a8004..f1359d2 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -121,6 +121,49 @@ margin-right: 12px; margin-bottom: 4px; } + .tag-transfer { + display: flex; + gap: 12px; + } + .tag-transfer-col { + flex: 1; + min-width: 0; + } + .tag-transfer-title { + font-size: 0.72rem; + color: #999; + margin-bottom: 4px; + } + .tag-transfer-list { + border: 1px solid #444; + border-radius: 4px; + min-height: 200px; + max-height: 300px; + overflow-y: auto; + padding: 4px; + } + .tag-move-item { + display: block; + padding: 3px 7px; + margin: 1px 0; + cursor: pointer; + border-radius: 3px; + font-size: 0.85rem; + color: #ddd; + } + .tag-move-item:hover { + background: #3a3a3a; + } + .tag-move-item.is-sub { + padding-left: 22px; + font-size: 0.8rem; + color: #b3b3b3; + } + .tag-transfer-empty { + color: #777; + font-size: 0.8rem; + padding: 6px; + } .chip.tag-chip { background: #9acc14; color: #fff; @@ -374,45 +417,19 @@

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