diff --git a/README.md b/README.md index c1375f7..f9a8a9d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,24 @@ The following challenges are available in this repository: - [Apollo GraphQL Server](challenges/apollo) - [Auth Not Verified](challenges/auth-not-verified) +- [Cache Conditional Validators](challenges/cache-conditional-validators) +- [Cache Control Directive Validation](challenges/cache-control-directive-validation) +- [Cache CPDoS Header Oversize](challenges/cache-cpdos-header-oversize) +- [Cache CPDoS Meta Character](challenges/cache-cpdos-meta-character) +- [Cache CPDoS Method Override](challenges/cache-cpdos-method-override) +- [Cache Deception via Delimiter](challenges/cache-deception-delimiter) +- [Cache Deception via Normalization/Traversal](challenges/cache-deception-normalization) +- [Cache Deception via Static Directory](challenges/cache-deception-static-directory) +- [Cache Deception via Static Extension](challenges/cache-deception-static-extension) +- [Cache Freshness Heuristic Caching](challenges/cache-freshness-heuristic) +- [Cache Key Explosion DoS](challenges/cache-key-explosion-dos) +- [Cache Poisoning via Fat GET](challenges/cache-poisoning-fat-get) +- [Cache Poisoning via Key Collision](challenges/cache-poisoning-key-collision) +- [Cache Poisoning via Parameter Cloaking](challenges/cache-poisoning-parameter-cloaking) +- [Cache Poisoning via Unkeyed Header](challenges/cache-poisoning-unkeyed-header) +- [Cache Sensitive Data Exposure](challenges/cache-sensitive-data-exposure) +- [Cache Stale Directive Bypass](challenges/cache-stale-directive-bypass) +- [Cache Vary Misconfiguration](challenges/cache-vary-misconfiguration) - [Discoverable](challenges/discoverable) - [HTTP Misconfigurations](challenges/http-misconfigurations) - [JWT None Algorithm Bypass](challenges/jwt-alg-none-bypass) diff --git a/challenges/cache-conditional-validators/.gitignore b/challenges/cache-conditional-validators/.gitignore new file mode 100644 index 0000000..2b7cd40 --- /dev/null +++ b/challenges/cache-conditional-validators/.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 + +cache-conditional-validators diff --git a/challenges/cache-conditional-validators/Dockerfile b/challenges/cache-conditional-validators/Dockerfile new file mode 100644 index 0000000..c3eeaa7 --- /dev/null +++ b/challenges/cache-conditional-validators/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-conditional-validators/ ./challenges/cache-conditional-validators/ + +WORKDIR /app/challenges/cache-conditional-validators +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-conditional-validators . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-conditional-validators /usr/bin/cache-conditional-validators + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-conditional-validators"] +CMD ["serve"] diff --git a/challenges/cache-conditional-validators/README.md b/challenges/cache-conditional-validators/README.md new file mode 100644 index 0000000..9898969 --- /dev/null +++ b/challenges/cache-conditional-validators/README.md @@ -0,0 +1,44 @@ +# Conditional Request / Validator Misuse + +This challenge demonstrates broken cache validators: `/resource` never changes, but the vulnerable configuration issues a brand-new, random `ETag` on every response (a "flapping" validator) and ignores `If-None-Match` / `If-Modified-Since` entirely — so conditional requests never get a `304 Not Modified`, defeating revalidation and forcing a full response on every request. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /resource` — a static document that should support conditional requests + +## Confirming the finding + +```bash +# fetch twice and compare ETags - vulnerable: different every time; fixed: stable +curl -s -D - http://localhost:8080/resource -o /dev/null | grep ETag +curl -s -D - http://localhost:8080/resource -o /dev/null | grep ETag + +# send a conditional request with the ETag just received +ETAG=$(curl -s -D - http://localhost:8080/resource -o /dev/null | grep -i etag | cut -d' ' -f2 | tr -d '\r') +curl -s -o /dev/null -w "%{http_code}\n" -H "If-None-Match: $ETAG" http://localhost:8080/resource +# expect 200 (vulnerable) vs 304 (fixed) +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a new random ETag is issued every time and conditional requests are ignored +go run main.go serve --vulnerable=true + +# fixed: a stable ETag/Last-Modified is issued and conditional requests are honored with 304 +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-conditional-validators/go.mod b/challenges/cache-conditional-validators/go.mod new file mode 100644 index 0000000..5d058f5 --- /dev/null +++ b/challenges/cache-conditional-validators/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-conditional-validators + +go 1.26 + +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/cache-conditional-validators/go.sum b/challenges/cache-conditional-validators/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-conditional-validators/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-conditional-validators/main.go b/challenges/cache-conditional-validators/main.go new file mode 100644 index 0000000..c713bcd --- /dev/null +++ b/challenges/cache-conditional-validators/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-conditional-validators/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-conditional-validators/serve/server.go b/challenges/cache-conditional-validators/serve/server.go new file mode 100644 index 0000000..60ba555 --- /dev/null +++ b/challenges/cache-conditional-validators/serve/server.go @@ -0,0 +1,57 @@ +package serve + +import ( + "crypto/rand" + "encoding/hex" + "log" + "net/http" +) + +const ( + fixedBody = "hello, world" + fixedETag = `"a1b2c3d4"` + lastModified = "Wed, 21 Oct 2020 07:28:00 GMT" + contentTypeVl = "text/plain" +) + +func randomETag() string { + b := make([]byte, 4) + rand.Read(b) + return `"` + hex.EncodeToString(b) + `"` +} + +func RunServer(port string, vulnerable bool) { + // /resource is a static, unchanging document. A well-behaved origin + // should hand out a stable validator (ETag/Last-Modified) and honor + // conditional requests with 304 Not Modified. The vulnerable + // configuration issues a brand-new, random ETag on every response + // (a "flapping" validator) and never checks If-None-Match / + // If-Modified-Since at all, defeating revalidation entirely - every + // conditional request still gets a full 200 response. + http.HandleFunc("/resource", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", contentTypeVl) + + if vulnerable { + w.Header().Set("ETag", randomETag()) + w.Write([]byte(fixedBody)) + return + } + + w.Header().Set("ETag", fixedETag) + w.Header().Set("Last-Modified", lastModified) + + if inm := r.Header.Get("If-None-Match"); inm != "" && inm == fixedETag { + w.WriteHeader(http.StatusNotModified) + return + } + if ims := r.Header.Get("If-Modified-Since"); ims != "" && ims == lastModified { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Write([]byte(fixedBody)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-control-directive-validation/.gitignore b/challenges/cache-control-directive-validation/.gitignore new file mode 100644 index 0000000..e263ce0 --- /dev/null +++ b/challenges/cache-control-directive-validation/.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 + +cache-control-directive-validation diff --git a/challenges/cache-control-directive-validation/Dockerfile b/challenges/cache-control-directive-validation/Dockerfile new file mode 100644 index 0000000..87a796c --- /dev/null +++ b/challenges/cache-control-directive-validation/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-control-directive-validation/ ./challenges/cache-control-directive-validation/ + +WORKDIR /app/challenges/cache-control-directive-validation +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-control-directive-validation . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-control-directive-validation /usr/bin/cache-control-directive-validation + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-control-directive-validation"] +CMD ["serve"] diff --git a/challenges/cache-control-directive-validation/README.md b/challenges/cache-control-directive-validation/README.md new file mode 100644 index 0000000..c2fc0a6 --- /dev/null +++ b/challenges/cache-control-directive-validation/README.md @@ -0,0 +1,37 @@ +# Conflicting / Invalid Cache-Control Directives + +This challenge demonstrates a self-contradictory `Cache-Control` header: `/report` sends `no-store, max-age=3600, public, private` all at once — directives that cannot coexist per RFC 9111 (`no-store` forbids any storage while `max-age` implies storage; `public` and `private` make opposite claims about shared-cache eligibility). Different cache implementations resolve this ambiguity inconsistently, which is itself a risk for sensitive responses. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /report` — returns a sensitive JSON report + +## Confirming the finding + +```bash +curl -s -D - http://localhost:8080/report -o /dev/null | grep -i cache-control +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: Cache-Control combines no-store, max-age, public and private in one contradictory header +go run main.go serve --vulnerable=true + +# fixed: Cache-Control unambiguously declares no-store only +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-control-directive-validation/go.mod b/challenges/cache-control-directive-validation/go.mod new file mode 100644 index 0000000..8c1798a --- /dev/null +++ b/challenges/cache-control-directive-validation/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-control-directive-validation + +go 1.26 + +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/cache-control-directive-validation/go.sum b/challenges/cache-control-directive-validation/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-control-directive-validation/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-control-directive-validation/main.go b/challenges/cache-control-directive-validation/main.go new file mode 100644 index 0000000..a137d5e --- /dev/null +++ b/challenges/cache-control-directive-validation/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-control-directive-validation/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-control-directive-validation/serve/server.go b/challenges/cache-control-directive-validation/serve/server.go new file mode 100644 index 0000000..3bcd42d --- /dev/null +++ b/challenges/cache-control-directive-validation/serve/server.go @@ -0,0 +1,28 @@ +package serve + +import ( + "log" + "net/http" +) + +func RunServer(port string, vulnerable bool) { + // /report returns a sensitive report. The vulnerable configuration + // emits a self-contradictory Cache-Control header: "no-store" (never + // persist this response) combined with "max-age=3600" (persist it for + // an hour) and "public, private" (shareable and per-user at once). + // RFC 9111 says a directive parser must be able to flag these as + // invalid/conflicting, and different cache implementations resolve the + // ambiguity inconsistently, which is itself a risk. + http.HandleFunc("/report", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if vulnerable { + w.Header().Set("Cache-Control", "no-store, max-age=3600, public, private") + } else { + w.Header().Set("Cache-Control", "no-store") + } + w.Write([]byte(`{"report": "quarterly figures"}`)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-cpdos-header-oversize/.gitignore b/challenges/cache-cpdos-header-oversize/.gitignore new file mode 100644 index 0000000..874ed22 --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/.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 + +cache-cpdos-header-oversize diff --git a/challenges/cache-cpdos-header-oversize/Dockerfile b/challenges/cache-cpdos-header-oversize/Dockerfile new file mode 100644 index 0000000..577b182 --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-cpdos-header-oversize/ ./challenges/cache-cpdos-header-oversize/ + +WORKDIR /app/challenges/cache-cpdos-header-oversize +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-cpdos-header-oversize . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-cpdos-header-oversize /usr/bin/cache-cpdos-header-oversize + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-cpdos-header-oversize"] +CMD ["serve"] diff --git a/challenges/cache-cpdos-header-oversize/README.md b/challenges/cache-cpdos-header-oversize/README.md new file mode 100644 index 0000000..4f138fe --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/README.md @@ -0,0 +1,43 @@ +# CPDoS: HTTP Header Oversize (HHO) + +This challenge demonstrates the "HTTP Header Oversize" cache poisoning denial-of-service pattern: the shared cache/CDN accepts request header blocks up to 20480 bytes, but the origin behind it only accepts up to 8192 bytes and returns `431 Request Header Fields Too Large` beyond that. In the vulnerable configuration, the CDN caches that error response and replays it to every subsequent, legitimate caller of the same URL. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /` — returns `welcome` for normal requests + +## Exploiting it + +```bash +# craft a header block between the origin's limit (8192) and the CDN's limit (20480) +PADDING=$(python3 -c "print('A' * 10000)") + +curl -s -D - -H "X-Padding: $PADDING" http://localhost:8080/ -o /dev/null + +# any subsequent, legitimate caller of "/" now gets the cached 431 error +curl -s -D - http://localhost:8080/ -o /dev/null +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the origin's 431 error response is cached and replayed to later callers +go run main.go serve --vulnerable=true + +# fixed: error responses are never cached, regardless of what triggered them +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-cpdos-header-oversize/go.mod b/challenges/cache-cpdos-header-oversize/go.mod new file mode 100644 index 0000000..1e94428 --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-cpdos-header-oversize + +go 1.26 + +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/cache-cpdos-header-oversize/go.sum b/challenges/cache-cpdos-header-oversize/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-cpdos-header-oversize/main.go b/challenges/cache-cpdos-header-oversize/main.go new file mode 100644 index 0000000..4d2e344 --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-cpdos-header-oversize/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-cpdos-header-oversize/serve/server.go b/challenges/cache-cpdos-header-oversize/serve/server.go new file mode 100644 index 0000000..f3f0ae6 --- /dev/null +++ b/challenges/cache-cpdos-header-oversize/serve/server.go @@ -0,0 +1,96 @@ +package serve + +import ( + "log" + "net/http" + "sync" +) + +// originHeaderLimit mimics a typical origin's header-block size limit +// (e.g. Apache's default of 8192 bytes). The CDN in front of it, in the +// vulnerable configuration, accepts much larger header blocks (e.g. +// CloudFront's 20480 bytes) and forwards them straight through - a size +// mismatch that lets an attacker craft a request the CDN accepts but the +// origin rejects with an error. +const originHeaderLimit = 8192 + +// cdnHeaderLimit is the shared cache/CDN's own, larger limit. +const cdnHeaderLimit = 20480 + +type cache struct { + mu sync.Mutex + store map[string]cachedResponse +} + +type cachedResponse struct { + status int + body string +} + +func newCache() *cache { + return &cache{store: make(map[string]cachedResponse)} +} + +func headerBlockSize(r *http.Request) int { + size := 0 + for name, values := range r.Header { + for _, v := range values { + size += len(name) + len(v) + } + } + return size +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + key := r.URL.Path + + c.mu.Lock() + resp, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.WriteHeader(resp.status) + w.Write([]byte(resp.body)) + return + } + + size := headerBlockSize(r) + + // the CDN enforces its own, larger limit before ever forwarding + // the request upstream + if size > cdnHeaderLimit { + http.Error(w, "request header fields too large", http.StatusRequestHeaderFieldsTooLarge) + return + } + + var status int + var body string + if size > originHeaderLimit { + // the origin rejects a header block this large + status = http.StatusRequestHeaderFieldsTooLarge + body = "431 Request Header Fields Too Large" + } else { + status = http.StatusOK + body = "welcome" + } + + if vulnerable && status != http.StatusOK { + // the CDN caches this error response and replays it to every + // subsequent, legitimate caller of the same URL - a denial of + // service via HTTP Header Oversize (CPDoS "HHO") + c.mu.Lock() + c.store[key] = cachedResponse{status: status, body: body} + c.mu.Unlock() + } + + w.Header().Set("X-Cache", "MISS") + w.WriteHeader(status) + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-cpdos-meta-character/.gitignore b/challenges/cache-cpdos-meta-character/.gitignore new file mode 100644 index 0000000..899571a --- /dev/null +++ b/challenges/cache-cpdos-meta-character/.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 + +cache-cpdos-meta-character diff --git a/challenges/cache-cpdos-meta-character/Dockerfile b/challenges/cache-cpdos-meta-character/Dockerfile new file mode 100644 index 0000000..ba1da9f --- /dev/null +++ b/challenges/cache-cpdos-meta-character/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-cpdos-meta-character/ ./challenges/cache-cpdos-meta-character/ + +WORKDIR /app/challenges/cache-cpdos-meta-character +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-cpdos-meta-character . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-cpdos-meta-character /usr/bin/cache-cpdos-meta-character + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-cpdos-meta-character"] +CMD ["serve"] diff --git a/challenges/cache-cpdos-meta-character/README.md b/challenges/cache-cpdos-meta-character/README.md new file mode 100644 index 0000000..b508503 --- /dev/null +++ b/challenges/cache-cpdos-meta-character/README.md @@ -0,0 +1,41 @@ +# CPDoS: HTTP Meta Character (HMC) + +This challenge demonstrates the "HTTP Meta Character" cache poisoning denial-of-service pattern: the shared cache/CDN forwards a percent-decoded query value containing control characters (NUL, CR, LF, ...) straight through, while the origin's stricter input validation rejects it with a `400 Bad Request`. The cache keys purely on the path, unaware of the query value that caused the error, so it caches the error and replays it to every subsequent caller of the same path — regardless of their own query value. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /?name=` — greets the caller by name for normal requests + +## Exploiting it + +```bash +# a query value containing a NUL byte the origin rejects +curl -s -D - "http://localhost:8080/?name=bad%00name" -o /dev/null + +# any subsequent caller of "/", even with a clean name, now gets the cached 400 error +curl -s -D - "http://localhost:8080/?name=alice" -o /dev/null +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the origin's 400 error response is cached, keyed on path only, and replayed to later callers +go run main.go serve --vulnerable=true + +# fixed: error responses are never cached, regardless of what triggered them +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-cpdos-meta-character/go.mod b/challenges/cache-cpdos-meta-character/go.mod new file mode 100644 index 0000000..6502115 --- /dev/null +++ b/challenges/cache-cpdos-meta-character/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-cpdos-meta-character + +go 1.26 + +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/cache-cpdos-meta-character/go.sum b/challenges/cache-cpdos-meta-character/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-cpdos-meta-character/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-cpdos-meta-character/main.go b/challenges/cache-cpdos-meta-character/main.go new file mode 100644 index 0000000..cf818dc --- /dev/null +++ b/challenges/cache-cpdos-meta-character/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-cpdos-meta-character/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-cpdos-meta-character/serve/server.go b/challenges/cache-cpdos-meta-character/serve/server.go new file mode 100644 index 0000000..0dc9f5b --- /dev/null +++ b/challenges/cache-cpdos-meta-character/serve/server.go @@ -0,0 +1,86 @@ +package serve + +import ( + "log" + "net/http" + "strings" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]cachedResponse +} + +type cachedResponse struct { + status int + body string +} + +func newCache() *cache { + return &cache{store: make(map[string]cachedResponse)} +} + +// metaCharacters are control characters a CDN happily forwards after +// percent-decoding a query value, but that the origin's stricter input +// validation rejects outright. +var metaCharacters = []string{"\r", "\n", "\x00", "\a"} + +func containsMetaCharacter(v string) bool { + for _, ch := range metaCharacters { + if strings.Contains(v, ch) { + return true + } + } + return false +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // the cache keys purely on the path, unaware of the query value's + // content, while the origin below actually inspects it + key := r.URL.Path + + c.mu.Lock() + resp, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.WriteHeader(resp.status) + w.Write([]byte(resp.body)) + return + } + + name := r.URL.Query().Get("name") + + var status int + var body string + if containsMetaCharacter(name) { + // the origin's stricter input validation rejects control + // characters in this field outright + status = http.StatusBadRequest + body = "400 Bad Request: invalid character in name" + } else { + status = http.StatusOK + body = "welcome, " + name + } + + if vulnerable && status != http.StatusOK { + // the CDN cached the resulting origin error keyed on the path + // alone, unaware the query value that triggered it even + // existed - CPDoS "HTTP Meta Character" (HMC) + c.mu.Lock() + c.store[key] = cachedResponse{status: status, body: body} + c.mu.Unlock() + } + + w.Header().Set("X-Cache", "MISS") + w.WriteHeader(status) + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-cpdos-method-override/.gitignore b/challenges/cache-cpdos-method-override/.gitignore new file mode 100644 index 0000000..7e44b28 --- /dev/null +++ b/challenges/cache-cpdos-method-override/.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 + +cache-cpdos-method-override diff --git a/challenges/cache-cpdos-method-override/Dockerfile b/challenges/cache-cpdos-method-override/Dockerfile new file mode 100644 index 0000000..6cf88c6 --- /dev/null +++ b/challenges/cache-cpdos-method-override/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-cpdos-method-override/ ./challenges/cache-cpdos-method-override/ + +WORKDIR /app/challenges/cache-cpdos-method-override +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-cpdos-method-override . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-cpdos-method-override /usr/bin/cache-cpdos-method-override + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-cpdos-method-override"] +CMD ["serve"] diff --git a/challenges/cache-cpdos-method-override/README.md b/challenges/cache-cpdos-method-override/README.md new file mode 100644 index 0000000..94b37b9 --- /dev/null +++ b/challenges/cache-cpdos-method-override/README.md @@ -0,0 +1,41 @@ +# CPDoS: HTTP Method Override (HMO) + +This challenge demonstrates the "HTTP Method Override" cache poisoning denial-of-service pattern: the origin honors `X-HTTP-Method-Override` (and similar) headers to let a `GET` request be re-interpreted as `DELETE`/`PUT`/`PATCH`, which this endpoint rejects with a `405`. The shared cache/CDN keys purely on the real `GET /` request line, unaware that the override header changed the effective method — so it caches the resulting error and replays it to every subsequent, legitimate `GET /` caller. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /` — returns `welcome` for normal requests + +## Exploiting it + +```bash +# a plain GET with a method-override header the origin honors but the cache ignores +curl -s -D - -H "X-HTTP-Method-Override: DELETE" http://localhost:8080/ -o /dev/null + +# any subsequent, legitimate GET caller of "/" now gets the cached 405 error +curl -s -D - http://localhost:8080/ -o /dev/null +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: method-override headers are honored, and the resulting error is cached +go run main.go serve --vulnerable=true + +# fixed: method-override headers are ignored entirely, and error responses are never cached +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-cpdos-method-override/go.mod b/challenges/cache-cpdos-method-override/go.mod new file mode 100644 index 0000000..f84f6b5 --- /dev/null +++ b/challenges/cache-cpdos-method-override/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-cpdos-method-override + +go 1.26 + +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/cache-cpdos-method-override/go.sum b/challenges/cache-cpdos-method-override/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-cpdos-method-override/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-cpdos-method-override/main.go b/challenges/cache-cpdos-method-override/main.go new file mode 100644 index 0000000..3c3ffd6 --- /dev/null +++ b/challenges/cache-cpdos-method-override/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-cpdos-method-override/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-cpdos-method-override/serve/server.go b/challenges/cache-cpdos-method-override/serve/server.go new file mode 100644 index 0000000..99675a5 --- /dev/null +++ b/challenges/cache-cpdos-method-override/serve/server.go @@ -0,0 +1,86 @@ +package serve + +import ( + "log" + "net/http" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]cachedResponse +} + +type cachedResponse struct { + status int + body string +} + +func newCache() *cache { + return &cache{store: make(map[string]cachedResponse)} +} + +// methodOverrideHeaders are honored by the vulnerable origin to let a GET +// request be re-interpreted as a different, possibly blocked, method - a +// pattern some frameworks support for HTML forms that can't send PUT/DELETE +// directly. +var methodOverrideHeaders = []string{"X-HTTP-Method-Override", "X-Method-Override", "X-HTTP-Method"} + +// blockedMethods are rejected by the origin with an error, per its routing +// rules (only GET is exposed on this endpoint). +var blockedMethods = map[string]bool{"DELETE": true, "PUT": true, "PATCH": true} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + key := r.URL.Path + + c.mu.Lock() + resp, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.WriteHeader(resp.status) + w.Write([]byte(resp.body)) + return + } + + effectiveMethod := r.Method + if vulnerable { + for _, h := range methodOverrideHeaders { + if v := r.Header.Get(h); v != "" { + effectiveMethod = v + break + } + } + } + + var status int + var body string + if blockedMethods[effectiveMethod] { + // the origin rejects the (overridden) method with an error + status = http.StatusMethodNotAllowed + body = "405 Method Not Allowed" + } else { + status = http.StatusOK + body = "welcome" + } + + if vulnerable && status != http.StatusOK { + // the CDN caches keyed on GET /, unaware that a method-override + // header changed the effective method the origin acted on - + // CPDoS "HTTP Method Override" (HMO) + c.mu.Lock() + c.store[key] = cachedResponse{status: status, body: body} + c.mu.Unlock() + } + + w.Header().Set("X-Cache", "MISS") + w.WriteHeader(status) + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-deception-delimiter/.gitignore b/challenges/cache-deception-delimiter/.gitignore new file mode 100644 index 0000000..850d1c0 --- /dev/null +++ b/challenges/cache-deception-delimiter/.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 + +cache-deception-delimiter diff --git a/challenges/cache-deception-delimiter/Dockerfile b/challenges/cache-deception-delimiter/Dockerfile new file mode 100644 index 0000000..b73cfcb --- /dev/null +++ b/challenges/cache-deception-delimiter/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-deception-delimiter/ ./challenges/cache-deception-delimiter/ + +WORKDIR /app/challenges/cache-deception-delimiter +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-deception-delimiter . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-deception-delimiter /usr/bin/cache-deception-delimiter + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-deception-delimiter"] +CMD ["serve"] diff --git a/challenges/cache-deception-delimiter/README.md b/challenges/cache-deception-delimiter/README.md new file mode 100644 index 0000000..4a694c8 --- /dev/null +++ b/challenges/cache-deception-delimiter/README.md @@ -0,0 +1,41 @@ +# Web Cache Deception via Origin-Only Delimiters + +This challenge demonstrates delimiter-based web cache deception: the shared cache treats characters like `;`, `%00`, `%0a`, or `$` as marking the end of the meaningful path, and decides to cache a request when whatever follows the delimiter looks like a static suffix (`.css`, `.js`). The origin, however, ignores the delimiter entirely and keeps serving the same dynamic, authenticated `/account` response — so the cache stores that sensitive response under a path anyone can guess. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /account[]` — authenticated account data, e.g. `/account;a.css`, `/account%00a.js` + +## Exploiting it + +```bash +# victim, authenticated, requests a delimiter-suffixed variant of /account +curl --path-as-is -H "Authorization: Bearer victim-secret-token" "http://localhost:8080/account;a.css" + +# attacker, unauthenticated, requests the same path and receives the victim's cached data +curl --path-as-is "http://localhost:8080/account;a.css" +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: origin-only delimiters followed by a static-looking suffix are cached +go run main.go serve --vulnerable=true + +# fixed: the authenticated response is never cached, regardless of any delimiter/suffix appended to the path +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-deception-delimiter/go.mod b/challenges/cache-deception-delimiter/go.mod new file mode 100644 index 0000000..c67ebba --- /dev/null +++ b/challenges/cache-deception-delimiter/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-delimiter + +go 1.26 + +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/cache-deception-delimiter/go.sum b/challenges/cache-deception-delimiter/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-deception-delimiter/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-deception-delimiter/main.go b/challenges/cache-deception-delimiter/main.go new file mode 100644 index 0000000..c8fb7dd --- /dev/null +++ b/challenges/cache-deception-delimiter/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-delimiter/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-deception-delimiter/serve/server.go b/challenges/cache-deception-delimiter/serve/server.go new file mode 100644 index 0000000..b72f4a0 --- /dev/null +++ b/challenges/cache-deception-delimiter/serve/server.go @@ -0,0 +1,86 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "strings" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +// delimiters the shared cache treats as "the origin must have stopped +// parsing the path here, so anything after this is a static suffix" - while +// the origin itself ignores them entirely and keeps routing on the full +// path up to the delimiter. +var originOnlyDelimiters = []string{";", "%00", "%0a", "$"} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // registered directly (bypassing http.ServeMux) so delimiter + // characters in the raw path reach the handler unmodified + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath := r.URL.Path + + if !strings.HasPrefix(rawPath, "/account") { + http.NotFound(w, r) + return + } + + cacheable := false + if vulnerable { + for _, d := range originOnlyDelimiters { + if idx := strings.Index(rawPath, d); idx != -1 { + suffix := rawPath[idx+len(d):] + // the cache sees a static-looking suffix after the + // delimiter (e.g. ".css") and decides to cache the + // whole response, even though the delimiter and + // suffix carry no meaning to the origin + if strings.HasSuffix(suffix, ".css") || strings.HasSuffix(suffix, ".js") { + cacheable = true + } + break + } + } + } + + token := r.Header.Get("Authorization") + body := fmt.Sprintf(`{"email": "%s@example.com", "session_token": "%s"}`, strings.TrimPrefix(token, "Bearer "), token) + + if cacheable { + // the origin ignores everything from the delimiter onward and + // serves the same authenticated /account response regardless + key := rawPath + c.mu.Lock() + cached, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(cached)) + return + } + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Cache", "BYPASS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, handler)) +} diff --git a/challenges/cache-deception-normalization/.gitignore b/challenges/cache-deception-normalization/.gitignore new file mode 100644 index 0000000..7f5cad7 --- /dev/null +++ b/challenges/cache-deception-normalization/.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 + +cache-deception-normalization diff --git a/challenges/cache-deception-normalization/Dockerfile b/challenges/cache-deception-normalization/Dockerfile new file mode 100644 index 0000000..3ef0b2e --- /dev/null +++ b/challenges/cache-deception-normalization/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-deception-normalization/ ./challenges/cache-deception-normalization/ + +WORKDIR /app/challenges/cache-deception-normalization +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-deception-normalization . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-deception-normalization /usr/bin/cache-deception-normalization + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-deception-normalization"] +CMD ["serve"] diff --git a/challenges/cache-deception-normalization/README.md b/challenges/cache-deception-normalization/README.md new file mode 100644 index 0000000..a65873a --- /dev/null +++ b/challenges/cache-deception-normalization/README.md @@ -0,0 +1,43 @@ +# Web Cache Deception via Nested Encoding / Normalization Mismatch + +This challenge demonstrates a cache-vs-origin decoding-depth mismatch: the shared cache percent-decodes a path only once before deciding cacheability, while the origin keeps decoding and normalizes `.`/`..` segments before routing. A doubly-encoded `/` (`%252F`) survives the cache's single decoding pass as an opaque `%2F` token — keeping the path looking like it's safely under `/static/` — while the origin fully decodes it into a real path separator and traversal that resolves to the authenticated `/account` endpoint. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /account` — authenticated account data +- `GET /static/` — generic static asset content + +## Exploiting it + +```bash +# victim, authenticated, requests a nested-encoded traversal path +curl --path-as-is -H "Authorization: Bearer victim-secret-token" \ + "http://localhost:8080/static/..%252F..%252Faccount" + +# attacker, unauthenticated, requests the same path and receives the victim's cached data +curl --path-as-is "http://localhost:8080/static/..%252F..%252Faccount" +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the cache only decodes the path once before deciding cacheability +go run main.go serve --vulnerable=true + +# fixed: the cache fully decodes and normalizes the path the same way the origin does +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-deception-normalization/go.mod b/challenges/cache-deception-normalization/go.mod new file mode 100644 index 0000000..b5e06b3 --- /dev/null +++ b/challenges/cache-deception-normalization/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-normalization + +go 1.26 + +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/cache-deception-normalization/go.sum b/challenges/cache-deception-normalization/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-deception-normalization/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-deception-normalization/main.go b/challenges/cache-deception-normalization/main.go new file mode 100644 index 0000000..ac5f18f --- /dev/null +++ b/challenges/cache-deception-normalization/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-normalization/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-deception-normalization/serve/server.go b/challenges/cache-deception-normalization/serve/server.go new file mode 100644 index 0000000..f63c1f4 --- /dev/null +++ b/challenges/cache-deception-normalization/serve/server.go @@ -0,0 +1,124 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "net/url" + "strings" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +// decodeOnce percent-decodes a path a single time, mimicking a cache that +// stops after one decoding pass. A nested/double-encoded segment (e.g. +// "%252F", which is "%2F" with its own "%" escaped) survives a single pass +// as the still-opaque literal text "%2F", hiding a real "/" path separator +// from the cache's view of the path. +func decodeOnce(path string) string { + decoded, err := url.PathUnescape(path) + if err != nil { + return path + } + return decoded +} + +// decodeFully keeps percent-decoding until no escapes remain, then collapses +// "." / ".." segments - the same normalization an origin router applies +// before matching a route. +func decodeFully(path string) string { + for { + decoded, err := url.PathUnescape(path) + if err != nil || decoded == path { + break + } + path = decoded + } + segments := strings.Split(path, "/") + var stack []string + for _, s := range segments { + switch s { + case "", ".": + continue + case "..": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + default: + stack = append(stack, s) + } + } + return "/" + strings.Join(stack, "/") +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // registered directly (bypassing http.ServeMux) so encoded path + // segments reach the handler unmodified + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath := r.RequestURI + if idx := strings.Index(rawPath, "?"); idx != -1 { + rawPath = rawPath[:idx] + } + token := r.Header.Get("Authorization") + + // the origin always fully decodes and normalizes before routing + originPath := decodeFully(rawPath) + + cacheableStatic := false + if vulnerable { + // the cache decodes only once, so a double-encoded "/" is + // still hidden inside what looks like a single opaque + // "/static/..." segment + cacheableStatic = strings.HasPrefix(decodeOnce(rawPath), "/static/") + } else { + // fixed: the cache decodes and normalizes exactly like the + // origin does, so it sees the same effective path + cacheableStatic = strings.HasPrefix(originPath, "/static/") + } + + var body string + switch { + case originPath == "/account": + body = fmt.Sprintf(`{"email": "%s@example.com", "session_token": "%s"}`, strings.TrimPrefix(token, "Bearer "), token) + default: + body = "// static asset contents" + } + + if cacheableStatic && originPath == "/account" { + key := rawPath + c.mu.Lock() + cached, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(cached)) + return + } + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + + if originPath == "/account" { + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Cache", "BYPASS") + } + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, handler)) +} diff --git a/challenges/cache-deception-static-directory/.gitignore b/challenges/cache-deception-static-directory/.gitignore new file mode 100644 index 0000000..f625cf1 --- /dev/null +++ b/challenges/cache-deception-static-directory/.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 + +cache-deception-static-directory diff --git a/challenges/cache-deception-static-directory/Dockerfile b/challenges/cache-deception-static-directory/Dockerfile new file mode 100644 index 0000000..1a922db --- /dev/null +++ b/challenges/cache-deception-static-directory/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-deception-static-directory/ ./challenges/cache-deception-static-directory/ + +WORKDIR /app/challenges/cache-deception-static-directory +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-deception-static-directory . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-deception-static-directory /usr/bin/cache-deception-static-directory + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-deception-static-directory"] +CMD ["serve"] diff --git a/challenges/cache-deception-static-directory/README.md b/challenges/cache-deception-static-directory/README.md new file mode 100644 index 0000000..26cf40e --- /dev/null +++ b/challenges/cache-deception-static-directory/README.md @@ -0,0 +1,42 @@ +# Web Cache Deception via Static-Directory Rule + +This challenge demonstrates directory-rule web cache deception: the shared cache caches anything whose *raw* path starts under `/static/` or `/assets/`, without normalizing path-traversal segments first. The origin, however, normalizes the path before routing, so `/static/x/../../account` is cached by the edge (because it superficially starts with `/static/`) while actually being served as the dynamic, authenticated `/account` response by the origin. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /account` — authenticated account data +- `GET /static/` — generic static asset content + +## Exploiting it + +```bash +# victim, authenticated, requests a traversal path that looks static to the cache +curl --path-as-is -H "Authorization: Bearer victim-secret-token" "http://localhost:8080/static/x/../../account" + +# attacker, unauthenticated, requests the same path and receives the victim's cached data +curl --path-as-is "http://localhost:8080/static/x/../../account" +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the cache decides cacheability from the raw, un-normalized path prefix +go run main.go serve --vulnerable=true + +# fixed: the static-directory caching rule is never applied to a path that normalizes outside of it +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-deception-static-directory/go.mod b/challenges/cache-deception-static-directory/go.mod new file mode 100644 index 0000000..6f2af5c --- /dev/null +++ b/challenges/cache-deception-static-directory/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-static-directory + +go 1.26 + +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/cache-deception-static-directory/go.sum b/challenges/cache-deception-static-directory/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-deception-static-directory/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-deception-static-directory/main.go b/challenges/cache-deception-static-directory/main.go new file mode 100644 index 0000000..e9d663f --- /dev/null +++ b/challenges/cache-deception-static-directory/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-static-directory/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-deception-static-directory/serve/server.go b/challenges/cache-deception-static-directory/serve/server.go new file mode 100644 index 0000000..36f6242 --- /dev/null +++ b/challenges/cache-deception-static-directory/serve/server.go @@ -0,0 +1,105 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "strings" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +// staticDirPrefixes are directory prefixes that the shared cache always +// caches, regardless of content-type, because the CDN rule is written as +// "cache everything under /static/**". +var staticDirPrefixes = []string{"/static/", "/assets/"} + +func normalizePath(path string) string { + segments := strings.Split(path, "/") + var stack []string + for _, s := range segments { + switch s { + case "", ".": + continue + case "..": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + default: + stack = append(stack, s) + } + } + return "/" + strings.Join(stack, "/") +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // registered directly (bypassing http.ServeMux) so path traversal + // segments in the raw path reach the handler unmodified + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath := r.URL.Path + token := r.Header.Get("Authorization") + + cacheableByRule := false + if vulnerable { + for _, prefix := range staticDirPrefixes { + if strings.HasPrefix(rawPath, prefix) { + // the CDN caches anything whose RAW path starts under + // a static directory, without normalizing traversal + // segments first + cacheableByRule = true + break + } + } + } + + // the origin normalizes the path and routes on the *normalized* + // result, so "/static/x/../../account" is served as the dynamic + // /account handler even though the cache saw a "/static/" prefix + normalized := normalizePath(rawPath) + + var body string + switch { + case normalized == "/account": + body = fmt.Sprintf(`{"email": "%s@example.com", "session_token": "%s"}`, strings.TrimPrefix(token, "Bearer "), token) + default: + body = "// static asset contents" + } + + if cacheableByRule && normalized == "/account" { + key := rawPath + c.mu.Lock() + cached, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(cached)) + return + } + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + + if normalized == "/account" { + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Cache", "BYPASS") + } + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, handler)) +} diff --git a/challenges/cache-deception-static-extension/.gitignore b/challenges/cache-deception-static-extension/.gitignore new file mode 100644 index 0000000..809a24f --- /dev/null +++ b/challenges/cache-deception-static-extension/.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 + +cache-deception-static-extension diff --git a/challenges/cache-deception-static-extension/Dockerfile b/challenges/cache-deception-static-extension/Dockerfile new file mode 100644 index 0000000..e8a3c8b --- /dev/null +++ b/challenges/cache-deception-static-extension/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-deception-static-extension/ ./challenges/cache-deception-static-extension/ + +WORKDIR /app/challenges/cache-deception-static-extension +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-deception-static-extension . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-deception-static-extension /usr/bin/cache-deception-static-extension + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-deception-static-extension"] +CMD ["serve"] diff --git a/challenges/cache-deception-static-extension/README.md b/challenges/cache-deception-static-extension/README.md new file mode 100644 index 0000000..9e5381c --- /dev/null +++ b/challenges/cache-deception-static-extension/README.md @@ -0,0 +1,42 @@ +# Web Cache Deception via Static Extension Confusion + +This challenge demonstrates the classic web cache deception (WCD) pattern: appending a static-looking extension (`.css`, `.js`, `.png`, ...) to a dynamic, authenticated endpoint's path causes a shared cache to treat the per-user response as a cacheable static asset, then replay it to any unauthenticated caller. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /profile` — returns the caller's authenticated profile, including a session token +- `GET /profile.css`, `/profile.js`, `/profile.png`, ... — same authenticated handler, reachable under static-looking paths + +## Exploiting it + +```bash +# victim, authenticated, is lured into requesting a static-looking variant of their own profile +curl -H "Authorization: Bearer victim-secret-token" http://localhost:8080/profile.css + +# attacker, unauthenticated, requests the same static-looking path and receives the victim's cached data +curl http://localhost:8080/profile.css +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: any static-looking extension appended to /profile is cached and served to subsequent callers +go run main.go serve --vulnerable=true + +# fixed: static-looking extensions on the authenticated path return 404 and are never cached +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-deception-static-extension/go.mod b/challenges/cache-deception-static-extension/go.mod new file mode 100644 index 0000000..010b31a --- /dev/null +++ b/challenges/cache-deception-static-extension/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-static-extension + +go 1.26 + +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/cache-deception-static-extension/go.sum b/challenges/cache-deception-static-extension/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-deception-static-extension/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-deception-static-extension/main.go b/challenges/cache-deception-static-extension/main.go new file mode 100644 index 0000000..5ec3e0d --- /dev/null +++ b/challenges/cache-deception-static-extension/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-deception-static-extension/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-deception-static-extension/serve/server.go b/challenges/cache-deception-static-extension/serve/server.go new file mode 100644 index 0000000..4f1ec74 --- /dev/null +++ b/challenges/cache-deception-static-extension/serve/server.go @@ -0,0 +1,94 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "strings" + "sync" +) + +var staticExtensions = []string{".css", ".js", ".png", ".jpg", ".ico", ".woff2"} + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +func hasStaticExtension(path string) bool { + for _, ext := range staticExtensions { + if strings.HasSuffix(path, ext) { + return true + } + } + return false +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // /profile is a dynamic, authenticated endpoint. The shared cache in + // front of it decides cacheability purely from the URL's file + // extension, the classic web cache deception pattern: appending a + // static-looking extension such as ".css" to a dynamic path fools the + // cache into treating a per-user response as a shared static asset. + // + // The origin router below, in its vulnerable configuration, also + // serves the same dynamic handler regardless of any trailing + // extension, so the sensitive response is both produced AND cached + // under a path anyone can guess and request without authentication. + // + // Omer Gil, original web cache deception research (2017). + http.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { + serveProfile(w, r, c, vulnerable) + }) + for _, ext := range staticExtensions { + ext := ext + http.HandleFunc("/profile"+ext, func(w http.ResponseWriter, r *http.Request) { + serveProfile(w, r, c, vulnerable) + }) + } + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func serveProfile(w http.ResponseWriter, r *http.Request, c *cache, vulnerable bool) { + token := r.Header.Get("Authorization") + + if vulnerable && hasStaticExtension(r.URL.Path) { + key := r.URL.Path + c.mu.Lock() + body, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(body)) + return + } + body = fmt.Sprintf(`{"email": "%s@example.com", "session_token": "%s"}`, strings.TrimPrefix(token, "Bearer "), token) + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + + if hasStaticExtension(r.URL.Path) { + // fixed: the origin does not serve dynamic, authenticated content + // under any path carrying a static-looking extension + w.Header().Set("Cache-Control", "no-store") + http.NotFound(w, r) + return + } + + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Cache", "BYPASS") + body := fmt.Sprintf(`{"email": "%s@example.com", "session_token": "%s"}`, strings.TrimPrefix(token, "Bearer "), token) + w.Write([]byte(body)) +} diff --git a/challenges/cache-freshness-heuristic/.gitignore b/challenges/cache-freshness-heuristic/.gitignore new file mode 100644 index 0000000..0743405 --- /dev/null +++ b/challenges/cache-freshness-heuristic/.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 + +cache-freshness-heuristic diff --git a/challenges/cache-freshness-heuristic/Dockerfile b/challenges/cache-freshness-heuristic/Dockerfile new file mode 100644 index 0000000..a088596 --- /dev/null +++ b/challenges/cache-freshness-heuristic/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-freshness-heuristic/ ./challenges/cache-freshness-heuristic/ + +WORKDIR /app/challenges/cache-freshness-heuristic +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-freshness-heuristic . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-freshness-heuristic /usr/bin/cache-freshness-heuristic + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-freshness-heuristic"] +CMD ["serve"] diff --git a/challenges/cache-freshness-heuristic/README.md b/challenges/cache-freshness-heuristic/README.md new file mode 100644 index 0000000..139628a --- /dev/null +++ b/challenges/cache-freshness-heuristic/README.md @@ -0,0 +1,39 @@ +# Heuristic Caching of Non-Cacheable Error Codes + +This challenge demonstrates over-eager heuristic caching: `/transient` always returns `503 Service Unavailable` with no explicit `Cache-Control`/`Expires` header. Per RFC 9110 §15.1, only `200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501` are heuristically cacheable by default — `503` is not among them. The vulnerable cache heuristically caches it anyway, so a single transient origin failure is replayed to every caller long after the origin has recovered. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /transient` — always returns a `503` with no caching directives + +## Confirming the finding + +```bash +curl -s -D - http://localhost:8080/transient -o /dev/null +curl -s -D - http://localhost:8080/transient -o /dev/null +# expect X-Cache: MISS both times (fixed) vs MISS then HIT (vulnerable) +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the 503 response is heuristically cached and replayed indefinitely +go run main.go serve --vulnerable=true + +# fixed: status codes outside RFC 9110's default-cacheable list are never heuristically cached +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-freshness-heuristic/go.mod b/challenges/cache-freshness-heuristic/go.mod new file mode 100644 index 0000000..7711c14 --- /dev/null +++ b/challenges/cache-freshness-heuristic/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-freshness-heuristic + +go 1.26 + +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/cache-freshness-heuristic/go.sum b/challenges/cache-freshness-heuristic/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-freshness-heuristic/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-freshness-heuristic/main.go b/challenges/cache-freshness-heuristic/main.go new file mode 100644 index 0000000..e14d713 --- /dev/null +++ b/challenges/cache-freshness-heuristic/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-freshness-heuristic/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-freshness-heuristic/serve/server.go b/challenges/cache-freshness-heuristic/serve/server.go new file mode 100644 index 0000000..a0cb6ff --- /dev/null +++ b/challenges/cache-freshness-heuristic/serve/server.go @@ -0,0 +1,63 @@ +package serve + +import ( + "log" + "net/http" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]cachedResponse +} + +type cachedResponse struct { + status int + body string +} + +func newCache() *cache { + return &cache{store: make(map[string]cachedResponse)} +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // /transient always errors with a 503 and carries no explicit caching + // directives. RFC 9110 §15.1 only lists 200, 203, 204, 206, 300, 301, + // 404, 405, 410, 414 and 501 as heuristically cacheable by default; 503 + // is not among them. The vulnerable cache heuristically caches it + // anyway, so a single transient origin failure gets replayed to every + // caller long after the origin has recovered. + http.HandleFunc("/transient", func(w http.ResponseWriter, r *http.Request) { + key := r.URL.Path + + c.mu.Lock() + resp, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.WriteHeader(resp.status) + w.Write([]byte(resp.body)) + return + } + + status := http.StatusServiceUnavailable + body := "503 Service Unavailable (transient)" + + if vulnerable { + // heuristically cached even though the status code and the + // absence of any Cache-Control/Expires header do not permit it + c.mu.Lock() + c.store[key] = cachedResponse{status: status, body: body} + c.mu.Unlock() + } + + w.Header().Set("X-Cache", "MISS") + w.WriteHeader(status) + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-key-explosion-dos/.gitignore b/challenges/cache-key-explosion-dos/.gitignore new file mode 100644 index 0000000..f350746 --- /dev/null +++ b/challenges/cache-key-explosion-dos/.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 + +cache-key-explosion-dos diff --git a/challenges/cache-key-explosion-dos/Dockerfile b/challenges/cache-key-explosion-dos/Dockerfile new file mode 100644 index 0000000..c68b3cf --- /dev/null +++ b/challenges/cache-key-explosion-dos/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-key-explosion-dos/ ./challenges/cache-key-explosion-dos/ + +WORKDIR /app/challenges/cache-key-explosion-dos +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-key-explosion-dos . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-key-explosion-dos /usr/bin/cache-key-explosion-dos + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-key-explosion-dos"] +CMD ["serve"] diff --git a/challenges/cache-key-explosion-dos/README.md b/challenges/cache-key-explosion-dos/README.md new file mode 100644 index 0000000..4241d8f --- /dev/null +++ b/challenges/cache-key-explosion-dos/README.md @@ -0,0 +1,40 @@ +# Cache-Key Explosion / Cache-Busting DoS + +This challenge demonstrates cache-storage exhaustion via unbounded query parameters: `/search?q=` lets the caller pick an arbitrary `q` value, and the vulnerable cache keys every distinct value as its own entry with no limit — so an attacker can generate unbounded cache entries (storage exhaustion) and force an origin request for every single unique value at volume (origin-hammering DoS), defeating the cache's purpose entirely. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /search?q=` — returns a JSON body and reports `X-Distinct-Cache-Entries` plus a running `origin_requests_so_far` counter + +## Exploiting it + +```bash +for i in $(seq 1 50); do + curl -s -D - "http://localhost:8080/search?q=$i" -o /dev/null | grep -E "X-Distinct-Cache-Entries|X-Cache" +done +# every request is a MISS and the distinct-entry / origin-request counters grow without bound +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: every distinct "q" value gets its own, unbounded cache entry +go run main.go serve --vulnerable=true + +# fixed: the number of distinct cache entries per path is capped; overflow values share a single bucket +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-key-explosion-dos/go.mod b/challenges/cache-key-explosion-dos/go.mod new file mode 100644 index 0000000..2e9311d --- /dev/null +++ b/challenges/cache-key-explosion-dos/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-key-explosion-dos + +go 1.26 + +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/cache-key-explosion-dos/go.sum b/challenges/cache-key-explosion-dos/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-key-explosion-dos/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-key-explosion-dos/main.go b/challenges/cache-key-explosion-dos/main.go new file mode 100644 index 0000000..7f0c1c9 --- /dev/null +++ b/challenges/cache-key-explosion-dos/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-key-explosion-dos/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-key-explosion-dos/serve/server.go b/challenges/cache-key-explosion-dos/serve/server.go new file mode 100644 index 0000000..4e3a43d --- /dev/null +++ b/challenges/cache-key-explosion-dos/serve/server.go @@ -0,0 +1,77 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "sync" +) + +// maxDistinctKeys caps how many distinct cache entries the (fixed) cache +// allows for a given path, beyond which it falls back to a single, shared +// entry rather than growing without bound. +const maxDistinctKeys = 16 + +type cache struct { + mu sync.Mutex + store map[string]int +} + +func newCache() *cache { + return &cache{store: make(map[string]int)} +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + var originHits int + var originHitsMu sync.Mutex + + // /search takes an arbitrary "q" query parameter, and, in the + // vulnerable configuration, the cache keys on the full, unbounded query + // string - so an attacker can generate an unlimited number of distinct + // cache entries (or, if the cache is small, force an eviction storm and + // an origin request for every single value) simply by varying an + // unkeyed-in-practice parameter across many requests. + http.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + + key := q + if !vulnerable { + // fixed: cap the number of distinct cache entries kept per + // path; once the limit is reached, further distinct values + // share a single "overflow" bucket instead of exploding the + // cache's storage or hammering the origin per unique value + c.mu.Lock() + if _, exists := c.store[key]; !exists && len(c.store) >= maxDistinctKeys { + key = "__overflow__" + } + c.mu.Unlock() + } + + c.mu.Lock() + _, hit := c.store[key] + if !hit { + c.store[key] = 0 + } + c.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if hit { + w.Header().Set("X-Cache", "HIT") + } else { + w.Header().Set("X-Cache", "MISS") + originHitsMu.Lock() + originHits++ + originHitsMu.Unlock() + } + + w.Header().Set("X-Distinct-Cache-Entries", fmt.Sprintf("%d", len(c.store))) + originHitsMu.Lock() + hits := originHits + originHitsMu.Unlock() + w.Write([]byte(fmt.Sprintf(`{"results": [], "origin_requests_so_far": %d}`, hits))) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-poisoning-fat-get/.gitignore b/challenges/cache-poisoning-fat-get/.gitignore new file mode 100644 index 0000000..40b117e --- /dev/null +++ b/challenges/cache-poisoning-fat-get/.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 + +cache-poisoning-fat-get diff --git a/challenges/cache-poisoning-fat-get/Dockerfile b/challenges/cache-poisoning-fat-get/Dockerfile new file mode 100644 index 0000000..2c9cf84 --- /dev/null +++ b/challenges/cache-poisoning-fat-get/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-poisoning-fat-get/ ./challenges/cache-poisoning-fat-get/ + +WORKDIR /app/challenges/cache-poisoning-fat-get +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-poisoning-fat-get . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-poisoning-fat-get /usr/bin/cache-poisoning-fat-get + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-poisoning-fat-get"] +CMD ["serve"] diff --git a/challenges/cache-poisoning-fat-get/README.md b/challenges/cache-poisoning-fat-get/README.md new file mode 100644 index 0000000..c86fe0e --- /dev/null +++ b/challenges/cache-poisoning-fat-get/README.md @@ -0,0 +1,41 @@ +# Cache Poisoning via Fat GET + +This challenge demonstrates a "fat GET": `/search` accepts its search term both as a `q` query parameter and, non-standardly, in the GET request body. The shared cache keys purely on the URL (query string included), since GET bodies aren't expected to influence responses, but the vulnerable origin still lets a request body override the query parameter — letting an attacker poison the cached entry for a URL with a body-controlled payload that every subsequent, body-less caller of that URL then receives. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /search?q=term` — reflects the search term, optionally overridden by the request body + +## Exploiting it + +```bash +# poison the cache entry for "/search?q=cats" with a body-controlled payload +curl -X GET "http://localhost:8080/search?q=cats" -d "" + +# any subsequent caller of the same URL, without a body, gets the poisoned response +curl "http://localhost:8080/search?q=cats" +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a GET request body overrides the query parameter, but the cache key is unaware of the body +go run main.go serve --vulnerable=true + +# fixed: the origin only ever reads the query parameter and ignores GET bodies entirely +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-poisoning-fat-get/go.mod b/challenges/cache-poisoning-fat-get/go.mod new file mode 100644 index 0000000..f32f92b --- /dev/null +++ b/challenges/cache-poisoning-fat-get/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-poisoning-fat-get + +go 1.26 + +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/cache-poisoning-fat-get/go.sum b/challenges/cache-poisoning-fat-get/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-poisoning-fat-get/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-poisoning-fat-get/main.go b/challenges/cache-poisoning-fat-get/main.go new file mode 100644 index 0000000..581cb95 --- /dev/null +++ b/challenges/cache-poisoning-fat-get/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-poisoning-fat-get/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-poisoning-fat-get/serve/server.go b/challenges/cache-poisoning-fat-get/serve/server.go new file mode 100644 index 0000000..ca851d9 --- /dev/null +++ b/challenges/cache-poisoning-fat-get/serve/server.go @@ -0,0 +1,66 @@ +package serve + +import ( + "fmt" + "io" + "log" + "net/http" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // /search is a "fat GET": it accepts the query term both as a query + // string parameter and, non-standardly, as a request body. The cache + // keys purely on the URL (as caches normally do, since GET bodies are + // not expected to affect the response), but the vulnerable origin still + // reads the body if present and lets it override the query parameter. + // + // An attacker can therefore send a GET with a body to a URL that's + // already cached (or about to be) and have the cache store the + // body-influenced response under the body-less cache key, poisoning it + // for every subsequent caller of that same URL. + http.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + key := r.URL.Path + "?" + r.URL.RawQuery + + c.mu.Lock() + body, hit := c.store[key] + c.mu.Unlock() + + w.Header().Set("Content-Type", "text/html") + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(body)) + return + } + + term := r.URL.Query().Get("q") + if vulnerable { + if b, err := io.ReadAll(r.Body); err == nil && len(b) > 0 { + term = string(b) + } + } + + body = fmt.Sprintf("Results for: %s", term) + + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-poisoning-key-collision/.gitignore b/challenges/cache-poisoning-key-collision/.gitignore new file mode 100644 index 0000000..8ae7099 --- /dev/null +++ b/challenges/cache-poisoning-key-collision/.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 + +cache-poisoning-key-collision diff --git a/challenges/cache-poisoning-key-collision/Dockerfile b/challenges/cache-poisoning-key-collision/Dockerfile new file mode 100644 index 0000000..92f08a6 --- /dev/null +++ b/challenges/cache-poisoning-key-collision/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-poisoning-key-collision/ ./challenges/cache-poisoning-key-collision/ + +WORKDIR /app/challenges/cache-poisoning-key-collision +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-poisoning-key-collision . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-poisoning-key-collision /usr/bin/cache-poisoning-key-collision + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-poisoning-key-collision"] +CMD ["serve"] diff --git a/challenges/cache-poisoning-key-collision/README.md b/challenges/cache-poisoning-key-collision/README.md new file mode 100644 index 0000000..c77d310 --- /dev/null +++ b/challenges/cache-poisoning-key-collision/README.md @@ -0,0 +1,42 @@ +# Cache Poisoning via URL-Parser Discrepancy ("Cache What/Where") + +This challenge demonstrates a cache-vs-origin path-parsing mismatch: the shared cache normalizes `../` traversal segments before computing its cache key, while the origin routes requests on the raw, un-normalized path (matching the `/static/` prefix first). An attacker can request a path that the origin serves as static content but that the cache normalizes down to a completely different, high-value key such as `/account` — storing attacker-influenced content under it for every subsequent caller. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /static/` — served by the origin as static-bundle content +- `GET /account` — returns sensitive account data (balance, CSRF token) + +## Exploiting it + +```bash +# the cache normalizes this to "/account" and stores the static response under that key +curl --path-as-is "http://localhost:8080/static/main.js/../../account" + +# any subsequent caller of "/account" now receives the poisoned, attacker-influenced response +curl "http://localhost:8080/account" +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the cache key is computed from the normalized path, colliding with the origin's raw-path routing +go run main.go serve --vulnerable=true + +# fixed: the cache key is computed from the raw, un-normalized path, matching how the origin actually routes it +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-poisoning-key-collision/go.mod b/challenges/cache-poisoning-key-collision/go.mod new file mode 100644 index 0000000..4bf7d1c --- /dev/null +++ b/challenges/cache-poisoning-key-collision/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-poisoning-key-collision + +go 1.26 + +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/cache-poisoning-key-collision/go.sum b/challenges/cache-poisoning-key-collision/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-poisoning-key-collision/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-poisoning-key-collision/main.go b/challenges/cache-poisoning-key-collision/main.go new file mode 100644 index 0000000..a8416b3 --- /dev/null +++ b/challenges/cache-poisoning-key-collision/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-poisoning-key-collision/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-poisoning-key-collision/serve/server.go b/challenges/cache-poisoning-key-collision/serve/server.go new file mode 100644 index 0000000..f3b936e --- /dev/null +++ b/challenges/cache-poisoning-key-collision/serve/server.go @@ -0,0 +1,110 @@ +package serve + +import ( + "log" + "net/http" + "strings" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +// cacheKeyVulnerable mimics a CDN that normalizes path traversal segments +// BEFORE computing the cache key, while the origin below resolves the +// request by its own routing rules on the raw, un-normalized path - a +// discrepancy that lets a request for one path be stored under a +// completely different key. +// +// e.g. "/static/main.js/../../account" is normalized by the cache to +// "/account" (its cache key), but the origin's router matches the raw +// "/static/" prefix first and serves static-bundle content, so an +// attacker-chosen response ends up cached under the high-value "/account" +// key - a "cache what/where" primitive. +// +// Doyhenard, "Gotta cache 'em all" (Black Hat USA 2024). +func cacheKeyVulnerable(rawPath string) string { + return normalizePath(rawPath) +} + +// cacheKeyFixed keys on the raw, un-normalized path, matching how the +// origin actually routes it - no discrepancy, no collision. +func cacheKeyFixed(rawPath string) string { + return rawPath +} + +func normalizePath(path string) string { + segments := strings.Split(path, "/") + var stack []string + for _, s := range segments { + switch s { + case "", ".": + continue + case "..": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + default: + stack = append(stack, s) + } + } + return "/" + strings.Join(stack, "/") +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // registered directly as the server's handler (not via http.ServeMux) + // so the raw, un-normalized request path reaches the handler exactly as + // sent, the same way a real reverse proxy would forward it upstream + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath := r.URL.Path + + var key string + if vulnerable { + key = cacheKeyVulnerable(rawPath) + } else { + key = cacheKeyFixed(rawPath) + } + + c.mu.Lock() + body, hit := c.store[key] + c.mu.Unlock() + + w.Header().Set("Content-Type", "text/plain") + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(body)) + return + } + + // the origin routes on the raw path prefix, exactly like a real + // reverse proxy / app router would, with no path normalization + normalized := normalizePath(rawPath) + switch { + case strings.HasPrefix(rawPath, "/static/"): + body = "// static bundle contents (attacker-influenced filename: " + rawPath + ")" + case normalized == "/account": + body = `{"balance": 1000, "csrf_token": "super-secret-csrf-token"}` + default: + w.WriteHeader(http.StatusNotFound) + body = "not found" + } + + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, handler)) +} diff --git a/challenges/cache-poisoning-parameter-cloaking/.gitignore b/challenges/cache-poisoning-parameter-cloaking/.gitignore new file mode 100644 index 0000000..f0126b2 --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/.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 + +cache-poisoning-parameter-cloaking diff --git a/challenges/cache-poisoning-parameter-cloaking/Dockerfile b/challenges/cache-poisoning-parameter-cloaking/Dockerfile new file mode 100644 index 0000000..4df7fb1 --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-poisoning-parameter-cloaking/ ./challenges/cache-poisoning-parameter-cloaking/ + +WORKDIR /app/challenges/cache-poisoning-parameter-cloaking +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-poisoning-parameter-cloaking . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-poisoning-parameter-cloaking /usr/bin/cache-poisoning-parameter-cloaking + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-poisoning-parameter-cloaking"] +CMD ["serve"] diff --git a/challenges/cache-poisoning-parameter-cloaking/README.md b/challenges/cache-poisoning-parameter-cloaking/README.md new file mode 100644 index 0000000..bf49a2a --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/README.md @@ -0,0 +1,41 @@ +# Cache Poisoning via Parameter Cloaking + +This challenge demonstrates parameter cloaking (a form of web cache entanglement): the shared cache in front of `/greet` keys on the query string up to the first `;`, while the origin application parses `;` as an additional parameter separator (as some frameworks, e.g. Rails, historically do). An attacker can "cloak" a payload behind a semicolon so it reaches the origin but is invisible to the cache key, poisoning the cached entry for every other caller with the same visible key. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /greet?id=1` — reflects the `name` query parameter into the response + +## Exploiting it + +```bash +# the cache keys on "id=1" only; the origin still reads "name" after the ";" +curl "http://localhost:8080/greet?id=1;name=" + +# any subsequent caller asking for id=1 gets the poisoned, cloaked response +curl "http://localhost:8080/greet?id=1" +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the cache key stops at the first ";", but the origin still parses parameters after it +go run main.go serve --vulnerable=true + +# fixed: the cache key includes the full, unmodified query string, and the origin never treats ";" as a parameter separator +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-poisoning-parameter-cloaking/go.mod b/challenges/cache-poisoning-parameter-cloaking/go.mod new file mode 100644 index 0000000..1c177e4 --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-poisoning-parameter-cloaking + +go 1.26 + +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/cache-poisoning-parameter-cloaking/go.sum b/challenges/cache-poisoning-parameter-cloaking/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-poisoning-parameter-cloaking/main.go b/challenges/cache-poisoning-parameter-cloaking/main.go new file mode 100644 index 0000000..bbab711 --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-poisoning-parameter-cloaking/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-poisoning-parameter-cloaking/serve/server.go b/challenges/cache-poisoning-parameter-cloaking/serve/server.go new file mode 100644 index 0000000..8b69189 --- /dev/null +++ b/challenges/cache-poisoning-parameter-cloaking/serve/server.go @@ -0,0 +1,95 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "strings" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]string +} + +func newCache() *cache { + return &cache{store: make(map[string]string)} +} + +// parseExcludedVulnerable mimics a shared cache that keys on the "id" query +// parameter but treats everything after a ";" as opaque path/matrix +// parameters (Rails-style), so it never looks past the ";" when building the +// cache key. +func cacheKeyVulnerable(r *http.Request) string { + raw := r.URL.RawQuery + if idx := strings.Index(raw, ";"); idx != -1 { + raw = raw[:idx] + } + return r.URL.Path + "?" + raw +} + +// cacheKeyFixed keys on the full, decoded query string so a value smuggled +// in after a ";" is still part of the key. +func cacheKeyFixed(r *http.Request) string { + return r.URL.Path + "?" + r.URL.RawQuery +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // /greet reflects the "name" query parameter into the response. The + // application itself (unlike the cache) parses ";" as an additional + // parameter separator, so "?id=1;name=attacker" is read by the app as + // name=attacker while the cache only sees "id=1" as the key - letting an + // attacker "cloak" an unkeyed payload behind a keyed-looking parameter + // and poison the shared cache entry for id=1. + // + // Kettle, "Web Cache Entanglement" (Black Hat USA 2020). + http.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) { + var key string + if vulnerable { + key = cacheKeyVulnerable(r) + } else { + key = cacheKeyFixed(r) + } + + c.mu.Lock() + body, hit := c.store[key] + c.mu.Unlock() + + w.Header().Set("Content-Type", "text/html") + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(body)) + return + } + + name := "world" + if vulnerable { + // the application splits on ";" the same way the origin's + // framework would, so a value cloaked after the cache's + // delimiter is still honored + raw := r.URL.RawQuery + for _, part := range strings.Split(raw, ";") { + if strings.HasPrefix(part, "name=") { + name = strings.TrimPrefix(part, "name=") + } + } + } else if v := r.URL.Query().Get("name"); v != "" { + name = v + } + + body = fmt.Sprintf("Hello, %s!", name) + + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-poisoning-unkeyed-header/.gitignore b/challenges/cache-poisoning-unkeyed-header/.gitignore new file mode 100644 index 0000000..5b9e961 --- /dev/null +++ b/challenges/cache-poisoning-unkeyed-header/.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 + +cache-poisoning-unkeyed-header diff --git a/challenges/cache-poisoning-unkeyed-header/Dockerfile b/challenges/cache-poisoning-unkeyed-header/Dockerfile new file mode 100644 index 0000000..72d8ac8 --- /dev/null +++ b/challenges/cache-poisoning-unkeyed-header/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-poisoning-unkeyed-header/ ./challenges/cache-poisoning-unkeyed-header/ + +WORKDIR /app/challenges/cache-poisoning-unkeyed-header +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-poisoning-unkeyed-header . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-poisoning-unkeyed-header /usr/bin/cache-poisoning-unkeyed-header + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-poisoning-unkeyed-header"] +CMD ["serve"] diff --git a/challenges/cache-poisoning-unkeyed-header/README.md b/challenges/cache-poisoning-unkeyed-header/README.md new file mode 100644 index 0000000..e08a304 --- /dev/null +++ b/challenges/cache-poisoning-unkeyed-header/README.md @@ -0,0 +1,41 @@ +# Web Cache Poisoning via Unkeyed Header + +This challenge demonstrates classic web cache poisoning: the origin reflects the caller-supplied `X-Forwarded-Host` header into an absolute link and a ``, host, host) + + if vulnerable { + c.mu.Lock() + c.store[key] = body + c.mu.Unlock() + } + + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-sensitive-data-exposure/.gitignore b/challenges/cache-sensitive-data-exposure/.gitignore new file mode 100644 index 0000000..01783e2 --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/.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 + +cache-sensitive-data-exposure diff --git a/challenges/cache-sensitive-data-exposure/Dockerfile b/challenges/cache-sensitive-data-exposure/Dockerfile new file mode 100644 index 0000000..f6aead8 --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-sensitive-data-exposure/ ./challenges/cache-sensitive-data-exposure/ + +WORKDIR /app/challenges/cache-sensitive-data-exposure +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-sensitive-data-exposure . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-sensitive-data-exposure /usr/bin/cache-sensitive-data-exposure + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-sensitive-data-exposure"] +CMD ["serve"] diff --git a/challenges/cache-sensitive-data-exposure/README.md b/challenges/cache-sensitive-data-exposure/README.md new file mode 100644 index 0000000..1334f05 --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/README.md @@ -0,0 +1,41 @@ +# Cache-Based Sensitive Information Disclosure + +This challenge demonstrates a broad cache-based information disclosure finding: `/dashboard` issues a fresh session cookie and CSRF token per request and returns personalized JSON, but never sends `Cache-Control: private, no-store`. A shared cache in front of it stores the first caller's `Set-Cookie`, CSRF token, and personal data, and replays all of it — session included — to every subsequent caller of the same URL. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /dashboard` — returns a personalized JSON payload with a session cookie and CSRF token + +## Exploiting it + +```bash +# victim's session cookie and CSRF token get cached +curl -s -D - http://localhost:8080/dashboard -o /dev/null + +# attacker requests the same URL and receives the victim's session cookie and CSRF token +curl -s -D - http://localhost:8080/dashboard -o /dev/null +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: no Cache-Control directive is sent, so the shared cache stores and replays session data +go run main.go serve --vulnerable=true + +# fixed: Cache-Control: private, no-store is sent and the response is never cached +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-sensitive-data-exposure/go.mod b/challenges/cache-sensitive-data-exposure/go.mod new file mode 100644 index 0000000..3500c59 --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-sensitive-data-exposure + +go 1.26 + +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/cache-sensitive-data-exposure/go.sum b/challenges/cache-sensitive-data-exposure/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-sensitive-data-exposure/main.go b/challenges/cache-sensitive-data-exposure/main.go new file mode 100644 index 0000000..4943a68 --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-sensitive-data-exposure/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-sensitive-data-exposure/serve/server.go b/challenges/cache-sensitive-data-exposure/serve/server.go new file mode 100644 index 0000000..91b410f --- /dev/null +++ b/challenges/cache-sensitive-data-exposure/serve/server.go @@ -0,0 +1,90 @@ +package serve + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "net/http" + "sync" +) + +type cache struct { + mu sync.Mutex + store map[string]cachedResponse +} + +type cachedResponse struct { + body string + setCookie string + cacheable bool + csrfToken string + statusCode int +} + +func newCache() *cache { + return &cache{store: make(map[string]cachedResponse)} +} + +func newToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // /dashboard issues a fresh session cookie and CSRF token per request + // (as a login/dashboard page typically does), and returns a + // personalized JSON payload. The vulnerable origin doesn't mark this + // response as private, so a shared cache in front of it stores and + // replays the first caller's session cookie, CSRF token and personal + // data to everyone else who requests the same URL. + http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) { + key := r.URL.Path + + c.mu.Lock() + entry, hit := c.store[key] + c.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if hit && entry.cacheable { + w.Header().Set("Set-Cookie", entry.setCookie) + w.Header().Set("X-CSRF-Token", entry.csrfToken) + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(entry.body)) + return + } + + sessionID := newToken() + csrfToken := newToken() + body := fmt.Sprintf(`{"user": "user-%s", "balance": 4200, "csrf_token": %q}`, sessionID[:6], csrfToken) + setCookie := "session=" + sessionID + "; HttpOnly" + + entry = cachedResponse{ + body: body, + setCookie: setCookie, + csrfToken: csrfToken, + cacheable: vulnerable, // vulnerable: no Cache-Control: private, no-store is sent, so a shared cache stores it anyway + } + + if !vulnerable { + w.Header().Set("Cache-Control", "private, no-store") + } + + if vulnerable { + c.mu.Lock() + c.store[key] = entry + c.mu.Unlock() + } + + w.Header().Set("Set-Cookie", setCookie) + w.Header().Set("X-CSRF-Token", csrfToken) + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-stale-directive-bypass/.gitignore b/challenges/cache-stale-directive-bypass/.gitignore new file mode 100644 index 0000000..35c5013 --- /dev/null +++ b/challenges/cache-stale-directive-bypass/.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 + +cache-stale-directive-bypass diff --git a/challenges/cache-stale-directive-bypass/Dockerfile b/challenges/cache-stale-directive-bypass/Dockerfile new file mode 100644 index 0000000..fee4b3e --- /dev/null +++ b/challenges/cache-stale-directive-bypass/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-stale-directive-bypass/ ./challenges/cache-stale-directive-bypass/ + +WORKDIR /app/challenges/cache-stale-directive-bypass +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-stale-directive-bypass . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-stale-directive-bypass /usr/bin/cache-stale-directive-bypass + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-stale-directive-bypass"] +CMD ["serve"] diff --git a/challenges/cache-stale-directive-bypass/README.md b/challenges/cache-stale-directive-bypass/README.md new file mode 100644 index 0000000..cf93253 --- /dev/null +++ b/challenges/cache-stale-directive-bypass/README.md @@ -0,0 +1,48 @@ +# stale-while-revalidate / stale-if-error Not Honored + +This challenge demonstrates a cache that advertises `stale-while-revalidate` and `stale-if-error` in its `Cache-Control` header, but never actually honors them: once `max-age` expires, it always synchronously re-fetches from the origin, defeating both the performance benefit (serving stale instantly while revalidating in the background) and the resilience benefit (serving stale instead of surfacing an origin outage). + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /` — returns a timestamped body with `Cache-Control: max-age=2, stale-while-revalidate=5, stale-if-error=5` +- `GET /toggle-outage` — flips the simulated origin between up and down, to exercise `stale-if-error` + +## Exploiting it / confirming the finding + +```bash +# populate the cache +curl -s http://localhost:8080/ + +# wait past max-age (2s) but within stale-while-revalidate (5s) +sleep 3 +curl -s -D - http://localhost:8080/ -o /dev/null # expect X-Cache: STALE served instantly (fixed) vs MISS (vulnerable) + +# simulate an origin outage and confirm stale-if-error behavior +curl -s http://localhost:8080/toggle-outage +sleep 3 +curl -s -D - http://localhost:8080/ -o /dev/null # expect a stale 200 (fixed) vs a 500 (vulnerable) +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: stale-while-revalidate and stale-if-error are advertised but never honored +go run main.go serve --vulnerable=true + +# fixed: stale content is served within the declared windows while revalidating in the background / masking origin errors +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-stale-directive-bypass/go.mod b/challenges/cache-stale-directive-bypass/go.mod new file mode 100644 index 0000000..57658b1 --- /dev/null +++ b/challenges/cache-stale-directive-bypass/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-stale-directive-bypass + +go 1.26 + +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/cache-stale-directive-bypass/go.sum b/challenges/cache-stale-directive-bypass/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-stale-directive-bypass/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-stale-directive-bypass/main.go b/challenges/cache-stale-directive-bypass/main.go new file mode 100644 index 0000000..ee9796e --- /dev/null +++ b/challenges/cache-stale-directive-bypass/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-stale-directive-bypass/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-stale-directive-bypass/serve/server.go b/challenges/cache-stale-directive-bypass/serve/server.go new file mode 100644 index 0000000..722ae34 --- /dev/null +++ b/challenges/cache-stale-directive-bypass/serve/server.go @@ -0,0 +1,154 @@ +package serve + +import ( + "log" + "net/http" + "strconv" + "sync" + "time" +) + +const ( + maxAge = 2 * time.Second + staleWhileRevalidate = 5 * time.Second + staleIfError = 5 * time.Second +) + +type cacheEntry struct { + body string + storedAt time.Time + revalDone bool +} + +type cache struct { + mu sync.Mutex + entry *cacheEntry +} + +// failNow controls whether the origin is currently simulating an outage, +// toggled via /toggle-outage so a client can exercise stale-if-error. +type originState struct { + mu sync.Mutex + down bool + gen int +} + +func RunServer(port string, vulnerable bool) { + c := &cache{} + origin := &originState{} + + fetchFromOrigin := func() (string, int, bool) { + origin.mu.Lock() + down := origin.down + origin.gen++ + gen := origin.gen + origin.mu.Unlock() + if down { + return "", http.StatusInternalServerError, false + } + return time.Now().Format(time.RFC3339Nano) + " gen=" + strconv.Itoa(gen), http.StatusOK, true + } + + http.HandleFunc("/toggle-outage", func(w http.ResponseWriter, r *http.Request) { + origin.mu.Lock() + origin.down = !origin.down + down := origin.down + origin.mu.Unlock() + if down { + w.Write([]byte("origin is now DOWN")) + } else { + w.Write([]byte("origin is now UP")) + } + }) + + // / declares stale-while-revalidate and stale-if-error, and reports + // Cache-Control on every response so a scanner can confirm the + // directives are actually honored by the fronting cache (this handler + // itself plays the role of the shared cache + origin combined). + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Cache-Control", "max-age=2, stale-while-revalidate=5, stale-if-error=5") + + c.mu.Lock() + entry := c.entry + c.mu.Unlock() + + now := time.Now() + if entry != nil { + age := now.Sub(entry.storedAt) + fresh := age <= maxAge + + if fresh { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(entry.body)) + return + } + + if vulnerable { + // vulnerable: the cache never serves stale content and + // never honors stale-if-error - it always goes back to + // the origin once max-age has elapsed, defeating both + // directives even though it advertises them + body, status, ok := fetchFromOrigin() + if !ok { + http.Error(w, "origin unavailable", status) + return + } + c.mu.Lock() + c.entry = &cacheEntry{body: body, storedAt: now} + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + + // fixed: within the stale-while-revalidate window, serve the + // stale entry immediately and refresh it in the background + if age <= maxAge+staleWhileRevalidate { + w.Header().Set("X-Cache", "STALE") + w.Write([]byte(entry.body)) + go func() { + body, _, ok := fetchFromOrigin() + if ok { + c.mu.Lock() + c.entry = &cacheEntry{body: body, storedAt: time.Now()} + c.mu.Unlock() + } + }() + return + } + + // fixed: within the stale-if-error window, serve the stale + // entry if the origin errors out instead of surfacing the error + if age <= maxAge+staleIfError { + body, status, ok := fetchFromOrigin() + if !ok { + _ = status + w.Header().Set("X-Cache", "STALE") + w.Write([]byte(entry.body)) + return + } + c.mu.Lock() + c.entry = &cacheEntry{body: body, storedAt: now} + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + } + + body, status, ok := fetchFromOrigin() + if !ok { + http.Error(w, "origin unavailable", status) + return + } + c.mu.Lock() + c.entry = &cacheEntry{body: body, storedAt: now} + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/cache-vary-misconfiguration/.gitignore b/challenges/cache-vary-misconfiguration/.gitignore new file mode 100644 index 0000000..6fa99c3 --- /dev/null +++ b/challenges/cache-vary-misconfiguration/.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 + +cache-vary-misconfiguration diff --git a/challenges/cache-vary-misconfiguration/Dockerfile b/challenges/cache-vary-misconfiguration/Dockerfile new file mode 100644 index 0000000..918867a --- /dev/null +++ b/challenges/cache-vary-misconfiguration/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/cache-vary-misconfiguration/ ./challenges/cache-vary-misconfiguration/ + +WORKDIR /app/challenges/cache-vary-misconfiguration +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /cache-vary-misconfiguration . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /cache-vary-misconfiguration /usr/bin/cache-vary-misconfiguration + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["cache-vary-misconfiguration"] +CMD ["serve"] diff --git a/challenges/cache-vary-misconfiguration/README.md b/challenges/cache-vary-misconfiguration/README.md new file mode 100644 index 0000000..2fa5471 --- /dev/null +++ b/challenges/cache-vary-misconfiguration/README.md @@ -0,0 +1,41 @@ +# Missing Vary Header Cache Poisoning + +This challenge demonstrates a missing/incorrect `Vary` header: `/home` renders content that depends on the caller's session cookie, but the response omits `Vary: Cookie` and the shared cache keys purely on the path. The first cookie-bearing caller's personalized greeting is cached and served to every subsequent visitor, regardless of their own session. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /home` — greets the caller by name if a `session=` cookie is present + +## Exploiting it + +```bash +# a logged-in user's personalized response gets cached under the shared key +curl -s -H "Cookie: session=alice" http://localhost:8080/home + +# any subsequent visitor, even without a session, sees alice's greeting +curl -s http://localhost:8080/home +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: no Vary header is sent, and the cache key ignores the Cookie header entirely +go run main.go serve --vulnerable=true + +# fixed: Vary: Cookie is sent and the cache key includes the Cookie header +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +This challenge is intentionally vulnerable. Do not deploy it on a publicly accessible server, as this could expose you to attacks. + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/cache-vary-misconfiguration/go.mod b/challenges/cache-vary-misconfiguration/go.mod new file mode 100644 index 0000000..dca73bc --- /dev/null +++ b/challenges/cache-vary-misconfiguration/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/cache-vary-misconfiguration + +go 1.26 + +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/cache-vary-misconfiguration/go.sum b/challenges/cache-vary-misconfiguration/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/cache-vary-misconfiguration/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/cache-vary-misconfiguration/main.go b/challenges/cache-vary-misconfiguration/main.go new file mode 100644 index 0000000..dec7657 --- /dev/null +++ b/challenges/cache-vary-misconfiguration/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/cache-vary-misconfiguration/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/cache-vary-misconfiguration/serve/server.go b/challenges/cache-vary-misconfiguration/serve/server.go new file mode 100644 index 0000000..fb7b43c --- /dev/null +++ b/challenges/cache-vary-misconfiguration/serve/server.go @@ -0,0 +1,75 @@ +package serve + +import ( + "log" + "net/http" + "strings" + "sync" +) + +type cacheEntry struct { + body string +} + +type cache struct { + mu sync.Mutex + store map[string]cacheEntry +} + +func newCache() *cache { + return &cache{store: make(map[string]cacheEntry)} +} + +func RunServer(port string, vulnerable bool) { + c := newCache() + + // /home renders content whose language depends on the caller's session + // cookie, but the cache key (and the Vary declaration) do not account + // for it. The first cookie-bearing caller's personalized response gets + // stored under the shared, cookie-agnostic key and served to every + // other visitor regardless of their own session. + http.HandleFunc("/home", func(w http.ResponseWriter, r *http.Request) { + cookie := r.Header.Get("Cookie") + + key := r.URL.Path + if !vulnerable { + key += "|" + cookie + } + + c.mu.Lock() + entry, hit := c.store[key] + c.mu.Unlock() + + w.Header().Set("Content-Type", "text/html") + if vulnerable { + // Vary is absent even though the response demonstrably differs + // by Cookie - the precondition RFC 9111 requires callers (and + // caches) be told about via Vary + } else { + w.Header().Set("Vary", "Cookie") + } + + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(entry.body)) + return + } + + greeting := "Welcome, guest" + if strings.Contains(cookie, "session=") { + name := strings.TrimPrefix(cookie, "session=") + greeting = "Welcome back, " + name + } + body := "" + greeting + "" + + c.mu.Lock() + c.store[key] = cacheEntry{body: body} + c.mu.Unlock() + + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/go.work b/go.work index 58141cc..4578037 100644 --- a/go.work +++ b/go.work @@ -28,6 +28,24 @@ use ( ./challenges/jwt-weak-hmac-secret ./challenges/jwt-weak-rsa-key ./challenges/jwt-x5c-x5u-header-injection + ./challenges/cache-conditional-validators + ./challenges/cache-control-directive-validation + ./challenges/cache-cpdos-header-oversize + ./challenges/cache-cpdos-meta-character + ./challenges/cache-cpdos-method-override + ./challenges/cache-deception-delimiter + ./challenges/cache-deception-normalization + ./challenges/cache-deception-static-directory + ./challenges/cache-deception-static-extension + ./challenges/cache-freshness-heuristic + ./challenges/cache-key-explosion-dos + ./challenges/cache-poisoning-fat-get + ./challenges/cache-poisoning-key-collision + ./challenges/cache-poisoning-parameter-cloaking + ./challenges/cache-poisoning-unkeyed-header + ./challenges/cache-sensitive-data-exposure + ./challenges/cache-stale-directive-bypass + ./challenges/cache-vary-misconfiguration ./challenges/proxy-cache-deception ./challenges/proxy-client-ip-spoofing ./challenges/proxy-cors-misconfiguration diff --git a/go.work.sum b/go.work.sum index 3feca72..71f9a8f 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,9 +1,11 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=