Skip to content

Commit b4205ef

Browse files
committed
Add a JWT step type to the rule engine, and two JWT bypass rules
The declarative engine could only send static probes, so it could not test a token. A new jwt probe step lets a rule forge a variant of the signed in session's JSON Web Token and check the server still accepts it, reads only, the claims left unchanged so it proves the signature is not verified rather than forging a new identity. Three forges: the signature stripped, the algorithm set to none, and the token re-signed with a guessed HS256 secret from a short high signal list. The finding stands only when the forged token gets the signed in response and the signed out visitor does not, so a public page or a rejected forgery both stay silent. It tries a list of common authenticated endpoints and fires on the first that is behind the session. Two rules on it: a signature not verified check (alg:none and stripped) and a weak signing secret check, both authentication class, critical. They need a session with a JWT in it; without one they find nothing.
1 parent 36ecc02 commit b4205ef

3 files changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"id": "authentication-jwt-signature-not-verified",
3+
"class": "authentication",
4+
"safety": "read",
5+
"severity_floor": "critical",
6+
"title": "JWT signature is not verified (alg:none / stripped)",
7+
"summary": "The signed in JSON Web Token is accepted with its algorithm set to none or its signature removed, so anyone can mint a token for any account. This is a full authentication bypass.",
8+
"needs": [
9+
"session"
10+
],
11+
"probe": [
12+
{
13+
"jwt": {
14+
"paths": [
15+
"/api/me",
16+
"/api/user",
17+
"/api/account",
18+
"/api/profile",
19+
"/me",
20+
"/account",
21+
"/api/v1/me",
22+
"/api/users/me"
23+
],
24+
"forge": [
25+
"alg_none",
26+
"strip"
27+
]
28+
}
29+
}
30+
],
31+
"confirm": {
32+
"control": "The signed out view of the same endpoint, which must differ from the signed in one.",
33+
"differential": "status==200 and same(response, authed) and differ(response, anon)",
34+
"retest": 2
35+
},
36+
"reproduce": "Take the session JWT, set its header alg to \"none\" and drop the signature (keep the dot), and request an authenticated endpoint with it. A signed in response means the signature is not checked.",
37+
"remediate": "Reject alg:none and any algorithm the server did not issue; always verify the signature with the expected key.",
38+
"references": [],
39+
"mappings": {
40+
"cwe": "CWE-347",
41+
"owasp": "A07:2021"
42+
},
43+
"rulesets": [
44+
"jwt",
45+
"auth"
46+
]
47+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"id": "authentication-jwt-weak-secret",
3+
"class": "authentication",
4+
"safety": "read",
5+
"severity_floor": "critical",
6+
"title": "JWT is signed with a guessable secret",
7+
"summary": "The HS256 signing secret is a common weak value, so a token can be re-signed for any account. This is a full authentication bypass.",
8+
"needs": [
9+
"session"
10+
],
11+
"probe": [
12+
{
13+
"jwt": {
14+
"paths": [
15+
"/api/me",
16+
"/api/user",
17+
"/api/account",
18+
"/api/profile",
19+
"/me",
20+
"/account",
21+
"/api/v1/me",
22+
"/api/users/me"
23+
],
24+
"forge": [
25+
"weak_secret"
26+
]
27+
}
28+
}
29+
],
30+
"confirm": {
31+
"control": "The signed out view of the same endpoint, which must differ from the signed in one.",
32+
"differential": "status==200 and same(response, authed) and differ(response, anon)",
33+
"retest": 2
34+
},
35+
"reproduce": "Re-sign the session JWT (claims unchanged) with a common secret such as 'secret' using HS256, and request an authenticated endpoint with it. A signed in response means the secret is that weak value.",
36+
"remediate": "Use a long random signing secret from a secret manager, and rotate it.",
37+
"references": [],
38+
"mappings": {
39+
"cwe": "CWE-321",
40+
"owasp": "A07:2021"
41+
},
42+
"rulesets": [
43+
"jwt",
44+
"auth"
45+
]
46+
}

‎app/core/dast_checks/engine.py‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
from __future__ import annotations
2323

2424
import ast
25+
import base64
26+
import hashlib
27+
import hmac
28+
import json
2529
import re
2630
import secrets
2731
import time
@@ -307,6 +311,17 @@ def run_rule(spec: dict, ctx: dict) -> dict:
307311
out["status"], out["detail"] = "error", f"{type(exc).__name__}: {exc}"[:200]
308312
return out
309313

314+
# A rule with a jwt step forges a variant of the signed in token and checks
315+
# the server still accepts it: its own path, reads only.
316+
if any(isinstance(s, dict) and s.get("jwt") for s in spec.get("probe") or []):
317+
try:
318+
out["findings"] = _run_jwt(spec, ctx, domains, budget)
319+
except Refused as exc:
320+
out["status"], out["detail"] = "refused", str(exc)
321+
except Exception as exc: # noqa: BLE001
322+
out["status"], out["detail"] = "error", f"{type(exc).__name__}: {exc}"[:200]
323+
return out
324+
310325
client = Client(base_url, domains, spec["safety"], budget,
311326
allow_private=bool(ctx.get("allow_private")),
312327
headers=ctx.get("headers"))
@@ -377,6 +392,125 @@ def _b_objects(client_b: Client, base_url: str, code_routes) -> list:
377392
return out[:MAX_ROUTES]
378393

379394

