back-to-the-past
Category: Web
Description
"Back to the Future" never made sense as a title
back-to-the-future.tjc.tf
Attachments: server.zip
Write-up
When we navigate to the provided URL, we are presented with the following webpage:
We will proceed to register an account with the following details:
However, we encounter the following page:
After analyzing the codebase, we have determined our end goal:
Text Only |
---|
| @app.route("/retro")
@login_required()
def retro(user):
if int(user["year"]) > 1970:
return render_template("retro.html", flag="you aren't *retro* enough")
else:
return render_template("retro.html", flag=flag)
|
Upon accessing the endpoint, we are presented with the following view:
Our objective is to navigate to the /retro
endpoint with the year
field in our session set to a value less than or equal to 1970
. At first glance, it may seem simple to create a user with a year
set to <= 1970
, right? To find out more, let's take a closer look at the code snippet related to the /register
endpoint which handles user registrations:
Text Only |
---|
| @app.route("/register", methods=["POST"])
def post_register():
username = request.form["username"]
year = request.form["year"]
if username in u2id:
return redirect("/register?msg=So+unoriginal")
if not username:
return redirect("/register?msg=No+username+provided")
if not year.isnumeric() or not 1970 < int(year) < 2024:
return redirect("/register?msg=Invalid+year")
id = str(uuid.uuid4())
u2id[username] = id
u2year[username] = year
res = make_response(redirect("/"))
res.set_cookie("token", generate_token(id, username, year))
return res
|
As we can observe, there are validations in place that restrict the year
value to be between 1970
and 2024
(exclusive). Modifying the year
post-registration would require forging the JWT session tokens and regenerating the token's signature to prevent it from appearing tampered.
Now, let's examine the JWT encoding/decoding functionalities used by the server to create/verify session tokens:
Text Only |
---|
| def encode(payload, secret, algorithm=None):
if not algorithm or algorithm not in possible_algorithms:
raise ValueError("invalid algorithm")
header = {"typ": "JWT", "alg": algorithm}
b64header = base64url_encode(json.dumps(header).encode())
b64payload = base64url_encode(json.dumps(payload).encode())
if algorithm == "HS256":
h = hmac.HMAC(secret, hashes.SHA256())
h.update(b".".join([b64header, b64payload]))
signature = h.finalize()
elif algorithm == "RS256":
priv = serialization.load_pem_private_key(
secret, password=None, backend=default_backend()
)
signature = priv.sign(
b".".join([b64header, b64payload]),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256(),
)
return b".".join([b64header, b64payload, base64url_encode(signature)])
def decode(token, secret, algorithms=None):
if not algorithms or any(alg not in possible_algorithms for alg in algorithms):
return None
if token.count(b".") != 2:
return None
header, payload, signature = token.split(b".")
if not header or not payload or not signature:
return None
try:
json_header = json.loads(base64url_decode(header))
json_payload = json.loads(base64url_decode(payload))
decoded_signature = base64url_decode(signature)
alg_to_use = json_header["alg"]
if alg_to_use == "HS256":
h = hmac.HMAC(secret, hashes.SHA256())
h.update(b".".join([header, payload]))
h.verify(decoded_signature)
elif alg_to_use == "RS256":
pub = serialization.load_pem_public_key(secret)
pub.verify(
decoded_signature,
b".".join([header, payload]),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
),
hashes.SHA256(),
)
return json_payload
except Exception as e:
print(e)
return None
|
It's worth noting that both functions support encoding/decoding using the RS256
(asymmetric encryption) and HS256
(symmetric encryption) algorithms.
Here is our current session token:
Text Only |
---|
| eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJSUzI1NiJ9.eyJpZCI6ICI3N2Y1MmU4Zi02NDIzLTRmZDQtYjY5YS01OWMzODFiNGZiNWQiLCAidXNlcm5hbWUiOiAidGVzdCIsICJ5ZWFyIjogIjE5NzEifQ.BvpXxu4x9RzmQEj3IVPhn3-7VvWUKy-sXxjcXnHLp4iuYvR8kYk-xcxO4mn1QPRJ7Et4rNUaZOuaFt4PIumpGajSt66W8Au6xbDN3Iim_rRd9eiWyfRGkUcy2rZkfwFty1zVGN3qpmsyhP3I9yPzJEgUP8ON7pNDQH71CxbtKhdcFyDDQJHFbApjdOP94Pf0mg0ajVsjKc8fMlRXX4CIO0XcS2s-aq_A9zNWUHTZXluxDySFc3vkANj111TCafUYc4o1Wui7CjvcSpOe35Yilzrenz6h-XD4c-JieKVXn8M8YTm2xSgfwiG7-F4XxVsk7bGVRVKxzN9kIHEARB64sQ
|
When parsed using tools like https://jwt.io/, we can observe the header:
Text Only |
---|
| {
"typ": "JWT",
"alg": "RS256"
}
|
And the payload:
Text Only |
---|
| {
"id": "77f52e8f-6423-4fd4-b69a-59c381b4fb5d",
"username": "test",
"year": "1971"
}
|
Currently, our session token is signed using asymmetric encryption (RS256
). Since obtaining the private key from server is not feasible, a more practical approach would be to "force" our session token to utilize the HS256
algorithm. By doing so, we can utilize the publicly accessible public key located at static/public.pem
to create/verify our session token, allowing us to maliciously control the payload of our session token.
To generate a new malicious session token using the HS256
algorithm, you can utilize the solve.py script.
When we update our session token with the malicious version, the flag will be displayed in the /retro
page.
Flag: tjctf{very_very_retro_3bbff613}