Skip to content
Closed
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
6 changes: 6 additions & 0 deletions app/controllers/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''),
}

Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions review/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions review/templates/reviewer/editcrackme.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ <h3>Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}</h3>
<label class="form-label" for="flag">Flag</label>
<input class="form-input" type="text" id="flag" name="flag" value="{{ crackme.flag }}"
placeholder="{{ flag_format_hint }}">
<label class="form-label" for="observed_flag">Flag observed from the crackme file</label>
<input class="form-input" type="text" id="observed_flag" name="observed_flag" autocomplete="off" placeholder="CMO{...}">
<p class="form-input-hint">Required when setting or changing a flag, or replacing a flagged file.</p>
<p class="form-input-hint">
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.
Expand Down
4 changes: 4 additions & 0 deletions review/templates/reviewer/viewcrackme.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ <h3>Auto-validation</h3>
<label class="form-label" for="flag">Flag</label>
<input class="form-input" type="text" id="flag" name="flag" value="{{ crackme.flag }}" required>
</div>
<div class="form-group">
<label class="form-label" for="observed_flag">Flag independently observed from the downloaded crackme</label>
<input class="form-input" type="text" id="observed_flag" name="observed_flag" required autocomplete="off" placeholder="CMO{...}">
</div>
{% else %}
<p>The author did not enable auto-validation.</p>
{% endif %}
Expand Down
2 changes: 2 additions & 0 deletions templates/crackme/create.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ <h3>Quick Rules</h3>
Players submit the flag to earn points. Only reviewers can see it.
</p>
<input class="form-input" type="text" id="flag" name="flag" placeholder="CMO{your_crackmes_flag}" value="{{ form.flag|default('') }}">
<label class="form-label" for="observed_flag" style="margin-top: 8px;">Flag observed from the uploaded file</label>
<input class="form-input" type="text" id="observed_flag" name="observed_flag" placeholder="Run or inspect your file and enter its exact output" value="{{ form.observed_flag|default('') }}">
<label class="form-label" for="points" style="margin-top: 8px;">Points</label>
<input class="form-input" type="number" id="points" name="points" min="100" max="600" step="1" value="{{ form.points|default('') }}">
<p class="form-input-hint">Defaults to difficulty &times; 100. You may choose any whole number from 100 to 600.</p>
Expand Down
2 changes: 1 addition & 1 deletion templates/rules/crackmerules.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ <h3>Auto-validation (optional)</h3>
<p>Auto-validation and providing source code are separate, optional choices. Tick <b>Auto-validation</b> if you want solvers to submit a flag for points. It requires:</p>
<ul>
<li><b>The flag</b> your crackme prints when it is solved, in the format <code>CMO{...}</code> &mdash; 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.</li>
</ul>
<p>You may separately upload a private source ZIP for reviewers, with or without auto-validation. For a keygenme,
Expand Down
45 changes: 45 additions & 0 deletions tests/test_solves.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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
Expand Down
Loading