package scanner import ( "encoding/base64" "encoding/json" "fmt" "strings" ) // JWTInfo — разобранный JWT: header/payload как обычные map (без // строгой схемы, потому что claims произвольны), плюс findings от // пассивного анализа. type JWTInfo struct { Header map[string]interface{} Payload map[string]interface{} Findings []Finding } // DecodeJWT разбирает токен на header/payload (без проверки подписи — // это как раз то, что должна уметь любая программа, читающая JWT, без // знания секрета) и прогоняет пассивный анализ через analyzeJWT. func DecodeJWT(token string) (*JWTInfo, error) { parts := strings.Split(strings.TrimSpace(token), ".") if len(parts) < 2 { return nil, fmt.Errorf("не похоже на JWT: ожидалось минимум 2 сегмента через точку, получено %d", len(parts)) } headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, fmt.Errorf("header: %w", err) } payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("payload: %w", err) } info := &JWTInfo{} if err := json.Unmarshal(headerBytes, &info.Header); err != nil { return nil, fmt.Errorf("header не валидный JSON: %w", err) } if err := json.Unmarshal(payloadBytes, &info.Payload); err != nil { return nil, fmt.Errorf("payload не валидный JSON: %w", err) } info.Findings = analyzeJWT(info) return info, nil } // analyzeJWT — пассивные проверки без единого сетевого запроса: смотрим // только на то, что уже видно в декодированном токене. func analyzeJWT(info *JWTInfo) []Finding { var findings []Finding alg, hasAlg := info.Header["alg"].(string) switch { case !hasAlg: findings = append(findings, Finding{ Check: "JWT", Severity: SeverityMedium, Detail: "В заголовке нет поля alg — нестандартный токен, стоит разобраться отдельно", }) case strings.EqualFold(alg, "none"): findings = append(findings, Finding{ Check: "JWT", Severity: SeverityHigh, Detail: "Заголовок уже объявляет alg: none — если сервер это принимает, подпись не нужна вообще", }) case strings.HasPrefix(strings.ToUpper(alg), "HS"): findings = append(findings, Finding{ Check: "JWT", Severity: SeverityInfo, Detail: fmt.Sprintf("Симметричный алгоритм %s — стоит попробовать подобрать секрет по словарю (burterm это не делает автоматически, нужен внешний инструмент вроде hashcat)", alg), }) } if _, ok := info.Payload["exp"]; !ok { findings = append(findings, Finding{ Check: "JWT", Severity: SeverityMedium, Detail: "В payload нет поля exp — токен не истекает", }) } return findings } // AlgNoneVariant возвращает вариант токена с тем же header (кроме alg, // заменённого на "none") и тем же payload, но без подписи вообще — // классический payload для проверки, не принимает ли сервер токены без // подписи (CVE-класс "JWT alg:none bypass"). Результат нужно вручную // вставить в запрос (например, через Repeater) — сама подстановка в // заголовок Authorization зависит от того, как конкретное приложение // его использует, и это не автоматизируется универсально. func AlgNoneVariant(token string) (string, error) { parts := strings.Split(strings.TrimSpace(token), ".") if len(parts) < 2 { return "", fmt.Errorf("не похоже на JWT") } headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", fmt.Errorf("header: %w", err) } var header map[string]interface{} if err := json.Unmarshal(headerBytes, &header); err != nil { return "", fmt.Errorf("header не валидный JSON: %w", err) } header["alg"] = "none" newHeaderBytes, err := json.Marshal(header) if err != nil { return "", fmt.Errorf("сериализация header: %w", err) } newHeader := base64.RawURLEncoding.EncodeToString(newHeaderBytes) // Подпись убирается целиком — остаётся завершающая точка без // третьего сегмента, это и есть канонический вид alg:none токена. return newHeader + "." + parts[1] + ".", nil }