Shortcut

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:

  1. Expected: hash( api_version + "admin" + PASSWORD )
  2. 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.

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:

  1. Send a POST request with a JSON body.
  2. Set api_version to a list containing a very long string of ‘A’s.
  3. Set user and pass to anything (they will be ignored).

Why it works:

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}