Home > Writeups > WHAMazon! JWT 3 - RSA Revenge

WHAMazon! JWT 3 - RSA Revenge

Using provided RSA private key components to manually implement PKCS#1 v1.5 signing and forge a valid RS256 JWT admin token.

RSA Revenge

Challenge Description

The bots have RSA encryption too? I mean come on DUDE!

Flag: Raptor{WH4M4Z0N_SW4RM_TR14G3_PR0T0C0L}


Enumeration

The service hands over the full RSA key material on request:

n    97452179804246749206816413111878367754425865592282861657905292286...
e    65537
d    8581359074268092675473969209281017750443133959947272451100469075232...
p    10287990365652638176543828295368272666299538517809345889150546608...
q    9472421370999669029704850336193657397934390355306464177875741272...

And issues a signed RS256 token with "admin": false. This is the same structure as JWT 1 and 2, get a token, flip admin to true, submit it, but this time the signature scheme is RSA-SHA256 (RS256) instead of HMAC.

First attempts: - Tried the HS256 secret from JWT 1, wrong algorithm entirely - Tried the none algorithm attack from JWT 2, server rejected it

With the full private key (n, e, d, p, q) provided, the correct path is to sign a forged token ourselves using that key.


The Script

The script implements RSA PKCS#1 v1.5 signing from scratch using only the raw key components, no cryptography or PyJWT libraries required.

import json
import base64
import hashlib

n = 97452179804246749206816413111878367754425865592282861657905292286171357625528164472024559078940539496098948474943802278094177746415275122730415111178240773574418455586366496971906485834764419250190178184537096117389281750608990153575463755982313780717312097019643878967940218255318145640655335491226327789913
e = 65537
d = 8581359074268092675473969209281017750443133959947272451100469075232233774156934818012019629286751810915773252497073148708080927942422642679360141700255235824963498099828435338263087218037552265115034126548723391133957707923784979913332482322723607056800136961201598449762429942810758347379691262544423702513
p = 10287990365652638176543828295368272666299538517809345889150546608110074170695277957021191927696360330330641519139351301709008127131304631872327776390046141
q = 9472421370999669029704850336193657397934390355306464177875741272444173513579146567401228726573755437740034983315668955201126936522945931134923392080146893

def base64url_encode(data):
    if isinstance(data, str):
        data = data.encode('utf-8')
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8')

def long_to_bytes(n):
    return n.to_bytes((n.bit_length() + 7) // 8, 'big')

def pkcs1_v15_sign(message, d, n):
    # Step 1: SHA-256 hash of the message
    h = hashlib.sha256(message).digest()

    # Step 2: Wrap hash in ASN.1 DigestInfo structure for SHA-256
    digest_info = b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20' + h

    # Step 3: PKCS#1 v1.5 padding: 0x00 || 0x01 || 0xFF...0xFF || 0x00 || DigestInfo
    key_length = (n.bit_length() + 7) // 8
    ps_length = key_length - len(digest_info) - 3
    padded = b'\x00\x01' + (b'\xff' * ps_length) + b'\x00' + digest_info

    # Step 4: RSA sign: m^d mod n
    m = int.from_bytes(padded, 'big')
    s = pow(m, d, n)

    signature = long_to_bytes(s)
    if len(signature) < key_length:
        signature = b'\x00' * (key_length - len(signature)) + signature

    return signature

# Build the JWT with admin: true
header_str = '{"alg": "RS256", "typ": "JWT"}'
payload_str = '{"sub": "worker-001", "admin": true}'

header_encoded = base64url_encode(header_str)
payload_encoded = base64url_encode(payload_str)

message = f"{header_encoded}.{payload_encoded}".encode('utf-8')
signature = pkcs1_v15_sign(message, d, n)
signature_encoded = base64url_encode(signature)

jwt_token = f"{header_encoded}.{payload_encoded}.{signature_encoded}"
print(jwt_token)

What each step does:

1. SHA-256 hash. RS256 signs SHA256(header.payload), not the raw data directly. This produces a fixed 32-byte digest regardless of input length.

2. ASN.1 DigestInfo wrapping. PKCS#1 v1.5 doesn't sign the raw hash, it wraps it in a fixed ASN.1 structure that identifies the hash algorithm used. The byte sequence \x30\x31\x30\x0d... is the standardised ASN.1 encoding for "this is a SHA-256 hash, here it is." This allows the verifier to confirm both the signature and the algorithm.

3. PKCS#1 v1.5 padding. The padded message is 0x00 || 0x01 || 0xFF...0xFF || 0x00 || DigestInfo, sized to match the key length in bytes. The 0xFF padding fills the gap between the fixed structures. This padding scheme is deterministic, same message always produces the same padded value, which is the key difference from OAEP (which uses random padding).

4. RSA signing: m^d mod n. The padded message is treated as a big integer and raised to the power of the private exponent d modulo n. Python's pow(m, d, n) handles this efficiently even for 1024-bit numbers.

5. JWT assembly. The resulting signature bytes are base64url-encoded and appended as the third segment: header.payload.signature.


Exploitation

Submitting the forged token:

✅ ADMIN OVERRIDE GRANTED

Toggle worker status check on, call medical:

🚑 MEDICAL DISPATCHED
Raptor{WH4M4Z0N_SW4RM_TR14G3_PR0T0C0L}

JWT 1 vs 2 vs 3

JWT 1 JWT 2 JWT 3
Algorithm HS256 none RS256
Attack Known-secret re-sign Unsigned token Private key sign
Key exposure Secret given directly N/A Full RSA key given
Tooling jwt.io Manual base64 Custom script

All three challenges exposed the key material, the escalating difficulty came from what you needed to do with it. HS256 re-signing is a one-click operation in jwt.io. RS256 signing requires understanding PKCS#1 v1.5 padding and either a crypto library or building it yourself.


Key Takeaways

RS256 JWTs are only secure when the private key is kept secret. Handing over d, p, and q via an API endpoint is identical in consequence to handing over the HS256 secret in JWT 1, the attacker can sign anything they want. The algorithm being asymmetric doesn't add security if both halves of the key pair are accessible.

PKCS#1 v1.5 is also worth knowing as a signing scheme, it's deterministic (no randomness in signature generation), which makes it verifiable but also means signing the same message twice always produces the same signature. Modern systems prefer PSS padding for RSA signatures, which adds randomness and is harder to attack, but v1.5 remains extremely common in legacy systems and CTF challenges.