281 lines
9.1 KiB
Markdown
281 lines
9.1 KiB
Markdown
# Core Security Concepts
|
|
|
|
This document explains the security ideas behind each check the tool runs. The
|
|
goal is that by the end you could re-derive every finding yourself, without the
|
|
tool.
|
|
|
|
## What a JWT Actually Is
|
|
|
|
### What It Is
|
|
|
|
A JSON Web Token is three pieces joined by dots:
|
|
|
|
```
|
|
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhZG1pbiJ9 . PGnRccPTXeax...
|
|
header payload signature
|
|
```
|
|
|
|
The header and payload are JSON objects, each base64url encoded. The signature
|
|
is computed over the string `header.payload`. The whole thing is a JWS, a
|
|
signed token. Decode the first two parts and you can read everything in them.
|
|
|
|
### Why It Matters
|
|
|
|
The single most important fact about a JWT is that the payload is **encoded,
|
|
not encrypted**. base64url is reversible by anyone. If you put a password or a
|
|
Social Security number in a claim, every party that touches the token can read
|
|
it: the browser, proxies, logs, error trackers.
|
|
|
|
### How It Works
|
|
|
|
```
|
|
header = {"alg": "HS256", "typ": "JWT"}
|
|
payload = {"sub": "admin", "exp": 1767225600}
|
|
|
|
signing_input = base64url(header) + "." + base64url(payload)
|
|
signature = HMAC_SHA256(secret, signing_input)
|
|
|
|
token = signing_input + "." + base64url(signature)
|
|
```
|
|
|
|
Verification recomputes the signature and compares. That is the whole model,
|
|
and every attack below is an attack on one step of it.
|
|
|
|
### Common Attacks
|
|
|
|
1. **Read the claims** - decode the payload and harvest anything sensitive.
|
|
2. **Tamper and re-sign** - if the secret is weak, change the claims and sign
|
|
again with the guessed secret.
|
|
3. **Tamper without re-signing** - trick the verifier into skipping or
|
|
weakening the signature check (the `none` and confusion attacks below).
|
|
|
|
### Defense Strategies
|
|
|
|
Keep the secret strong and private, pin the algorithm, set a short expiry, and
|
|
never put anything in the payload you would not print in a log. The checks in
|
|
`checks.py` map one to one onto these defenses.
|
|
|
|
## The alg none Downgrade
|
|
|
|
### What It Is
|
|
|
|
The JWT header names the algorithm used to sign the token. One legal value in
|
|
early implementations was `none`, meaning "this token is unsigned". A verifier
|
|
that reads the algorithm from the token and honors `none` will accept a token
|
|
with an empty signature.
|
|
|
|
### Why It Matters
|
|
|
|
The attacker takes a valid token, rewrites the header to `{"alg":"none"}`,
|
|
edits the payload to say `"role":"admin"`, deletes the signature, and sends it.
|
|
A vulnerable server treats it as authentic.
|
|
|
|
This is not hypothetical. In 2015, a wide range of JWT libraries were found to
|
|
accept `alg: none` by default, tracked as **CVE-2015-9235** for the popular
|
|
`jsonwebtoken` Node library and echoed across many others. The disclosure by
|
|
Auth0 that year is the reason "always pin the algorithm" became standard advice.
|
|
|
|
### How It Works
|
|
|
|
```
|
|
Original (HS256, signed):
|
|
{"alg":"HS256"}.{"sub":"alice","role":"user"}.<valid signature>
|
|
|
|
Forged (none, unsigned):
|
|
{"alg":"none"}.{"sub":"alice","role":"admin"}.
|
|
^ empty signature segment
|
|
```
|
|
|
|
The tool flags this in `check_alg_none` (`src/jwt_auditor/checks.py:68`). It
|
|
compares `token.algorithm.lower()` to `"none"` and, when it matches, returns a
|
|
critical finding.
|
|
|
|
### Defense Strategies
|
|
|
|
Never let the token choose the algorithm. Decide server side which algorithms
|
|
are acceptable and reject everything else, including `none`:
|
|
|
|
```python
|
|
# the safe pattern, expressed in pseudocode
|
|
ALLOWED = {"HS256"}
|
|
if token.header["alg"] not in ALLOWED:
|
|
reject()
|
|
```
|
|
|
|
## Weak HMAC Secrets
|
|
|
|
### What It Is
|
|
|
|
HS256, HS384, and HS512 sign with HMAC, which uses a shared secret. The
|
|
security of the token rests entirely on that secret being unguessable. When a
|
|
developer picks the secret by hand, they pick something like `secret`,
|
|
`changeme`, or the placeholder `your-256-bit-secret` from the jwt.io debugger.
|
|
|
|
### Why It Matters
|
|
|
|
Because the attacker holds the token, they can guess the secret **offline**.
|
|
There is no server to rate limit them. They try a candidate, recompute the
|
|
HMAC, and compare it to the signature already in the token. A match means they
|
|
found the key and can now forge any token they want.
|
|
|
|
### How It Works
|
|
|
|
```
|
|
for candidate in wordlist:
|
|
if HMAC(candidate, signing_input) == token.signature:
|
|
print("secret is", candidate)
|
|
break
|
|
```
|
|
|
|
The tool does exactly this in `crack_hmac_secret`
|
|
(`src/jwt_auditor/signatures.py:76`). The built in wordlist in `wordlist.py`
|
|
holds the secrets that actually appear in the wild.
|
|
|
|
### Common Pitfalls
|
|
|
|
**Mistake: a short or human chosen secret**
|
|
```python
|
|
# Bad
|
|
SECRET = "myappsecret"
|
|
|
|
# Good
|
|
SECRET = secrets.token_bytes(32) # 32 random bytes from a CSPRNG
|
|
```
|
|
|
|
**Mistake: timing the comparison with ==**
|
|
```python
|
|
# Bad, leaks how many leading bytes matched via timing
|
|
if computed == token.signature:
|
|
...
|
|
|
|
# Good, constant time
|
|
if hmac.compare_digest(computed, token.signature):
|
|
...
|
|
```
|
|
|
|
That second mistake is why `verify_hmac` uses `hmac.compare_digest`
|
|
(`src/jwt_auditor/signatures.py:73`).
|
|
|
|
## Algorithm Confusion (RS256 to HS256)
|
|
|
|
### What It Is
|
|
|
|
RS256 signs with a private key and verifies with a public key. The public key
|
|
is meant to be public. Algorithm confusion happens when a server verifies with
|
|
"whatever algorithm the token says", and an attacker changes the algorithm from
|
|
RS256 to HS256.
|
|
|
|
### Why It Matters
|
|
|
|
Now the server runs HMAC verification. The HMAC secret it uses is the only key
|
|
it has: the RSA public key. That key is not secret. The attacker downloads it,
|
|
signs a forged HS256 token with it, and the server accepts the forgery.
|
|
|
|
This class of bug has appeared repeatedly, including in widely used libraries,
|
|
and is catalogued as **CWE-347: Improper Verification of Cryptographic
|
|
Signature**. It is subtle because RS256 by itself is fine. The bug is in the
|
|
verifier accepting the header's choice.
|
|
|
|
### How It Works
|
|
|
|
```
|
|
Server has: rsa_public_key (published, not secret)
|
|
|
|
Attacker builds:
|
|
header = {"alg":"HS256"}
|
|
payload = {"sub":"admin"}
|
|
signature = HMAC(rsa_public_key, header.payload)
|
|
|
|
Server, trusting the header, verifies with HMAC(rsa_public_key, ...) -> match
|
|
```
|
|
|
|
The tool proves this when you pass `--public-key`. `key_confusion_secret`
|
|
(`src/jwt_auditor/signatures.py:95`) tries the public key bytes as an HMAC
|
|
secret across the HS algorithms and reports a match.
|
|
|
|
### Defense Strategies
|
|
|
|
Pin the algorithm on the verifier so an RS256 endpoint only ever runs RSA
|
|
verification. Do not derive the algorithm from the token.
|
|
|
|
## How These Concepts Relate
|
|
|
|
```
|
|
alg is attacker controlled
|
|
↓
|
|
enables → alg none (no signature at all)
|
|
↓
|
|
enables → RS to HS confusion (public key becomes the HMAC secret)
|
|
|
|
secret is weak
|
|
↓
|
|
enables → offline secret cracking, then arbitrary forgery
|
|
```
|
|
|
|
Every one of these traces back to the same root cause: trusting data inside the
|
|
token to decide how to verify the token.
|
|
|
|
## Industry Standards and Frameworks
|
|
|
|
### OWASP
|
|
|
|
- **OWASP API Security Top 10, API2:2023 Broken Authentication** - weak or
|
|
misconfigured token verification is the core of this category.
|
|
- **OWASP JWT Cheat Sheet** - the source for "always use an allowlist of
|
|
algorithms" and "do not accept `none`".
|
|
|
|
### CWE
|
|
|
|
- **CWE-347: Improper Verification of Cryptographic Signature** - the `none`
|
|
and confusion attacks both live here.
|
|
- **CWE-321: Use of Hard coded Cryptographic Key** - the weak secret case.
|
|
- **CWE-522: Insufficiently Protected Credentials** - secrets or PII in the
|
|
payload.
|
|
|
|
## Real World Examples
|
|
|
|
### Case Study 1: alg none in JWT libraries (2015)
|
|
|
|
Security researchers at Auth0 published a widely cited writeup showing that many
|
|
JWT libraries accepted `alg: none` and, separately, were vulnerable to the RS to
|
|
HS confusion. What made it dangerous was the default behavior: a developer
|
|
calling `verify(token)` with no extra arguments got the insecure path. The fix
|
|
across the ecosystem was to require the caller to state the expected algorithm.
|
|
|
|
### Case Study 2: secrets in the payload
|
|
|
|
A recurring finding in API assessments is a JWT payload that includes an email,
|
|
a phone number, or an internal role map. The developers assumed the token was
|
|
opaque because it looks like random text. It is not. Anyone who captured the
|
|
token from a log or a proxy read the data directly. This is why
|
|
`check_sensitive_data` (`src/jwt_auditor/checks.py:345`) exists.
|
|
|
|
## Testing Your Understanding
|
|
|
|
Before moving on, make sure you can answer:
|
|
|
|
1. Why can an attacker crack an HMAC secret offline, when a login form would
|
|
rate limit them?
|
|
2. In the RS to HS confusion attack, what is the "secret" the attacker signs
|
|
with, and why do they have it?
|
|
3. A colleague says the JWT is safe to store a password in because "it is
|
|
signed". What is wrong with that reasoning?
|
|
|
|
If any of these are fuzzy, re-read the matching section. The implementation
|
|
will make more sense once these click.
|
|
|
|
## Further Reading
|
|
|
|
**Essential:**
|
|
|
|
- OWASP JSON Web Token Cheat Sheet - the practical do and do not list.
|
|
- RFC 7519 (JWT) and RFC 7515 (JWS) - the actual specifications. Short and
|
|
readable.
|
|
|
|
**Deep dives:**
|
|
|
|
- The Auth0 2015 writeup on critical JWT vulnerabilities - the origin of the
|
|
standard advice.
|
|
- PortSwigger Web Security Academy, JWT attacks - hands on labs for `none`,
|
|
weak secrets, and confusion.
|