mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 22:40:54 -07:00
numericClaim only accepted exp as a json number, but some emitters serialize it as a quoted string. a token with a past string exp was reported as missing exp instead of expired, an expiry false negative and a missing-claim false positive on the same token. also parse a string value via strconv.ParseFloat, falling back to absent when it does not parse.
364 lines
12 KiB
Go
364 lines
12 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package scan
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fixed jwt fixtures, generated offline. each exercises a distinct weakness.
|
|
const (
|
|
// header {alg:none}, payload {sub:admin}, empty signature - forgeable.
|
|
jwtNone = "eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0." +
|
|
"eyJzdWIiOiAiYWRtaW4iLCAicm9sZSI6ICJ1c2VyIn0."
|
|
|
|
// HS256, no exp claim, signed with the bundled weak secret "secret".
|
|
jwtWeakHS256 = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
|
|
"eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogInRlc3RlciJ9." +
|
|
"JOjVfLa8gp3cvFkNVgOnmdrI1MCHZRA_ChBmCPF-Z8w"
|
|
|
|
// HS256, exp in 2001 (long past), signed with a secret not in the wordlist.
|
|
jwtExpired = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
|
|
"eyJzdWIiOiAieCIsICJleHAiOiAxMDAwMDAwMDAwfQ." +
|
|
"gr28Ffm4wJkonHGSKmMD5Rj7e1pTt2o_EwG6lMWQeSc"
|
|
|
|
// HS256 carrying a plaintext password claim (jwt bodies are not encrypted).
|
|
jwtSensitive = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
|
|
"eyJzdWIiOiAieCIsICJwYXNzd29yZCI6ICJodW50ZXIyIiwgImV4cCI6IDk5OTk5OTk5OTl9." +
|
|
"rjEf0CUa7_qppuINi6zL9vupJIX0rzSBhul7kKM9uSA"
|
|
|
|
// HS384, signed with the bundled weak secret "secret".
|
|
jwtWeakHS384 = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9." +
|
|
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
|
|
"OXNTuzKiGLxnpUjL24vvKlQzdOD-YKMinN8eu_v5luTXDUF65bHAQnz-M3VG2TVh"
|
|
|
|
// HS512, signed with the bundled weak secret "secret".
|
|
jwtWeakHS512 = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9." +
|
|
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
|
|
"CXcZz0F9TTPg--B4WV1Vzty3gG_wcDG86H5QDSRe94MpcVXIcRTBK6H7OmqFyG4nNWYNXPOODCu426bgQMOzRQ"
|
|
|
|
// HS384/HS512 signed with a strong secret absent from the wordlist; these
|
|
// must never be cracked (no false positive on the wide-digest path).
|
|
jwtStrongHS384 = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9." +
|
|
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
|
|
"vHhjZPoXZnnZEvVYxX64J2wm8qWk-e6y_T20qTy_Su6sPmoUSMHS4tv6_D-hfwrY"
|
|
jwtStrongHS512 = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9." +
|
|
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
|
|
"80ueFa0oI88ftySkn_MJ12GAd1r2cahXt_ICtCfWx58wJoAvEocbjBPC_efzOp8vm_39GlcCCDLeb6cFix3DBw"
|
|
)
|
|
|
|
// hasIssue reports whether the analyzed token carries an issue of the given kind.
|
|
func hasIssue(token *JWTToken, kind string) bool {
|
|
for i := 0; i < len(token.Issues); i++ {
|
|
if token.Issues[i].Kind == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestJWT_AlgNoneAndMissingExpFlagged(t *testing.T) {
|
|
// serve the alg:none token in the Authorization header echo.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Authorization", "Bearer "+jwtNone)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected exactly one analyzed token, got %+v", result)
|
|
}
|
|
|
|
token := &result.Tokens[0]
|
|
if !hasIssue(token, "alg:none") {
|
|
t.Errorf("expected alg:none to be flagged, got issues %+v", token.Issues)
|
|
}
|
|
if !hasIssue(token, "missing exp") {
|
|
t.Errorf("expected missing exp to be flagged, got issues %+v", token.Issues)
|
|
}
|
|
// the preview must never carry the whole token.
|
|
if len(token.Preview) >= len(jwtNone) {
|
|
t.Errorf("preview should be trimmed, got full token %q", token.Preview)
|
|
}
|
|
}
|
|
|
|
func TestJWT_WeakSecretCracked(t *testing.T) {
|
|
// token rides in a Set-Cookie this time.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: jwtWeakHS256})
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected one token, got %+v", result)
|
|
}
|
|
|
|
token := &result.Tokens[0]
|
|
if token.WeakKey != "secret" {
|
|
t.Errorf("expected weak secret 'secret' to be cracked, got %q", token.WeakKey)
|
|
}
|
|
if !hasIssue(token, "weak hmac secret") {
|
|
t.Errorf("expected weak hmac secret issue, got %+v", token.Issues)
|
|
}
|
|
if !hasIssue(token, "rs256->hs256 confusion surface") {
|
|
t.Errorf("expected hmac confusion surface to be flagged, got %+v", token.Issues)
|
|
}
|
|
}
|
|
|
|
func TestJWT_WeakSecretCrackedHS384HS512(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
token string
|
|
}{
|
|
{"HS384", jwtWeakHS384},
|
|
{"HS512", jwtWeakHS512},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: tc.token})
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected one token, got %+v", result)
|
|
}
|
|
|
|
token := &result.Tokens[0]
|
|
if token.WeakKey != "secret" {
|
|
t.Errorf("expected weak secret 'secret' cracked on %s, got %q", tc.name, token.WeakKey)
|
|
}
|
|
if !hasIssue(token, "weak hmac secret") {
|
|
t.Errorf("expected weak hmac secret issue on %s, got %+v", tc.name, token.Issues)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJWT_StrongSecretNotCrackedHS384HS512(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
token string
|
|
}{
|
|
{"HS384", jwtStrongHS384},
|
|
{"HS512", jwtStrongHS512},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: tc.token})
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected one token, got %+v", result)
|
|
}
|
|
|
|
token := &result.Tokens[0]
|
|
if token.WeakKey != "" {
|
|
t.Errorf("strong-secret %s token must not be cracked, got %q", tc.name, token.WeakKey)
|
|
}
|
|
if hasIssue(token, "weak hmac secret") {
|
|
t.Errorf("strong-secret %s token must not raise a weak-secret issue, got %+v", tc.name, token.Issues)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHMACHash(t *testing.T) {
|
|
cases := []struct {
|
|
alg string
|
|
wantOK bool
|
|
wantSize int // digest bytes when ok
|
|
}{
|
|
{"HS256", true, 32},
|
|
{"HS384", true, 48},
|
|
{"HS512", true, 64},
|
|
{"hs256", true, 32}, // alg match is case-insensitive
|
|
{"", false, 0},
|
|
{"none", false, 0},
|
|
{"RS256", false, 0},
|
|
{"ES256", false, 0},
|
|
{"HS1", false, 0},
|
|
{"HS", false, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
newHash, ok := hmacHash(tc.alg)
|
|
if ok != tc.wantOK {
|
|
t.Errorf("hmacHash(%q) ok = %v, want %v", tc.alg, ok, tc.wantOK)
|
|
continue
|
|
}
|
|
if ok && newHash().Size() != tc.wantSize {
|
|
t.Errorf("hmacHash(%q) digest size = %d, want %d", tc.alg, newHash().Size(), tc.wantSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrackHMAC_RejectsMalformedAndNonHMAC(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
alg string
|
|
}{
|
|
{"non-hmac alg", jwtWeakHS384, "RS256"},
|
|
{"unknown hs alg", jwtWeakHS384, "HS1"},
|
|
{"too few parts", "only.two", "HS256"},
|
|
{"non-base64 signature", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.!!!notb64!!!", "HS256"},
|
|
}
|
|
for _, tc := range cases {
|
|
if secret, ok := crackHMAC(tc.raw, tc.alg); ok || secret != "" {
|
|
t.Errorf("%s: crackHMAC = (%q, %v), want (\"\", false)", tc.name, secret, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJWT_ExpiredFlagged(t *testing.T) {
|
|
// token in the response body.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"token":"` + jwtExpired + `"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected one token, got %+v", result)
|
|
}
|
|
if !hasIssue(&result.Tokens[0], "expired token") {
|
|
t.Errorf("expected expired token to be flagged, got %+v", result.Tokens[0].Issues)
|
|
}
|
|
// a strong, unguessed secret must not be cracked.
|
|
if result.Tokens[0].WeakKey != "" {
|
|
t.Errorf("did not expect a cracked key on the strong-secret token, got %q", result.Tokens[0].WeakKey)
|
|
}
|
|
}
|
|
|
|
func TestJWT_SensitiveClaimFlagged(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(jwtSensitive))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected one token, got %+v", result)
|
|
}
|
|
if !hasIssue(&result.Tokens[0], "sensitive plaintext claim") {
|
|
t.Errorf("expected sensitive claim to be flagged, got %+v", result.Tokens[0].Issues)
|
|
}
|
|
}
|
|
|
|
func TestNumericClaim_AcceptsStringEncodedExp(t *testing.T) {
|
|
// some jwt stacks (older php/java libs) serialize NumericDate as a quoted
|
|
// string instead of a json number. a stringly-typed exp must still count
|
|
// as present, or a genuinely expired token reads as "missing exp" instead.
|
|
cases := []struct {
|
|
name string
|
|
payload map[string]any
|
|
wantF float64
|
|
wantOK bool
|
|
}{
|
|
{"string past exp", map[string]any{"exp": "1000000000"}, 1000000000, true},
|
|
{"string future exp", map[string]any{"exp": "9999999999"}, 9999999999, true},
|
|
{"float exp still works", map[string]any{"exp": float64(1000000000)}, 1000000000, true},
|
|
{"garbage string exp", map[string]any{"exp": "not-a-number"}, 0, false},
|
|
{"missing exp", map[string]any{}, 0, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f, ok := numericClaim(tc.payload, "exp")
|
|
if ok != tc.wantOK || f != tc.wantF {
|
|
t.Errorf("numericClaim(%+v) = (%v, %v), want (%v, %v)", tc.payload, f, ok, tc.wantF, tc.wantOK)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJWT_StringExpStillDetectsExpiry(t *testing.T) {
|
|
// header {alg:none}, payload {sub:x, exp:"1000000000"} (string-typed, long past).
|
|
// exp is a valid number, just quoted, so this must flag "expired token" and
|
|
// must NOT also claim the expiry is missing.
|
|
jwtStringExpPast := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0." +
|
|
"eyJzdWIiOiJ4IiwiZXhwIjoiMTAwMDAwMDAwMCJ9."
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(jwtStringExpPast))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result == nil || len(result.Tokens) != 1 {
|
|
t.Fatalf("expected one token, got %+v", result)
|
|
}
|
|
|
|
token := &result.Tokens[0]
|
|
if !hasIssue(token, "expired token") {
|
|
t.Errorf("expected string-encoded past exp to be flagged expired, got %+v", token.Issues)
|
|
}
|
|
if hasIssue(token, "missing exp") {
|
|
t.Errorf("string-encoded exp must not be reported as missing, got %+v", token.Issues)
|
|
}
|
|
}
|
|
|
|
func TestJWT_NoTokens(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("nothing to see here"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := JWT(srv.URL, 5*time.Second, "")
|
|
if err != nil {
|
|
t.Fatalf("JWT: %v", err)
|
|
}
|
|
if result != nil {
|
|
t.Errorf("expected nil result when no tokens present, got %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestJWTResult_ResultType(t *testing.T) {
|
|
r := &JWTResult{}
|
|
if r.ResultType() != "jwt" {
|
|
t.Errorf("expected result type 'jwt', got %q", r.ResultType())
|
|
}
|
|
}
|