Shortcut
- Category: Crypto
Description
Login to get the flag :) Take some shortcuts if you find them.
Challenge by Aviya Erenfeld
Sources were attached.
Solution
We are given a Flask web application with a /login endpoint. It implements a custom authentication scheme using bcrypt.
def validate_params(params):
if any([x not in params for x in ["user", "pass", "cmd", "api_version"]]):
return False
if params["cmd"] != "get_flag": # only one command is supported right now
return False
if len(params["api_version"]) != 1: # version is only one char
return False
return True
def auth(params):
salt = bcrypt.gensalt(rounds=12)
version = str(params["api_version"]).encode()
expected = bcrypt.hashpw(version + b"admin" + PASSWORD.encode(), salt)
received = bcrypt.hashpw(version + params["user"].encode() + params["pass"].encode(), salt)
return expected == received
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
params = request.args
elif request.method == 'POST':
params = request.get_json()
else:
abort(500)
if not validate_params(params):
abort(422)
if not auth(params):
abort(401)
return FLAG + "\n"
The core logic revolves around an api_version parameter. The server checks two hashes:
- Expected:
hash( api_version + "admin" + PASSWORD ) - Received:
hash( api_version + user + pass )
If these two hashes match, we get the flag.
There are two distinct vulnerabilities that, when combined, allow us to completely bypass authentication.
1. The Bcrypt Truncation Limit
The bcrypt algorithm has a well-known limitation: it only processes the first 72 bytes of input. Any characters beyond the 72nd byte are strictly ignored.
In this challenge, the hash input is constructed as:
version + ...
If version itself is 72 bytes or longer, bcrypt will never “see” the user, pass, or even the real PASSWORD appended to the end. The hash will be calculated solely based on the version string.
2. Type Confusion in validate_params
The server tries to prevent shenanigans with a validation check:
if len(params["api_version"]) != 1: # version is only one char
return False
The intention is to ensure api_version is a single character (like "1").
However, len() in Python works on both strings and lists.
- If
api_versionis"A", length is 1. (Valid) - If
api_versionis["AAAA..."], length is also 1 (it’s a list with one item). (Valid!)
The Logic Flaw
Later in the auth function:
version = str(params["api_version"]).encode()
If we pass a list ["AAAA..."], Python’s str() converts it to the string representation: "['AAAA...']".
This creates a massive string that passes the len() == 1 check but is long enough to trigger the 72-byte truncation.
Exploitation Strategy
The Exploit:
- Send a POST request with a JSON body.
- Set
api_versionto a list containing a very long string of ‘A’s. - Set
userandpassto anything (they will be ignored).
Why it works:
- Validation:
len(["AAA..."])is 1. Check passes. - Auth Calculation:
Expectedhash input:"['AAA...']" + "admin" + PASSWORDReceivedhash input:"['AAA...']" + "a" + "a"
- Truncation: Since
"['AAA...']"is > 72 bytes,bcrypttruncates both inputs to just the first 72 bytes of the version string. - Result:
hash(truncated_part) == hash(truncated_part). Authentication bypassed.
Solution Command
You can solve this with a single curl command:
curl -X POST [https://bstlv25-shortcut.chals.io/login](https://bstlv25-shortcut.chals.io/login) \
-H "Content-Type: application/json" \
-d '{"user": "a", "pass": "a", "cmd": "get_flag", "api_version": ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]}'
BSidesTLV2025{b3tter_t0_r3turn_4n_err0r}