/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · Blazing-fast pentesting suite : : ▄█ █ █▀ · BSD 3-Clause License : : : : (c) 2022-2026 vmfunc, xyzeva, : : lunchcat alumni & contributors : : : ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· */ package scan import ( "context" "crypto/hmac" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/json" "fmt" "hash" "io" "net/http" "regexp" "strconv" "strings" "time" "github.com/vmfunc/sif/internal/httpx" "github.com/vmfunc/sif/internal/logger" "github.com/vmfunc/sif/internal/output" ) // JWTResult collects every token discovered on the target plus the offline // analysis of each one. type JWTResult struct { Tokens []JWTToken `json:"tokens,omitempty"` } // JWTToken is one decoded jwt and the weaknesses found in it. Token is trimmed // to a short prefix so we never log a full credential. type JWTToken struct { Source string `json:"source"` // where we found it (header name / cookie / body) Preview string `json:"preview"` // first chars of the raw token, never the whole thing Alg string `json:"alg"` // header alg claim Issues []JWTIssue `json:"issues"` // the weaknesses, ranked Claims map[string]any `json:"claims"` // decoded payload (for reporting) WeakKey string `json:"weak_key"` // cracked hmac secret, empty when none } // JWTIssue is a single weakness with a severity so the report layer can rank it. type JWTIssue struct { Kind string `json:"kind"` Severity string `json:"severity"` Detail string `json:"detail"` } // jwtBodyReadCap bounds how much of the response body we slurp looking for // tokens; a jwt riding in the body is near the top, so a megabyte is plenty // without letting a huge response exhaust memory. const jwtBodyReadCap = 1 << 20 // jwtPreviewLen is how many leading characters of a token we keep for evidence. // enough to identify the token in a report, short enough to never be the whole // credential. const jwtPreviewLen = 16 // the three structural jwt severities. const ( jwtSevCritical = "critical" jwtSevHigh = "high" jwtSevMedium = "medium" jwtSevLow = "low" ) // jwtRegex matches a compact-serialization jwt: three base64url segments split // by dots. the header always starts "eyJ" (base64url of `{"`), which anchors the // match and keeps it from firing on arbitrary dotted tokens. var jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*`) // jwtWeakSecrets is a tiny offline wordlist of hmac secrets seen in tutorials, // boilerplate and leaked configs. cracking one means anyone can forge tokens, so // a hit is critical. kept short on purpose - this is a smoke test, not john. var jwtWeakSecrets = []string{ "secret", "secretkey", "secret_key", "your-256-bit-secret", "changeme", "password", "jwt", "jwtsecret", "key", "test", "admin", "supersecret", "s3cr3t", "qwerty", "123456", } // sensitiveClaimKeys are payload fields that should never travel in a readable // jwt body (the payload is only base64, not encrypted). a match is a disclosure. var sensitiveClaimKeys = []string{ "password", "passwd", "secret", "api_key", "apikey", "ssn", "credit_card", "card_number", "private_key", "access_key", } // JWT fetches the target once, harvests every jwt from the response headers, // cookies and body, then analyzes each one entirely offline. func JWT(targetURL string, timeout time.Duration, logdir string) (*JWTResult, error) { log := output.Module("JWT") log.Start() sanitizedURL := stripScheme(targetURL) if logdir != "" { if err := logger.WriteHeader(sanitizedURL, logdir, "JWT discovery + offline analysis"); err != nil { log.Error("error creating log file: %v", err) return nil, fmt.Errorf("create jwt log: %w", err) } } client := httpx.Client(timeout) req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody) if err != nil { return nil, fmt.Errorf("build jwt request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch jwt target: %w", err) } defer resp.Body.Close() // one read, capped; everything past this point is offline. body, err := io.ReadAll(io.LimitReader(resp.Body, jwtBodyReadCap)) if err != nil { return nil, fmt.Errorf("read jwt body: %w", err) } raws := harvestJWTs(resp, string(body)) if len(raws) == 0 { log.Info("no jwts found on target") log.Complete(0, "found") return nil, nil //nolint:nilnil // absence of a token is not an error } result := &JWTResult{Tokens: make([]JWTToken, 0, len(raws))} for _, hit := range raws { token, ok := analyzeJWT(hit.source, hit.raw) if !ok { continue } result.Tokens = append(result.Tokens, token) for i := 0; i < len(token.Issues); i++ { iss := token.Issues[i] log.Warn("jwt %s: %s (%s)", renderJWTSeverity(iss.Severity), iss.Kind, hit.source) if logdir != "" { _ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("JWT %s: %s - %s [%s]\n", iss.Severity, iss.Kind, iss.Detail, hit.source)) } } } if len(result.Tokens) == 0 { log.Complete(0, "found") return nil, nil //nolint:nilnil // tokens were malformed, nothing to report } log.Complete(len(result.Tokens), "analyzed") return result, nil } // jwtHit ties a raw token to where it came from so the report can attribute it. type jwtHit struct { source string raw string } // harvestJWTs pulls every jwt out of the response: Authorization-style headers, // Set-Cookie values and the body. dedup keys on the raw token so the same value // echoed in two places is reported once. func harvestJWTs(resp *http.Response, body string) []jwtHit { seen := make(map[string]struct{}) var hits []jwtHit add := func(source, raw string) { if _, ok := seen[raw]; ok { return } seen[raw] = struct{}{} hits = append(hits, jwtHit{source: source, raw: raw}) } for name, values := range resp.Header { for i := 0; i < len(values); i++ { for _, m := range jwtRegex.FindAllString(values[i], -1) { add("header:"+name, m) } } } for _, c := range resp.Cookies() { for _, m := range jwtRegex.FindAllString(c.Value, -1) { add("cookie:"+c.Name, m) } } for _, m := range jwtRegex.FindAllString(body, -1) { add("body", m) } return hits } // analyzeJWT decodes the header and payload (offline base64url, never verifying a // signature against the network) and runs every weakness check. ok is false when // the token doesn't decode into a real header+payload, so junk that matched the // regex is dropped rather than reported. func analyzeJWT(source, raw string) (JWTToken, bool) { parts := strings.Split(raw, ".") if len(parts) != 3 { return JWTToken{}, false } header, err := decodeJWTSegment(parts[0]) if err != nil { return JWTToken{}, false } payload, err := decodeJWTSegment(parts[1]) if err != nil { return JWTToken{}, false } alg, _ := header["alg"].(string) token := JWTToken{ Source: source, Preview: previewToken(raw), Alg: alg, Claims: payload, } token.Issues = append(token.Issues, jwtAlgIssues(alg)...) token.Issues = append(token.Issues, jwtClaimIssues(payload)...) // only bother cracking when the alg is actually hmac; an asymmetric token // has no shared secret to guess. if isHMACAlg(alg) { if secret, ok := crackHMAC(raw, alg); ok { token.WeakKey = secret token.Issues = append(token.Issues, JWTIssue{ Kind: "weak hmac secret", Severity: jwtSevCritical, Detail: "signature verifies against bundled weak secret " + secret, }) } } return token, true } // jwtAlgIssues flags the algorithm-level weaknesses: alg:none (no signature at // all) and the RS256->HS256 confusion surface (an asymmetric-looking token whose // header says HS*, meaning a server that loads the public key as an hmac secret // can be forged). func jwtAlgIssues(alg string) []JWTIssue { var issues []JWTIssue lower := strings.ToLower(alg) if lower == "none" || alg == "" { issues = append(issues, JWTIssue{ Kind: "alg:none", Severity: jwtSevCritical, Detail: "token declares no signature algorithm; forgeable", }) return issues } if isHMACAlg(alg) { issues = append(issues, JWTIssue{ Kind: "rs256->hs256 confusion surface", Severity: jwtSevMedium, Detail: "token is HMAC-signed; if the server also accepts asymmetric algs " + "with the same verifier, a public key can be used as the HMAC secret", }) } return issues } // jwtClaimIssues inspects the decoded payload for missing/expired expiry and any // plaintext sensitive claims (the payload is base64, not encrypted). func jwtClaimIssues(payload map[string]any) []JWTIssue { var issues []JWTIssue exp, hasExp := numericClaim(payload, "exp") switch { case !hasExp: issues = append(issues, JWTIssue{ Kind: "missing exp", Severity: jwtSevMedium, Detail: "no expiry claim; token never ages out", }) case time.Now().After(time.Unix(int64(exp), 0)): issues = append(issues, JWTIssue{ Kind: "expired token", Severity: jwtSevLow, Detail: "exp is in the past; a server still honoring it is a bug", }) } for i := 0; i < len(sensitiveClaimKeys); i++ { key := sensitiveClaimKeys[i] if _, ok := payload[key]; ok { issues = append(issues, JWTIssue{ Kind: "sensitive plaintext claim", Severity: jwtSevHigh, Detail: "payload carries readable claim " + key + "; jwt bodies are not encrypted", }) } } return issues } // crackHMAC tries every bundled weak secret against the token's signature offline, // using the hash that matches alg (HS256/HS384/HS512). a verifying secret means the // token is forgeable; the wordlist catches lazy defaults, it is not a real cracker. func crackHMAC(raw, alg string) (string, bool) { newHash, ok := hmacHash(alg) if !ok { return "", false } parts := strings.Split(raw, ".") if len(parts) != 3 { return "", false } signingInput := parts[0] + "." + parts[1] want, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return "", false } for i := 0; i < len(jwtWeakSecrets); i++ { secret := jwtWeakSecrets[i] mac := hmac.New(newHash, []byte(secret)) mac.Write([]byte(signingInput)) if hmac.Equal(mac.Sum(nil), want) { return secret, true } } return "", false } // hmacHash maps an HMAC jwt alg to its hash constructor; ok is false for any // non-HMAC or unknown alg. it is stricter than isHMACAlg: the confusion-surface // finding fires on any HS* alg, but cracking needs a computable digest width. func hmacHash(alg string) (func() hash.Hash, bool) { switch strings.ToUpper(alg) { case "HS256": return sha256.New, true case "HS384": return sha512.New384, true case "HS512": return sha512.New, true default: return nil, false } } // decodeJWTSegment base64url-decodes one jwt segment into a claims map. jwt uses // unpadded base64url, but some emitters pad anyway, so try raw first then padded. func decodeJWTSegment(seg string) (map[string]any, error) { data, err := base64.RawURLEncoding.DecodeString(seg) if err != nil { data, err = base64.URLEncoding.DecodeString(seg) if err != nil { return nil, fmt.Errorf("base64url decode segment: %w", err) } } var claims map[string]any if err := json.Unmarshal(data, &claims); err != nil { return nil, fmt.Errorf("unmarshal jwt segment: %w", err) } return claims, nil } // numericClaim pulls a numeric claim out of the payload. json numbers decode to // float64, but some jwt libraries (older php/java stacks in particular) encode // NumericDate as a quoted string, so a string that parses as a number counts too. // otherwise a stringly-typed exp reads as absent and a genuinely expired token // slips through as "missing exp" instead of "expired". func numericClaim(payload map[string]any, key string) (float64, bool) { v, ok := payload[key] if !ok { return 0, false } switch n := v.(type) { case float64: return n, true case string: f, err := strconv.ParseFloat(n, 64) if err != nil { return 0, false } return f, true default: return 0, false } } // isHMACAlg reports whether alg is one of the HMAC family (HS256/HS384/HS512). func isHMACAlg(alg string) bool { return strings.HasPrefix(strings.ToUpper(alg), "HS") } // previewToken trims a raw token to a short prefix so evidence never carries the // whole credential. func previewToken(raw string) string { if len(raw) <= jwtPreviewLen { return raw } return raw[:jwtPreviewLen] + "..." } func renderJWTSeverity(severity string) string { switch severity { case jwtSevCritical: return output.SeverityCritical.Render(severity) case jwtSevHigh: return output.SeverityHigh.Render(severity) case jwtSevMedium: return output.SeverityMedium.Render(severity) default: return output.SeverityLow.Render(severity) } } // ResultType identifies jwt findings for the result registry. func (r *JWTResult) ResultType() string { return "jwt" } var _ ScanResult = (*JWTResult)(nil)