Skip to content

Commit 226d954

Browse files
committed
Added inline/onsite writeups
1 parent b1dea76 commit 226d954

14 files changed

Lines changed: 768 additions & 108 deletions

File tree

app/controllers/solution.py

Lines changed: 156 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,78 @@
11
"""
2-
Solution controller - Solution uploading.
2+
Solution controller - Solution uploading and viewing.
33
"""
44

55
import os
66
from html import escape as html_escape
7-
from flask import Blueprint, render_template, request, redirect, flash, session, abort
7+
from flask import Blueprint, render_template, request, redirect, flash, session, abort, current_app
88
from werkzeug.utils import secure_filename
99
import bleach
1010
from app.models.crackme import crackme_by_hexid
11-
from app.models.solution import solution_create, solutions_by_user_and_crackme
11+
from app.models.solution import solution_create, solution_exists, solution_by_hexid
1212
from app.models.notification import notification_add
1313
from app.models.errors import ErrNoResult
1414
from app.services.recaptcha import verify as verify_recaptcha
1515
from app.services.limiter import limit
16-
from app.services.view import FLASH_ERROR, FLASH_SUCCESS
16+
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, is_valid_hexid
1717
from app.services.archive import is_archive_password_protected, is_pe_file, is_single_file_archive, is_unsupported_archive
1818
from app.services.discord import notify_new_solution
19+
from app.services.crypto import get_obfuscation_key_base64, get_obfuscation_salt
1920
from app.controllers.decorators import login_required
2021

2122
solution_bp = Blueprint('solution', __name__)
2223

23-
# Upload folder for solutions
2424
UPLOAD_FOLDER = 'tmp/solution'
2525
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
26+
MAX_INFO_LENGTH = 200
27+
MAX_CONTENT_LENGTH = 50000
28+
MIN_CONTENT_LENGTH = 200
2629

2730

28-
@solution_bp.route('/upload/solution/<hexidcrackme>', methods=['GET'])
29-
@login_required
30-
def upload_solution_get(hexidcrackme):
31-
"""Display the solution upload form."""
31+
def _get_crackme_or_abort(hexid):
32+
"""Fetch crackme by hexid, abort with 404/500 on error."""
33+
if not is_valid_hexid(hexid):
34+
abort(404)
3235
try:
33-
crackme = crackme_by_hexid(hexidcrackme)
36+
return crackme_by_hexid(hexid)
3437
except ErrNoResult:
3538
abort(404)
3639
except Exception as e:
3740
print(f"Error getting crackme: {e}")
3841
abort(500)
3942

