|
22 | 22 | from __future__ import annotations |
23 | 23 |
|
24 | 24 | import ast |
| 25 | +import base64 |
| 26 | +import hashlib |
| 27 | +import hmac |
| 28 | +import json |
25 | 29 | import re |
26 | 30 | import secrets |
27 | 31 | import time |
@@ -307,6 +311,17 @@ def run_rule(spec: dict, ctx: dict) -> dict: |
307 | 311 | out["status"], out["detail"] = "error", f"{type(exc).__name__}: {exc}"[:200] |
308 | 312 | return out |
309 | 313 |
|
| 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 | + |
310 | 325 | client = Client(base_url, domains, spec["safety"], budget, |
311 | 326 | allow_private=bool(ctx.get("allow_private")), |
312 | 327 | headers=ctx.get("headers")) |
@@ -377,6 +392,125 @@ def _b_objects(client_b: Client, base_url: str, code_routes) -> list: |
377 | 392 | return out[:MAX_ROUTES] |
378 | 393 |
|
379 | 394 |
|
| 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 | + |
380 | 514 | def _run_two_account(spec: dict, ctx: dict, domains, budget: Budget) -> list: |
381 | 515 | """Account A reads an object that belongs to account B. -> findings. |
382 | 516 |
|
|
0 commit comments