395+
# Common weak signing secrets for the HS256 re-sign test. A short, high signal
396+
# list: a hit means the secret is guessable, not a brute force.
397+
WEAK_JWT_SECRETS = ["secret", "password", "changeme", "123456", "key", "jwt",
398+
"admin", "test", "secretkey", "your-256-bit-secret",
399+
"your_jwt_secret", "supersecret", "token", "s3cr3t",
400+
"jwtsecret", "qwerty", "P@ssw0rd"]
401+
_JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*")
402+
403+
404+
def _b64url_dec(s: str) -> bytes:
405+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
406+
407+
408+
def _b64url_enc(b: bytes) -> str:
409+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
410+
411+
412+
def _find_jwt(headers: dict):
413+
"""The first JWT carried in the session, as (header name, value, token)."""
414+
for name, value in (headers or {}).items():
415+
for m in _JWT_RE.finditer(str(value)):
416+
tok = m.group(0)
417+
try:
418+
head = json.loads(_b64url_dec(tok.split(".")[0]))
419+
except Exception: # noqa: BLE001 - not a real JWT, keep looking
420+
continue
421+
if isinstance(head, dict) and "alg" in head:
422+
return name, value, tok
423+
return None
424+
425+
426+
def _forge_jwt(tok: str, mode: str, secret: str = "") -> str:
427+
"""A variant of the token: the signature stripped, the algorithm set to
428+
none, or the token re-signed with a guessed secret. The claims are left
429+
exactly as they were, so this proves the signature is not checked, it does
430+
not forge a new identity."""
431+
head_b, payload_b, _sig = tok.split(".")
432+
head = json.loads(_b64url_dec(head_b))
433+
if mode == "strip":
434+
return f"{head_b}.{payload_b}."
435+
if mode == "alg_none":
436+
head["alg"] = "none"
437+
return f"{_b64url_enc(json.dumps(head, separators=(',', ':')).encode())}.{payload_b}."
438+
if mode == "weak_secret":
439+
head["alg"] = "HS256"
440+
new_head = _b64url_enc(json.dumps(head, separators=(",", ":")).encode())
441+
sig = hmac.new(secret.encode(), f"{new_head}.{payload_b}".encode(), hashlib.sha256).digest()
442+
return f"{new_head}.{payload_b}.{_b64url_enc(sig)}"
443+
return tok
444+
445+
446+
def _run_jwt(spec: dict, ctx: dict, domains, budget: Budget) -> list:
447+
"""Forge a variant of the signed in session's JWT and check the server still
448+
accepts it. -> findings. Reads only, claims unchanged.
449+
450+
The finding stands only when the forged token gets the signed in response
451+
and the signed out visitor does not, so a public page or a rejected forgery
452+
both stay silent.
453+
"""
454+
base_url = ctx.get("base_url") or ""
455+
allow_private = bool(ctx.get("allow_private"))
456+
headers = ctx.get("headers") or {}
457+
found = _find_jwt(headers)
458+
if not found:
459+
return [] # the session carries no JWT, so there is nothing to forge
460+
name, value, tok = found
461+
step = next((s.get("jwt") for s in spec.get("probe") or []
462+
if isinstance(s, dict) and s.get("jwt")), {})
463+
paths = step.get("paths") or ([step["path"]] if step.get("path") else ["/"])
464+
modes = [m for m in (step.get("forge") or ["alg_none", "strip"])
465+
if m in ("alg_none", "strip", "weak_secret")]
466+
467+
authed = Client(base_url, domains, "read", budget, allow_private, headers)
468+
anon = Client(base_url, domains, "read", budget, allow_private, None)
469+
confirm = spec.get("confirm") or {}
470+
differential = confirm.get("differential") or "False"
471+
retest = int(confirm.get("retest") or 1)
472+
said = {"alg_none": "an alg:none token", "strip": "a token with its signature stripped",
473+
"weak_secret": "a token re-signed with a guessed secret"}
474+
475+
for path in paths[:8]:
476+
resp_authed = authed.request("GET", path)
477+
# The endpoint must really be behind the session; the differential then
478+
# confirms, per attempt, that the forgery got the signed in response and
479+
# the signed out one did not, so a public page stays silent.
480+
if resp_authed.get("status") != 200:
481+
continue
482+
resp_anon = anon.request("GET", path)
483+
for mode in modes:
484+
for secret in (WEAK_JWT_SECRETS if mode == "weak_secret" else [""]):
485+
try:
486+
forged = _forge_jwt(tok, mode, secret)
487+
except Exception: # noqa: BLE001 - a malformed token, skip this mode
488+
break
489+
forged_headers = dict(headers)
490+
forged_headers[name] = value.replace(tok, forged)
491+
client = Client(base_url, domains, "read", budget, allow_private, forged_headers)
492+
confirmations, resp = 0, None
493+
for _attempt in range(retest):
494+
resp = client.request("GET", path)
495+
env = _env_for(resp, {}, False,
496+
saved={"authed": resp_authed, "anon": resp_anon})
497+
if evaluate(differential, env):
498+
confirmations += 1
499+
else:
500+
break
501+
if confirmations == retest and confirmations > 0:
502+
f = _finding(spec, client, resp, path)
503+
how = (f"a token re-signed with the weak secret '{secret}'"
504+
if mode == "weak_secret" else said.get(mode, mode))
505+
f["evidence"] = (
506+
f"The signed in session's JWT was replaced with {how}, its claims "
507+
f"unchanged, and the server returned the signed in response at {path} "
508+
"all the same, so it is not verifying the token's signature. The "
509+
"signed out visitor is turned away.")[:800]
510+
return [f]
511+
return []
512+
513+
380514
def _run_two_account(spec: dict, ctx: dict, domains, budget: Budget) -> list:
381515
"""Account A reads an object that belongs to account B. -> findings.
382516

0 commit comments

Comments
 (0)