43+
44+
def _get_solution_or_abort(hexid):
45+
"""Fetch solution by hexid, abort with 404/500 on error."""
46+
if not is_valid_hexid(hexid):
47+
abort(404)
48+
try:
49+
return solution_by_hexid(hexid)
50+
except ErrNoResult:
51+
abort(404)
52+
except Exception as e:
53+
print(f"Error getting solution: {e}")
54+
abort(500)
55+
56+
57+
def _send_notifications_and_render_success(username, crackme):
58+
"""Send notifications and render the success page after solution creation."""
59+
try:
60+
notification_add(username, f"Your solution for '{html_escape(crackme['name'])}' is waiting approval!")
61+
notify_new_solution(username, crackme['name'])
62+
except Exception as e:
63+
print(f"Notification error: {e}")
64+
65+
return render_template('submission/success.html',
66+
submission_type='Writeup',
67+
name=crackme['name'],
68+
username=username)
69+
70+
71+
@solution_bp.route('/upload/solution/<hexidcrackme>', methods=['GET'])
72+
@login_required
73+
def upload_solution_get(hexidcrackme):
74+
"""Display the solution upload form."""
75+
crackme = _get_crackme_or_abort(hexidcrackme)
4076
return render_template('solution/create.html',
4177
hexidcrackme=hexidcrackme,
4278
username=crackme.get('author', ''),
@@ -47,105 +83,167 @@ def upload_solution_get(hexidcrackme):
4783
@login_required
4884
@limit("20 per day", key_func=lambda: session.get('name'))
4985
def upload_solution_post(hexidcrackme):
50-
"""Handle solution upload."""
86+
"""Handle solution file upload."""
87+
_get_crackme_or_abort(hexidcrackme) # Validate crackme exists
5188
username = session.get('name')
52-
53-
info = bleach.clean(request.form.get('info', ''))
89+
redirect_url = f'/upload/solution/{hexidcrackme}'
5490

5591
# Check if user already submitted a solution
5692
try:
57-
solutions_by_user_and_crackme(username, hexidcrackme)
58-
flash("You've already submitted a solution to this crackme", FLASH_ERROR)
59-
return redirect(f'/upload/solution/{hexidcrackme}')
93+
if solution_exists(username, hexidcrackme):
94+
flash("You've already submitted a solution to this crackme", FLASH_ERROR)
95+
return redirect(redirect_url)
6096
except ErrNoResult:
61-
pass # No existing solution, continue
97+
abort(404)
6298

63-
# Validate reCAPTCHA
6499
if not verify_recaptcha(request):
65100
flash('reCAPTCHA invalid!', FLASH_ERROR)
66-
return redirect(f'/upload/solution/{hexidcrackme}')
101+
return redirect(redirect_url)
67102

68-
# Check for file
69-
if 'file' not in request.files:
103+
# Validate file presence
104+
if 'file' not in request.files or request.files['file'].filename == '':
70105
flash('Field missing: file', FLASH_ERROR)
71-
return redirect(f'/upload/solution/{hexidcrackme}')
106+
return redirect(redirect_url)
72107

73108
file = request.files['file']
74-
if file.filename == '':
75-
flash('Field missing: file', FLASH_ERROR)
76-
return redirect(f'/upload/solution/{hexidcrackme}')
77109

78-
# Check file size from header
110+
# Check file size (header and actual)
79111
if file.content_length and file.content_length > MAX_FILE_SIZE:
80112
flash('This file is too large!', FLASH_ERROR)
81-
return redirect(f'/upload/solution/{hexidcrackme}')
113+
return redirect(redirect_url)
82114

83-
# Read file data
84115
try:
85116
data = file.read()
86-
if len(data) > MAX_FILE_SIZE:
87-
flash('This file is too large!', FLASH_ERROR)
88-
return redirect(f'/upload/solution/{hexidcrackme}')
89117
except Exception as e:
90118
print(f"Error reading file: {e}")
91119
abort(500)
92120

121+
if len(data) > MAX_FILE_SIZE:
122+
flash('This file is too large!', FLASH_ERROR)
123+
return redirect(redirect_url)
124+
93125
# Check for unsupported archive formats (RAR, tar, etc.)
94126
if is_unsupported_archive(data):
95127
flash('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.', FLASH_ERROR)
96-
return redirect(f'/upload/solution/{hexidcrackme}')
128+
return redirect(redirect_url)
97129

98130
# Check for password-protected archives
99131
if is_archive_password_protected(data):
100132
flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR)
101-
return redirect(f'/upload/solution/{hexidcrackme}')
133+
return redirect(redirect_url)
102134

103135
# Check for single-file archives
104136
if is_single_file_archive(data):
105137
flash('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.', FLASH_ERROR)
106-
return redirect(f'/upload/solution/{hexidcrackme}')
138+
return redirect(redirect_url)
107139

