Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
263 changes: 233 additions & 30 deletions app/controllers/crackme.py

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion app/controllers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
83 changes: 80 additions & 3 deletions app/models/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -281,8 +306,18 @@ def crackme_by_user_and_name(username, name, visible=True):
return result


def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, labels=None):
"""Prepare a crackme object without inserting it."""
def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename,
labels=None, flag=None, source_original_filename=None):
"""Prepare a crackme object without inserting it.

Args:
flag: The author's flag when they opted into auto-validation, else None.
Its presence is what marks a crackme as auto-validated -- there is
no separate flag to keep in sync. Stored in cleartext for reviewers;
never rendered on the public site.
source_original_filename: Filename of the private source archive that
accompanies an auto-validated submission (reviewers only).
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

Expand All @@ -307,10 +342,19 @@ def crackme_create_prepare(name, info, username, lang, arch, platform, size, ori
'platform': platform,
'size': size,
'original_filename': original_filename,
'labels': labels or []
'labels': labels or [],
'flag': flag,
'source_original_filename': source_original_filename,
# Assigned by a reviewer when approving or editing; see app.services.points.
'official_difficulty': None,
}


def crackme_is_auto_validated(crackme):
"""Return True if a crackme accepts flag submissions."""
return bool(crackme.get('flag'))


def crackme_insert(crackme):
"""Insert a prepared crackme into the database."""
if not check_connection():
Expand Down Expand Up @@ -408,6 +452,39 @@ def crackme_set_labels(hexid, labels):
return old_labels


def crackme_set_official_difficulty(hexid, difficulty):
"""Store the difficulty level a reviewer assigned to a crackme.

This is the number solves are priced at (see :mod:`app.services.points`).
It is deliberately separate from the ``difficulty`` field, which is the
community rating average and keeps moving as people rate the crackme.

Args:
hexid: The hex ID of the crackme
difficulty: Difficulty level 1-6

Returns:
True if the crackme was updated, False if it was not found or the
difficulty was out of range.
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

try:
difficulty = int(difficulty)
except (TypeError, ValueError):
return False

if difficulty < 1 or difficulty > 6:
return False

result = get_collection('crackme').update_one(
{'hexid': hexid},
{'$set': {'official_difficulty': difficulty}}
)
return result.matched_count == 1


def crackme_by_hexid_any(hexid):
"""Get crackme by hex ID regardless of visibility status.

Expand Down
103 changes: 103 additions & 0 deletions app/models/solve.py
Original file line number Diff line number Diff line change
@@ -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}
)
55 changes: 55 additions & 0 deletions app/services/flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Flag format and comparison for auto-validated crackmes.

Authors of an auto-validated crackme give us the correct flag once, at upload
time. It is stored in cleartext so reviewers can read it: verifying that a
submission really is solvable, and fixing a mistyped flag afterwards, both need
the actual value, and a hash would leave a wrong flag undetectable until users
started failing on it.

Cleartext storage means the flag must never leave the reviewer tool: the public
crackme page renders a fixed set of fields and the flag is not among them (see
``crackme_view``), and submissions are only ever compared against it here.
"""

import hmac
import re

# Standardised flag format, per issue #127: a CMO prefix and a brace-delimited
# body, so a flag is always a single unambiguous token that authors can embed in
# a binary and users can copy-paste.
FLAG_PREFIX = 'CMO'
FLAG_BODY_MAX = 56
# Printable ASCII (0x21-0x7e) minus the braces, which keeps the closing brace
# unambiguous. Keeping a flag a single whitespace-free ASCII token means neither
# copy-pasting it out of a terminal nor re-encoding it can silently change it.
FLAG_PATTERN = re.compile(
r'^%s\{[\x21-\x7a\x7c\x7e]{1,%d}\}$' % (FLAG_PREFIX, FLAG_BODY_MAX)
)

FLAG_FORMAT_HINT = f'Flags look like {FLAG_PREFIX}{{...}}'


def normalize_flag(flag):
"""Return a submitted flag with surrounding whitespace removed.

Users copy flags out of terminals, so leading/trailing whitespace is noise
rather than a wrong answer. Inner characters are left untouched -- they are
part of the flag.
"""
return (flag or '').strip()


def is_valid_flag_format(flag):
"""Return True if the flag matches the standardised CMO{...} format."""
return bool(FLAG_PATTERN.match(flag or ''))


def flags_match(stored_flag, submitted_flag):
"""Return True if a submitted flag matches the crackme's stored one.

Compared in constant time so the response can't be used to recover the flag
character by character.
"""
if not stored_flag or not submitted_flag:
return False
return hmac.compare_digest(stored_flag, submitted_flag)
1 change: 1 addition & 0 deletions app/services/limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/services/points.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading