From 649fafb3a5accbd445bf862efbc03e59368cd1ae Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Sat, 5 Sep 2026 09:05:59 +0200 Subject: [PATCH] feat: add a not vulnerable mode --- README.md | 4 ++ challenges/apollo/README.md | 9 +++ challenges/apollo/src/index.ts | 14 ++++- challenges/auth-not-verified/README.md | 12 ++++ challenges/auth-not-verified/serve/server.go | 10 ++- challenges/discoverable/README.md | 12 ++++ challenges/discoverable/serve/server.go | 11 +++- challenges/http-misconfigurations/README.md | 12 ++++ .../http-misconfigurations/serve/server.go | 40 +++++++++--- challenges/jwt-alg-none-bypass/README.md | 11 +++- challenges/jwt-alg-none-bypass/main.go | 2 +- .../jwt-alg-none-bypass/serve/server.go | 4 +- challenges/jwt-apple-token-relay/README.md | 9 +++ .../jwt-apple-token-relay/serve/server.go | 9 ++- challenges/jwt-blank-secret/README.md | 12 ++++ challenges/jwt-blank-secret/main.go | 7 ++- challenges/jwt-blank-secret/serve/server.go | 11 +++- .../jwt-claim-oversized-value/README.md | 12 ++++ challenges/jwt-claim-oversized-value/main.go | 2 +- .../jwt-claim-oversized-value/serve/server.go | 59 +++++++++++++++--- challenges/jwt-claim-special-chars/README.md | 12 ++++ challenges/jwt-claim-special-chars/main.go | 2 +- .../jwt-claim-special-chars/serve/server.go | 62 +++++++++++++++---- challenges/jwt-claim-type-confusion/README.md | 12 ++++ challenges/jwt-claim-type-confusion/main.go | 2 +- .../jwt-claim-type-confusion/serve/server.go | 57 +++++++++++++---- challenges/jwt-cross-service-relay/README.md | 9 +++ .../jwt-cross-service-relay/serve/server.go | 9 ++- challenges/jwt-facebook-token-relay/README.md | 9 +++ .../jwt-facebook-token-relay/serve/server.go | 9 ++- challenges/jwt-google-token-relay/README.md | 9 +++ .../jwt-google-token-relay/serve/server.go | 9 ++- challenges/jwt-hmac-confusion/README.md | 12 ++++ challenges/jwt-hmac-confusion/main.go | 2 +- challenges/jwt-hmac-confusion/serve/server.go | 8 ++- challenges/jwt-issuer-confusion/README.md | 12 ++++ .../jwt-issuer-confusion/serve/server.go | 34 +++++++--- challenges/jwt-jku-header-injection/README.md | 12 ++++ challenges/jwt-jku-header-injection/main.go | 2 +- .../jwt-jku-header-injection/serve/server.go | 8 ++- challenges/jwt-jwk-header-injection/README.md | 12 ++++ challenges/jwt-jwk-header-injection/main.go | 2 +- .../jwt-jwk-header-injection/serve/server.go | 6 +- .../jwt-jwks-issuer-injection/README.md | 12 ++++ challenges/jwt-jwks-issuer-injection/main.go | 2 +- .../jwt-jwks-issuer-injection/serve/server.go | 10 ++- challenges/jwt-kid-path-traversal/README.md | 12 ++++ challenges/jwt-kid-path-traversal/main.go | 2 +- .../jwt-kid-path-traversal/serve/server.go | 11 +++- challenges/jwt-kid-sql-injection/README.md | 12 ++++ challenges/jwt-kid-sql-injection/main.go | 2 +- .../jwt-kid-sql-injection/serve/server.go | 14 +++-- .../jwt-microsoft-token-relay/README.md | 9 +++ .../jwt-microsoft-token-relay/serve/server.go | 9 ++- challenges/jwt-not-verified/README.md | 12 ++++ challenges/jwt-not-verified/main.go | 2 +- challenges/jwt-not-verified/serve/server.go | 42 +++++++++++-- challenges/jwt-null-signature/README.md | 12 ++++ challenges/jwt-null-signature/main.go | 2 +- challenges/jwt-null-signature/serve/server.go | 5 +- challenges/jwt-psychic-signature/README.md | 12 ++++ challenges/jwt-psychic-signature/main.go | 2 +- .../jwt-psychic-signature/serve/server.go | 11 +++- challenges/jwt-strong-eddsa-key/README.md | 12 ++++ .../keys/weak_private_key.pem | 3 + .../keys/weak_public_key.pem | 3 + challenges/jwt-strong-eddsa-key/main.go | 10 ++- .../jwt-strong-eddsa-key/serve/server.go | 14 +++-- challenges/jwt-weak-hmac-secret/README.md | 12 ++++ challenges/jwt-weak-hmac-secret/main.go | 7 ++- .../jwt-weak-hmac-secret/serve/server.go | 11 +++- challenges/jwt-weak-rsa-key/README.md | 12 ++++ .../keys/strong_private_key.pem | 52 ++++++++++++++++ .../keys/strong_public_key.pem | 14 +++++ challenges/jwt-weak-rsa-key/main.go | 9 ++- challenges/jwt-weak-rsa-key/serve/server.go | 13 ++-- .../jwt-x5c-x5u-header-injection/README.md | 12 ++++ .../jwt-x5c-x5u-header-injection/main.go | 2 +- .../serve/server.go | 16 +++-- challenges/strong-api-key/README.md | 12 ++++ challenges/strong-api-key/serve/server.go | 44 ++++++++++--- challenges/strong-http-basic/README.md | 12 ++++ challenges/strong-http-basic/serve/server.go | 36 +++++++---- common/cmd.go | 15 +++-- 84 files changed, 939 insertions(+), 150 deletions(-) create mode 100644 challenges/jwt-strong-eddsa-key/keys/weak_private_key.pem create mode 100644 challenges/jwt-strong-eddsa-key/keys/weak_public_key.pem create mode 100644 challenges/jwt-weak-rsa-key/keys/strong_private_key.pem create mode 100644 challenges/jwt-weak-rsa-key/keys/strong_public_key.pem diff --git a/README.md b/README.md index 0aea2be..16a43ed 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ git clone https://github.com/cerberauth/api-vulns-challenges.git 4. Exploit the Vulnerability: Once the environment is set up, attempt to exploit the vulnerability as per the challenge instructions. Document your findings and the steps you took. +### Vulnerable vs. fixed mode + +Every challenge server can run in two modes, toggled with the `--vulnerable` flag on its `serve` command (`--vulnerable=true` by default): the vulnerable mode reproduces the flaw the challenge is named after, while `--vulnerable=false` runs the fixed, non-vulnerable implementation of the same API. This lets you validate a scanner or exploit against the vulnerable server, then confirm it no longer works against the fixed one. See each challenge's README for the specific behavior difference between modes. + 5. Share Your Results: If you wish, you can share your findings, write-ups, or solutions by submitting a pull request to this repository. ## Challenges diff --git a/challenges/apollo/README.md b/challenges/apollo/README.md index ce12ad1..95ab079 100644 --- a/challenges/apollo/README.md +++ b/challenges/apollo/README.md @@ -8,6 +8,15 @@ This challenge demonstrates an Apollo GraphQL server. npm install && npm start ``` +## Modes + +The server supports two modes, toggled with the `VULNERABLE` environment variable (defaults to `true`): + +```bash +VULNERABLE=true npm start # vulnerable: introspection enabled, CORS allows any origin +VULNERABLE=false npm start # fixed: introspection disabled, CORS restricted to a trusted origin +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/apollo/src/index.ts b/challenges/apollo/src/index.ts index 0a7c384..fe8a14c 100644 --- a/challenges/apollo/src/index.ts +++ b/challenges/apollo/src/index.ts @@ -46,6 +46,11 @@ const resolvers = { }, }; +// Toggle between the vulnerable and the fixed, non-vulnerable configuration. +// Defaults to the vulnerable mode, matching the other challenges in this +// repository. Set VULNERABLE=false to run the fixed configuration. +const vulnerable = process.env.VULNERABLE !== 'false'; + const app = express(); const httpServer = http.createServer(app); @@ -68,13 +73,20 @@ const server = new ApolloServer({ typeDefs, resolvers, plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + // vulnerable: introspection lets anyone dump the full schema, including + // fields and types never meant to be discoverable by a client + introspection: vulnerable, }); await server.start(); app.use( '/graphql', - cors(), + // vulnerable: any origin is allowed to make credentialed cross-site + // requests to the GraphQL endpoint + vulnerable + ? cors() + : cors({ origin: 'https://trusted.example.com' }), helmet(), express.json(), logger, diff --git a/challenges/auth-not-verified/README.md b/challenges/auth-not-verified/README.md index 6922b40..95a1ff2 100644 --- a/challenges/auth-not-verified/README.md +++ b/challenges/auth-not-verified/README.md @@ -8,6 +8,18 @@ This challenge demonstrates an API where authentication is not verified. go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: any request is accepted, no authentication is checked at all +go run main.go serve --vulnerable=true + +# fixed: a bearer token is required +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/auth-not-verified/serve/server.go b/challenges/auth-not-verified/serve/server.go index bb48817..01d7815 100644 --- a/challenges/auth-not-verified/serve/server.go +++ b/challenges/auth-not-verified/serve/server.go @@ -7,9 +7,17 @@ import ( "github.com/cerberauth/api-vulns-challenges/common" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if !vulnerable { + // fixed: a bearer token is required and actually checked + if _, ok := common.ExtractBearerToken(r); !ok { + w.WriteHeader(401) + return + } + } + w.WriteHeader(204) }) diff --git a/challenges/discoverable/README.md b/challenges/discoverable/README.md index 5597cd1..1564915 100644 --- a/challenges/discoverable/README.md +++ b/challenges/discoverable/README.md @@ -8,6 +8,18 @@ This challenge demonstrates an API that is discoverable through an OpenAPI speci go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the OpenAPI spec under ./static is served publicly +go run main.go serve --vulnerable=true + +# fixed: only the health endpoints are exposed, the spec is not served +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/discoverable/serve/server.go b/challenges/discoverable/serve/server.go index d89d645..32e2787 100644 --- a/challenges/discoverable/serve/server.go +++ b/challenges/discoverable/serve/server.go @@ -7,10 +7,15 @@ import ( "github.com/cerberauth/api-vulns-challenges/common" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() - fs := http.FileServer(http.Dir("./static")) - mux.Handle("/", fs) + + if vulnerable { + // vulnerable: the OpenAPI spec (and any other file under ./static) + // is served publicly, letting anyone enumerate the full API surface + fs := http.FileServer(http.Dir("./static")) + mux.Handle("/", fs) + } mux.HandleFunc("/health/ready", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/challenges/http-misconfigurations/README.md b/challenges/http-misconfigurations/README.md index c5966c2..32fe4ae 100644 --- a/challenges/http-misconfigurations/README.md +++ b/challenges/http-misconfigurations/README.md @@ -8,6 +8,18 @@ This challenge demonstrates various HTTP misconfigurations, including CORS, CSP, go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: method override headers bypass method checks, CORS is wide open, cookies miss Secure/HttpOnly/SameSite/expiration, CSP allows framing +go run main.go serve --vulnerable=true + +# fixed: each endpoint returns its hardened counterpart +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/http-misconfigurations/serve/server.go b/challenges/http-misconfigurations/serve/server.go index 8550bce..114f18e 100644 --- a/challenges/http-misconfigurations/serve/server.go +++ b/challenges/http-misconfigurations/serve/server.go @@ -6,7 +6,7 @@ import ( "time" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNoContent) @@ -20,7 +20,12 @@ func RunServer(port string) { } w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodGet || r.Header.Get("X-HTTP-Method-Override") == http.MethodGet || r.URL.Query().Get("_method") == http.MethodGet { + // vulnerable: a GET-only endpoint can also be reached with the real + // method overridden via a header or query parameter, which lets an + // attacker bypass method-based access controls (e.g. a proxy/WAF + // rule that only inspects r.Method) + overridden := vulnerable && (r.Header.Get("X-HTTP-Method-Override") == http.MethodGet || r.URL.Query().Get("_method") == http.MethodGet) + if r.Method == http.MethodGet || overridden { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"message": "GET method"}`)) } else { @@ -30,13 +35,22 @@ func RunServer(port string) { http.HandleFunc("/headers/cors-wildcard", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") + if vulnerable { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com") + w.Header().Set("Vary", "Origin") + } w.WriteHeader(http.StatusNoContent) }) http.HandleFunc("/headers/csp-frame-ancestors", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Security-Policy", "frame-ancestors 'http://example.com'") + if vulnerable { + w.Header().Set("Content-Security-Policy", "frame-ancestors 'http://example.com'") + } else { + w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") + } w.WriteHeader(http.StatusNoContent) }) @@ -45,7 +59,7 @@ func RunServer(port string) { Name: "unsecure", Value: "unsecure", SameSite: http.SameSiteStrictMode, - Secure: false, + Secure: !vulnerable, HttpOnly: true, Expires: time.Now().Add(24 * time.Hour), }) @@ -58,7 +72,7 @@ func RunServer(port string) { Name: "unsecure", Value: "unsecure", SameSite: http.SameSiteStrictMode, - HttpOnly: false, + HttpOnly: !vulnerable, Secure: true, Expires: time.Now().Add(24 * time.Hour), }) @@ -67,10 +81,14 @@ func RunServer(port string) { }) http.HandleFunc("/cookies/samesite-none", func(w http.ResponseWriter, r *http.Request) { + sameSite := http.SameSiteNoneMode + if !vulnerable { + sameSite = http.SameSiteStrictMode + } http.SetCookie(w, &http.Cookie{ Name: "unsecure", Value: "unsecure", - SameSite: http.SameSiteNoneMode, + SameSite: sameSite, HttpOnly: true, Secure: true, Expires: time.Now().Add(24 * time.Hour), @@ -80,13 +98,17 @@ func RunServer(port string) { }) http.HandleFunc("/cookies/no-expiration", func(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ + cookie := &http.Cookie{ Name: "unsecure", Value: "unsecure", SameSite: http.SameSiteStrictMode, HttpOnly: true, Secure: true, - }) + } + if !vulnerable { + cookie.Expires = time.Now().Add(24 * time.Hour) + } + http.SetCookie(w, cookie) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNoContent) }) diff --git a/challenges/jwt-alg-none-bypass/README.md b/challenges/jwt-alg-none-bypass/README.md index a2f3846..d76d75c 100644 --- a/challenges/jwt-alg-none-bypass/README.md +++ b/challenges/jwt-alg-none-bypass/README.md @@ -5,7 +5,16 @@ This challenge demonstrates a JWT implementation that is vulnerable to the 'none ## How to run it ```bash -go run main.go +go run main.go serve +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: accepts tokens signed with alg "none" +go run main.go serve --vulnerable=false # fixed: only HMAC-signed tokens are accepted, alg "none" is rejected ``` ## Disclaimer diff --git a/challenges/jwt-alg-none-bypass/main.go b/challenges/jwt-alg-none-bypass/main.go index da87b5d..1b856c7 100644 --- a/challenges/jwt-alg-none-bypass/main.go +++ b/challenges/jwt-alg-none-bypass/main.go @@ -8,7 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "name": "John Doe", diff --git a/challenges/jwt-alg-none-bypass/serve/server.go b/challenges/jwt-alg-none-bypass/serve/server.go index 50585b9..681382c 100644 --- a/challenges/jwt-alg-none-bypass/serve/server.go +++ b/challenges/jwt-alg-none-bypass/serve/server.go @@ -9,7 +9,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -20,7 +20,7 @@ func RunServer(port string) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // fake vulnerability - if token.Method.Alg() == "none" { + if vulnerable && token.Method.Alg() == "none" { return jwt.UnsafeAllowNoneSignatureType, nil } diff --git a/challenges/jwt-apple-token-relay/README.md b/challenges/jwt-apple-token-relay/README.md index fab504d..4de356b 100644 --- a/challenges/jwt-apple-token-relay/README.md +++ b/challenges/jwt-apple-token-relay/README.md @@ -8,6 +8,15 @@ This challenge demonstrates a token cross-service relay attack against a relying go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: the audience (`aud`) claim is never checked +go run main.go serve --vulnerable=false # fixed: the token must also match the expected audience +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-apple-token-relay/serve/server.go b/challenges/jwt-apple-token-relay/serve/server.go index 99583e3..38b2761 100644 --- a/challenges/jwt-apple-token-relay/serve/server.go +++ b/challenges/jwt-apple-token-relay/serve/server.go @@ -16,7 +16,7 @@ const ( VictimAudience = "com.victim.app" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -43,12 +43,17 @@ func RunServer(port string) { // The relying party checks the Apple signature and issuer only. // It never validates the audience, so an ID token minted by Apple // for a different, attacker-controlled app is accepted here too. + parserOpts := []jwt.ParserOption{jwt.WithIssuer(Issuer)} + if !vulnerable { + parserOpts = append(parserOpts, jwt.WithAudience(VictimAudience)) + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return idpPublicKey, nil - }, jwt.WithIssuer(Issuer)) + }, parserOpts...) if err != nil || !token.Valid { fmt.Println(err) diff --git a/challenges/jwt-blank-secret/README.md b/challenges/jwt-blank-secret/README.md index b1a6270..ae2bf78 100644 --- a/challenges/jwt-blank-secret/README.md +++ b/challenges/jwt-blank-secret/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that uses a blank secret for si go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: tokens signed with a blank HMAC secret are accepted +go run main.go serve --vulnerable=true + +# fixed: a strong, high-entropy HMAC secret is required +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-blank-secret/main.go b/challenges/jwt-blank-secret/main.go index 1a7a0a2..97f9581 100644 --- a/challenges/jwt-blank-secret/main.go +++ b/challenges/jwt-blank-secret/main.go @@ -8,14 +8,17 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "name": "John Doe", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), }) - return token.SignedString([]byte("")) + if vulnerable { + return token.SignedString([]byte("")) + } + return token.SignedString([]byte(serve.Secret)) } func main() { diff --git a/challenges/jwt-blank-secret/serve/server.go b/challenges/jwt-blank-secret/serve/server.go index 3208f3e..0b9a329 100644 --- a/challenges/jwt-blank-secret/serve/server.go +++ b/challenges/jwt-blank-secret/serve/server.go @@ -9,7 +9,10 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func RunServer(port string) { +// Secret is the strong HMAC secret used when the server runs in its fixed, non-vulnerable mode. +const Secret = "lZ9AgUXt0ZWpMcRfQ9vloftnVqRQy72kOl4479SFdyc" + +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -23,7 +26,11 @@ func RunServer(port string) { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte(""), nil + if vulnerable { + // vulnerable: blank secret, trivially guessable + return []byte(""), nil + } + return []byte(Secret), nil }) if token != nil && token.Valid { diff --git a/challenges/jwt-claim-oversized-value/README.md b/challenges/jwt-claim-oversized-value/README.md index 72d5a6f..35d5bc5 100644 --- a/challenges/jwt-claim-oversized-value/README.md +++ b/challenges/jwt-claim-oversized-value/README.md @@ -16,6 +16,18 @@ This is one of a family of related challenges, each isolating a different class go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: an oversized coupon claim panics the handler with an out-of-range index, and the panic (with stack trace) is leaked in the response +go run main.go serve --vulnerable=true + +# fixed: the index is bounds-checked and panics never leak internals to the client +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-claim-oversized-value/main.go b/challenges/jwt-claim-oversized-value/main.go index 83a3dd5..7febcc7 100644 --- a/challenges/jwt-claim-oversized-value/main.go +++ b/challenges/jwt-claim-oversized-value/main.go @@ -8,7 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "coupon": "SAVE10", diff --git a/challenges/jwt-claim-oversized-value/serve/server.go b/challenges/jwt-claim-oversized-value/serve/server.go index 63ce376..03ea976 100644 --- a/challenges/jwt-claim-oversized-value/serve/server.go +++ b/challenges/jwt-claim-oversized-value/serve/server.go @@ -44,7 +44,22 @@ func debugRecoveryMiddleware(next http.Handler) http.Handler { }) } -func RunServer(port string) { +// recoveryMiddleware is the fixed counterpart of debugRecoveryMiddleware: it +// still recovers from panics so the server keeps running, but never leaks +// the panic message or stack trace to the client. +func recoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Println("recovered from panic:", rec) + w.WriteHeader(http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -65,20 +80,44 @@ func RunServer(port string) { } claims := token.Claims.(jwt.MapClaims) - coupon := claims["coupon"].(string) - // The coupon's raw length indexes straight into a fixed-size - // lookup table with no upper bound. Every coupon the developers - // ever tested with was a handful of characters, so this always - // worked - until a claim value far longer than expected (which - // is exactly what a length/size-boundary fuzz mutation sends) - // walks past the end of the slice. - discount := discountTiers[len(coupon)] + var coupon string + if vulnerable { + coupon = claims["coupon"].(string) + } else { + coupon, ok = claims["coupon"].(string) + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + } + + var discount string + if vulnerable { + // The coupon's raw length indexes straight into a fixed-size + // lookup table with no upper bound. Every coupon the developers + // ever tested with was a handful of characters, so this always + // worked - until a claim value far longer than expected (which + // is exactly what a length/size-boundary fuzz mutation sends) + // walks past the end of the slice. + discount = discountTiers[len(coupon)] + } else { + // fixed: the index is clamped to the table's bounds + idx := len(coupon) + if idx >= len(discountTiers) { + idx = len(discountTiers) - 1 + } + discount = discountTiers[idx] + } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"coupon":%q,"discount":%q}`, coupon, discount) }) log.Println("Server started at port", port) - log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(debugRecoveryMiddleware(mux)))) + recovery := debugRecoveryMiddleware + if !vulnerable { + recovery = recoveryMiddleware + } + log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(recovery(mux)))) } diff --git a/challenges/jwt-claim-special-chars/README.md b/challenges/jwt-claim-special-chars/README.md index e6a7d68..c9c73e0 100644 --- a/challenges/jwt-claim-special-chars/README.md +++ b/challenges/jwt-claim-special-chars/README.md @@ -16,6 +16,18 @@ This is one of a family of related challenges, each isolating a different class go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: an invalid regexp filter claim panics the handler, and the panic (with stack trace) is leaked in the response +go run main.go serve --vulnerable=true + +# fixed: invalid patterns are rejected instead of panicking, and panics never leak internals to the client +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-claim-special-chars/main.go b/challenges/jwt-claim-special-chars/main.go index 2cd47c4..f229124 100644 --- a/challenges/jwt-claim-special-chars/main.go +++ b/challenges/jwt-claim-special-chars/main.go @@ -8,7 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "filter": ".*", diff --git a/challenges/jwt-claim-special-chars/serve/server.go b/challenges/jwt-claim-special-chars/serve/server.go index cbcc272..a1cb9ec 100644 --- a/challenges/jwt-claim-special-chars/serve/server.go +++ b/challenges/jwt-claim-special-chars/serve/server.go @@ -35,7 +35,22 @@ func debugRecoveryMiddleware(next http.Handler) http.Handler { }) } -func RunServer(port string) { +// recoveryMiddleware is the fixed counterpart of debugRecoveryMiddleware: it +// still recovers from panics so the server keeps running, but never leaks +// the panic message or stack trace to the client. +func recoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Println("recovered from panic:", rec) + w.WriteHeader(http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -56,16 +71,37 @@ func RunServer(port string) { } claims := token.Claims.(jwt.MapClaims) - rawFilter := claims["filter"].(string) - // The "filter" claim lets a client scope which of their own - // records get returned, so it's compiled straight into a regexp - // with MustCompile instead of Compile+error-check - nobody - // expected a claim to contain anything but a simple pattern. - // Special/meta characters that don't form valid regexp syntax - // (unbalanced brackets, braces, parens, a trailing backslash, ...) - // panic instead of being rejected as a bad filter. - filter := regexp.MustCompile(rawFilter) + var rawFilter string + if vulnerable { + rawFilter = claims["filter"].(string) + } else { + rawFilter, ok = claims["filter"].(string) + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + } + + var filter *regexp.Regexp + if vulnerable { + // The "filter" claim lets a client scope which of their own + // records get returned, so it's compiled straight into a regexp + // with MustCompile instead of Compile+error-check - nobody + // expected a claim to contain anything but a simple pattern. + // Special/meta characters that don't form valid regexp syntax + // (unbalanced brackets, braces, parens, a trailing backslash, ...) + // panic instead of being rejected as a bad filter. + filter = regexp.MustCompile(rawFilter) + } else { + // fixed: invalid patterns are rejected instead of panicking + var err error + filter, err = regexp.Compile(rawFilter) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + } records := []string{"invoice-1001", "invoice-1002", "invoice-1003"} matches := make([]string, 0, len(records)) @@ -80,5 +116,9 @@ func RunServer(port string) { }) log.Println("Server started at port", port) - log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(debugRecoveryMiddleware(mux)))) + recovery := debugRecoveryMiddleware + if !vulnerable { + recovery = recoveryMiddleware + } + log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(recovery(mux)))) } diff --git a/challenges/jwt-claim-type-confusion/README.md b/challenges/jwt-claim-type-confusion/README.md index e7e1f3b..61b3118 100644 --- a/challenges/jwt-claim-type-confusion/README.md +++ b/challenges/jwt-claim-type-confusion/README.md @@ -16,6 +16,18 @@ This is one of a family of related challenges, each isolating a different class go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a claim of the wrong JSON type panics the handler, and the panic (with stack trace) is leaked in the response +go run main.go serve --vulnerable=true + +# fixed: claim types are checked before use, and panics never leak internals to the client +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-claim-type-confusion/main.go b/challenges/jwt-claim-type-confusion/main.go index 2858447..2113e04 100644 --- a/challenges/jwt-claim-type-confusion/main.go +++ b/challenges/jwt-claim-type-confusion/main.go @@ -8,7 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "name": "John Doe", diff --git a/challenges/jwt-claim-type-confusion/serve/server.go b/challenges/jwt-claim-type-confusion/serve/server.go index 652f7d1..4cea01d 100644 --- a/challenges/jwt-claim-type-confusion/serve/server.go +++ b/challenges/jwt-claim-type-confusion/serve/server.go @@ -34,7 +34,22 @@ func debugRecoveryMiddleware(next http.Handler) http.Handler { }) } -func RunServer(port string) { +// recoveryMiddleware is the fixed counterpart of debugRecoveryMiddleware: it +// still recovers from panics so the server keeps running, but never leaks +// the panic message or stack trace to the client. +func recoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Println("recovered from panic:", rec) + w.WriteHeader(http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -56,20 +71,40 @@ func RunServer(port string) { claims := token.Claims.(jwt.MapClaims) - // Both claims are trusted blindly and type-asserted straight to - // the type the happy path expects, with no ok-check. A signature - // check only proves who signed the token, not that its claim - // values have the right shape - a claim of the wrong JSON type - // (number, bool, object) or JSON null (which decodes to a bare - // nil interface{} and fails the assertion exactly like a wrong - // type does) crashes the handler instead of being rejected. - name := claims["name"].(string) - roles := claims["roles"].([]interface{}) + var name string + var roles []interface{} + if vulnerable { + // Both claims are trusted blindly and type-asserted straight to + // the type the happy path expects, with no ok-check. A signature + // check only proves who signed the token, not that its claim + // values have the right shape - a claim of the wrong JSON type + // (number, bool, object) or JSON null (which decodes to a bare + // nil interface{} and fails the assertion exactly like a wrong + // type does) crashes the handler instead of being rejected. + name = claims["name"].(string) + roles = claims["roles"].([]interface{}) + } else { + // fixed: claim types are checked before use + name, ok = claims["name"].(string) + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + roles, ok = claims["roles"].([]interface{}) + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"name":%q,"roles":%d}`, name, len(roles)) }) log.Println("Server started at port", port) - log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(debugRecoveryMiddleware(mux)))) + recovery := debugRecoveryMiddleware + if !vulnerable { + recovery = recoveryMiddleware + } + log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(recovery(mux)))) } diff --git a/challenges/jwt-cross-service-relay/README.md b/challenges/jwt-cross-service-relay/README.md index bdd9c18..f254d40 100644 --- a/challenges/jwt-cross-service-relay/README.md +++ b/challenges/jwt-cross-service-relay/README.md @@ -8,6 +8,15 @@ This challenge demonstrates a token cross-service relay attack. Both "Service A" go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: the audience (`aud`) claim is never checked +go run main.go serve --vulnerable=false # fixed: the token must also match the expected audience +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-cross-service-relay/serve/server.go b/challenges/jwt-cross-service-relay/serve/server.go index 876145a..03d6c8a 100644 --- a/challenges/jwt-cross-service-relay/serve/server.go +++ b/challenges/jwt-cross-service-relay/serve/server.go @@ -16,7 +16,7 @@ const ( ServiceBAudience = "service-b-client-id" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -43,12 +43,17 @@ func RunServer(port string) { // Service B only checks the signature and issuer of the shared IdP. // It never validates the audience, so any token minted by the IdP // for another service (e.g. Service A) is accepted here too. + parserOpts := []jwt.ParserOption{jwt.WithIssuer(Issuer)} + if !vulnerable { + parserOpts = append(parserOpts, jwt.WithAudience(ServiceBAudience)) + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return idpPublicKey, nil - }, jwt.WithIssuer(Issuer)) + }, parserOpts...) if err != nil || !token.Valid { fmt.Println(err) diff --git a/challenges/jwt-facebook-token-relay/README.md b/challenges/jwt-facebook-token-relay/README.md index 06d37b3..b821186 100644 --- a/challenges/jwt-facebook-token-relay/README.md +++ b/challenges/jwt-facebook-token-relay/README.md @@ -8,6 +8,15 @@ This challenge demonstrates a token cross-service relay attack against a relying go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: the audience (`aud`) claim is never checked +go run main.go serve --vulnerable=false # fixed: the token must also match the expected audience +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-facebook-token-relay/serve/server.go b/challenges/jwt-facebook-token-relay/serve/server.go index a3cd2dc..3d20c44 100644 --- a/challenges/jwt-facebook-token-relay/serve/server.go +++ b/challenges/jwt-facebook-token-relay/serve/server.go @@ -16,7 +16,7 @@ const ( VictimAudience = "1029384756192837" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -43,12 +43,17 @@ func RunServer(port string) { // The relying party checks the Facebook signature and issuer only. // It never validates the audience, so an ID token minted by Facebook // for a different, attacker-controlled app is accepted here too. + parserOpts := []jwt.ParserOption{jwt.WithIssuer(Issuer)} + if !vulnerable { + parserOpts = append(parserOpts, jwt.WithAudience(VictimAudience)) + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return idpPublicKey, nil - }, jwt.WithIssuer(Issuer)) + }, parserOpts...) if err != nil || !token.Valid { fmt.Println(err) diff --git a/challenges/jwt-google-token-relay/README.md b/challenges/jwt-google-token-relay/README.md index 405c0d4..8ac5e39 100644 --- a/challenges/jwt-google-token-relay/README.md +++ b/challenges/jwt-google-token-relay/README.md @@ -8,6 +8,15 @@ This challenge demonstrates a token cross-service relay attack against a relying go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: the audience (`aud`) claim is never checked +go run main.go serve --vulnerable=false # fixed: the token must also match the expected audience +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-google-token-relay/serve/server.go b/challenges/jwt-google-token-relay/serve/server.go index 4e3fed0..08b3d35 100644 --- a/challenges/jwt-google-token-relay/serve/server.go +++ b/challenges/jwt-google-token-relay/serve/server.go @@ -16,7 +16,7 @@ const ( VictimAudience = "184921307134-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6.apps.googleusercontent.com" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -43,12 +43,17 @@ func RunServer(port string) { // The relying party checks the Google signature and issuer only. // It never validates the audience, so an ID token minted by Google // for a different, attacker-controlled app is accepted here too. + parserOpts := []jwt.ParserOption{jwt.WithIssuer(Issuer)} + if !vulnerable { + parserOpts = append(parserOpts, jwt.WithAudience(VictimAudience)) + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return idpPublicKey, nil - }, jwt.WithIssuer(Issuer)) + }, parserOpts...) if err != nil || !token.Valid { fmt.Println(err) diff --git a/challenges/jwt-hmac-confusion/README.md b/challenges/jwt-hmac-confusion/README.md index 200646a..1f40d27 100644 --- a/challenges/jwt-hmac-confusion/README.md +++ b/challenges/jwt-hmac-confusion/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that is vulnerable to HMAC/RSA go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: HMAC-signed tokens are verified using the RSA public key bytes as the HMAC secret +go run main.go serve --vulnerable=true + +# fixed: only RSA-signed tokens are accepted, HMAC/RSA confusion is not possible +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-hmac-confusion/main.go b/challenges/jwt-hmac-confusion/main.go index 6d0eb6d..3f419a3 100644 --- a/challenges/jwt-hmac-confusion/main.go +++ b/challenges/jwt-hmac-confusion/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-hmac-confusion/serve/server.go b/challenges/jwt-hmac-confusion/serve/server.go index c620198..168decc 100644 --- a/challenges/jwt-hmac-confusion/serve/server.go +++ b/challenges/jwt-hmac-confusion/serve/server.go @@ -11,7 +11,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -41,8 +41,10 @@ func RunServer(port string) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // VULNERABILITY: accepts HMAC tokens using RSA public key as secret - if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok { - return publicKeyBytes, nil + if vulnerable { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok { + return publicKeyBytes, nil + } } if _, ok := token.Method.(*jwt.SigningMethodRSA); ok { diff --git a/challenges/jwt-issuer-confusion/README.md b/challenges/jwt-issuer-confusion/README.md index 97f3f8c..badb54c 100644 --- a/challenges/jwt-issuer-confusion/README.md +++ b/challenges/jwt-issuer-confusion/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation trusting two issuers, where onl go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: tokens claiming issuer B are accepted without any signature check +go run main.go serve --vulnerable=true + +# fixed: both issuers are properly signature-verified against their own key +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-issuer-confusion/serve/server.go b/challenges/jwt-issuer-confusion/serve/server.go index 887ab16..ec10d33 100644 --- a/challenges/jwt-issuer-confusion/serve/server.go +++ b/challenges/jwt-issuer-confusion/serve/server.go @@ -16,7 +16,7 @@ const ( IssuerB = "https://issuer-b.example.com" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -32,6 +32,16 @@ func RunServer(port string) { log.Fatal(err) } + issuerBPublicKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", "issuer-b_public_key.pem")) + if err != nil { + log.Fatal(err) + } + + issuerBPublicKey, err := jwt.ParseRSAPublicKeyFromPEM(issuerBPublicKeyBytes) + if err != nil { + log.Fatal(err) + } + parser := jwt.NewParser() mux := http.NewServeMux() @@ -64,13 +74,23 @@ func RunServer(port string) { return issuerAPublicKey, nil }) case IssuerB: - // VULNERABILITY: issuer-b is a legacy/trusted issuer whose tokens are - // parsed without any signature verification, so claims (including "iss") - // can be forged freely as long as the "iss" value matches this branch. - token, _, err = parser.ParseUnverified(tokenString, jwt.MapClaims{}) - if err == nil { - token.Valid = true + if vulnerable { + // VULNERABILITY: issuer-b is a legacy/trusted issuer whose tokens are + // parsed without any signature verification, so claims (including "iss") + // can be forged freely as long as the "iss" value matches this branch. + token, _, err = parser.ParseUnverified(tokenString, jwt.MapClaims{}) + if err == nil { + token.Valid = true + } + break } + + token, err = jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return issuerBPublicKey, nil + }) default: err = fmt.Errorf("unknown issuer: %v", issuer) } diff --git a/challenges/jwt-jku-header-injection/README.md b/challenges/jwt-jku-header-injection/README.md index 17b5db7..565b644 100644 --- a/challenges/jwt-jku-header-injection/README.md +++ b/challenges/jwt-jku-header-injection/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation vulnerable to **JKU header inje go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the jku header is fetched and trusted as the verification key source +go run main.go serve --vulnerable=true + +# fixed: the jku header is ignored, verification always uses the server's pinned key +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-jku-header-injection/main.go b/challenges/jwt-jku-header-injection/main.go index efb536b..18549b4 100644 --- a/challenges/jwt-jku-header-injection/main.go +++ b/challenges/jwt-jku-header-injection/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-jku-header-injection/serve/server.go b/challenges/jwt-jku-header-injection/serve/server.go index 9215116..8ddd6dc 100644 --- a/challenges/jwt-jku-header-injection/serve/server.go +++ b/challenges/jwt-jku-header-injection/serve/server.go @@ -78,7 +78,7 @@ func fetchJWKS(jkuURL string) (*jwks, error) { return &set, nil } -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -112,6 +112,12 @@ func RunServer(port string) { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } + if !vulnerable { + // fixed: the jku header is never trusted, the server always + // verifies against its own pinned, statically configured key + return legitPublicKey, nil + } + jkuURL, _ := token.Header["jku"].(string) if jkuURL == "" { return legitPublicKey, nil diff --git a/challenges/jwt-jwk-header-injection/README.md b/challenges/jwt-jwk-header-injection/README.md index 5cbb00c..3cf672e 100644 --- a/challenges/jwt-jwk-header-injection/README.md +++ b/challenges/jwt-jwk-header-injection/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation vulnerable to **CVE-2018-0114** go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: an embedded jwk header is trusted as the verification key +go run main.go serve --vulnerable=true + +# fixed: the jwk header is ignored, verification always uses the server's pinned key +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-jwk-header-injection/main.go b/challenges/jwt-jwk-header-injection/main.go index 3f45727..ba4d0a4 100644 --- a/challenges/jwt-jwk-header-injection/main.go +++ b/challenges/jwt-jwk-header-injection/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-jwk-header-injection/serve/server.go b/challenges/jwt-jwk-header-injection/serve/server.go index 5891b70..b7902fb 100644 --- a/challenges/jwt-jwk-header-injection/serve/server.go +++ b/challenges/jwt-jwk-header-injection/serve/server.go @@ -46,7 +46,7 @@ func jwkFromHeader(raw interface{}) (*rsa.PublicKey, error) { }, nil } -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -79,10 +79,12 @@ func RunServer(port string) { // trusted as the verification key instead of being checked // against the server's own pinned key. Any token can carry its // own key and sign itself with it. - if rawJWK, present := token.Header["jwk"]; present { + if rawJWK, present := token.Header["jwk"]; vulnerable && present { return jwkFromHeader(rawJWK) } + // fixed: the jwk header is never trusted, always verify against + // the server's own pinned key return legitPublicKey, nil }) diff --git a/challenges/jwt-jwks-issuer-injection/README.md b/challenges/jwt-jwks-issuer-injection/README.md index 0c6b810..dfbbfeb 100644 --- a/challenges/jwt-jwks-issuer-injection/README.md +++ b/challenges/jwt-jwks-issuer-injection/README.md @@ -10,6 +10,18 @@ go run main.go serve This starts the vulnerable API on port 8080 and a mock legitimate identity provider (serving its real JWKS) on port 8090. +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the issuer claim is used unchecked to build the JWKS URL to fetch +go run main.go serve --vulnerable=true + +# fixed: the issuer claim is checked against an allowlist before being used +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-jwks-issuer-injection/main.go b/challenges/jwt-jwks-issuer-injection/main.go index 8546c46..2bebb4c 100644 --- a/challenges/jwt-jwks-issuer-injection/main.go +++ b/challenges/jwt-jwks-issuer-injection/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-jwks-issuer-injection/serve/server.go b/challenges/jwt-jwks-issuer-injection/serve/server.go index 0e1d01c..181c334 100644 --- a/challenges/jwt-jwks-issuer-injection/serve/server.go +++ b/challenges/jwt-jwks-issuer-injection/serve/server.go @@ -96,7 +96,7 @@ func runIdentityProvider(publicKey *rsa.PublicKey) { log.Fatal(http.ListenAndServe(":"+idpPort, mux)) } -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -136,6 +136,14 @@ func RunServer(port string) { return } + if !vulnerable && issuer != LegitIssuer { + // fixed: the issuer claim is checked against an allowlist of + // trusted issuers before being used to build the JWKS URL + fmt.Println("untrusted issuer:", issuer) + w.WriteHeader(401) + return + } + set, err := fetchJWKS(issuer) if err != nil { fmt.Println(err) diff --git a/challenges/jwt-kid-path-traversal/README.md b/challenges/jwt-kid-path-traversal/README.md index 3ee9db0..dceace0 100644 --- a/challenges/jwt-kid-path-traversal/README.md +++ b/challenges/jwt-kid-path-traversal/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that is vulnerable to path trav go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the kid header is used as-is to read a key file, allowing path traversal +go run main.go serve --vulnerable=true + +# fixed: the kid is restricted to a file name within the keys directory +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-kid-path-traversal/main.go b/challenges/jwt-kid-path-traversal/main.go index cd08f2b..c3e56f0 100644 --- a/challenges/jwt-kid-path-traversal/main.go +++ b/challenges/jwt-kid-path-traversal/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-kid-path-traversal/serve/server.go b/challenges/jwt-kid-path-traversal/serve/server.go index f1cb284..ef54c44 100644 --- a/challenges/jwt-kid-path-traversal/serve/server.go +++ b/challenges/jwt-kid-path-traversal/serve/server.go @@ -5,12 +5,13 @@ import ( "log" "net/http" "os" + "path/filepath" "github.com/cerberauth/api-vulns-challenges/common" "github.com/golang-jwt/jwt/v5" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -29,8 +30,14 @@ func RunServer(port string) { return nil, fmt.Errorf("missing kid header") } + keyPath := kid + if !vulnerable { + // fixed: the kid is restricted to a file name within the keys directory + keyPath = filepath.Join("keys", filepath.Base(kid)) + } + // VULNERABILITY: no path sanitization - allows path traversal - keyBytes, err := os.ReadFile(kid) + keyBytes, err := os.ReadFile(keyPath) if err != nil { return nil, fmt.Errorf("key not found: %v", err) } diff --git a/challenges/jwt-kid-sql-injection/README.md b/challenges/jwt-kid-sql-injection/README.md index 401ae25..757cc92 100644 --- a/challenges/jwt-kid-sql-injection/README.md +++ b/challenges/jwt-kid-sql-injection/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that is vulnerable to SQL injec go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the kid header is concatenated directly into a SQL query +go run main.go serve --vulnerable=true + +# fixed: the kid is passed as a bound query parameter +go run main.go serve --vulnerable=false +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-kid-sql-injection/main.go b/challenges/jwt-kid-sql-injection/main.go index 899d718..3add388 100644 --- a/challenges/jwt-kid-sql-injection/main.go +++ b/challenges/jwt-kid-sql-injection/main.go @@ -11,7 +11,7 @@ import ( const defaultKid = "default" const defaultSecret = "supersecretkey_stored_in_database" -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "name": "John Doe", diff --git a/challenges/jwt-kid-sql-injection/serve/server.go b/challenges/jwt-kid-sql-injection/serve/server.go index c67b98a..7403787 100644 --- a/challenges/jwt-kid-sql-injection/serve/server.go +++ b/challenges/jwt-kid-sql-injection/serve/server.go @@ -30,7 +30,7 @@ func initDB() (*sql.DB, error) { return db, nil } -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { db, err := initDB() if err != nil { log.Fatal(err) @@ -55,10 +55,16 @@ func RunServer(port string) { return nil, fmt.Errorf("missing kid header") } - // VULNERABILITY: SQL injection via unsanitized kid header - query := "SELECT secret FROM keys WHERE kid = '" + kid + "'" var secret string - err := db.QueryRow(query).Scan(&secret) + var err error + if vulnerable { + // VULNERABILITY: SQL injection via unsanitized kid header + query := "SELECT secret FROM keys WHERE kid = '" + kid + "'" + err = db.QueryRow(query).Scan(&secret) + } else { + // fixed: the kid is passed as a bound parameter + err = db.QueryRow("SELECT secret FROM keys WHERE kid = ?", kid).Scan(&secret) + } if err != nil { return nil, fmt.Errorf("key not found: %v", err) } diff --git a/challenges/jwt-microsoft-token-relay/README.md b/challenges/jwt-microsoft-token-relay/README.md index ea6bb59..29f1845 100644 --- a/challenges/jwt-microsoft-token-relay/README.md +++ b/challenges/jwt-microsoft-token-relay/README.md @@ -8,6 +8,15 @@ This challenge demonstrates a token cross-service relay attack against a relying go run main.go serve ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: the audience (`aud`) claim is never checked +go run main.go serve --vulnerable=false # fixed: the token must also match the expected audience +``` + ## How to exploit it ```bash diff --git a/challenges/jwt-microsoft-token-relay/serve/server.go b/challenges/jwt-microsoft-token-relay/serve/server.go index 2a7c6d2..8e960db 100644 --- a/challenges/jwt-microsoft-token-relay/serve/server.go +++ b/challenges/jwt-microsoft-token-relay/serve/server.go @@ -16,7 +16,7 @@ const ( VictimAudience = "a4f8c2e1-9b3d-4f5a-8c6e-1d2f3a4b5c6d" ) -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -43,12 +43,17 @@ func RunServer(port string) { // The relying party checks the Microsoft signature and issuer only. // It never validates the audience, so an ID token minted by Microsoft // for a different, attacker-controlled app is accepted here too. + parserOpts := []jwt.ParserOption{jwt.WithIssuer(Issuer)} + if !vulnerable { + parserOpts = append(parserOpts, jwt.WithAudience(VictimAudience)) + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return idpPublicKey, nil - }, jwt.WithIssuer(Issuer)) + }, parserOpts...) if err != nil || !token.Valid { fmt.Println(err) diff --git a/challenges/jwt-not-verified/README.md b/challenges/jwt-not-verified/README.md index 6f87492..d7a262e 100644 --- a/challenges/jwt-not-verified/README.md +++ b/challenges/jwt-not-verified/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that does not verify the signat go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the token signature is never checked +go run main.go serve --vulnerable=true + +# fixed: the token signature is verified against the server's public key +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-not-verified/main.go b/challenges/jwt-not-verified/main.go index bc38432..57c0d86 100644 --- a/challenges/jwt-not-verified/main.go +++ b/challenges/jwt-not-verified/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-not-verified/serve/server.go b/challenges/jwt-not-verified/serve/server.go index 1629697..3862dac 100644 --- a/challenges/jwt-not-verified/serve/server.go +++ b/challenges/jwt-not-verified/serve/server.go @@ -1,15 +1,37 @@ package serve import ( + "crypto" "fmt" "log" "net/http" + "os" + "path" "github.com/cerberauth/api-vulns-challenges/common" "github.com/golang-jwt/jwt/v5" ) -func RunServer(port string) { +func readPublicKey() (crypto.PublicKey, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + + publicKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", "public_key.pem")) + if err != nil { + return nil, err + } + + return jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes) +} + +func RunServer(port string, vulnerable bool) { + publicKey, err := readPublicKey() + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -18,12 +40,24 @@ func RunServer(port string) { return } - token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + var valid bool + if vulnerable { + // vulnerable: the signature is never checked + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + valid = token != nil && err == nil + } else { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return publicKey, nil + }) + valid = err == nil && token.Valid + } - if token != nil && err == nil { + if valid { w.WriteHeader(204) } else { - fmt.Println(err) w.WriteHeader(401) } }) diff --git a/challenges/jwt-null-signature/README.md b/challenges/jwt-null-signature/README.md index 5ad648b..b5c2996 100644 --- a/challenges/jwt-null-signature/README.md +++ b/challenges/jwt-null-signature/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that is vulnerable to null sign go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a token with an empty signature segment is treated as valid +go run main.go serve --vulnerable=true + +# fixed: the signature is always verified, an empty signature is rejected +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-null-signature/main.go b/challenges/jwt-null-signature/main.go index 947e89f..fd7ae82 100644 --- a/challenges/jwt-null-signature/main.go +++ b/challenges/jwt-null-signature/main.go @@ -8,7 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "name": "John Doe", diff --git a/challenges/jwt-null-signature/serve/server.go b/challenges/jwt-null-signature/serve/server.go index 3d5f196..4ce0583 100644 --- a/challenges/jwt-null-signature/serve/server.go +++ b/challenges/jwt-null-signature/serve/server.go @@ -12,7 +12,7 @@ import ( var secret = []byte("strong-hmac-secret") -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -27,7 +27,8 @@ func RunServer(port string) { return } - if len(parts[2]) == 0 { + if vulnerable && len(parts[2]) == 0 { + // VULNERABILITY: an empty signature segment is treated as valid w.WriteHeader(204) return } diff --git a/challenges/jwt-psychic-signature/README.md b/challenges/jwt-psychic-signature/README.md index 26f14ec..18459c9 100644 --- a/challenges/jwt-psychic-signature/README.md +++ b/challenges/jwt-psychic-signature/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that is vulnerable to the Psych go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: uses a faulty ECDSA verification routine reproducing CVE-2022-21449 (r=0, s=0 bypasses the check) +go run main.go serve --vulnerable=true + +# fixed: uses the standard library's correct ECDSA verification +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-psychic-signature/main.go b/challenges/jwt-psychic-signature/main.go index 8989dce..9acd3b9 100644 --- a/challenges/jwt-psychic-signature/main.go +++ b/challenges/jwt-psychic-signature/main.go @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-psychic-signature/serve/server.go b/challenges/jwt-psychic-signature/serve/server.go index e66f708..cc7a402 100644 --- a/challenges/jwt-psychic-signature/serve/server.go +++ b/challenges/jwt-psychic-signature/serve/server.go @@ -46,7 +46,7 @@ func readPublicKey() (*ecdsa.PublicKey, error) { return key, nil } -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { publicKey, err := readPublicKey() if err != nil { log.Fatal(err) @@ -92,7 +92,14 @@ func RunServer(port string) { hash := sha256.Sum256([]byte(parts[0] + "." + parts[1])) - if !vulnerableECDSAVerify(publicKey, hash[:], r2, s2) { + verify := vulnerableECDSAVerify + if !vulnerable { + verify = func(pub *ecdsa.PublicKey, hash []byte, r, s *big.Int) bool { + return ecdsa.Verify(pub, hash, r, s) + } + } + + if !verify(publicKey, hash[:], r2, s2) { w.WriteHeader(401) return } diff --git a/challenges/jwt-strong-eddsa-key/README.md b/challenges/jwt-strong-eddsa-key/README.md index 4b9f474..6c68205 100644 --- a/challenges/jwt-strong-eddsa-key/README.md +++ b/challenges/jwt-strong-eddsa-key/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a secure JWT implementation using EdDSA. go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the EdDSA key was generated from a predictable, low-entropy seed +go run main.go serve --vulnerable=true + +# fixed: the EdDSA key was generated with a cryptographically secure random source +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-strong-eddsa-key/keys/weak_private_key.pem b/challenges/jwt-strong-eddsa-key/keys/weak_private_key.pem new file mode 100644 index 0000000..adb710d --- /dev/null +++ b/challenges/jwt-strong-eddsa-key/keys/weak_private_key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +-----END PRIVATE KEY----- diff --git a/challenges/jwt-strong-eddsa-key/keys/weak_public_key.pem b/challenges/jwt-strong-eddsa-key/keys/weak_public_key.pem new file mode 100644 index 0000000..d055eba --- /dev/null +++ b/challenges/jwt-strong-eddsa-key/keys/weak_public_key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAO2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik= +-----END PUBLIC KEY----- diff --git a/challenges/jwt-strong-eddsa-key/main.go b/challenges/jwt-strong-eddsa-key/main.go index 01ba09f..005bca0 100644 --- a/challenges/jwt-strong-eddsa-key/main.go +++ b/challenges/jwt-strong-eddsa-key/main.go @@ -10,13 +10,19 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err } - privateKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", "private_key.pem")) + keyFile := "private_key.pem" + if vulnerable { + // vulnerable: the key was generated from a predictable/low-entropy seed + keyFile = "weak_private_key.pem" + } + + privateKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", keyFile)) if err != nil { return "", err } diff --git a/challenges/jwt-strong-eddsa-key/serve/server.go b/challenges/jwt-strong-eddsa-key/serve/server.go index a1833a3..07a6b23 100644 --- a/challenges/jwt-strong-eddsa-key/serve/server.go +++ b/challenges/jwt-strong-eddsa-key/serve/server.go @@ -13,13 +13,19 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func readPublicKey() (crypto.PublicKey, error) { +func readPublicKey(vulnerable bool) (crypto.PublicKey, error) { cwd, err := os.Getwd() if err != nil { return nil, err } - publicKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", "public_key.pem")) + keyFile := "public_key.pem" + if vulnerable { + // vulnerable: the key was generated from a predictable/low-entropy seed + keyFile = "weak_public_key.pem" + } + + publicKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", keyFile)) if err != nil { return nil, err } @@ -27,8 +33,8 @@ func readPublicKey() (crypto.PublicKey, error) { return jwt.ParseEdPublicKeyFromPEM(publicKeyBytes) } -func RunServer(port string) { - publicKey, err := readPublicKey() +func RunServer(port string, vulnerable bool) { + publicKey, err := readPublicKey(vulnerable) if err != nil { log.Fatal(err) } diff --git a/challenges/jwt-weak-hmac-secret/README.md b/challenges/jwt-weak-hmac-secret/README.md index ef393ac..9f7d3ae 100644 --- a/challenges/jwt-weak-hmac-secret/README.md +++ b/challenges/jwt-weak-hmac-secret/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that uses a weak HMAC secret. go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a short, dictionary-guessable HMAC secret is used +go run main.go serve --vulnerable=true + +# fixed: a strong, high-entropy HMAC secret is used +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-weak-hmac-secret/main.go b/challenges/jwt-weak-hmac-secret/main.go index 5d31118..8d55668 100644 --- a/challenges/jwt-weak-hmac-secret/main.go +++ b/challenges/jwt-weak-hmac-secret/main.go @@ -8,14 +8,17 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", "name": "John Doe", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), }) - return token.SignedString([]byte("secret")) + if vulnerable { + return token.SignedString([]byte("secret")) + } + return token.SignedString([]byte(serve.Secret)) } func main() { diff --git a/challenges/jwt-weak-hmac-secret/serve/server.go b/challenges/jwt-weak-hmac-secret/serve/server.go index 9f434fd..6405621 100644 --- a/challenges/jwt-weak-hmac-secret/serve/server.go +++ b/challenges/jwt-weak-hmac-secret/serve/server.go @@ -9,7 +9,10 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func RunServer(port string) { +// Secret is the strong HMAC secret used when the server runs in its fixed, non-vulnerable mode. +const Secret = "HrRTjeiKsE0oHEH1zslgk9kGa6PEDl6Q1eIqWw4DNlo" + +func RunServer(port string, vulnerable bool) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tokenString, ok := common.ExtractBearerToken(r) @@ -23,7 +26,11 @@ func RunServer(port string) { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte("secret"), nil + if vulnerable { + // vulnerable: short, dictionary-guessable secret + return []byte("secret"), nil + } + return []byte(Secret), nil }) if token != nil && token.Valid { diff --git a/challenges/jwt-weak-rsa-key/README.md b/challenges/jwt-weak-rsa-key/README.md index a8f825f..4f5ebfb 100644 --- a/challenges/jwt-weak-rsa-key/README.md +++ b/challenges/jwt-weak-rsa-key/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a JWT implementation that uses a weak RSA key. go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a weak 512-bit RSA key, practical to factor, is used +go run main.go serve --vulnerable=true + +# fixed: a strong 4096-bit RSA key is used +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/jwt-weak-rsa-key/keys/strong_private_key.pem b/challenges/jwt-weak-rsa-key/keys/strong_private_key.pem new file mode 100644 index 0000000..39794eb --- /dev/null +++ b/challenges/jwt-weak-rsa-key/keys/strong_private_key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCiGliOdM8Qxz3p +HTPnm70+oHFMRhURAntJurFc7rPLtO3uklWK0IcCqtElxUy1vnibUTovfi6p3rP5 +SZbd17rjJSIIjkj1jhmkpUvkfFCJ+Hmy6ryubkUTqs6XxA+Yovyiem438vRNbK9Q +VRbVjR23h9Vo3yU7yMNzLEQlH/ytxv1dmnpxHElVE1aW0l/2p9w+Of3jmMAIbhGS +5LTc2flSLtovJx/dAKF+II4J3hC8483ok4GjiIx446XfQA3TURzV4YTdK39pSp1j +Wy0CYhy/IwR57gCnyVCaQ6v4A8U9qoLgWgdnAspS2Hw5u5AdMz42U+3WeGd9kFMN +bpTFtNpzjbD79FftAyuGo/o1SqWpDfDA1suI6duUrH81ZIRW9vtiNiLpXn9AJDXG +1O8kQriLkAIzdxBkfK+/2k3ZbQW272StTxMmSXV4ov1M4OuRtL8rVmeWDUHFM3K+ +083/bE8Yv3Ulh36Iu7I3NFSFIirmyrwqResEhaj9DVSCRE51d4UibAhSaHofZfpP +tqQhlK6peUidOXvjeiJdpLQ2wIm/Pt4C8YjKPAkcKTf2hB/K/F4CensLfrH285et +nlLdsBu8fujBpADYNdaaJU5pIJM+QqR3waDUw2nJO74HzoMI5AiH5xo16R/Ur8IO +p1E6ACoxAZM3D3bIWaLLIEtbeTnbUwIDAQABAoIB/zX4XGF4CxJxZbyEJzLK4x12 +xQ7KrPSe+H5X9uh6CGKOfMtgZKMw21whlDx/FiTB1sxF0BD/6Wc+xwJSMSRm+8mC +aMnW2doa027TM4r4pZOwhJAEl2bzYMgA2rasy/v9NttqPRTbG7/eml3X1HWajrk/ +ZrSddK57dg7Z+udQUB0lGuDCdFcBPpxQZQPjGCFC5CrCinh4FiIv2KoHA+w/t3Xy +El0pKYxAj+plyjn2Rno21cZdvH8CzNMus75T0PE9fzGUdTkLsVd2mc36ptb5oEM2 +KbZMoQ1eEvf0KYkLuFL6WKm/+mGeYYsimbEFdMsy950v/wg+UnrIie9d2UykjDhk +0YhXbrx4tAc7N83P5bDUHKKeMHHMdK1zspX7c44lZPO8xulyJqcWo5XIhegolfH+ +5qbQR3gsyPBXGQ6ZjoTSz/kV58WPRN4DScu6t4ohtvgC3qx/5qJxfl1l50APFaZE +5HlvSc4jgx7c/xJkNY2u72oUdLuOgqhtLSTp9wzjuqCZHsFwreX+daQT8aYhH9BW +qR0iJmc7GYatGxezAU60lemRAgEXk4CFXgYV/kae/NqWDtyvbW/hgSYuUf+YQx1t +KG0Vn584kojVQzQ3hLZdmryMNAM2TNNl0Bs2OnG928JDBfwJXf8vrt4eAeC3/an8 +Ldq1mXILD8VDHR8tTb0CggEBANo4e1PObDtc/hIPC/ab2Navjk6FNQykNYAvGxPW +xk4o732oX/QGckCZFTtWwGXUNU1F39Q3FLQYjn0t8TOJQ64nfGX9NGpEPE52IYap +gbCnHNe+5ujK/6NCsgrvqaa+StFlOcTuoOrvlsNwnl5sQzObaWcndSjf1nCoIZ/+ +7lo4mETRKDufJuz5DktpzgIWnSnPsP7jLxPH3bFeDVp7Y1C+lvPNl/4TOjcI3yyv +fxF16Nv1x53FYTr6zfYHtvZpQsF3/wY7kcwoKMmGsghqh1umLsSl9+0yw1kn9Qau +ByUXOcCG4/DTi+ewjQSKBnMGjzlVKiTh3zmYX1DOLNKy9/8CggEBAL4qu2p6c8DH +ueyWDk5tny5WIWxEvQYQMn9igsxPpH2Fw4UhlalbX73kjrEyzPDwCgbtPfeORk9i +sKMyVkKn2FUrLjakPV2EpxwYad950jKV5xIdqodbCWj1DmlaIqAEaibnJIuCeE3B +Jau1V27m85tETUoUHeQ3IswGq9vyuej/5i37IhW5NyBTv1qnX6qz2dzqMtRioaKd +gezVTe0fDwIqyCSdv+iE3iZv7tWiq5Y3d+h4tK5L1F6VLLD+0uHDKns+izlnciFp +5WDLgjG4y6ysrtnrdsV1FQJoTsbTvKe1CIIi2XLi9eySS6v2xaT2B8YcSuxsMsid +8+YQAJ/XvK0CggEBALa3ODwxyNnjwTCpvKCeWW9Yy1TfoE2XaNyNlKSF3sEu8WhL +hRw0EZIZG3f16V7Z0k+DRCjSfdlV/mi+AQnf0vWNRlG3Mx4PSFeB/gxc/aRGKJZU +187vuiCfBai2h1yAVPEDSt2j2MUeGyRFtbTl/VbGt/iRRz4UPd9SR+VJdZXBcW2d +m4xgdB1TZC6sxrtk5+9MYazp2E89uFh8ob2zNrMzAf3dlXg97S6LlSI7R6/9BB64 +qo1l6UWTHsT0LLQh6q1Hphc35ZoFvjEPQR5e5DtO6DXrqa3IJV0PYdBYAs+BpO+Z +gl3xI2G5qDm2wDOruFyO/JypK1l0RcrI9yrsg00CggEAej9aL778Poeme/KSy9Qo +qk7wOXuO0CEFdWJMly7znUWqRSEr5q04xQfyuvLfUFve9kj4tvWdOVdGGgM39K2V +olqZ4MKAOGgYTTVcii7z81TuMHUltNHPIMu4Mf2hmKB95PheQ9jhoYpyvwlaZY7O +N5gW33JDLPxMwD/hpwlIfgJTZGe2p6RjTcLIlmxWUZRAj4V2m5W0NqduaGNPLwny +SgBanT9TqRQRA0CdlnR2PaD20lkWPZ29rffTvQxzYHJoEsZIFeTsN5KtxCXPUktr +k1uHI8v8lD8J/Hw1eBeRhXYZPB9PDk3crfH191wx/jOzF6YEfCuqEdGIgvLyk87K +GQKCAQEAqRlQFJ7aweOf+Y+lJAgAAKAGzVLQ4VapzmdL9yuLKy0dwXw/dtX4oNM2 +cq5Bo77jBC1Bxg298W4JpvxHJ1uJ+h6TivaJXZ8WU84hxyrveu/8fNoOXnNVKcFK +rjEqcibZX0QsuaD/ibPlU+E+/9QS8qwAC2ruqdn3DbBLXs8NHdHJxK6jqMc7jpx/ +gp3NsboxYJPQ4CjcQ90CN28aWBGPpXBLsAq+aTnQ9kFUIZUOckF1rk2rctMqfTFG +1KKS5yCtyN7tAt+PgaoGkd6Y/Wcn1YblWbvNlQvLY82T0M9DbQvnopZpMF2lSidt ++RlQW4VaL0XkAcVeFlqMn82Lo5YhfQ== +-----END PRIVATE KEY----- diff --git a/challenges/jwt-weak-rsa-key/keys/strong_public_key.pem b/challenges/jwt-weak-rsa-key/keys/strong_public_key.pem new file mode 100644 index 0000000..6648b5d --- /dev/null +++ b/challenges/jwt-weak-rsa-key/keys/strong_public_key.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAohpYjnTPEMc96R0z55u9 +PqBxTEYVEQJ7SbqxXO6zy7Tt7pJVitCHAqrRJcVMtb54m1E6L34uqd6z+UmW3de6 +4yUiCI5I9Y4ZpKVL5HxQifh5suq8rm5FE6rOl8QPmKL8onpuN/L0TWyvUFUW1Y0d +t4fVaN8lO8jDcyxEJR/8rcb9XZp6cRxJVRNWltJf9qfcPjn945jACG4RkuS03Nn5 +Ui7aLycf3QChfiCOCd4QvOPN6JOBo4iMeOOl30AN01Ec1eGE3St/aUqdY1stAmIc +vyMEee4Ap8lQmkOr+APFPaqC4FoHZwLKUth8ObuQHTM+NlPt1nhnfZBTDW6UxbTa +c42w+/RX7QMrhqP6NUqlqQ3wwNbLiOnblKx/NWSEVvb7YjYi6V5/QCQ1xtTvJEK4 +i5ACM3cQZHyvv9pN2W0Ftu9krU8TJkl1eKL9TODrkbS/K1Znlg1BxTNyvtPN/2xP +GL91JYd+iLuyNzRUhSIq5sq8KkXrBIWo/Q1UgkROdXeFImwIUmh6H2X6T7akIZSu +qXlInTl743oiXaS0NsCJvz7eAvGIyjwJHCk39oQfyvxeAnp7C36x9vOXrZ5S3bAb +vH7owaQA2DXWmiVOaSCTPkKkd8Gg1MNpyTu+B86DCOQIh+caNekf1K/CDqdROgAq +MQGTNw92yFmiyyBLW3k521MCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/challenges/jwt-weak-rsa-key/main.go b/challenges/jwt-weak-rsa-key/main.go index 507b756..03ded61 100644 --- a/challenges/jwt-weak-rsa-key/main.go +++ b/challenges/jwt-weak-rsa-key/main.go @@ -10,13 +10,18 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err } - privateKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", "private_key.pem")) + keyFile := "private_key.pem" + if !vulnerable { + keyFile = "strong_private_key.pem" + } + + privateKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", keyFile)) if err != nil { return "", err } diff --git a/challenges/jwt-weak-rsa-key/serve/server.go b/challenges/jwt-weak-rsa-key/serve/server.go index 65b9c2d..1b18f11 100644 --- a/challenges/jwt-weak-rsa-key/serve/server.go +++ b/challenges/jwt-weak-rsa-key/serve/server.go @@ -13,13 +13,18 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func readPublicKey() (crypto.PublicKey, error) { +func readPublicKey(vulnerable bool) (crypto.PublicKey, error) { cwd, err := os.Getwd() if err != nil { return nil, err } - publicKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", "public_key.pem")) + keyFile := "public_key.pem" + if !vulnerable { + keyFile = "strong_public_key.pem" + } + + publicKeyBytes, err := os.ReadFile(path.Join(cwd, "keys", keyFile)) if err != nil { return nil, err } @@ -27,8 +32,8 @@ func readPublicKey() (crypto.PublicKey, error) { return jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes) } -func RunServer(port string) { - publicKey, err := readPublicKey() +func RunServer(port string, vulnerable bool) { + publicKey, err := readPublicKey(vulnerable) if err != nil { log.Fatal(err) } diff --git a/challenges/jwt-x5c-x5u-header-injection/README.md b/challenges/jwt-x5c-x5u-header-injection/README.md index 12b5ab9..20b0c3b 100644 --- a/challenges/jwt-x5c-x5u-header-injection/README.md +++ b/challenges/jwt-x5c-x5u-header-injection/README.md @@ -10,6 +10,18 @@ go run main.go serve This starts the vulnerable API on port 8080. The legit token flow embeds the server's own self-signed certificate in the `x5c` header, which the API also happens to accept — because it accepts *any* certificate presented in the header. +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: certificates supplied via the x5c/x5u headers are trusted with no chain-of-trust check +go run main.go serve --vulnerable=true + +# fixed: the x5c/x5u headers are ignored, verification always uses the server's pinned certificate +go run main.go serve --vulnerable=false +``` + ## How to exploit it ### Via `x5c` (embedded self-signed cert chain) diff --git a/challenges/jwt-x5c-x5u-header-injection/main.go b/challenges/jwt-x5c-x5u-header-injection/main.go index 3111620..d04a7fe 100644 --- a/challenges/jwt-x5c-x5u-header-injection/main.go +++ b/challenges/jwt-x5c-x5u-header-injection/main.go @@ -12,7 +12,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func generateToken() (string, error) { +func generateToken(vulnerable bool) (string, error) { cwd, err := os.Getwd() if err != nil { return "", err diff --git a/challenges/jwt-x5c-x5u-header-injection/serve/server.go b/challenges/jwt-x5c-x5u-header-injection/serve/server.go index ede17c4..f7b341f 100644 --- a/challenges/jwt-x5c-x5u-header-injection/serve/server.go +++ b/challenges/jwt-x5c-x5u-header-injection/serve/server.go @@ -83,7 +83,7 @@ func publicKeyFromX5U(header map[string]interface{}) (*rsa.PublicKey, error) { return parseCertDER(body) } -func RunServer(port string) { +func RunServer(port string, vulnerable bool) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -126,13 +126,17 @@ func RunServer(port string) { // Trust whatever certificate material the caller supplies in // the header instead of validating it against a pinned // certificate or keystore. - if pub, err := publicKeyFromX5C(token.Header); err == nil { - return pub, nil - } - if pub, err := publicKeyFromX5U(token.Header); err == nil { - return pub, nil + if vulnerable { + if pub, err := publicKeyFromX5C(token.Header); err == nil { + return pub, nil + } + if pub, err := publicKeyFromX5U(token.Header); err == nil { + return pub, nil + } } + // fixed: x5c/x5u headers are never trusted, always verify + // against the server's own pinned certificate return legitPublicKey, nil }) diff --git a/challenges/strong-api-key/README.md b/challenges/strong-api-key/README.md index e649dfb..7ca7abf 100644 --- a/challenges/strong-api-key/README.md +++ b/challenges/strong-api-key/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a secure API using a strong API key. go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a short, predictable API key and a non-constant-time comparison are used +go run main.go serve --vulnerable=true + +# fixed: a strong, high-entropy API key and a constant-time comparison are used +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/strong-api-key/serve/server.go b/challenges/strong-api-key/serve/server.go index 7389aa4..e2123d6 100644 --- a/challenges/strong-api-key/serve/server.go +++ b/challenges/strong-api-key/serve/server.go @@ -1,10 +1,12 @@ package serve import ( - "crypto/rand" + crand "crypto/rand" + "crypto/subtle" "encoding/base64" "fmt" "log" + "math/rand/v2" "net/http" "github.com/cerberauth/api-vulns-challenges/common" @@ -12,24 +14,52 @@ import ( func generateStrongAPIKey() (string, error) { b := make([]byte, 32) - _, err := rand.Read(b) + _, err := crand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } -func RunServer(port string) { - apiKey, err := generateStrongAPIKey() - if err != nil { - log.Fatal(err) +// generateWeakAPIKey mimics a short, predictable key derived from a +// low-entropy, non-cryptographic random source - practical to brute-force. +func generateWeakAPIKey() string { + const digits = "0123456789" + b := make([]byte, 6) + for i := range b { + b[i] = digits[rand.IntN(len(digits))] + } + return string(b) +} + +func RunServer(port string, vulnerable bool) { + var apiKey string + if vulnerable { + apiKey = generateWeakAPIKey() + } else { + var err error + apiKey, err = generateStrongAPIKey() + if err != nil { + log.Fatal(err) + } } fmt.Println("API Key:", apiKey) mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-API-Key") != apiKey { + provided := r.Header.Get("X-API-Key") + + var valid bool + if vulnerable { + // vulnerable: non-constant-time comparison, in addition to the + // key itself being short and guessable + valid = provided == apiKey + } else { + valid = subtle.ConstantTimeCompare([]byte(provided), []byte(apiKey)) == 1 + } + + if !valid { w.WriteHeader(401) return } diff --git a/challenges/strong-http-basic/README.md b/challenges/strong-http-basic/README.md index 828139e..9a2f26b 100644 --- a/challenges/strong-http-basic/README.md +++ b/challenges/strong-http-basic/README.md @@ -8,6 +8,18 @@ This challenge demonstrates a secure API using strong HTTP Basic authentication. go run main.go ``` +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: well-known credentials (admin/password) and a non-constant-time comparison are used +go run main.go serve --vulnerable=true + +# fixed: randomly generated credentials and a constant-time comparison are used +go run main.go serve --vulnerable=false +``` + ## Disclaimer The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. diff --git a/challenges/strong-http-basic/serve/server.go b/challenges/strong-http-basic/serve/server.go index b86a8c3..21f8b59 100644 --- a/challenges/strong-http-basic/serve/server.go +++ b/challenges/strong-http-basic/serve/server.go @@ -26,24 +26,38 @@ func generateBasicPassword() string { return base64.URLEncoding.EncodeToString(bytes) } -func RunServer(port string) { - username := generateRandomBasicUsername() - fmt.Println("Username:", username) - password := generateBasicPassword() - fmt.Println("Password:", password) +func RunServer(port string, vulnerable bool) { + var expectedUsername, expectedPassword string + if vulnerable { + // vulnerable: well-known, dictionary-guessable credentials + expectedUsername = "admin" + expectedPassword = "password" + } else { + expectedUsername = generateRandomBasicUsername() + expectedPassword = generateBasicPassword() + } + fmt.Println("Username:", expectedUsername) + fmt.Println("Password:", expectedPassword) - expectedUsernameHash := sha256.Sum256([]byte(username)) - expectedPasswordHash := sha256.Sum256([]byte(password)) + expectedUsernameHash := sha256.Sum256([]byte(expectedUsername)) + expectedPasswordHash := sha256.Sum256([]byte(expectedPassword)) mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if ok { - usernameHash := sha256.Sum256([]byte(username)) - passwordHash := sha256.Sum256([]byte(password)) + var usernameMatch, passwordMatch bool + if vulnerable { + // vulnerable: plain, non-constant-time comparison, prone to timing attacks + usernameMatch = username == expectedUsername + passwordMatch = password == expectedPassword + } else { + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) - usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1) - passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1) + usernameMatch = subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1 + passwordMatch = subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1 + } if usernameMatch && passwordMatch { w.WriteHeader(http.StatusNoContent) diff --git a/common/cmd.go b/common/cmd.go index 9745b8c..5d5b660 100644 --- a/common/cmd.go +++ b/common/cmd.go @@ -8,26 +8,31 @@ import ( "github.com/spf13/cobra" ) -func NewJwtCmd(generate func() (string, error)) *cobra.Command { - return &cobra.Command{ +func NewJwtCmd(generate func(vulnerable bool) (string, error)) *cobra.Command { + var vulnerable bool + cmd := &cobra.Command{ Use: "jwt", Run: func(cmd *cobra.Command, args []string) { - tokenString, err := generate() + tokenString, err := generate(vulnerable) if err != nil { log.Fatal(err) } fmt.Print(tokenString) }, } + cmd.Flags().BoolVar(&vulnerable, "vulnerable", true, "Generate a token as issued in the vulnerable setup. Set to false to generate a token matching the fixed, non-vulnerable server") + return cmd } -func Execute(runner func(port string), additionalCmds ...*cobra.Command) { +func Execute(runner func(port string, vulnerable bool), additionalCmds ...*cobra.Command) { var port string + var vulnerable bool serveCmd := &cobra.Command{ Use: "serve", - Run: func(cmd *cobra.Command, args []string) { runner(port) }, + Run: func(cmd *cobra.Command, args []string) { runner(port, vulnerable) }, } serveCmd.Flags().StringVarP(&port, "port", "p", "8080", "Port to listen on") + serveCmd.Flags().BoolVar(&vulnerable, "vulnerable", true, "Run the server in its vulnerable mode. Set to false to run the fixed, non-vulnerable implementation") rootCmd := &cobra.Command{Use: "app"} rootCmd.AddCommand(serveCmd)