Santa Claus Mystery

Back in 2019 my friend discovered that our university's SMTP server is not protected by anything, so finding the port that the server runs on meant that you could use telnet to login into the server. You could sent emails as anyone, literally anyone, any non-existing email or even existing emails. Kind of an unlimited power, yet it wasn't used to forage anything or do anything bad. No students were harmed. It only came to some trickstery, every student received a mail from the Santa Claus wishing them Merry Christmas. The message also had a secret message encoded, it was a red text on a red background encoded using the Caesar cipher (we were learning about ciphers at that time and it was just an easter egg).
You might be wondering how we got every possible student's email. Well, turns out that there was a hole in one of thousand functionalities of the university's online system. If I'm not mistaken, by browsing through some calendar (that no one used) you could modify the URL arguments to preview other people calendars, what lead to beeing able to preview their name, surname and student index. With some petty, selenium automations we were able to collect the database of every student on the university, at that time it was around 20 000 entries.
The mystery Santa Claus email caused some commotion but everyone forgot about it in the next two month and so did we, but the idea cameback after the next summer break, what if we did Santa Claus v2 for 2020, but bigger and better? Like hosting some sort of a competition. We started theorizing how to do it. There were a few points we had to fulfill:
- it has to be anonymous, the uni can't know it's us
- if it's a competition everyone should start at the same time to have even chances
- there should be an award for the first person to complete all the stages
- it would be nice to be able to preview who is on which stage
With some basic guidelines, I was given the task to implement the backend. At that time I had some experience in Django but i wanted to learn some Flask and what better time than to field test it on hundreds of other people :D
To start things off I designed a simple database that would keep the information about students, stages and some fun statistics.
class Student(db.Model):
index = db.Column(db.Text, primary_key=True, unique=True)
name = db.Column(db.Text)
curr_stage = db.Column(db.Integer)
hash = db.Column(db.Text, unique=True)
def __repr__(self) -> str:
return f"<Student {self.index}, {self.curr_stage}, {self.hash}>"
class Stage(db.Model):
id = db.Column(db.Integer, primary_key=True, unique=True)
desc = db.Column(db.Text)
method = db.Column(db.Text)
def __repr__(self) -> str:
return f"<Stage {self.id}, {self.desc}, {self.method}>"
class StudentStage(db.Model):
id = db.Column(db.Integer, primary_key=True, nullable=False)
student_index = db.Column(db.Text, db.ForeignKey("student.index"), nullable=False)
stage_index = db.Column(db.Integer, db.ForeignKey("stage.id"), nullable=False)
started = db.Column(db.DateTime, nullable=False, default=datetime.now)
finished = db.Column(db.DateTime, nullable=True)
student = db.relationship("Student", backref=db.backref("posts", lazy=True))
stage = db.relationship("Stage", backref=db.backref("posts"), lazy=True)
def __repr__(self) -> str:
return f"<{self.student_index}, {self.stage_index}, {self.started.strftime('%d-%m %H:%M:%S')}, {self.finished.strftime('%d-%m %H:%M:%S')}>"
Because we were sending personalized emails we could embed some information inside the email that would be later on returned backed to us, so that we could identify the sender. We didn't want for anyone to just type their name, it would ruin the fun, so the idea was that we were going to send a gif that will have instructions embedded into it with steganography. The instructions included the URL of the server and a booby trap in EXIF comment that was not entirely a trap. Using the substitution cipher we could easily embed the student index in there and make it look like gibberish.
album = (f'{album}########')[:8]
for i in range(len(album)):
album = album[:i] + lookup_table[album[i]] + album[i+1:]
data_to_encode = bytes(f'z{album}Qnothere33J5ZceD6p'[:length], encoding='ASCII')
And the image looked like this:
Now we had an image with two messages, one for contestants and one for us, all that was left was to upload this image into our server. Upon visiting the uncovered URL you were presented a ominous page with just a input field for a file.
The only acceptable files were of course images and upon further validation we made sure that it is our picture.
@app.route("/image_upload", methods=["POST"])
def image_upload():
if request.method == "POST":
if "DDdimg" not in request.files:
return redirect(url_for("file_upload_error"))
file = request.files["DDdimg"]
if file.filename == "":
return redirect(url_for("file_upload_error"))
if file and allowed_file(file.filename):
# get album number from image
try:
# get gif exif comment field by raw bytes
file.seek(0x323)
albumbytes = file.read(31)[1:9]
album = ""
# decode it
for i in range(len(albumbytes)):
album += reverse_lookup_table[chr(albumbytes[i])]
album = album.replace("#", "")
# validate if this 'deciphered' comment contains proper album number
finds = []
finds += re.findall("^[0-9]{4}-wiz$", album)
finds += re.findall("^[0-9]{4}$", album)
finds += re.findall("^[0-9]{5}$", album)
finds += re.findall("^[0-9]{6}$", album)
if not finds:
raise Exception("IMPROPER STUDENT ALBUM NUMBER FOUND")
new_hash = register_student(album)
print(f'Registered "{album}"')
return redirect(url_for("zagadka", hash_id=new_hash))
except Exception as e:
print(e)
return redirect(url_for("file_upload_error"))
If the image was correct we could register a student into our database. Upon registration a student was given an unique hash which was nothing more than an ID. Having this hash enabled us to prepare one endpoint, that would serve every stage for every student all that we needed to do is to check in the database on which stage currently is the given hash and return the corresponding html.
@app.route("/zagadka/<hash_id>")
def zagadka(hash_id, success=False):
# get the student with hash
student = Student.query.filter_by(hash=hash_id).first()
stage = student.curr_stage
for_html = {"hash_id": hash_id}
return render_template(f"zagadka{stage}.html", data=for_html)
Next up, we needed some sort of a key that would contain all the answers. The safest place at that time was the backend files themselves. Keeping it as simple as possible I created a list of dictionaries that would just contain the answers:
answers = [
{
'stage': 1,
'answer': {
'goto': 'nextstage'
}
},
{
'stage': 2,
'answer': {
'hello': 'D7EIKaN'
}
},
{
'stage': 3,
'answer': {
'hejo': 'd4d915ac4641a1e5ebd9224'
}
},
...
]
and a second list that would provide some additional information of the stages, like the on success / on failure messages and the method to send the answer. Some of the stages had their answer sent to the backend in the form of input whereas others used AJAX.
stages_info = [
{
'id': 1,
'desc': 'Info o tokenie',
'method': 'FORM',
},
{
'id': 2,
'desc': 'Znajdz dzeikana',
'method': 'AJAX',
'on_success': 'Dziekan piwo 🍻',
'on_fail': 'Nie tutaj'
},
{
'id': 3,
'desc': 'Rickroll',
'method': 'AJAX',
'on_success': 'Hooray!',
'on_fail': 'Try again :('
},
...
]
Because the students had unique hashes the requests to check the answers were sent to only one endpoint that did all the validation:
@app.route("/next_stage/<hash_id>", methods=["POST", "GET"])
def next_stage(hash_id):
"""Route used for the answer validation. Gets the student by hash,
then checks the curr_stage, compares the answer and if needed,
proceeds student to the next stage.
"""
# get the student
student = Student.query.filter_by(hash=hash_id).first()
curr_stage = student.curr_stage
# get the correct answer
answer = [x["answer"] for x in answers if x["stage"] == curr_stage]
# get the current stage info
stage_info = [x for x in stages_info if x["id"] == curr_stage]
if len(stage_info) > 0 and stage_info[0]:
stage_info = stage_info[0]
else:
print(f"[ERROR] stage {curr_stage} not found")
# check all the answers
if len(answer) > 0 and answer[0]:
if stage_info["method"] == "AJAX":
if request.get_json() is None:
return jsonify({"correct": False, "message": stage_info["on_fail"]})
print(f"STAGE {curr_stage} :{request.get_json()}")
if all(item in request.get_json().items() for item in answer[0].items()):
# if the answers are right, proceed to the next stage
proceed_student_to_next_stage(student.index)
print(f"STUDENT {student.index} has passed stage {curr_stage}")
return jsonify(
xd={"correct": True, "message": stage_info["on_success"]}
)
return jsonify(xd={"correct": False, "message": stage_info["on_fail"]})
elif stage_info["method"] == "FORM":
if request.form.to_dict() is not None:
# flatten the dict from 'x' : ['d']
req_answer = {
key: value[0]
for (key, value) in request.form.to_dict(flat=False).items()
}
print(f"### STAGE {curr_stage}: {req_answer}")
if all(item in req_answer.items() for item in answer[0].items()):
proceed_student_to_next_stage(student.index)
print(f"STUDENT {student.index} has passed stage {curr_stage}")
return redirect(url_for("zagadka", hash_id=hash_id))
return redirect(url_for("zagadka", hash_id=hash_id))
So assuming that someone guessed the solution correctly, they were redirected to the next stage using the previous method.
And one of the final steps, the most pleasing for us as the creators was to create a statistics page. A one that would enable us to preview who was on which stage and how much they spent on it. Because of pretty simple database it came down to a few lines of code:
@app.route("/stats/<index>")
def student_stats(index):
"""Route used for specific student stats"""
for_html = {}
for_html["student"] = Student.query.get(index)
stages = [x.__dict__ for x in StudentStage.query.filter_by(student_index=index)]
# calculate the duration of each stage
for stage in stages:
if not stage["finished"]:
stage["duration"] = "IN PROGRESS"
else:
duration = stage["finished"] - stage["started"]
hours, mins, secs = convert_timedelta(duration)
stage["duration"] = f"{hours} hours, {mins} mins, {secs} seconds"
for_html["stages"] = stages
return render_template("student_stats.html", data=for_html)
inserted into the template:
And some even fancier statistics:
@app.route("/stats/globalstats")
def global_stat():
"""Route used for global stats"""
for_html = {}
for_html["globalstats"] = (
db.session.query(
StudentStage.stage_index,
Stage.desc,
func.count(StudentStage.stage_index) - func.count(StudentStage.finished),
func.count(StudentStage.finished),
func.avg(
(
func.julianday(StudentStage.finished)
- func.julianday(StudentStage.started)
)
),
func.min(
(
func.julianday(StudentStage.finished)
- func.julianday(StudentStage.started)
)
),
Student.index,
Student.hash,
)
.group_by(StudentStage.stage_index)
.join(Student, StudentStage.student_index == Student.index)
.join(Stage, StudentStage.stage_index == Stage.id)
.all()
)
return render_template("global_stats.html", data=for_html)
That would come down to
The screenshots that you're seeing are from the final database dump with some real data. To remain untrackable we used Tor and have never revealed any data of anyone taking part in it.
Even though it took a big effort to pull this out, every minute was worth it. People were theorizing on who stands behind this, could it be a special task from a lecturer for a better grade? Well they quickly realized that it wasn't that as some puzzles were to degenerate for a lecturer to create them. You might be wondering what was the ultimate prize? We hid a real world mystery gift with a diploma and a drink burried in a pile of leaves. It took a good few days to go through all the 10 stages for the winner. For everyone else that reached stage 10 they were granted an email to the santa (us) to say some nice words <3