diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py
index 3d447eb..bd5c774 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 get_tag_groups, get_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', []),
+ tag_groups=get_tag_groups(),
+ tags_dataset_url=get_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', tag_groups=get_tag_groups())
@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', tag_groups=get_tag_groups())
name = bleach.clean(request.form.get('name', ''))
info = bleach.clean(request.form.get('info', ''))
@@ -157,6 +164,9 @@ def upload_crackme_post():
arch = bleach.clean(request.form.get('arch', ''))
platform = request.form.get('platform', '')
difficulty = request.form.get('difficulty', '')
+ # Keep only values from the controlled vocabulary. Tags are not mandatory
+ # (a crackme with no matching technique may legitimately have none).
+ tags = normalize_tags(request.form.getlist('tags'))
# Validate difficulty
try:
@@ -165,22 +175,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', tag_groups=get_tag_groups())
# Validate reCAPTCHA
if not verify_recaptcha(request):
flash('reCAPTCHA invalid!', FLASH_ERROR)
- return render_template('crackme/create.html')
+ return render_template('crackme/create.html', tag_groups=get_tag_groups())
# 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', tag_groups=get_tag_groups())
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', tag_groups=get_tag_groups())
# Read file data
file_data = file.read()
@@ -188,22 +198,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', tag_groups=get_tag_groups())
# 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', tag_groups=get_tag_groups())
# 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', tag_groups=get_tag_groups())
# 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', tag_groups=get_tag_groups())
# Store the uploaded file size
size = len(file_data)
@@ -212,7 +222,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', tag_groups=get_tag_groups())
except ErrNoResult:
pass # No duplicate, continue
@@ -221,7 +231,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 +249,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', tag_groups=get_tag_groups())
# Insert crackme into database
try:
@@ -361,3 +371,53 @@ 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', [])
+ # The form submits the full desired set of applied tags; derive the add/remove
+ # sets by diffing against what the crackme currently carries.
+ desired = normalize_tags(request.form.getlist('applied'))
+ add = [t for t in desired if t not in current]
+ remove = [t for t in current if t not in desired]
+ note = bleach.clean(request.form.get('note', ''))[:500]
+
+ if not add and not remove:
+ flash('No tag changes were selected.', 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..eccf324 100644
--- a/app/controllers/search.py
+++ b/app/controllers/search.py
@@ -2,60 +2,79 @@
Search controller - Searching crackmes.
"""
-from flask import Blueprint, render_template, request, redirect, url_for
+from flask import Blueprint, render_template, request
from app.models.crackme import search_crackme, random_crackmes
from app.models.errors import ErrUnavailable
+from app.services.tags import get_tag_groups, normalize_tags
search_bp = Blueprint('search', __name__)
@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={})
+ """Display the search page.
+
+ Searches are driven by URL query parameters so results are bookmarkable and
+ shareable (issue #162). A bare ``/search`` with no parameters shows the empty
+ form; any parameter (e.g. ``/search?tags=Packer``) runs a search.
+ """
+ if not request.args:
+ return render_template('search/search.html', crackmes=[], page=1, has_more=False,
+ show_all=False, search_params={}, tag_groups=get_tag_groups())
+ return _render_search(request.args)
@search_bp.route('/search', methods=['POST'])
def search_post():
- """Handle search form submission."""
- name = request.form.get('name', '')
- author = request.form.get('author', '')
+ """Handle a POST search submission (kept for backward compatibility)."""
+ return _render_search(request.form)
+
+
+def _render_search(source):
+ """Run a crackme search from a request MultiDict (``request.args`` or form).
+
+ ``source`` provides ``.get`` and ``.getlist``, so the same logic serves both
+ bookmarkable GET URLs and legacy POST submissions.
+ """
+ name = source.get('name', '')
+ author = source.get('author', '')
# Use getlist() for multi-select fields
- lang = request.form.getlist('lang')
- arch = request.form.getlist('arch')
- platform = request.form.getlist('platform')
+ lang = source.getlist('lang')
+ arch = source.getlist('arch')
+ platform = source.getlist('platform')
+ tags = normalize_tags(source.getlist('tags'))
# Get difficulty range
try:
- difficulty_min = int(request.form.get('difficulty-min', 1))
+ difficulty_min = int(source.get('difficulty-min', 1))
except (ValueError, TypeError):
difficulty_min = 1
try:
- difficulty_max = int(request.form.get('difficulty-max', 6))
+ difficulty_max = int(source.get('difficulty-max', 6))
except (ValueError, TypeError):
difficulty_max = 6
# Get quality range
try:
- quality_min = int(request.form.get('quality-min', 1))
+ quality_min = int(source.get('quality-min', 1))
except (ValueError, TypeError):
quality_min = 1
try:
- quality_max = int(request.form.get('quality-max', 6))
+ quality_max = int(source.get('quality-max', 6))
except (ValueError, TypeError):
quality_max = 6
# Get downloads range
try:
- downloads_min = int(request.form.get('downloads-min', 0))
+ downloads_min = int(source.get('downloads-min', 0))
if downloads_min < 0:
downloads_min = 0
except (ValueError, TypeError):
downloads_min = 0
- downloads_max_str = request.form.get('downloads-max', '')
+ downloads_max_str = source.get('downloads-max', '')
if downloads_max_str == '' or downloads_max_str.lower() == 'any':
downloads_max = None
else:
@@ -68,13 +87,13 @@ def search_post():
# Get solutions range
try:
- solutions_min = int(request.form.get('solutions-min', 0))
+ solutions_min = int(source.get('solutions-min', 0))
if solutions_min < 0:
solutions_min = 0
except (ValueError, TypeError):
solutions_min = 0
- solutions_max_str = request.form.get('solutions-max', '')
+ solutions_max_str = source.get('solutions-max', '')
if solutions_max_str == '' or solutions_max_str.lower() == 'any':
solutions_max = None
else:
@@ -87,13 +106,13 @@ def search_post():
# Get comments range
try:
- comments_min = int(request.form.get('comments-min', 0))
+ comments_min = int(source.get('comments-min', 0))
if comments_min < 0:
comments_min = 0
except (ValueError, TypeError):
comments_min = 0
- comments_max_str = request.form.get('comments-max', '')
+ comments_max_str = source.get('comments-max', '')
if comments_max_str == '' or comments_max_str.lower() == 'any':
comments_max = None
else:
@@ -125,31 +144,31 @@ def parse_size_with_unit(value_str, unit_str):
except (ValueError, TypeError):
return None
- size_min_str = request.form.get('size-min', '')
- size_min_unit = request.form.get('size-min-unit', 'KB')
+ size_min_str = source.get('size-min', '')
+ size_min_unit = source.get('size-min-unit', 'KB')
size_min = parse_size_with_unit(size_min_str, size_min_unit)
if size_min is None:
size_min = 0
- size_max_str = request.form.get('size-max', '')
- size_max_unit = request.form.get('size-max-unit', 'MB')
+ size_max_str = source.get('size-max', '')
+ size_max_unit = source.get('size-max-unit', 'MB')
size_max = parse_size_with_unit(size_max_str, size_max_unit)
# Get page number and show_all flag
try:
- page = int(request.form.get('page', 1))
+ page = int(source.get('page', 1))
if page < 1:
page = 1
except (ValueError, TypeError):
page = 1
- show_all = request.form.get('show_all') == '1'
+ show_all = source.get('show_all') == '1'
# Get sort options
- sort_by = request.form.get('sort_by', 'date')
+ sort_by = source.get('sort_by', 'date')
if sort_by not in ('date', 'size', 'downloads', 'solutions', 'comments', 'quality', 'difficulty'):
sort_by = 'date'
- sort_order = request.form.get('sort_order', 'desc')
+ sort_order = source.get('sort_order', 'desc')
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'
@@ -161,6 +180,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 +207,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 +233,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 +260,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,
+ tag_groups=get_tag_groups())
@search_bp.route('/random', methods=['GET'])
@@ -276,4 +299,5 @@ def random_get():
page=1,
has_more=False,
show_all=True,
- search_params=search_params)
+ search_params=search_params,
+ tag_groups=get_tag_groups())
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..5aaab1a
--- /dev/null
+++ b/app/services/tags.py
@@ -0,0 +1,297 @@
+"""
+Obfuscation tag vocabulary and helpers.
+
+Crackmes can be labeled with high-level anti-analysis / obfuscation tags
+(anti-debugging, string encryption, packers, ...) plus finer **sub-labels**
+nested under some of those classes. The labels come from the crackmes-RE dataset
+(AI-generated, so **not guaranteed accurate**).
+
+The controlled vocabulary is **stored in the ``tag_vocabulary`` MongoDB
+collection** (a single document) so it can be updated without code changes when
+the dataset changes -- regenerate it (and re-tag crackmes) from the dataset
+with ``script/sync_tags.py``. The ``DEFAULT_*`` values below are the
+built-in baseline: they seed that collection and act as a fallback when it is
+empty (fresh DB, tests, DB unavailable).
+
+Callers should use the accessor functions (``get_tag_groups()``,
+``normalize_tags()``, ...) rather than reading module-level constants, because
+the active vocabulary is resolved from the database at runtime.
+"""
+
+from app.services.database import get_collection, check_connection
+
+# Collection + document id that hold the live vocabulary.
+VOCAB_COLLECTION = "tag_vocabulary"
+VOCAB_ID = "current"
+
+# Where the "?" next to the tags links to (the AI-generated source dataset).
+DATASET_URL = "https://github.com/crackmesone/crackmes-re-dataset"
+
+# ---------------------------------------------------------------------------
+# Built-in baseline (used to seed the DB collection and as a fallback)
+# ---------------------------------------------------------------------------
+
+# High-level obfuscation classes, ordered by how common they are.
+DEFAULT_CLASSES = [
+ "Anti-debugging",
+ "Packer",
+ "String / data encryption",
+ "Self-modifying / runtime decrypt",
+ "Code virtualization / VM",
+ "Crypto / hash algorithm",
+ "Control-flow obfuscation",
+ "Anti-tamper / integrity",
+ "Anti-disassembly",
+ "Import / API obfuscation",
+ "Custom / generic obfuscation",
+ "Encoding (base64/hex)",
+ "Binary hardening (ASLR/PIE/canary)",
+ "Anti-VM / sandbox",
+ "Nag / trial",
+ "Anti-static analysis",
+]
+
+# Finer sub-labels nested under their parent class.
+DEFAULT_SUBLABELS = {
+ "Anti-debugging": [
+ "IsDebuggerPresent",
+ "Debugger/tool window detection",
+ "Timing (rdtsc/GetTickCount)",
+ "PEB BeingDebugged / NtGlobalFlag",
+ "INT3 / 0xCC breakpoint scan",
+ "ptrace (Linux)",
+ "Exception-based (SEH/VEH/INT2D)",
+ "Hardware breakpoints (DRx)",
+ "NtQueryInformationProcess",
+ "OutputDebugString",
+ "CheckRemoteDebuggerPresent",
+ "Self-debug / block debugger",
+ "TLS callback",
+ "Anti-dump",
+ "Parent-process check",
+ "Anti-attach / thread suspension",
+ "DbgBreakPoint/DbgUiRemoteBreakin patch",
+ "CloseHandle invalid-handle",
+ ],
+ "Packer": [
+ "UPX",
+ "FSG",
+ "ASPack",
+ "MPRESS",
+ "tElock",
+ "VMProtect",
+ "Petite",
+ "Yoda",
+ "ASProtect",
+ ".NET Reactor",
+ "ConfuserEx",
+ "PECompact",
+ "exepack",
+ "Enigma",
+ "Themida",
+ "ExeCryptor",
+ "WinLicense",
+ "SmartAssembly",
+ "Other named (Morphine/Neolite/PEtiteā¦)",
+ "PELock",
+ "CodeVirtualizer",
+ "PKLite",
+ "MEW",
+ "Dotfuscator",
+ ],
+ "Control-flow obfuscation": [
+ "Spaghetti / junk-branch",
+ "Exception / interrupt-based",
+ "Indirect / computed jumps & calls",
+ "State machine / dispatcher",
+ "Control-flow flattening (CFF)",
+ "Return-address / stack-based",
+ ],
+ "Anti-disassembly": [
+ "Junk / garbage bytes",
+ "Malformed PE / bad bytes (UD2)",
+ "Opaque predicates",
+ "Overlapping / misaligned instructions",
+ "Jump-based desync",
+ ],
+ # AES / Base64 / RC4 / TEA-XTEA also appear as string-encryption ciphers, so
+ # they are qualified with "(crypto)" here to keep each tag under one parent.
+ "Crypto / hash algorithm": [
+ "MD5",
+ "CRC32",
+ "Base64 (crypto)",
+ "RSA",
+ "AES (crypto)",
+ "SHA-256",
+ "Other / custom hash",
+ "RC4 (crypto)",
+ "TEA / XTEA (crypto)",
+ "SHA-1",
+ "Blowfish",
+ "DES / 3DES",
+ ],
+ # ...and the same four ciphers are qualified with "(encryption)" here.
+ "String / data encryption": [
+ "XOR",
+ "Base64 (encryption)",
+ "AES (encryption)",
+ "RC4 (encryption)",
+ "TEA / XTEA (encryption)",
+ "Substitution / table",
+ ],
+}
+
+# Dataset field name -> parent class (which sub-label list each field feeds).
+DEFAULT_FIELD_PARENTS = {
+ "antidebug_methods": "Anti-debugging",
+ "packers": "Packer",
+ "controlflow_methods": "Control-flow obfuscation",
+ "antidisasm_methods": "Anti-disassembly",
+ "crypto_methods": "Crypto / hash algorithm",
+ "encryption_methods": "String / data encryption",
+}
+
+# Some algorithm names appear under more than one field; they are qualified with
+# the source context so each maps to exactly one parent (e.g. dataset "AES" ->
+# "AES (crypto)" or "AES (encryption)").
+DEFAULT_QUALIFY_SUFFIX = {"crypto_methods": "crypto", "encryption_methods": "encryption"}
+DEFAULT_QUALIFY_VALUES = ["AES", "Base64", "RC4", "TEA / XTEA"]
+
+
+def default_vocabulary_doc():
+ """The built-in vocabulary as a plain dict (also used to seed the DB)."""
+ return {
+ "classes": list(DEFAULT_CLASSES),
+ "sublabels": {k: list(v) for k, v in DEFAULT_SUBLABELS.items()},
+ "field_parents": dict(DEFAULT_FIELD_PARENTS),
+ "qualify_suffix": dict(DEFAULT_QUALIFY_SUFFIX),
+ "qualify_values": list(DEFAULT_QUALIFY_VALUES),
+ "dataset_url": DATASET_URL,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Vocabulary object + DB loading (cached)
+# ---------------------------------------------------------------------------
+
+class Vocabulary:
+ """An immutable view of the controlled vocabulary with derived lookups."""
+
+ def __init__(self, doc):
+ self.classes = list(doc.get("classes") or [])
+ self.sublabels = {k: list(v) for k, v in (doc.get("sublabels") or {}).items()}
+ self.field_parents = dict(doc.get("field_parents") or {})
+ self.qualify_suffix = dict(doc.get("qualify_suffix") or {})
+ self.qualify_values = set(doc.get("qualify_values") or [])
+ self.dataset_url = doc.get("dataset_url") or DATASET_URL
+
+ # Grouped view for templates: class then its (possibly empty) sub-labels.
+ self.tag_groups = [
+ {"tag": c, "sublabels": self.sublabels.get(c, [])}
+ for c in self.classes
+ ]
+ # Flat canonical order: each class immediately followed by its sub-labels.
+ self.all_tags = []
+ for group in self.tag_groups:
+ self.all_tags.append(group["tag"])
+ self.all_tags.extend(group["sublabels"])
+ self.tag_set = set(self.all_tags)
+ self._order = {tag: i for i, tag in enumerate(self.all_tags)}
+
+ def normalize(self, raw_tags):
+ if not raw_tags:
+ return []
+ seen = set()
+ for tag in raw_tags:
+ if isinstance(tag, str):
+ tag = tag.strip()
+ if tag in self.tag_set:
+ seen.add(tag)
+ return sorted(seen, key=lambda t: self._order[t])
+
+ def is_valid(self, tag):
+ return tag in self.tag_set
+
+ def sublabel_tag(self, field, value):
+ suffix = self.qualify_suffix.get(field)
+ if suffix and value in self.qualify_values:
+ return "{} ({})".format(value, suffix)
+ return value
+
+
+_cache = None
+
+
+def _load_vocabulary_doc():
+ """Return the vocabulary document from the DB, or the default if absent."""
+ try:
+ if check_connection():
+ doc = get_collection(VOCAB_COLLECTION).find_one({"_id": VOCAB_ID})
+ if doc:
+ return doc
+ except Exception as e: # pragma: no cover - defensive; fall back to default
+ print(f"Tag vocabulary load error, using default: {e}")
+ return default_vocabulary_doc()
+
+
+def get_vocabulary():
+ """Return the active :class:`Vocabulary`, loading (and caching) it lazily."""
+ global _cache
+ if _cache is None:
+ _cache = Vocabulary(_load_vocabulary_doc())
+ return _cache
+
+
+def reload_vocabulary():
+ """Drop the cache so the next access re-reads the DB (after a sync/edit)."""
+ global _cache
+ _cache = None
+
+
+# ---------------------------------------------------------------------------
+# Public API (stable for controllers, templates, scripts)
+# ---------------------------------------------------------------------------
+
+def normalize_tags(raw_tags):
+ """Validate, de-duplicate, and canonically order a list of tags."""
+ return get_vocabulary().normalize(raw_tags)
+
+
+def is_valid_tag(tag):
+ """Return True if ``tag`` is part of the active controlled vocabulary."""
+ return get_vocabulary().is_valid(tag)
+
+
+def dataset_sublabel_tag(field, value):
+ """Map a raw dataset sub-label ``(field, value)`` to its canonical tag."""
+ return get_vocabulary().sublabel_tag(field, value)
+
+
+def get_tag_groups():
+ """Ordered ``[{"tag", "sublabels"}]`` for rendering the tag picker."""
+ return get_vocabulary().tag_groups
+
+
+def get_all_tags():
+ """Flat list of every valid tag in canonical order."""
+ return get_vocabulary().all_tags
+
+
+def get_classes():
+ """The high-level obfuscation classes, ordered."""
+ return get_vocabulary().classes
+
+
+def get_sublabels():
+ """Mapping of parent class -> ordered sub-labels."""
+ return get_vocabulary().sublabels
+
+
+def get_sublabel_fields():
+ """Mapping of dataset field name -> parent class."""
+ return get_vocabulary().field_parents
+
+
+def get_dataset_url():
+ """URL the tags "?" help link points at."""
+ return get_vocabulary().dataset_url
diff --git a/review/routes.py b/review/routes.py
index f189040..a9b9cb4 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 get_tag_groups, 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,
+ tag_groups=get_tag_groups()
)
@@ -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,
+ tag_groups=get_tag_groups()
)
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 %}
+
+
+
+
+
The number of tag change requests to review:
+
{{ tagreq_cnt }}
+
+
+
+
+
+
+ Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}
+
+
+
+
+
+ {% 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/editcrackme.html b/review/templates/reviewer/editcrackme.html
index 8f9ad7f..3787012 100644
--- a/review/templates/reviewer/editcrackme.html
+++ b/review/templates/reviewer/editcrackme.html
@@ -36,7 +36,7 @@
+ {% if crackme %}
+
+
+ {% endif %}
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 @@
+
+
+
+