Skip to content

Commit 3330c50

Browse files
committed
Added inline/onsite writeups
1 parent ba502e2 commit 3330c50

File tree

14 files changed

+767
-107
lines changed

14 files changed

+767
-107
lines changed

app/controllers/solution.py

Lines changed: 155 additions & 57 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, is_valid_hexid
1717
from app.services.archive import is_archive_password_protected, is_pe_file
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,95 +83,157 @@ 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 password-protected archives
94126
if is_archive_password_protected(data):
95127
flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR)
96-
return redirect(f'/upload/solution/{hexidcrackme}')
128+
return redirect(redirect_url)
97129

98-
# Check for PE files (patched binaries are not allowed)
130+
# Check for PE files (patched binaries not allowed)
99131
if is_pe_file(file.filename, data):
100-
flash('Executable files (PE binaries) are not allowed as solutions. '
101-
'Please submit a writeup that analyzes the algorithm instead of a patched binary.', FLASH_ERROR)
102-
return redirect(f'/upload/solution/{hexidcrackme}')
132+
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)
133+
return redirect(redirect_url)
134+
135+
info = bleach.clean(request.form.get('info', ''))
136+
if len(info) > MAX_INFO_LENGTH:
137+
flash(f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', FLASH_ERROR)
138+
return redirect(redirect_url)
103139

104-
# Secure filename (fallback to "unnamed" if filename has only unsafe characters)
105140
original_filename = secure_filename(file.filename) or "unnamed"
106141

107-
# Create solution
108142
try:
109-
solution = solution_create(info, username, hexidcrackme, original_filename)
143+
solution, crackme = solution_create(info, username, hexidcrackme, original_filename)
110144
except Exception as e:
111145
print(f"Error creating solution: {e}")
112146
abort(500)
113147

114-
# Create path using hexid only
115-
safe_path = os.path.join(UPLOAD_FOLDER, solution['hexid'])
116-
117-
# Ensure upload directory exists
148+
# Save uploaded file
118149
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
119-
120-
# Save file
121150
try:
122-
with open(safe_path, 'wb') as f:
151+
with open(os.path.join(UPLOAD_FOLDER, solution['hexid']), 'wb') as f:
123152
f.write(data)
124153
except Exception as e:
125154
print(f"File write error: {e}")
126155
flash('An error occurred on the server. Please try again later.', FLASH_ERROR)
127-
return redirect(f'/upload/solution/{hexidcrackme}')
156+
return redirect(redirect_url)
157+
158+
return _send_notifications_and_render_success(username, crackme)
159+
128160

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

138-
return render_template('submission/success.html',
139-
submission_type='Writeup',
140-
name=crackme['name'],
141-
username=username)
229+
# Save markdown content
230+
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
231+
try:
232+
with open(os.path.join(UPLOAD_FOLDER, solution['hexid']), 'w', encoding='utf-8') as f:
233+
f.write(content)
234+
except Exception as e:
235+
print(f"File write error: {e}")
236+
flash('An error occurred on the server. Please try again later.', FLASH_ERROR)
237+
return redirect(redirect_url)
238+
239+
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
@@ -7,7 +7,7 @@
77
import struct
88

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

1212

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

0 commit comments

Comments
 (0)