116 lines
4.9 KiB
Go
116 lines
4.9 KiB
Go
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
|
||
}
|