diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 07465b3..45cb8c1 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -252,6 +252,7 @@ def _submitted_form_values(): 'labels': normalize_labels(request.form.getlist('labels')), 'auto_validation': bool(request.form.get('auto_validation')), 'flag': request.form.get('flag', ''), + 'observed_flag': request.form.get('observed_flag', ''), 'points': request.form.get('points', ''), } @@ -327,6 +328,11 @@ def upload_crackme_post(): flag = normalize_flag(request.form.get('flag', '')) if not is_valid_flag_format(flag): return _upload_rejected(f'Invalid flag format. {FLAG_FORMAT_HINT}') + observed_flag = normalize_flag(request.form.get('observed_flag', '')) + if not is_valid_flag_format(observed_flag) or observed_flag != flag: + return _upload_rejected( + 'The flag observed from the uploaded file must match the submitted CMO flag.' + ) points_value = request.form.get('points') or str(diff_int * 100) try: diff --git a/review/routes.py b/review/routes.py index c0784b5..41ac7e6 100644 --- a/review/routes.py +++ b/review/routes.py @@ -1825,6 +1825,12 @@ def approvecrackme(current_user): 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, message=f"Invalid flag format. {FLAG_FORMAT_HINT}" )) + observed_flag = normalize_flag(request.form.get('observed_flag', '')) + if not is_valid_flag_format(observed_flag) or observed_flag != flag: + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message='The flag observed from the uploaded file must match the stored CMO flag.' + )) updates['flag'] = flag # Reviewers may adjust the author's proposed reward before approval. The @@ -2355,6 +2361,11 @@ def _apply_crackme_edit(current_user, crackme_obj, request): if flag and not is_valid_flag_format(flag): return f"Invalid flag format. {FLAG_FORMAT_HINT}", None updates['flag'] = flag or None + replacing_file = bool(request.files.get('file') and request.files['file'].filename) + if flag and (flag != crackme_obj.get('flag') or replacing_file): + observed_flag = normalize_flag(request.form.get('observed_flag', '')) + if not is_valid_flag_format(observed_flag) or observed_flag != flag: + return "The flag observed from the crackme file must match the stored CMO flag.", None # Replacement source archive (reviewer-only, never published) source_data = None diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 88b71f9..63f01f1 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -109,6 +109,9 @@
Required when setting or changing a flag, or replacing a flagged file.
The flag solvers submit for points. Leave empty (or tick below) and this crackme accepts no flag submissions at all. Setting one on a crackme that had none turns auto-validation on. diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 7bb416a..5d63b70 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -81,6 +81,10 @@
The author did not enable auto-validation.
{% endif %} diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 73815a0..0f67e4e 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -117,6 +117,8 @@Defaults to difficulty × 100. You may choose any whole number from 100 to 600.
diff --git a/templates/rules/crackmerules.html b/templates/rules/crackmerules.html index 77ea506..3a2f300 100644 --- a/templates/rules/crackmerules.html +++ b/templates/rules/crackmerules.html @@ -71,7 +71,7 @@Auto-validation and providing source code are separate, optional choices. Tick Auto-validation if you want solvers to submit a flag for points. It requires:
CMO{...} — no
- spaces or braces inside. Reviewers can read it, because they need it to confirm your crackme is
+ spaces or braces inside. Enter the flag you observed from the uploaded file separately so it can be checked against the submitted flag. Reviewers can read it, because they need it to confirm your crackme is
solvable; it is never shown anywhere on the site.You may separately upload a private source ZIP for reviewers, with or without auto-validation. For a keygenme, diff --git a/tests/test_solves.py b/tests/test_solves.py index dcd30bb..dc40a28 100644 --- a/tests/test_solves.py +++ b/tests/test_solves.py @@ -118,6 +118,8 @@ def _upload(client, monkeypatch, tmp_path, **extra): 'file': (BytesIO(b'challenge-binary'), 'challenge.bin'), } data.update(extra) + if data.get('auto_validation') and 'observed_flag' not in data: + data['observed_flag'] = data.get('flag', '') return client.post('/upload/crackme', data=data, content_type='multipart/form-data', follow_redirects=True) @@ -162,6 +164,16 @@ def test_auto_validation_requires_a_well_formed_flag_but_no_source_archive( assert stored['source_original_filename'] is None +def test_upload_rejects_a_file_flag_that_disagrees_with_the_submitted_flag( + alice_client, db, alice, tmp_path, monkeypatch): + response = _upload(alice_client, monkeypatch, tmp_path, + auto_validation='on', flag=FLAG, + observed_flag='CTF{wrong_format}') + + assert b'flag observed from the uploaded file must match' in response.data + assert db.crackme.count_documents({'name': 'Flagged Challenge'}) == 0 + + def test_source_archive_without_auto_validation( alice_client, db, alice, tmp_path, monkeypatch): response = _upload(alice_client, monkeypatch, tmp_path, @@ -519,12 +531,15 @@ def _approval_data(crackme, **overrides): 'arch': crackme['arch'], 'platform': crackme['platform'], 'flag': crackme.get('flag') or '', + 'observed_flag': crackme.get('flag') or '', 'official_points': str(round( (crackme.get('official_difficulty') or 3) * 100 )), 'labels': crackme.get('labels', []), } data.update(overrides) + if 'flag' in overrides and 'observed_flag' not in overrides: + data['observed_flag'] = overrides['flag'] return data @@ -574,6 +589,24 @@ def test_approval_records_the_official_difficulty( assert stored['flag'] == 'CMO{reviewer_corrected}' +def test_approval_rejects_a_mismatched_observed_file_flag( + reviewer_client, db, flagged_crackme, monkeypatch, tmp_path): + from review import routes + + pending, _, _ = _review_dirs(monkeypatch, tmp_path) + db.crackme.update_one({'_id': flagged_crackme['_id']}, + {'$set': {'visible': False}}) + (pending / 'crackme' / flagged_crackme['hexid']).write_bytes(b'binary') + response = reviewer_client.post( + '/review/approvecrackme', + data=_approval_data(flagged_crackme, observed_flag='CTF{different}'), + follow_redirects=True, + ) + + assert b'flag observed from the uploaded file must match' in response.data + assert db.crackme.find_one({'_id': flagged_crackme['_id']})['visible'] is False + + @pytest.mark.parametrize('points', ['99', '601', '250.5', 'many']) def test_review_approval_rejects_invalid_points( reviewer_client, db, flagged_crackme, monkeypatch, tmp_path, points): @@ -795,9 +828,12 @@ def _edit(client, crackme, **overrides): 'arch': crackme.get('arch', ''), 'platform': crackme.get('platform', ''), 'flag': crackme.get('flag') or '', + 'observed_flag': crackme.get('flag') or '', 'official_points': str(round((crackme.get('official_difficulty') or 0) * 100) or ''), } data.update(overrides) + if 'flag' in overrides and 'observed_flag' not in overrides: + data['observed_flag'] = overrides['flag'] return client.post('/review/editcrackme', data=data, content_type='multipart/form-data') @@ -879,6 +915,15 @@ def test_admin_edit_rejects_a_malformed_flag_or_difficulty( assert stored['official_difficulty'] == 3 +def test_admin_cannot_change_flag_without_matching_file_observation( + admin_client, db, flagged_crackme): + response = _edit(admin_client, flagged_crackme, + flag='CMO{corrected}', observed_flag='CTF{different}') + + assert b'flag observed from the crackme file must match' in response.data + assert db.crackme.find_one({'_id': flagged_crackme['_id']})['flag'] == FLAG + + def test_admin_replaces_the_private_source_archive( admin_client, db, flagged_crackme, monkeypatch, tmp_path): from review import routes