burterm/internal/scanner/jwt.go
2026-09-14 10:55:07 +03:00

116 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}