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 @@

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

+ + +

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 @@

Auto-validation

+
+ + +
{% else %}

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 @@

Quick Rules

Players submit the flag to earn points. Only reviewers can see it.

+ +

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 (optional)

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:

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