108140
# Check for PE files (patched binaries are not allowed)
109141
if is_pe_file(file.filename, data):
110-
flash('Executable files (PE binaries) are not allowed as solutions. '
111-
'Please submit a writeup that analyzes the algorithm instead of a patched binary.', FLASH_ERROR)
112-
return redirect(f'/upload/solution/{hexidcrackme}')
142+
flash('Executable files (PE binaries) are not allowed as solutions. Please submit a writeup that analyzes the algorithm instead of a patched binary.', FLASH_ERROR)
143+
return redirect(redirect_url)
144+
145+
info = bleach.clean(request.form.get('info', ''))
146+
if len(info) > MAX_INFO_LENGTH:
147+
flash(f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', FLASH_ERROR)
148+
return redirect(redirect_url)
113149

114-
# Secure filename (fallback to "unnamed" if filename has only unsafe characters)
115150
original_filename = secure_filename(file.filename) or "unnamed"
116151

117-
# Create solution
118152
try:
119-
solution = solution_create(info, username, hexidcrackme, original_filename)
153+
solution, crackme = solution_create(info, username, hexidcrackme, original_filename)
120154
except Exception as e:
121155
print(f"Error creating solution: {e}")
122156
abort(500)
123157

124-
# Create path using hexid only
125-
safe_path = os.path.join(UPLOAD_FOLDER, solution['hexid'])
126-
127-
# Ensure upload directory exists
158+
# Save uploaded file
128159
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
129-
130-
# Save file
131160
try:
132-
with open(safe_path, 'wb') as f:
161+
with open(os.path.join(UPLOAD_FOLDER, solution['hexid']), 'wb') as f:
133162
f.write(data)
134163
except Exception as e:
135164
print(f"File write error: {e}")
136165
flash('An error occurred on the server. Please try again later.', FLASH_ERROR)
137-
return redirect(f'/upload/solution/{hexidcrackme}')
166+
return redirect(redirect_url)
167+
168+
return _send_notifications_and_render_success(username, crackme)
169+
138170

139-
# Send notification
171+
@solution_bp.route('/solution/<hexid>', methods=['GET'])
172+
@login_required
173+
def view_solution(hexid):
174+
"""Display a solution's writeup page."""
175+
solution = _get_solution_or_abort(hexid)
176+
salt = get_obfuscation_salt(current_app.config)
177+
178+
return render_template('solution/read.html',
179+
solution=solution,
180+
obfuscation_key=get_obfuscation_key_base64(hexid, salt))
181+
182+
183+
@solution_bp.route('/upload/solution/<hexidcrackme>/editor', methods=['GET'])
184+
@login_required
185+
def editor_solution_get(hexidcrackme):
186+
"""Display the web-based markdown editor for writing solutions."""
187+
crackme = _get_crackme_or_abort(hexidcrackme)
188+
return render_template('solution/editor.html',
189+
hexidcrackme=hexidcrackme,
190+
username=crackme.get('author', ''),
191+
crackmename=crackme.get('name', ''),
192+
min_content_length=MIN_CONTENT_LENGTH,
193+
max_content_length=MAX_CONTENT_LENGTH)
194+
195+
196+
@solution_bp.route('/upload/solution/<hexidcrackme>/editor', methods=['POST'])
197+
@login_required
198+
@limit("20 per day", key_func=lambda: session.get('name'))
199+
def editor_solution_post(hexidcrackme):
200+
"""Handle solution submission from the web editor."""
201+
_get_crackme_or_abort(hexidcrackme) # Validate crackme exists
202+
username = session.get('name')
203+
redirect_url = f'/upload/solution/{hexidcrackme}/editor'
204+
205+
# Check if user already submitted a solution
140206
try:
141-
crackme = crackme_by_hexid(hexidcrackme)
142-
notification_add(username, f"Your solution for '{html_escape(crackme['name'])}' is waiting approval!")
143-
# Send Discord notification
144-
notify_new_solution(username, crackme['name'])
207+
if solution_exists(username, hexidcrackme):
208+
flash("You've already submitted a solution to this crackme", FLASH_ERROR)
209+
return redirect(redirect_url)
210+
except ErrNoResult:
211+
abort(404)
212+
213+
if not verify_recaptcha(request):
214+
flash('reCAPTCHA invalid!', FLASH_ERROR)
215+
return redirect(redirect_url)
216+
217+
content = request.form.get('content', '').strip()
218+
219+
# Validate content length
220+
if len(content) < MIN_CONTENT_LENGTH:
221+
flash(f'Your writeup is too short. Please write at least {MIN_CONTENT_LENGTH} characters.', FLASH_ERROR)
222+
return redirect(redirect_url)
223+
224+
if len(content) > MAX_CONTENT_LENGTH:
225+
flash(f'Your writeup exceeds the maximum length of {MAX_CONTENT_LENGTH:,} characters.', FLASH_ERROR)
226+
return redirect(redirect_url)
227+
228+
info = bleach.clean(request.form.get('info', ''))
229+
if len(info) > MAX_INFO_LENGTH:
230+
flash(f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', FLASH_ERROR)
231+
return redirect(redirect_url)
232+
233+
try:
234+
solution, crackme = solution_create(info, username, hexidcrackme, original_filename='writeup.md', has_markdown=True)
145235
except Exception as e:
146-
print(f"Notification error: {e}")
236+
print(f"Error creating solution: {e}")
237+
abort(500)
147238

148-
return render_template('submission/success.html',
149-
submission_type='Writeup',
150-
name=crackme['name'],
151-
username=username)
239+
# Save markdown content
240+
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
241+
try:
242+
with open(os.path.join(UPLOAD_FOLDER, solution['hexid']), 'w', encoding='utf-8') as f:
243+
f.write(content)
244+
except Exception as e:
245+
print(f"File write error: {e}")
246+
flash('An error occurred on the server. Please try again later.', FLASH_ERROR)
247+
return redirect(redirect_url)
248+
249+
return _send_notifications_and_render_success(username, crackme)

app/models/solution.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Solution model for database operations.
33
"""
44

5-
from datetime import datetime
5+
from datetime import datetime, timezone
66
from bson import ObjectId
77
from pymongo import DESCENDING
88
from app.services.database import get_collection, check_connection
@@ -72,28 +72,26 @@ def solutions_by_user(username):
7272
return solutions
7373

7474

75-
def solutions_by_user_and_crackme(username, crackme_hexid):
76-
"""Get solution by user and crackme."""
75+
def solution_exists(username, crackme_hexid):
76+
"""Check if a user has already submitted a solution for a crackme.
77+
78+
Raises:
79+
ErrNoResult: If the crackme doesn't exist
80+
ErrUnavailable: If the database is unavailable
81+
"""
7782
if not check_connection():
7883
raise ErrUnavailable("Database is unavailable")
7984

80-
# First get the crackme
81-
from app.models.crackme import crackme_by_hexid
82-
try:
83-
crackme = crackme_by_hexid(crackme_hexid)
84-
except ErrNoResult:
85-
raise ErrNoResult("Solution not found")
85+
crackme_collection = get_collection('crackme')
86+
crackme = crackme_collection.find_one({'hexid': crackme_hexid}, {'_id': 1})
87+
if not crackme:
88+
raise ErrNoResult("Crackme not found")
8689

8790
collection = get_collection('solution')
88-
result = collection.find_one({
89-
'crackmeid': crackme['_id'],
90-
'author': username
91-
})
92-
93-
if result is None:
94-
raise ErrNoResult("Solution not found")
95-
96-
return result
91+
return collection.find_one(
92+
{'crackmeid': crackme['_id'], 'author': username},
93+
{'_id': 1}
94+
) is not None
9795

9896

9997
def solutions_by_crackme(crackme_object_id):
@@ -129,7 +127,7 @@ def get_solution_authors(crackme_hexid):
129127
return set(solution['author'] for solution in solutions)
130128

131129

132-
def solution_create(info, username, crackme_hexid, original_filename):
130+
def solution_create(info, username, crackme_hexid, original_filename=None, has_markdown=False):
133131
"""Create a new solution."""
134132
if not check_connection():
135133
raise ErrUnavailable("Database is unavailable")
@@ -148,12 +146,13 @@ def solution_create(info, username, crackme_hexid, original_filename):
148146
'crackmeid': crackme['_id'],
149147
'crackmehexid': crackme['hexid'],
150148
'crackmename': crackme['name'],
151-
'created_at': datetime.utcnow(),
149+
'created_at': datetime.now(timezone.utc),
152150
'author': username,
153151
'visible': False,
154152
'deleted': False,
155-
'original_filename': original_filename
153+
'original_filename': original_filename,
154+
'has_markdown': has_markdown
156155
}
157156

158157
collection.insert_one(solution)
159-
return solution
158+
return solution, crackme

app/services/archive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
# PE file extensions (case-insensitive)
12-
PE_EXTENSIONS = {'.exe', '.dll', '.sys', '.scr', '.ocx', '.com', '.drv', '.cpl', '.efi'}
12+
PE_EXTENSIONS = frozenset({'.exe', '.dll', '.sys', '.scr', '.ocx', '.com', '.drv', '.cpl', '.efi'})
1313

1414

1515
def is_pe_file(filename: str, file_data: bytes) -> bool:

0 commit comments

Comments
 (0)