From bf4b2041beabc72a2f540217f3f9b065cde6ea54 Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Sun, 13 Sep 2026 17:30:43 +0200 Subject: [PATCH] feat: add proxy related challenges --- README.md | 16 ++ challenges/nginx-alias-traversal/Dockerfile | 14 ++ challenges/nginx-alias-traversal/README.md | 35 +++++ .../conf/nginx.fixed.conf | 19 +++ .../conf/nginx.vulnerable.conf | 21 +++ .../docker-entrypoint.sh | 12 ++ .../html/public/index.html | 7 + .../html/secret/flag.txt | 1 + challenges/proxy-cache-deception/.gitignore | 14 ++ challenges/proxy-cache-deception/Dockerfile | 22 +++ challenges/proxy-cache-deception/README.md | 34 +++++ challenges/proxy-cache-deception/go.mod | 13 ++ challenges/proxy-cache-deception/go.sum | 11 ++ challenges/proxy-cache-deception/main.go | 10 ++ .../proxy-cache-deception/serve/server.go | 100 +++++++++++++ .../proxy-client-ip-spoofing/.gitignore | 14 ++ .../proxy-client-ip-spoofing/Dockerfile | 22 +++ challenges/proxy-client-ip-spoofing/README.md | 29 ++++ challenges/proxy-client-ip-spoofing/go.mod | 13 ++ challenges/proxy-client-ip-spoofing/go.sum | 11 ++ challenges/proxy-client-ip-spoofing/main.go | 10 ++ .../proxy-client-ip-spoofing/serve/server.go | 45 ++++++ .../proxy-config-static-analysis/Dockerfile | 12 ++ .../proxy-config-static-analysis/README.md | 37 +++++ .../configs/caddy/fixed.Caddyfile | 14 ++ .../configs/caddy/vulnerable.Caddyfile | 18 +++ .../configs/envoy/fixed.yaml | 32 ++++ .../configs/envoy/vulnerable.yaml | 35 +++++ .../configs/nginx/fixed.conf | 29 ++++ .../configs/nginx/vulnerable.conf | 30 ++++ .../configs/traefik/fixed.yml | 26 ++++ .../configs/traefik/vulnerable.yml | 26 ++++ .../proxy-cors-misconfiguration/.gitignore | 14 ++ .../proxy-cors-misconfiguration/Dockerfile | 22 +++ .../proxy-cors-misconfiguration/README.md | 34 +++++ challenges/proxy-cors-misconfiguration/go.mod | 13 ++ challenges/proxy-cors-misconfiguration/go.sum | 11 ++ .../proxy-cors-misconfiguration/main.go | 10 ++ .../serve/server.go | 49 +++++++ .../proxy-host-header-injection/.gitignore | 14 ++ .../proxy-host-header-injection/Dockerfile | 22 +++ .../proxy-host-header-injection/README.md | 34 +++++ challenges/proxy-host-header-injection/go.mod | 13 ++ challenges/proxy-host-header-injection/go.sum | 11 ++ .../proxy-host-header-injection/main.go | 10 ++ .../serve/server.go | 40 +++++ .../proxy-http2-authority-spoofing/.gitignore | 14 ++ .../proxy-http2-authority-spoofing/Dockerfile | 22 +++ .../proxy-http2-authority-spoofing/README.md | 35 +++++ .../proxy-http2-authority-spoofing/go.mod | 18 +++ .../proxy-http2-authority-spoofing/go.sum | 15 ++ .../proxy-http2-authority-spoofing/main.go | 10 ++ .../serve/server.go | 48 ++++++ challenges/proxy-info-disclosure/.gitignore | 14 ++ challenges/proxy-info-disclosure/Dockerfile | 22 +++ challenges/proxy-info-disclosure/README.md | 35 +++++ challenges/proxy-info-disclosure/go.mod | 13 ++ challenges/proxy-info-disclosure/go.sum | 11 ++ challenges/proxy-info-disclosure/main.go | 10 ++ .../proxy-info-disclosure/serve/server.go | 59 ++++++++ challenges/proxy-open-ssrf/.gitignore | 14 ++ challenges/proxy-open-ssrf/Dockerfile | 22 +++ challenges/proxy-open-ssrf/README.md | 34 +++++ challenges/proxy-open-ssrf/go.mod | 13 ++ challenges/proxy-open-ssrf/go.sum | 11 ++ challenges/proxy-open-ssrf/main.go | 10 ++ challenges/proxy-open-ssrf/serve/server.go | 81 +++++++++++ challenges/proxy-path-bypass/.gitignore | 14 ++ challenges/proxy-path-bypass/Dockerfile | 22 +++ challenges/proxy-path-bypass/README.md | 29 ++++ challenges/proxy-path-bypass/go.mod | 13 ++ challenges/proxy-path-bypass/go.sum | 11 ++ challenges/proxy-path-bypass/main.go | 10 ++ challenges/proxy-path-bypass/serve/server.go | 53 +++++++ challenges/proxy-rate-limit-bypass/.gitignore | 14 ++ challenges/proxy-rate-limit-bypass/Dockerfile | 22 +++ challenges/proxy-rate-limit-bypass/README.md | 33 +++++ challenges/proxy-rate-limit-bypass/go.mod | 13 ++ challenges/proxy-rate-limit-bypass/go.sum | 11 ++ challenges/proxy-rate-limit-bypass/main.go | 10 ++ .../proxy-rate-limit-bypass/serve/server.go | 79 ++++++++++ challenges/proxy-request-smuggling/.gitignore | 14 ++ challenges/proxy-request-smuggling/Dockerfile | 22 +++ challenges/proxy-request-smuggling/README.md | 36 +++++ challenges/proxy-request-smuggling/go.mod | 13 ++ challenges/proxy-request-smuggling/go.sum | 11 ++ challenges/proxy-request-smuggling/main.go | 10 ++ .../proxy-request-smuggling/serve/server.go | 137 ++++++++++++++++++ challenges/proxy-security-headers/.gitignore | 14 ++ challenges/proxy-security-headers/Dockerfile | 22 +++ challenges/proxy-security-headers/README.md | 26 ++++ challenges/proxy-security-headers/go.mod | 13 ++ challenges/proxy-security-headers/go.sum | 11 ++ challenges/proxy-security-headers/main.go | 10 ++ .../proxy-security-headers/serve/server.go | 28 ++++ .../proxy-template-injection/.gitignore | 14 ++ .../proxy-template-injection/Dockerfile | 22 +++ challenges/proxy-template-injection/README.md | 33 +++++ challenges/proxy-template-injection/go.mod | 13 ++ challenges/proxy-template-injection/go.sum | 11 ++ challenges/proxy-template-injection/main.go | 10 ++ .../proxy-template-injection/serve/server.go | 61 ++++++++ .../proxy-tls-misconfiguration/.gitignore | 14 ++ .../proxy-tls-misconfiguration/Dockerfile | 22 +++ .../proxy-tls-misconfiguration/README.md | 35 +++++ challenges/proxy-tls-misconfiguration/go.mod | 13 ++ challenges/proxy-tls-misconfiguration/go.sum | 11 ++ challenges/proxy-tls-misconfiguration/main.go | 10 ++ .../serve/server.go | 90 ++++++++++++ challenges/proxy-waf-bypass/.gitignore | 14 ++ challenges/proxy-waf-bypass/Dockerfile | 22 +++ challenges/proxy-waf-bypass/README.md | 33 +++++ challenges/proxy-waf-bypass/go.mod | 13 ++ challenges/proxy-waf-bypass/go.sum | 11 ++ challenges/proxy-waf-bypass/main.go | 10 ++ challenges/proxy-waf-bypass/serve/server.go | 57 ++++++++ go.work | 16 +- go.work.sum | 5 + 118 files changed, 2780 insertions(+), 1 deletion(-) create mode 100644 challenges/nginx-alias-traversal/Dockerfile create mode 100644 challenges/nginx-alias-traversal/README.md create mode 100644 challenges/nginx-alias-traversal/conf/nginx.fixed.conf create mode 100644 challenges/nginx-alias-traversal/conf/nginx.vulnerable.conf create mode 100644 challenges/nginx-alias-traversal/docker-entrypoint.sh create mode 100644 challenges/nginx-alias-traversal/html/public/index.html create mode 100644 challenges/nginx-alias-traversal/html/secret/flag.txt create mode 100644 challenges/proxy-cache-deception/.gitignore create mode 100644 challenges/proxy-cache-deception/Dockerfile create mode 100644 challenges/proxy-cache-deception/README.md create mode 100644 challenges/proxy-cache-deception/go.mod create mode 100644 challenges/proxy-cache-deception/go.sum create mode 100644 challenges/proxy-cache-deception/main.go create mode 100644 challenges/proxy-cache-deception/serve/server.go create mode 100644 challenges/proxy-client-ip-spoofing/.gitignore create mode 100644 challenges/proxy-client-ip-spoofing/Dockerfile create mode 100644 challenges/proxy-client-ip-spoofing/README.md create mode 100644 challenges/proxy-client-ip-spoofing/go.mod create mode 100644 challenges/proxy-client-ip-spoofing/go.sum create mode 100644 challenges/proxy-client-ip-spoofing/main.go create mode 100644 challenges/proxy-client-ip-spoofing/serve/server.go create mode 100644 challenges/proxy-config-static-analysis/Dockerfile create mode 100644 challenges/proxy-config-static-analysis/README.md create mode 100644 challenges/proxy-config-static-analysis/configs/caddy/fixed.Caddyfile create mode 100644 challenges/proxy-config-static-analysis/configs/caddy/vulnerable.Caddyfile create mode 100644 challenges/proxy-config-static-analysis/configs/envoy/fixed.yaml create mode 100644 challenges/proxy-config-static-analysis/configs/envoy/vulnerable.yaml create mode 100644 challenges/proxy-config-static-analysis/configs/nginx/fixed.conf create mode 100644 challenges/proxy-config-static-analysis/configs/nginx/vulnerable.conf create mode 100644 challenges/proxy-config-static-analysis/configs/traefik/fixed.yml create mode 100644 challenges/proxy-config-static-analysis/configs/traefik/vulnerable.yml create mode 100644 challenges/proxy-cors-misconfiguration/.gitignore create mode 100644 challenges/proxy-cors-misconfiguration/Dockerfile create mode 100644 challenges/proxy-cors-misconfiguration/README.md create mode 100644 challenges/proxy-cors-misconfiguration/go.mod create mode 100644 challenges/proxy-cors-misconfiguration/go.sum create mode 100644 challenges/proxy-cors-misconfiguration/main.go create mode 100644 challenges/proxy-cors-misconfiguration/serve/server.go create mode 100644 challenges/proxy-host-header-injection/.gitignore create mode 100644 challenges/proxy-host-header-injection/Dockerfile create mode 100644 challenges/proxy-host-header-injection/README.md create mode 100644 challenges/proxy-host-header-injection/go.mod create mode 100644 challenges/proxy-host-header-injection/go.sum create mode 100644 challenges/proxy-host-header-injection/main.go create mode 100644 challenges/proxy-host-header-injection/serve/server.go create mode 100644 challenges/proxy-http2-authority-spoofing/.gitignore create mode 100644 challenges/proxy-http2-authority-spoofing/Dockerfile create mode 100644 challenges/proxy-http2-authority-spoofing/README.md create mode 100644 challenges/proxy-http2-authority-spoofing/go.mod create mode 100644 challenges/proxy-http2-authority-spoofing/go.sum create mode 100644 challenges/proxy-http2-authority-spoofing/main.go create mode 100644 challenges/proxy-http2-authority-spoofing/serve/server.go create mode 100644 challenges/proxy-info-disclosure/.gitignore create mode 100644 challenges/proxy-info-disclosure/Dockerfile create mode 100644 challenges/proxy-info-disclosure/README.md create mode 100644 challenges/proxy-info-disclosure/go.mod create mode 100644 challenges/proxy-info-disclosure/go.sum create mode 100644 challenges/proxy-info-disclosure/main.go create mode 100644 challenges/proxy-info-disclosure/serve/server.go create mode 100644 challenges/proxy-open-ssrf/.gitignore create mode 100644 challenges/proxy-open-ssrf/Dockerfile create mode 100644 challenges/proxy-open-ssrf/README.md create mode 100644 challenges/proxy-open-ssrf/go.mod create mode 100644 challenges/proxy-open-ssrf/go.sum create mode 100644 challenges/proxy-open-ssrf/main.go create mode 100644 challenges/proxy-open-ssrf/serve/server.go create mode 100644 challenges/proxy-path-bypass/.gitignore create mode 100644 challenges/proxy-path-bypass/Dockerfile create mode 100644 challenges/proxy-path-bypass/README.md create mode 100644 challenges/proxy-path-bypass/go.mod create mode 100644 challenges/proxy-path-bypass/go.sum create mode 100644 challenges/proxy-path-bypass/main.go create mode 100644 challenges/proxy-path-bypass/serve/server.go create mode 100644 challenges/proxy-rate-limit-bypass/.gitignore create mode 100644 challenges/proxy-rate-limit-bypass/Dockerfile create mode 100644 challenges/proxy-rate-limit-bypass/README.md create mode 100644 challenges/proxy-rate-limit-bypass/go.mod create mode 100644 challenges/proxy-rate-limit-bypass/go.sum create mode 100644 challenges/proxy-rate-limit-bypass/main.go create mode 100644 challenges/proxy-rate-limit-bypass/serve/server.go create mode 100644 challenges/proxy-request-smuggling/.gitignore create mode 100644 challenges/proxy-request-smuggling/Dockerfile create mode 100644 challenges/proxy-request-smuggling/README.md create mode 100644 challenges/proxy-request-smuggling/go.mod create mode 100644 challenges/proxy-request-smuggling/go.sum create mode 100644 challenges/proxy-request-smuggling/main.go create mode 100644 challenges/proxy-request-smuggling/serve/server.go create mode 100644 challenges/proxy-security-headers/.gitignore create mode 100644 challenges/proxy-security-headers/Dockerfile create mode 100644 challenges/proxy-security-headers/README.md create mode 100644 challenges/proxy-security-headers/go.mod create mode 100644 challenges/proxy-security-headers/go.sum create mode 100644 challenges/proxy-security-headers/main.go create mode 100644 challenges/proxy-security-headers/serve/server.go create mode 100644 challenges/proxy-template-injection/.gitignore create mode 100644 challenges/proxy-template-injection/Dockerfile create mode 100644 challenges/proxy-template-injection/README.md create mode 100644 challenges/proxy-template-injection/go.mod create mode 100644 challenges/proxy-template-injection/go.sum create mode 100644 challenges/proxy-template-injection/main.go create mode 100644 challenges/proxy-template-injection/serve/server.go create mode 100644 challenges/proxy-tls-misconfiguration/.gitignore create mode 100644 challenges/proxy-tls-misconfiguration/Dockerfile create mode 100644 challenges/proxy-tls-misconfiguration/README.md create mode 100644 challenges/proxy-tls-misconfiguration/go.mod create mode 100644 challenges/proxy-tls-misconfiguration/go.sum create mode 100644 challenges/proxy-tls-misconfiguration/main.go create mode 100644 challenges/proxy-tls-misconfiguration/serve/server.go create mode 100644 challenges/proxy-waf-bypass/.gitignore create mode 100644 challenges/proxy-waf-bypass/Dockerfile create mode 100644 challenges/proxy-waf-bypass/README.md create mode 100644 challenges/proxy-waf-bypass/go.mod create mode 100644 challenges/proxy-waf-bypass/go.sum create mode 100644 challenges/proxy-waf-bypass/main.go create mode 100644 challenges/proxy-waf-bypass/serve/server.go diff --git a/README.md b/README.md index 16a43ed..c1375f7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,22 @@ The following challenges are available in this repository: - [JWT Weak HMAC Secret](challenges/jwt-weak-hmac-secret) - [JWT Weak RSA Key](challenges/jwt-weak-rsa-key) - [JWT x5c/x5u Header Injection](challenges/jwt-x5c-x5u-header-injection) +- [Nginx Alias Traversal](challenges/nginx-alias-traversal) +- [Proxy Cache Deception](challenges/proxy-cache-deception) +- [Proxy Client-IP Spoofing](challenges/proxy-client-ip-spoofing) +- [Proxy Config Static Analysis](challenges/proxy-config-static-analysis) +- [Proxy CORS Misconfiguration](challenges/proxy-cors-misconfiguration) +- [Proxy Host Header Injection](challenges/proxy-host-header-injection) +- [Proxy HTTP/2 Authority Spoofing](challenges/proxy-http2-authority-spoofing) +- [Proxy Information Disclosure](challenges/proxy-info-disclosure) +- [Proxy Open SSRF](challenges/proxy-open-ssrf) +- [Proxy Path Bypass](challenges/proxy-path-bypass) +- [Proxy Rate Limit Bypass](challenges/proxy-rate-limit-bypass) +- [Proxy Request Smuggling](challenges/proxy-request-smuggling) +- [Proxy Security Headers](challenges/proxy-security-headers) +- [Proxy Template Injection](challenges/proxy-template-injection) +- [Proxy TLS Misconfiguration](challenges/proxy-tls-misconfiguration) +- [Proxy WAF Bypass](challenges/proxy-waf-bypass) - [Strong API Key](challenges/strong-api-key) - [Strong HTTP Basic](challenges/strong-http-basic) diff --git a/challenges/nginx-alias-traversal/Dockerfile b/challenges/nginx-alias-traversal/Dockerfile new file mode 100644 index 0000000..f732484 --- /dev/null +++ b/challenges/nginx-alias-traversal/Dockerfile @@ -0,0 +1,14 @@ +FROM nginx:1.27-alpine + +COPY challenges/nginx-alias-traversal/conf/nginx.vulnerable.conf /etc/nginx/conf.d/nginx.vulnerable.conf +COPY challenges/nginx-alias-traversal/conf/nginx.fixed.conf /etc/nginx/conf.d/nginx.fixed.conf +COPY challenges/nginx-alias-traversal/html/ /usr/share/nginx/html/ +COPY challenges/nginx-alias-traversal/docker-entrypoint.sh /docker-entrypoint.sh + +RUN chmod +x /docker-entrypoint.sh + +EXPOSE 8080 + +ENV VULNERABLE=true + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/challenges/nginx-alias-traversal/README.md b/challenges/nginx-alias-traversal/README.md new file mode 100644 index 0000000..243066c --- /dev/null +++ b/challenges/nginx-alias-traversal/README.md @@ -0,0 +1,35 @@ +# Nginx-Specific Misconfigurations + +This challenge demonstrates the classic Nginx off-by-slash alias traversal: a `location` directive without a trailing slash combined with an `alias` directive lets a path like `/files../secret/flag.txt` escape the intended alias root, because Nginx strips only the literal `/files` prefix and appends the remainder verbatim to the alias path. + +## How to run it + +```bash +docker build -f Dockerfile -t nginx-alias-traversal ../.. +docker run --rm -p 8080:8080 nginx-alias-traversal +``` + +## Modes + +Unlike the Go-based challenges in this repository, this challenge is a real Nginx server, so the mode is toggled with the `VULNERABLE` environment variable at container startup (defaults to `true`): + +```bash +# vulnerable: location /files (no trailing slash) + alias escapes the alias root +docker run --rm -p 8080:8080 -e VULNERABLE=true nginx-alias-traversal + +# fixed: location /files/ (trailing slash) requires the path to stay under /files/ +docker run --rm -p 8080:8080 -e VULNERABLE=false nginx-alias-traversal +``` + +```bash +curl http://localhost:8080/files/index.html # public file, always reachable +curl http://localhost:8080/files../secret/flag.txt # only reachable in vulnerable mode +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/nginx-alias-traversal/conf/nginx.fixed.conf b/challenges/nginx-alias-traversal/conf/nginx.fixed.conf new file mode 100644 index 0000000..c35294b --- /dev/null +++ b/challenges/nginx-alias-traversal/conf/nginx.fixed.conf @@ -0,0 +1,19 @@ +worker_processes 1; +events { worker_connections 1024; } + +http { + include mime.types; + default_type application/octet-stream; + + server { + listen 8080; + server_name localhost; + + # fixed: the location has a trailing slash matching the alias, so a + # request must start with "/files/" to match this block at all, + # closing off the off-by-slash escape. + location /files/ { + alias /usr/share/nginx/html/public/; + } + } +} diff --git a/challenges/nginx-alias-traversal/conf/nginx.vulnerable.conf b/challenges/nginx-alias-traversal/conf/nginx.vulnerable.conf new file mode 100644 index 0000000..5856061 --- /dev/null +++ b/challenges/nginx-alias-traversal/conf/nginx.vulnerable.conf @@ -0,0 +1,21 @@ +worker_processes 1; +events { worker_connections 1024; } + +http { + include mime.types; + default_type application/octet-stream; + + server { + listen 8080; + server_name localhost; + + # vulnerable: the location has no trailing slash while alias does. + # nginx strips the "/files" prefix and appends the remainder + # verbatim to the alias path, so a request like "/files../secret/" + # resolves to "/usr/share/nginx/html/public/../secret/", escaping + # the intended alias root entirely. + location /files { + alias /usr/share/nginx/html/public/; + } + } +} diff --git a/challenges/nginx-alias-traversal/docker-entrypoint.sh b/challenges/nginx-alias-traversal/docker-entrypoint.sh new file mode 100644 index 0000000..9c36187 --- /dev/null +++ b/challenges/nginx-alias-traversal/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +VULNERABLE="${VULNERABLE:-true}" + +if [ "$VULNERABLE" = "true" ]; then + cp /etc/nginx/conf.d/nginx.vulnerable.conf /etc/nginx/nginx.conf +else + cp /etc/nginx/conf.d/nginx.fixed.conf /etc/nginx/nginx.conf +fi + +exec nginx -g "daemon off;" diff --git a/challenges/nginx-alias-traversal/html/public/index.html b/challenges/nginx-alias-traversal/html/public/index.html new file mode 100644 index 0000000..8e7ee56 --- /dev/null +++ b/challenges/nginx-alias-traversal/html/public/index.html @@ -0,0 +1,7 @@ + + + +

Public files

+

This directory only contains public, non-sensitive files.

+ + diff --git a/challenges/nginx-alias-traversal/html/secret/flag.txt b/challenges/nginx-alias-traversal/html/secret/flag.txt new file mode 100644 index 0000000..b77d2c0 --- /dev/null +++ b/challenges/nginx-alias-traversal/html/secret/flag.txt @@ -0,0 +1 @@ +This file lives outside the /files public alias root and must never be reachable through it. diff --git a/challenges/proxy-cache-deception/.gitignore b/challenges/proxy-cache-deception/.gitignore new file mode 100644 index 0000000..5093e48 --- /dev/null +++ b/challenges/proxy-cache-deception/.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 + +proxy-cache-deception diff --git a/challenges/proxy-cache-deception/Dockerfile b/challenges/proxy-cache-deception/Dockerfile new file mode 100644 index 0000000..6d874b4 --- /dev/null +++ b/challenges/proxy-cache-deception/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-cache-deception/ ./challenges/proxy-cache-deception/ + +WORKDIR /app/challenges/proxy-cache-deception +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-cache-deception . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-cache-deception /usr/bin/proxy-cache-deception + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-cache-deception"] +CMD ["serve"] diff --git a/challenges/proxy-cache-deception/README.md b/challenges/proxy-cache-deception/README.md new file mode 100644 index 0000000..2ab8f8a --- /dev/null +++ b/challenges/proxy-cache-deception/README.md @@ -0,0 +1,34 @@ +# Caching Behavior & Web Cache Deception + +This challenge demonstrates a shared cache that keys authenticated responses by path only (ignoring the `Authorization` header), and a web cache deception scenario where an authenticated endpoint's data is exposed under a static-looking path (`/assets/profile.js`) and gets cached and replayed to any caller. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /account` — returns data scoped to the caller's `Authorization` token +- `GET /assets/profile.js` — static-looking path backed by authenticated data + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the cache key ignores Authorization, so the first caller's response leaks to everyone; /assets/profile.js caches and serves authenticated data +go run main.go serve --vulnerable=true + +# fixed: the cache key includes the Authorization token, and authenticated content is never cached or exposed under a static path +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-cache-deception/go.mod b/challenges/proxy-cache-deception/go.mod new file mode 100644 index 0000000..f948ad9 --- /dev/null +++ b/challenges/proxy-cache-deception/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-cache-deception + +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/proxy-cache-deception/go.sum b/challenges/proxy-cache-deception/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-cache-deception/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/proxy-cache-deception/main.go b/challenges/proxy-cache-deception/main.go new file mode 100644 index 0000000..236339e --- /dev/null +++ b/challenges/proxy-cache-deception/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-cache-deception/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-cache-deception/serve/server.go b/challenges/proxy-cache-deception/serve/server.go new file mode 100644 index 0000000..c25e328 --- /dev/null +++ b/challenges/proxy-cache-deception/serve/server.go @@ -0,0 +1,100 @@ +package serve + +import ( + "fmt" + "log" + "net/http" + "strings" + "sync" +) + +type cacheEntry struct { + body string + contentType string +} + +// cache simulates a shared edge cache in front of the backend. +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() + + // /account returns data scoped to the caller's Authorization token. + http.HandleFunc("/account", func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + + // vulnerable: the cache key only considers the path, ignoring the + // Authorization header, so the first caller's authenticated response + // gets served to every subsequent caller regardless of their token + key := r.URL.Path + if !vulnerable { + key = r.URL.Path + "|" + token + } + + c.mu.Lock() + entry, hit := c.store[key] + c.mu.Unlock() + + if hit { + w.Header().Set("Content-Type", entry.contentType) + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(entry.body)) + return + } + + body := fmt.Sprintf(`{"email": "%s@example.com"}`, strings.TrimPrefix(token, "Bearer ")) + entry = cacheEntry{body: body, contentType: "application/json"} + + c.mu.Lock() + c.store[key] = entry + c.mu.Unlock() + + w.Header().Set("Content-Type", entry.contentType) + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(entry.body)) + }) + + // /assets/profile.js simulates web cache deception: an authenticated + // endpoint's content is exposed through a path that looks like a static + // asset, which shared caches will happily cache and serve to anyone. + http.HandleFunc("/assets/profile.js", func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/javascript") + + if vulnerable { + // cached purely by path, so the response containing the first + // authenticated caller's data is reused for anonymous requests + key := r.URL.Path + c.mu.Lock() + entry, hit := c.store[key] + c.mu.Unlock() + if hit { + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(entry.body)) + return + } + body := fmt.Sprintf("var profile = {token: %q};", token) + c.mu.Lock() + c.store[key] = cacheEntry{body: body} + c.mu.Unlock() + w.Header().Set("X-Cache", "MISS") + w.Write([]byte(body)) + return + } + + // fixed: authenticated content is never cached, and is not served + // under a static-looking path in the first place + w.Header().Set("Cache-Control", "no-store") + http.NotFound(w, r) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-client-ip-spoofing/.gitignore b/challenges/proxy-client-ip-spoofing/.gitignore new file mode 100644 index 0000000..2d39465 --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/.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 + +proxy-client-ip-spoofing diff --git a/challenges/proxy-client-ip-spoofing/Dockerfile b/challenges/proxy-client-ip-spoofing/Dockerfile new file mode 100644 index 0000000..a91ed64 --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-client-ip-spoofing/ ./challenges/proxy-client-ip-spoofing/ + +WORKDIR /app/challenges/proxy-client-ip-spoofing +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-client-ip-spoofing . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-client-ip-spoofing /usr/bin/proxy-client-ip-spoofing + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-client-ip-spoofing"] +CMD ["serve"] diff --git a/challenges/proxy-client-ip-spoofing/README.md b/challenges/proxy-client-ip-spoofing/README.md new file mode 100644 index 0000000..78a1ea2 --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/README.md @@ -0,0 +1,29 @@ +# Client-IP & Header Trust Boundary + +This challenge demonstrates a proxy trust boundary flaw: an internal-only endpoint is gated on the caller's IP address, but the vulnerable implementation trusts the client-controlled `X-Forwarded-For` / `X-Real-IP` headers instead of the real connection's remote address, allowing the loopback ACL to be bypassed by spoofing the header. + +## How to run it + +```bash +go run main.go serve +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: GET /internal/admin -H "X-Forwarded-For: 127.0.0.1" bypasses the loopback-only ACL +go run main.go serve --vulnerable=true + +# fixed: the ACL is enforced on the actual TCP remote address, the header is ignored +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-client-ip-spoofing/go.mod b/challenges/proxy-client-ip-spoofing/go.mod new file mode 100644 index 0000000..7452ec0 --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-client-ip-spoofing + +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/proxy-client-ip-spoofing/go.sum b/challenges/proxy-client-ip-spoofing/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/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/proxy-client-ip-spoofing/main.go b/challenges/proxy-client-ip-spoofing/main.go new file mode 100644 index 0000000..9b53d3a --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-client-ip-spoofing/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-client-ip-spoofing/serve/server.go b/challenges/proxy-client-ip-spoofing/serve/server.go new file mode 100644 index 0000000..5347e53 --- /dev/null +++ b/challenges/proxy-client-ip-spoofing/serve/server.go @@ -0,0 +1,45 @@ +package serve + +import ( + "log" + "net/http" + "strings" +) + +// trustedInternalIP is the only client IP allowed to reach the internal endpoint. +const trustedInternalIP = "127.0.0.1" + +func clientIP(r *http.Request, vulnerable bool) string { + if vulnerable { + // vulnerable: the client-controlled X-Forwarded-For / X-Real-IP + // headers are trusted as-is to make ACL and rate-limit decisions, + // even though they can be freely spoofed by the caller + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + } + host := r.RemoteAddr + if idx := strings.LastIndex(host, ":"); idx != -1 { + host = host[:idx] + } + return host +} + +func RunServer(port string, vulnerable bool) { + http.HandleFunc("/internal/admin", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if clientIP(r, vulnerable) != trustedInternalIP { + http.Error(w, `{"error": "forbidden"}`, http.StatusForbidden) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "internal admin panel"}`)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-config-static-analysis/Dockerfile b/challenges/proxy-config-static-analysis/Dockerfile new file mode 100644 index 0000000..70a8fdb --- /dev/null +++ b/challenges/proxy-config-static-analysis/Dockerfile @@ -0,0 +1,12 @@ +FROM busybox:1.36 + +WORKDIR /configs + +COPY challenges/proxy-config-static-analysis/configs/ ./ + +EXPOSE 8080 + +# this challenge ships static config files for a config-level lint/static +# analysis subcommand rather than a running proxy; the container just serves +# the files over HTTP so they can be fetched for out-of-band analysis +ENTRYPOINT ["httpd", "-f", "-p", "8080", "-h", "/configs"] diff --git a/challenges/proxy-config-static-analysis/README.md b/challenges/proxy-config-static-analysis/README.md new file mode 100644 index 0000000..da4af58 --- /dev/null +++ b/challenges/proxy-config-static-analysis/README.md @@ -0,0 +1,37 @@ +# Config-Level Static Analysis + +This challenge is not a running server: it is a set of sample reverse-proxy configuration files meant to exercise a config-level static analysis / lint subcommand (e.g. `proxyaudit lint `) against known misconfiguration patterns for Nginx, Traefik, Envoy and Caddy, each with a vulnerable and a fixed variant. + +## Layout + +``` +configs/ + nginx/vulnerable.conf nginx/fixed.conf + traefik/vulnerable.yml traefik/fixed.yml + envoy/vulnerable.yaml envoy/fixed.yaml + caddy/vulnerable.Caddyfile caddy/fixed.Caddyfile +``` + +## Findings covered + +- **Nginx**: missing root-location fallback exposing the filesystem, off-by-slash alias traversal, DNS resolver pointed at an untrusted/public server, blind `Host` forwarding upstream. +- **Traefik**: dashboard/API exposed without authentication (`api.insecure`), forwarded headers trusted from any client, wildcard CORS origin combined with credentials. +- **Envoy**: `xff_num_trusted_hops` misconfigured with `use_remote_address: false` (client-controlled trust boundary), admin interface bound to all interfaces instead of loopback. +- **Caddy**: `templates` directive enabling server-side template injection via reflected headers, blind `X-Forwarded-Host` forwarding, directory listing enabled. + +## How to use it + +Point your static analyzer at either variant, e.g.: + +```bash +proxyaudit lint configs/nginx/vulnerable.conf # expected: findings reported +proxyaudit lint configs/nginx/fixed.conf # expected: no findings +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-config-static-analysis/configs/caddy/fixed.Caddyfile b/challenges/proxy-config-static-analysis/configs/caddy/fixed.Caddyfile new file mode 100644 index 0000000..0be85b2 --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/caddy/fixed.Caddyfile @@ -0,0 +1,14 @@ +app.example.com { + # fixed: no server-side templating of proxied/reflected content + + reverse_proxy backend:8080 { + # fixed: X-Forwarded-Host is set to the known canonical host instead + # of trusting the client-supplied value + header_up X-Forwarded-Host app.example.com + } + + # fixed: directory listing disabled, only explicit files are served + file_server { + root /srv/static + } +} diff --git a/challenges/proxy-config-static-analysis/configs/caddy/vulnerable.Caddyfile b/challenges/proxy-config-static-analysis/configs/caddy/vulnerable.Caddyfile new file mode 100644 index 0000000..8cd59ec --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/caddy/vulnerable.Caddyfile @@ -0,0 +1,18 @@ +app.example.com { + # vulnerable: the templates directive evaluates Go template syntax in + # any response, including reflected request headers, enabling SSTI + # (see the proxy-template-injection challenge) + templates + + reverse_proxy backend:8080 { + # vulnerable: the client-controlled X-Forwarded-Host is forwarded + # upstream unmodified, letting the backend be tricked into + # generating links/redirects that point at an attacker-chosen host + header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} + } + + # vulnerable: directory listing is enabled on a static file path + file_server browse { + root /srv/static + } +} diff --git a/challenges/proxy-config-static-analysis/configs/envoy/fixed.yaml b/challenges/proxy-config-static-analysis/configs/envoy/fixed.yaml new file mode 100644 index 0000000..a6c1202 --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/envoy/fixed.yaml @@ -0,0 +1,32 @@ +# Envoy static configuration - fixed +static_resources: + listeners: + - name: listener_0 + address: + socket_address: { address: 0.0.0.0, port_value: 8080 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + # fixed: exactly one upstream hop (the load balancer) is trusted + xff_num_trusted_hops: 1 + use_remote_address: true + route_config: + name: local_route + virtual_hosts: + - name: backend + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: backend_service } + http_filters: + - name: envoy.filters.http.router + clusters: + - name: admin_interface + connect_timeout: 0.25s +# fixed: the admin interface only listens on loopback, unreachable from outside the host +admin: + address: + socket_address: { address: 127.0.0.1, port_value: 9901 } diff --git a/challenges/proxy-config-static-analysis/configs/envoy/vulnerable.yaml b/challenges/proxy-config-static-analysis/configs/envoy/vulnerable.yaml new file mode 100644 index 0000000..ff629d0 --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/envoy/vulnerable.yaml @@ -0,0 +1,35 @@ +# Envoy static configuration - vulnerable +static_resources: + listeners: + - name: listener_0 + address: + socket_address: { address: 0.0.0.0, port_value: 8080 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + # vulnerable: XFF is trusted from an unbounded number of hops, + # so a client can prepend a spoofed address that is treated as + # trusted by everything downstream + xff_num_trusted_hops: 0 + use_remote_address: false + route_config: + name: local_route + virtual_hosts: + - name: backend + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: backend_service } + http_filters: + - name: envoy.filters.http.router + clusters: + - name: admin_interface + # vulnerable: the Envoy admin interface (config dump, stats, /certs) is + # bound to all interfaces instead of loopback-only + connect_timeout: 0.25s +admin: + address: + socket_address: { address: 0.0.0.0, port_value: 9901 } diff --git a/challenges/proxy-config-static-analysis/configs/nginx/fixed.conf b/challenges/proxy-config-static-analysis/configs/nginx/fixed.conf new file mode 100644 index 0000000..08b0b1a --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/nginx/fixed.conf @@ -0,0 +1,29 @@ +worker_processes 1; +events { worker_connections 1024; } + +http { + server { + listen 8080; + server_name localhost; + + # fixed: explicit root fallback returns 404 instead of exposing the filesystem + root /usr/share/nginx/html; + location / { + try_files $uri $uri/ =404; + } + + # fixed: trailing slash on both location and alias, no traversal possible + location /files/ { + alias /usr/share/nginx/html/public/; + } + + # fixed: resolver pinned to a trusted, internal DNS server + resolver 10.0.0.2 valid=300s; + + # fixed: the upstream always receives a fixed, known-good Host value + location /api/ { + proxy_pass http://backend; + proxy_set_header Host app.example.com; + } + } +} diff --git a/challenges/proxy-config-static-analysis/configs/nginx/vulnerable.conf b/challenges/proxy-config-static-analysis/configs/nginx/vulnerable.conf new file mode 100644 index 0000000..a70cd04 --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/nginx/vulnerable.conf @@ -0,0 +1,30 @@ +worker_processes 1; +events { worker_connections 1024; } + +http { + # vulnerable: no explicit "root" location, so a request that doesn't + # match any other location falls through to the global root and can + # serve files from the server's filesystem, including this config file + root /etc/nginx; + + server { + listen 8080; + server_name localhost; + + # vulnerable: off-by-slash alias traversal (see nginx-alias-traversal challenge) + location /files { + alias /usr/share/nginx/html/public/; + } + + # vulnerable: resolver uses a public/untrusted DNS server, enabling + # DNS-spoofing based SSRF against upstreams resolved at request time + resolver 8.8.8.8; + + # vulnerable: proxied requests blindly forward the client's + # Host header upstream, enabling host header injection + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $http_host; + } + } +} diff --git a/challenges/proxy-config-static-analysis/configs/traefik/fixed.yml b/challenges/proxy-config-static-analysis/configs/traefik/fixed.yml new file mode 100644 index 0000000..63bfc69 --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/traefik/fixed.yml @@ -0,0 +1,26 @@ +# Traefik static configuration - fixed + +# fixed: the dashboard requires authentication and is not exposed on a +# public entrypoint (served separately behind its own protected router) +api: + dashboard: true + insecure: false + +entryPoints: + web: + address: ":80" + # fixed: forwarded headers are only trusted from the known, trusted proxy CIDR + websecure: + address: ":443" + forwardedHeaders: + trustedIPs: + - "10.0.0.0/8" + +http: + middlewares: + cors-restricted: + headers: + # fixed: a specific, trusted origin instead of a wildcard + accessControlAllowOriginList: + - "https://app.example.com" + accessControlAllowCredentials: true diff --git a/challenges/proxy-config-static-analysis/configs/traefik/vulnerable.yml b/challenges/proxy-config-static-analysis/configs/traefik/vulnerable.yml new file mode 100644 index 0000000..e75bfb6 --- /dev/null +++ b/challenges/proxy-config-static-analysis/configs/traefik/vulnerable.yml @@ -0,0 +1,26 @@ +# Traefik static configuration - vulnerable + +# vulnerable: the API/dashboard is exposed without authentication and +# reachable on the same entrypoint as public traffic +api: + dashboard: true + insecure: true + +entryPoints: + web: + address: ":80" + # vulnerable: forwarded headers (X-Forwarded-For, X-Forwarded-Host) are + # trusted from any client, not just from known upstream proxies + websecure: + address: ":443" + forwardedHeaders: + insecure: true + +http: + middlewares: + cors-open: + headers: + # vulnerable: wildcard origin combined with credentials + accessControlAllowOriginList: + - "*" + accessControlAllowCredentials: true diff --git a/challenges/proxy-cors-misconfiguration/.gitignore b/challenges/proxy-cors-misconfiguration/.gitignore new file mode 100644 index 0000000..f25faa7 --- /dev/null +++ b/challenges/proxy-cors-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 + +proxy-cors-misconfiguration diff --git a/challenges/proxy-cors-misconfiguration/Dockerfile b/challenges/proxy-cors-misconfiguration/Dockerfile new file mode 100644 index 0000000..6411325 --- /dev/null +++ b/challenges/proxy-cors-misconfiguration/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-cors-misconfiguration/ ./challenges/proxy-cors-misconfiguration/ + +WORKDIR /app/challenges/proxy-cors-misconfiguration +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-cors-misconfiguration . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-cors-misconfiguration /usr/bin/proxy-cors-misconfiguration + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-cors-misconfiguration"] +CMD ["serve"] diff --git a/challenges/proxy-cors-misconfiguration/README.md b/challenges/proxy-cors-misconfiguration/README.md new file mode 100644 index 0000000..ae1623b --- /dev/null +++ b/challenges/proxy-cors-misconfiguration/README.md @@ -0,0 +1,34 @@ +# CORS + +This challenge demonstrates a misconfigured CORS policy on an authenticated endpoint: any `Origin` (including `null`) is reflected back combined with `Access-Control-Allow-Credentials: true`, and the preflight response is overly permissive, allowing any site to read the authenticated response in a victim's browser. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /api/account` — returns account data, gated by cookies/credentials +- `OPTIONS /api/account` — CORS preflight + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: Origin is reflected with credentials allowed, null origin accepted, preflight allows any header/method +go run main.go serve --vulnerable=true + +# fixed: only the trusted origin is allowed, Vary: Origin is set, preflight is scoped down +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-cors-misconfiguration/go.mod b/challenges/proxy-cors-misconfiguration/go.mod new file mode 100644 index 0000000..80cae9d --- /dev/null +++ b/challenges/proxy-cors-misconfiguration/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-cors-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/proxy-cors-misconfiguration/go.sum b/challenges/proxy-cors-misconfiguration/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-cors-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/proxy-cors-misconfiguration/main.go b/challenges/proxy-cors-misconfiguration/main.go new file mode 100644 index 0000000..922140d --- /dev/null +++ b/challenges/proxy-cors-misconfiguration/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-cors-misconfiguration/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-cors-misconfiguration/serve/server.go b/challenges/proxy-cors-misconfiguration/serve/server.go new file mode 100644 index 0000000..a6fe1ec --- /dev/null +++ b/challenges/proxy-cors-misconfiguration/serve/server.go @@ -0,0 +1,49 @@ +package serve + +import ( + "log" + "net/http" +) + +func RunServer(port string, vulnerable bool) { + // vulnerable: the request Origin is reflected back with credentials + // allowed, and the "null" origin is accepted, letting any site (or a + // sandboxed iframe / file:// page) read authenticated responses + http.HandleFunc("/api/account", func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + w.Header().Set("Content-Type", "application/json") + + if vulnerable { + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + } else { + if origin == "https://app.example.com" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Vary", "Origin") + } + } + + if r.Method == http.MethodOptions { + if vulnerable { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "*") + w.Header().Set("Access-Control-Max-Age", "86400") + } else { + w.Header().Set("Access-Control-Allow-Methods", "GET") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Max-Age", "600") + } + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"email": "user@example.com", "plan": "premium"}`)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-host-header-injection/.gitignore b/challenges/proxy-host-header-injection/.gitignore new file mode 100644 index 0000000..efbdbc1 --- /dev/null +++ b/challenges/proxy-host-header-injection/.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 + +proxy-host-header-injection diff --git a/challenges/proxy-host-header-injection/Dockerfile b/challenges/proxy-host-header-injection/Dockerfile new file mode 100644 index 0000000..38b195a --- /dev/null +++ b/challenges/proxy-host-header-injection/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-host-header-injection/ ./challenges/proxy-host-header-injection/ + +WORKDIR /app/challenges/proxy-host-header-injection +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-host-header-injection . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-host-header-injection /usr/bin/proxy-host-header-injection + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-host-header-injection"] +CMD ["serve"] diff --git a/challenges/proxy-host-header-injection/README.md b/challenges/proxy-host-header-injection/README.md new file mode 100644 index 0000000..d1d2c73 --- /dev/null +++ b/challenges/proxy-host-header-injection/README.md @@ -0,0 +1,34 @@ +# Host Header & Routing Trust + +This challenge demonstrates trusting the client-supplied `Host` header: a password-reset endpoint reflects it into the generated reset link (host header injection / poisoning), and the root route uses it to select a backend, allowing virtual-host confusion. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `POST /password-reset` — returns a reset link built from the request's host +- `GET /` — routes based on the `Host` header + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: the reset link uses the raw Host header, and Host: internal-admin.local reaches the internal backend +go run main.go serve --vulnerable=true + +# fixed: the reset link always uses the canonical host, Host header is ignored for routing +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-host-header-injection/go.mod b/challenges/proxy-host-header-injection/go.mod new file mode 100644 index 0000000..0cc9690 --- /dev/null +++ b/challenges/proxy-host-header-injection/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-host-header-injection + +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/proxy-host-header-injection/go.sum b/challenges/proxy-host-header-injection/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-host-header-injection/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/proxy-host-header-injection/main.go b/challenges/proxy-host-header-injection/main.go new file mode 100644 index 0000000..cc2ca0a --- /dev/null +++ b/challenges/proxy-host-header-injection/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-host-header-injection/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-host-header-injection/serve/server.go b/challenges/proxy-host-header-injection/serve/server.go new file mode 100644 index 0000000..71964ff --- /dev/null +++ b/challenges/proxy-host-header-injection/serve/server.go @@ -0,0 +1,40 @@ +package serve + +import ( + "fmt" + "log" + "net/http" +) + +const canonicalHost = "app.example.com" + +func RunServer(port string, vulnerable bool) { + // vulnerable: the password-reset link is built from the client-supplied + // Host header, so an attacker can poison it to point at a host they + // control and hijack the reset token + http.HandleFunc("/password-reset", func(w http.ResponseWriter, r *http.Request) { + host := canonicalHost + if vulnerable { + host = r.Host + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"resetLink": "https://%s/reset?token=abc123"}`, host) + }) + + // vulnerable: a crafted Host header changes which backend the request is + // routed to, allowing virtual-host confusion + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if vulnerable && r.Host == "internal-admin.local" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "internal admin backend"}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "public backend"}`)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-http2-authority-spoofing/.gitignore b/challenges/proxy-http2-authority-spoofing/.gitignore new file mode 100644 index 0000000..3e13bc7 --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/.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 + +proxy-http2-authority-spoofing diff --git a/challenges/proxy-http2-authority-spoofing/Dockerfile b/challenges/proxy-http2-authority-spoofing/Dockerfile new file mode 100644 index 0000000..53e9ccc --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-http2-authority-spoofing/ ./challenges/proxy-http2-authority-spoofing/ + +WORKDIR /app/challenges/proxy-http2-authority-spoofing +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-http2-authority-spoofing . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-http2-authority-spoofing /usr/bin/proxy-http2-authority-spoofing + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-http2-authority-spoofing"] +CMD ["serve"] diff --git a/challenges/proxy-http2-authority-spoofing/README.md b/challenges/proxy-http2-authority-spoofing/README.md new file mode 100644 index 0000000..1e53124 --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/README.md @@ -0,0 +1,35 @@ +# HTTP/2 & HTTP/3 Protocol-Specific + +This challenge demonstrates an HTTP/2 (h2c) server that resolves routing purely from the `:authority` pseudo-header (exposed as `r.Host`) without cross-checking it against a legacy `Host` header also present on the request, allowing a mismatch between the two to be used for routing/ACL confusion during an HTTP/2-to-HTTP/1.1 downgrade. + +## How to run it + +```bash +go run main.go serve +``` + +The server speaks HTTP/2 cleartext (h2c) as well as HTTP/1.1 on the same port. + +## Endpoint + +- `GET /` — routes on `:authority` (`r.Host`) + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a mismatched ":authority" and "Host" header is accepted and routed on ":authority" alone +go run main.go serve --vulnerable=true + +# fixed: a mismatch between ":authority" and "Host" is rejected with 400, per RFC 7540 §8.1.2.3 +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-http2-authority-spoofing/go.mod b/challenges/proxy-http2-authority-spoofing/go.mod new file mode 100644 index 0000000..6901a7f --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/go.mod @@ -0,0 +1,18 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-http2-authority-spoofing + +go 1.26.0 + +require golang.org/x/net v0.59.0 + +require ( + github.com/spf13/cobra v1.10.2 // indirect + golang.org/x/text v0.42.0 // 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/proxy-http2-authority-spoofing/go.sum b/challenges/proxy-http2-authority-spoofing/go.sum new file mode 100644 index 0000000..0882434 --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/go.sum @@ -0,0 +1,15 @@ +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= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/challenges/proxy-http2-authority-spoofing/main.go b/challenges/proxy-http2-authority-spoofing/main.go new file mode 100644 index 0000000..89c2649 --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-http2-authority-spoofing/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-http2-authority-spoofing/serve/server.go b/challenges/proxy-http2-authority-spoofing/serve/server.go new file mode 100644 index 0000000..3a66e22 --- /dev/null +++ b/challenges/proxy-http2-authority-spoofing/serve/server.go @@ -0,0 +1,48 @@ +package serve + +import ( + "log" + "net/http" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +// trustedHost is the only virtual host the routing tier is meant to forward +// requests for. +const trustedHost = "app.example.com" + +func RunServer(port string, vulnerable bool) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // r.Host is populated from the HTTP/2 ":authority" pseudo-header. + // A legacy "Host" header can also be sent by some HTTP/1.1-era + // clients or intermediaries during the HTTP/2 downgrade; per RFC + // 7540 §8.1.2.3 they must match, otherwise the request must be + // rejected to avoid routing confusion. + legacyHost := r.Header.Get("Host") + + w.Header().Set("Content-Type", "application/json") + + if !vulnerable { + if legacyHost != "" && legacyHost != r.Host { + http.Error(w, `{"error": "authority/host mismatch"}`, http.StatusBadRequest) + return + } + } + // vulnerable: the mismatch is ignored, the request is routed purely + // on r.Host (derived from ":authority"), so a crafted legacy Host + // header that disagrees with ":authority" can smuggle a request past + // a downstream check that inspects the wrong field + if r.Host != trustedHost { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "public backend"}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "trusted app backend"}`)) + }) + + h2s := &http2.Server{} + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, h2c.NewHandler(handler, h2s))) +} diff --git a/challenges/proxy-info-disclosure/.gitignore b/challenges/proxy-info-disclosure/.gitignore new file mode 100644 index 0000000..0ab43be --- /dev/null +++ b/challenges/proxy-info-disclosure/.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 + +proxy-info-disclosure diff --git a/challenges/proxy-info-disclosure/Dockerfile b/challenges/proxy-info-disclosure/Dockerfile new file mode 100644 index 0000000..6a7ead2 --- /dev/null +++ b/challenges/proxy-info-disclosure/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-info-disclosure/ ./challenges/proxy-info-disclosure/ + +WORKDIR /app/challenges/proxy-info-disclosure +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-info-disclosure . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-info-disclosure /usr/bin/proxy-info-disclosure + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-info-disclosure"] +CMD ["serve"] diff --git a/challenges/proxy-info-disclosure/README.md b/challenges/proxy-info-disclosure/README.md new file mode 100644 index 0000000..af29a6c --- /dev/null +++ b/challenges/proxy-info-disclosure/README.md @@ -0,0 +1,35 @@ +# Information Disclosure & Exposed Management Interfaces + +This challenge demonstrates several information disclosure issues commonly found behind misconfigured reverse proxies: verbose error pages leaking backend internals, an unauthenticated management/health endpoint, and directory listing on a static path. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /error` — triggers a simulated backend error +- `GET /actuator/health` — internal management/health endpoint +- `GET /static/` — static file listing + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: /error leaks a stack trace and internal hostname, /actuator/health is reachable, /static/ lists its contents +go run main.go serve --vulnerable=true + +# fixed: /error returns a generic message, /actuator/health is not reachable, /static/ returns 404 instead of listing +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-info-disclosure/go.mod b/challenges/proxy-info-disclosure/go.mod new file mode 100644 index 0000000..29bdc93 --- /dev/null +++ b/challenges/proxy-info-disclosure/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-info-disclosure + +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/proxy-info-disclosure/go.sum b/challenges/proxy-info-disclosure/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-info-disclosure/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/proxy-info-disclosure/main.go b/challenges/proxy-info-disclosure/main.go new file mode 100644 index 0000000..0f5d01e --- /dev/null +++ b/challenges/proxy-info-disclosure/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-info-disclosure/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-info-disclosure/serve/server.go b/challenges/proxy-info-disclosure/serve/server.go new file mode 100644 index 0000000..a55d3bf --- /dev/null +++ b/challenges/proxy-info-disclosure/serve/server.go @@ -0,0 +1,59 @@ +package serve + +import ( + "fmt" + "log" + "net/http" +) + +func RunServer(port string, vulnerable bool) { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "ok"}`)) + }) + + // vulnerable: a verbose error page leaks the backend's internal path, + // hostname and a fake stack trace to the client + http.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + if vulnerable { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "panic: nil pointer dereference\n\ngoroutine 1 [running]:\n"+ + "main.handleRequest(0xc0001a4000)\n\t/srv/app/internal/backend-01.internal.corp/handlers/orders.go:42 +0x1a5\n"+ + "host: backend-01.internal.corp") + } else { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"error": "internal server error"}`)) + } + }) + + // vulnerable: an internal management dashboard is reachable from the + // public interface with no authentication + http.HandleFunc("/actuator/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if vulnerable { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "UP"}`)) + } else { + w.WriteHeader(http.StatusNotFound) + } + }) + + // vulnerable: directory listing is enabled on a proxy-served static path + http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + if vulnerable { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`

Index of /static/

`)) + } else { + http.NotFound(w, r) + } + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-open-ssrf/.gitignore b/challenges/proxy-open-ssrf/.gitignore new file mode 100644 index 0000000..3f9ec8d --- /dev/null +++ b/challenges/proxy-open-ssrf/.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 + +proxy-open-ssrf diff --git a/challenges/proxy-open-ssrf/Dockerfile b/challenges/proxy-open-ssrf/Dockerfile new file mode 100644 index 0000000..467518a --- /dev/null +++ b/challenges/proxy-open-ssrf/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-open-ssrf/ ./challenges/proxy-open-ssrf/ + +WORKDIR /app/challenges/proxy-open-ssrf +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-open-ssrf . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-open-ssrf /usr/bin/proxy-open-ssrf + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-open-ssrf"] +CMD ["serve"] diff --git a/challenges/proxy-open-ssrf/README.md b/challenges/proxy-open-ssrf/README.md new file mode 100644 index 0000000..9234594 --- /dev/null +++ b/challenges/proxy-open-ssrf/README.md @@ -0,0 +1,34 @@ +# Open Proxy / SSRF via the Proxy + +This challenge demonstrates a proxy fetch endpoint that acts as an open proxy: it forwards a caller-supplied URL with no allowlist, letting an attacker reach the proxy's own loopback interface or internal/private services (simulated by `/internal/secret`) that should never be reachable from the outside. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoints + +- `GET /internal/secret` — simulated internal-only service +- `GET /fetch?url=` — fetches the given URL server-side and returns its body + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: GET /fetch?url=http://127.0.0.1:8080/internal/secret leaks the internal secret +go run main.go serve --vulnerable=true + +# fixed: requests to loopback/private/link-local hosts are rejected with 403 +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-open-ssrf/go.mod b/challenges/proxy-open-ssrf/go.mod new file mode 100644 index 0000000..bac004f --- /dev/null +++ b/challenges/proxy-open-ssrf/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-open-ssrf + +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/proxy-open-ssrf/go.sum b/challenges/proxy-open-ssrf/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-open-ssrf/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/proxy-open-ssrf/main.go b/challenges/proxy-open-ssrf/main.go new file mode 100644 index 0000000..838f843 --- /dev/null +++ b/challenges/proxy-open-ssrf/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-open-ssrf/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-open-ssrf/serve/server.go b/challenges/proxy-open-ssrf/serve/server.go new file mode 100644 index 0000000..f7c4d0d --- /dev/null +++ b/challenges/proxy-open-ssrf/serve/server.go @@ -0,0 +1,81 @@ +package serve + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "time" +) + +// isPrivate reports whether host resolves to a loopback, link-local or +// private address that must never be reachable from the public fetch proxy. +func isPrivate(host string) bool { + ips, err := net.LookupIP(host) + if err != nil { + return true + } + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() { + return true + } + } + return false +} + +// internalSecret simulates a sensitive internal service only meant to be +// reachable from inside the network, never through the public proxy. +func internalSecret(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"aws_secret_access_key": "AKIA...leaked"}`)) +} + +func RunServer(port string, vulnerable bool) { + http.HandleFunc("/internal/secret", internalSecret) + + // /fetch acts as an open proxy: it fetches whatever URL the caller + // provides and returns the response body. + http.HandleFunc("/fetch", func(w http.ResponseWriter, r *http.Request) { + target := r.URL.Query().Get("url") + w.Header().Set("Content-Type", "application/json") + if target == "" { + http.Error(w, `{"error": "missing url"}`, http.StatusBadRequest) + return + } + + parsed, err := url.Parse(target) + if err != nil { + http.Error(w, `{"error": "invalid url"}`, http.StatusBadRequest) + return + } + + // vulnerable: no allowlist is enforced, so the proxy will happily + // reach the loopback interface, internal/private IP ranges, or any + // arbitrary external host on the caller's behalf + if !vulnerable && isPrivate(parsed.Hostname()) { + http.Error(w, `{"error": "target host is not allowed"}`, http.StatusForbidden) + return + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(target) + if err != nil { + http.Error(w, fmt.Sprintf(`{"error": %q}`, err.Error()), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, `{"error": "failed to read upstream response"}`, http.StatusBadGateway) + return + } + w.WriteHeader(resp.StatusCode) + w.Write(body) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-path-bypass/.gitignore b/challenges/proxy-path-bypass/.gitignore new file mode 100644 index 0000000..920e5f1 --- /dev/null +++ b/challenges/proxy-path-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 + +proxy-path-bypass diff --git a/challenges/proxy-path-bypass/Dockerfile b/challenges/proxy-path-bypass/Dockerfile new file mode 100644 index 0000000..8b1180f --- /dev/null +++ b/challenges/proxy-path-bypass/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-path-bypass/ ./challenges/proxy-path-bypass/ + +WORKDIR /app/challenges/proxy-path-bypass +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-path-bypass . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-path-bypass /usr/bin/proxy-path-bypass + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-path-bypass"] +CMD ["serve"] diff --git a/challenges/proxy-path-bypass/README.md b/challenges/proxy-path-bypass/README.md new file mode 100644 index 0000000..6cf8b1e --- /dev/null +++ b/challenges/proxy-path-bypass/README.md @@ -0,0 +1,29 @@ +# Path & Access Control Bypass + +This challenge demonstrates a proxy-level access control bypass: `/admin` is meant to be blocked, but the vulnerable implementation does a naive, case-sensitive prefix match on the raw path, which is defeated by path normalization tricks (case variation, encoded traversal, duplicate slashes) that the backend will still resolve to the protected path. + +## How to run it + +```bash +go run main.go serve +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: GET /ADMIN, GET //admin or GET /admin%2f..%2fadmin bypass the block +go run main.go serve --vulnerable=true + +# fixed: the path is decoded and normalized before the ACL check, so the bypasses no longer work +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-path-bypass/go.mod b/challenges/proxy-path-bypass/go.mod new file mode 100644 index 0000000..d5f4665 --- /dev/null +++ b/challenges/proxy-path-bypass/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-path-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/proxy-path-bypass/go.sum b/challenges/proxy-path-bypass/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-path-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/proxy-path-bypass/main.go b/challenges/proxy-path-bypass/main.go new file mode 100644 index 0000000..9753b29 --- /dev/null +++ b/challenges/proxy-path-bypass/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-path-bypass/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-path-bypass/serve/server.go b/challenges/proxy-path-bypass/serve/server.go new file mode 100644 index 0000000..634dbee --- /dev/null +++ b/challenges/proxy-path-bypass/serve/server.go @@ -0,0 +1,53 @@ +package serve + +import ( + "log" + "net/http" + "net/url" + "strings" +) + +// isBlockedPath enforces the ACL. In vulnerable mode it does a naive prefix +// check on the raw, undecoded path, which is what a proxy would do if it +// inspects the path before normalization. +func isBlockedPath(r *http.Request, vulnerable bool) bool { + path := r.URL.Path + if vulnerable { + return strings.HasPrefix(path, "/admin") + } + + // fixed: decode and normalize the path the same way the backend will + // interpret it before making the access decision + decoded, err := url.PathUnescape(path) + if err != nil { + decoded = path + } + cleaned := "/" + strings.Trim(strings.ToLower(decoded), "/") + for strings.Contains(cleaned, "//") { + cleaned = strings.ReplaceAll(cleaned, "//", "/") + } + return strings.HasPrefix(cleaned, "/admin") +} + +func RunServer(port string, vulnerable bool) { + // a plain http.HandlerFunc, not http.ServeMux, is used directly so that + // raw, non-normalized paths (e.g. "/ADMIN", "//admin", encoded traversal) + // reach the handler unchanged instead of being cleaned/redirected first, + // the same way a naive reverse proxy would forward them. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if isBlockedPath(r, vulnerable) { + http.Error(w, `{"error": "forbidden"}`, http.StatusForbidden) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "admin panel"}`)) + }) + + log.Println("Server started at port", port) + server := &http.Server{ + Addr: ":" + port, + Handler: handler, + } + log.Fatal(server.ListenAndServe()) +} diff --git a/challenges/proxy-rate-limit-bypass/.gitignore b/challenges/proxy-rate-limit-bypass/.gitignore new file mode 100644 index 0000000..3d7d26d --- /dev/null +++ b/challenges/proxy-rate-limit-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 + +proxy-rate-limit-bypass diff --git a/challenges/proxy-rate-limit-bypass/Dockerfile b/challenges/proxy-rate-limit-bypass/Dockerfile new file mode 100644 index 0000000..286366c --- /dev/null +++ b/challenges/proxy-rate-limit-bypass/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-rate-limit-bypass/ ./challenges/proxy-rate-limit-bypass/ + +WORKDIR /app/challenges/proxy-rate-limit-bypass +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-rate-limit-bypass . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-rate-limit-bypass /usr/bin/proxy-rate-limit-bypass + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-rate-limit-bypass"] +CMD ["serve"] diff --git a/challenges/proxy-rate-limit-bypass/README.md b/challenges/proxy-rate-limit-bypass/README.md new file mode 100644 index 0000000..db33999 --- /dev/null +++ b/challenges/proxy-rate-limit-bypass/README.md @@ -0,0 +1,33 @@ +# Rate Limiting & DoS Resilience + +This challenge demonstrates a rate limiter keyed on the client-controlled `X-Forwarded-For` header instead of the real connection address, letting an attacker bypass the throttle on a login endpoint simply by rotating the header value on every request. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `POST /api/login` — limited to 5 requests per 10 seconds per client + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: sending a different X-Forwarded-For on each request resets the limit +go run main.go serve --vulnerable=true + +# fixed: the limit is enforced on the actual TCP remote address, the header is ignored +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-rate-limit-bypass/go.mod b/challenges/proxy-rate-limit-bypass/go.mod new file mode 100644 index 0000000..4b0b444 --- /dev/null +++ b/challenges/proxy-rate-limit-bypass/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-rate-limit-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/proxy-rate-limit-bypass/go.sum b/challenges/proxy-rate-limit-bypass/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-rate-limit-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/proxy-rate-limit-bypass/main.go b/challenges/proxy-rate-limit-bypass/main.go new file mode 100644 index 0000000..8b1f92c --- /dev/null +++ b/challenges/proxy-rate-limit-bypass/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-rate-limit-bypass/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-rate-limit-bypass/serve/server.go b/challenges/proxy-rate-limit-bypass/serve/server.go new file mode 100644 index 0000000..f361a2e --- /dev/null +++ b/challenges/proxy-rate-limit-bypass/serve/server.go @@ -0,0 +1,79 @@ +package serve + +import ( + "log" + "net/http" + "strings" + "sync" + "time" +) + +const ( + limitCount = 5 + limitWindow = 10 * time.Second +) + +type limiter struct { + mu sync.Mutex + hits map[string][]time.Time +} + +func newLimiter() *limiter { + return &limiter{hits: make(map[string][]time.Time)} +} + +func (l *limiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-limitWindow) + + kept := l.hits[key][:0] + for _, t := range l.hits[key] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + l.hits[key] = kept + + if len(l.hits[key]) >= limitCount { + return false + } + l.hits[key] = append(l.hits[key], now) + return true +} + +// rateLimitKey identifies the caller for throttling purposes. +func rateLimitKey(r *http.Request, vulnerable bool) string { + if vulnerable { + // vulnerable: the rate limit key is taken from the client-controlled + // X-Forwarded-For header, so an attacker can bypass the limit simply + // by sending a different value on every request + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return xff + } + } + host := r.RemoteAddr + if idx := strings.LastIndex(host, ":"); idx != -1 { + host = host[:idx] + } + return host +} + +func RunServer(port string, vulnerable bool) { + l := newLimiter() + + http.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !l.allow(rateLimitKey(r, vulnerable)) { + http.Error(w, `{"error": "too many requests"}`, http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "login attempt processed"}`)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-request-smuggling/.gitignore b/challenges/proxy-request-smuggling/.gitignore new file mode 100644 index 0000000..5766e25 --- /dev/null +++ b/challenges/proxy-request-smuggling/.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 + +proxy-request-smuggling diff --git a/challenges/proxy-request-smuggling/Dockerfile b/challenges/proxy-request-smuggling/Dockerfile new file mode 100644 index 0000000..7a80194 --- /dev/null +++ b/challenges/proxy-request-smuggling/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-request-smuggling/ ./challenges/proxy-request-smuggling/ + +WORKDIR /app/challenges/proxy-request-smuggling +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-request-smuggling . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-request-smuggling /usr/bin/proxy-request-smuggling + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-request-smuggling"] +CMD ["serve"] diff --git a/challenges/proxy-request-smuggling/README.md b/challenges/proxy-request-smuggling/README.md new file mode 100644 index 0000000..58cee91 --- /dev/null +++ b/challenges/proxy-request-smuggling/README.md @@ -0,0 +1,36 @@ +# Request Smuggling / Desync + +This challenge implements a minimal raw HTTP/1.1 server (bypassing Go's `net/http`, which already rejects ambiguous requests) to demonstrate a TE.CL-style desync: when both `Content-Length` and `Transfer-Encoding: chunked` are present, the request boundary is determined by the chunked body, and any bytes appended after the terminating chunk are buffered on the connection and answered as if they were a second, smuggled request. + +## How to run it + +```bash +go run main.go serve +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: a request with both Content-Length and Transfer-Encoding: chunked is accepted, +# and content appended after the chunked body is processed as a smuggled second request +go run main.go serve --vulnerable=true + +# fixed: any request with both headers present is rejected with 400, per RFC 7230 §3.3.3 +go run main.go serve --vulnerable=false +``` + +Example probe with `netcat`, sending a chunked body that terminates early and smuggles a second request: + +```bash +printf 'POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nG' | nc localhost 8080 +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-request-smuggling/go.mod b/challenges/proxy-request-smuggling/go.mod new file mode 100644 index 0000000..c834dd3 --- /dev/null +++ b/challenges/proxy-request-smuggling/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-request-smuggling + +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/proxy-request-smuggling/go.sum b/challenges/proxy-request-smuggling/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-request-smuggling/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/proxy-request-smuggling/main.go b/challenges/proxy-request-smuggling/main.go new file mode 100644 index 0000000..9ef9087 --- /dev/null +++ b/challenges/proxy-request-smuggling/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-request-smuggling/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-request-smuggling/serve/server.go b/challenges/proxy-request-smuggling/serve/server.go new file mode 100644 index 0000000..80f19f4 --- /dev/null +++ b/challenges/proxy-request-smuggling/serve/server.go @@ -0,0 +1,137 @@ +package serve + +import ( + "bufio" + "fmt" + "log" + "net" + "net/textproto" + "strconv" + "strings" +) + +func writeResponse(conn net.Conn, status string, body string) { + fmt.Fprintf(conn, "HTTP/1.1 %s\r\n", status) + fmt.Fprintf(conn, "Content-Type: application/json\r\n") + fmt.Fprintf(conn, "Content-Length: %d\r\n", len(body)) + fmt.Fprintf(conn, "Connection: keep-alive\r\n\r\n") + conn.Write([]byte(body)) +} + +func readChunkedBody(r *bufio.Reader) ([]byte, error) { + var body []byte + for { + sizeLine, err := r.ReadString('\n') + if err != nil { + return nil, err + } + sizeLine = strings.TrimSpace(strings.SplitN(sizeLine, ";", 2)[0]) + size, err := strconv.ParseInt(sizeLine, 16, 64) + if err != nil { + return nil, err + } + if size == 0 { + // consume the trailing CRLF after the terminating 0-size chunk + r.ReadString('\n') + return body, nil + } + chunk := make([]byte, size) + if _, err := readFull(r, chunk); err != nil { + return nil, err + } + body = append(body, chunk...) + r.ReadString('\n') // consume trailing CRLF after chunk data + } +} + +func readFull(r *bufio.Reader, buf []byte) (int, error) { + total := 0 + for total < len(buf) { + n, err := r.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} + +// handleRequest parses and responds to one HTTP request on the connection. +// It returns false if the connection should be closed. +func handleRequest(conn net.Conn, r *bufio.Reader, vulnerable bool) bool { + requestLine, err := r.ReadString('\n') + if err != nil || strings.TrimSpace(requestLine) == "" { + return false + } + + tp := textproto.NewReader(r) + headers, err := tp.ReadMIMEHeader() + if err != nil { + return false + } + + contentLength := headers.Get("Content-Length") + transferEncoding := headers.Get("Transfer-Encoding") + + if !vulnerable && contentLength != "" && transferEncoding != "" { + // fixed: per RFC 7230 §3.3.3, a request with both Content-Length and + // Transfer-Encoding is ambiguous and must be rejected outright, + // closing the connection to remove any chance of a desync + writeResponse(conn, "400 Bad Request", `{"error": "ambiguous Content-Length/Transfer-Encoding"}`) + return false + } + + // vulnerable: when both headers are present, Transfer-Encoding takes + // priority and the request boundary is determined by the chunked body, + // exactly the "TE.CL" desync pattern - any bytes appended by the + // attacker after the terminating chunk are left buffered on the + // connection and get interpreted as the start of a smuggled request + if strings.EqualFold(transferEncoding, "chunked") { + if _, err := readChunkedBody(r); err != nil { + return false + } + } else if contentLength != "" { + n, err := strconv.Atoi(contentLength) + if err != nil { + return false + } + buf := make([]byte, n) + if _, err := readFull(r, buf); err != nil { + return false + } + } + + writeResponse(conn, "200 OK", `{"message": "request processed"}`) + return true +} + +func RunServer(port string, vulnerable bool) { + ln, err := net.Listen("tcp", ":"+port) + if err != nil { + log.Fatal(err) + } + log.Println("Server started at port", port) + + for { + conn, err := ln.Accept() + if err != nil { + continue + } + go func(c net.Conn) { + defer c.Close() + r := bufio.NewReader(c) + for { + if !handleRequest(c, r, vulnerable) { + return + } + if r.Buffered() == 0 { + return + } + // vulnerable: leftover buffered bytes (the smuggled + // request hidden in the previous request's body) are + // processed and answered as if they were a legitimate + // pipelined request on the same connection + } + }(conn) + } +} diff --git a/challenges/proxy-security-headers/.gitignore b/challenges/proxy-security-headers/.gitignore new file mode 100644 index 0000000..7b89284 --- /dev/null +++ b/challenges/proxy-security-headers/.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 + +proxy-security-headers diff --git a/challenges/proxy-security-headers/Dockerfile b/challenges/proxy-security-headers/Dockerfile new file mode 100644 index 0000000..b847deb --- /dev/null +++ b/challenges/proxy-security-headers/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-security-headers/ ./challenges/proxy-security-headers/ + +WORKDIR /app/challenges/proxy-security-headers +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-security-headers . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-security-headers /usr/bin/proxy-security-headers + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-security-headers"] +CMD ["serve"] diff --git a/challenges/proxy-security-headers/README.md b/challenges/proxy-security-headers/README.md new file mode 100644 index 0000000..7cb5841 --- /dev/null +++ b/challenges/proxy-security-headers/README.md @@ -0,0 +1,26 @@ +# HTTP Security Headers + +This challenge demonstrates a reverse proxy that forwards responses without injecting common hardening headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`) and leaks the upstream software banner via `Server` / `X-Powered-By`. + +## How to run it + +```bash +go run main.go serve +``` + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +go run main.go serve --vulnerable=true # vulnerable: no hardening headers, Server/X-Powered-By banners disclosed +go run main.go serve --vulnerable=false # fixed: hardening headers are injected, banners are removed +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-security-headers/go.mod b/challenges/proxy-security-headers/go.mod new file mode 100644 index 0000000..fb90dcd --- /dev/null +++ b/challenges/proxy-security-headers/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-security-headers + +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/proxy-security-headers/go.sum b/challenges/proxy-security-headers/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-security-headers/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/proxy-security-headers/main.go b/challenges/proxy-security-headers/main.go new file mode 100644 index 0000000..d4d2ee9 --- /dev/null +++ b/challenges/proxy-security-headers/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-security-headers/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-security-headers/serve/server.go b/challenges/proxy-security-headers/serve/server.go new file mode 100644 index 0000000..b16a1a7 --- /dev/null +++ b/challenges/proxy-security-headers/serve/server.go @@ -0,0 +1,28 @@ +package serve + +import ( + "log" + "net/http" +) + +func RunServer(port string, vulnerable bool) { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // vulnerable: the proxy forwards the backend response as-is, without + // injecting any of the common hardening headers, and leaks the + // upstream software banner + if vulnerable { + w.Header().Set("Server", "nginx/1.18.0 (Ubuntu)") + w.Header().Set("X-Powered-By", "Express") + } else { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "ok"}`)) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-template-injection/.gitignore b/challenges/proxy-template-injection/.gitignore new file mode 100644 index 0000000..d34b1a8 --- /dev/null +++ b/challenges/proxy-template-injection/.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 + +proxy-template-injection diff --git a/challenges/proxy-template-injection/Dockerfile b/challenges/proxy-template-injection/Dockerfile new file mode 100644 index 0000000..4471362 --- /dev/null +++ b/challenges/proxy-template-injection/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-template-injection/ ./challenges/proxy-template-injection/ + +WORKDIR /app/challenges/proxy-template-injection +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-template-injection . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-template-injection /usr/bin/proxy-template-injection + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-template-injection"] +CMD ["serve"] diff --git a/challenges/proxy-template-injection/README.md b/challenges/proxy-template-injection/README.md new file mode 100644 index 0000000..1be769e --- /dev/null +++ b/challenges/proxy-template-injection/README.md @@ -0,0 +1,33 @@ +# Proxy-Level Template Injection + +This challenge demonstrates a proxy templating directive (similar to Caddy's `templates` directive) that renders a reflected request header (`Referer`) through a server-side template engine, allowing an attacker to inject template directives (e.g. `{{env "SECRET_KEY"}}`) and read environment variables from the proxy process. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /` — renders the `Referer` header into the response body + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: curl -H 'Referer: {{env "SECRET_KEY"}}' http://localhost:8080/ leaks the SECRET_KEY env var +go run main.go serve --vulnerable=true + +# fixed: the header value is HTML-escaped and never evaluated as a template +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-template-injection/go.mod b/challenges/proxy-template-injection/go.mod new file mode 100644 index 0000000..7041622 --- /dev/null +++ b/challenges/proxy-template-injection/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-template-injection + +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/proxy-template-injection/go.sum b/challenges/proxy-template-injection/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-template-injection/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/proxy-template-injection/main.go b/challenges/proxy-template-injection/main.go new file mode 100644 index 0000000..dd60cc0 --- /dev/null +++ b/challenges/proxy-template-injection/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-template-injection/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-template-injection/serve/server.go b/challenges/proxy-template-injection/serve/server.go new file mode 100644 index 0000000..27a5224 --- /dev/null +++ b/challenges/proxy-template-injection/serve/server.go @@ -0,0 +1,61 @@ +package serve + +import ( + "bytes" + "html/template" + "log" + "net/http" + "os" + textTemplate "text/template" +) + +// funcs exposes a couple of primitives similar to what a proxy's templating +// directive (e.g. Caddy's `templates`) would offer, including reading files +// and environment variables. +var funcs = textTemplate.FuncMap{ + "env": os.Getenv, +} + +func RunServer(port string, vulnerable bool) { + if os.Getenv("SECRET_KEY") == "" { + os.Setenv("SECRET_KEY", "supersecret-internal-key") + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + referer := r.Header.Get("Referer") + w.Header().Set("Content-Type", "text/html") + + if vulnerable { + // vulnerable: the Referer header is rendered through the proxy's + // server-side templating engine, so an attacker-controlled value + // like "{{env \"SECRET_KEY\"}}" gets evaluated instead of + // displayed as plain text + tmpl, err := textTemplate.New("page").Funcs(funcs).Parse( + `Referred by: ` + referer + ``, + ) + if err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, nil); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return + } + w.Write(buf.Bytes()) + return + } + + // fixed: the value is HTML-escaped and never parsed as a template, + // so template directives are displayed as inert text + tmpl := template.Must(template.New("page").Parse( + `Referred by: {{.}}`, + )) + var buf bytes.Buffer + tmpl.Execute(&buf, referer) + w.Write(buf.Bytes()) + }) + + log.Println("Server started at port", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/challenges/proxy-tls-misconfiguration/.gitignore b/challenges/proxy-tls-misconfiguration/.gitignore new file mode 100644 index 0000000..ea9ae4a --- /dev/null +++ b/challenges/proxy-tls-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 + +proxy-tls-misconfiguration diff --git a/challenges/proxy-tls-misconfiguration/Dockerfile b/challenges/proxy-tls-misconfiguration/Dockerfile new file mode 100644 index 0000000..3e2c066 --- /dev/null +++ b/challenges/proxy-tls-misconfiguration/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-tls-misconfiguration/ ./challenges/proxy-tls-misconfiguration/ + +WORKDIR /app/challenges/proxy-tls-misconfiguration +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-tls-misconfiguration . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-tls-misconfiguration /usr/bin/proxy-tls-misconfiguration + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-tls-misconfiguration"] +CMD ["serve"] diff --git a/challenges/proxy-tls-misconfiguration/README.md b/challenges/proxy-tls-misconfiguration/README.md new file mode 100644 index 0000000..2f48eb8 --- /dev/null +++ b/challenges/proxy-tls-misconfiguration/README.md @@ -0,0 +1,35 @@ +# TLS / Transport + +This challenge demonstrates a TLS-terminating proxy with a weak configuration: obsolete protocol versions (TLS 1.0) and non-forward-secret cipher suites are accepted, and the `Strict-Transport-Security` header is never sent. + +## How to run it + +```bash +go run main.go serve +``` + +The server listens over HTTPS with an ephemeral self-signed certificate generated at startup. + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: TLS 1.0 and weak CBC/3DES ciphers are accepted, no HSTS header +go run main.go serve --vulnerable=true + +# fixed: only TLS 1.2+ with forward-secret AEAD ciphers is accepted, HSTS is sent with includeSubDomains and preload +go run main.go serve --vulnerable=false +``` + +```bash +curl -k --tls-max 1.0 --ciphers DES-CBC3-SHA https://localhost:8080/ +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-tls-misconfiguration/go.mod b/challenges/proxy-tls-misconfiguration/go.mod new file mode 100644 index 0000000..6845376 --- /dev/null +++ b/challenges/proxy-tls-misconfiguration/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-tls-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/proxy-tls-misconfiguration/go.sum b/challenges/proxy-tls-misconfiguration/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-tls-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/proxy-tls-misconfiguration/main.go b/challenges/proxy-tls-misconfiguration/main.go new file mode 100644 index 0000000..1472a94 --- /dev/null +++ b/challenges/proxy-tls-misconfiguration/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-tls-misconfiguration/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-tls-misconfiguration/serve/server.go b/challenges/proxy-tls-misconfiguration/serve/server.go new file mode 100644 index 0000000..f2027ef --- /dev/null +++ b/challenges/proxy-tls-misconfiguration/serve/server.go @@ -0,0 +1,90 @@ +package serve + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "log" + "math/big" + "net/http" + "time" +) + +// selfSignedCert generates an ephemeral self-signed certificate so the +// challenge does not depend on any external files. +func selfSignedCert() tls.Certificate { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + log.Fatal(err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + log.Fatal(err) + } + return cert +} + +func RunServer(port string, vulnerable bool) { + cert := selfSignedCert() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !vulnerable { + // fixed: HSTS is advertised with a long max-age, includeSubDomains and preload + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "ok"}`)) + }) + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + if vulnerable { + // vulnerable: obsolete protocol versions and weak, non-forward-secret + // cipher suites are still accepted, and HSTS is never sent + tlsConfig.MinVersion = tls.VersionTLS10 + tlsConfig.CipherSuites = []uint16{ + tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + } + } else { + // fixed: only modern, forward-secret protocol versions and ciphers are accepted + tlsConfig.MinVersion = tls.VersionTLS12 + tlsConfig.CipherSuites = []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + } + } + + server := &http.Server{ + Addr: ":" + port, + Handler: handler, + TLSConfig: tlsConfig, + // disable automatic HTTP/2, which mandates a modern cipher suite set + // incompatible with the weak configuration exercised by this challenge + TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, + } + + log.Println("Server started at port", port) + log.Fatal(server.ListenAndServeTLS("", "")) +} diff --git a/challenges/proxy-waf-bypass/.gitignore b/challenges/proxy-waf-bypass/.gitignore new file mode 100644 index 0000000..5861b2f --- /dev/null +++ b/challenges/proxy-waf-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 + +proxy-waf-bypass diff --git a/challenges/proxy-waf-bypass/Dockerfile b/challenges/proxy-waf-bypass/Dockerfile new file mode 100644 index 0000000..48c76ef --- /dev/null +++ b/challenges/proxy-waf-bypass/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY common/ ./common/ +COPY challenges/proxy-waf-bypass/ ./challenges/proxy-waf-bypass/ + +WORKDIR /app/challenges/proxy-waf-bypass +RUN CGO_ENABLED=0 GOWORK=off GOOS=linux go build -o /proxy-waf-bypass . + +FROM gcr.io/distroless/static-debian11:nonroot AS runner + +WORKDIR / + +COPY --from=builder --chown=nonroot:nonroot /proxy-waf-bypass /usr/bin/proxy-waf-bypass + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["proxy-waf-bypass"] +CMD ["serve"] diff --git a/challenges/proxy-waf-bypass/README.md b/challenges/proxy-waf-bypass/README.md new file mode 100644 index 0000000..0a5c81d --- /dev/null +++ b/challenges/proxy-waf-bypass/README.md @@ -0,0 +1,33 @@ +# WAF / Filtering Behavior Validation + +This challenge demonstrates a WAF/filtering layer in front of a search endpoint. The vulnerable server performs no filtering at all, letting a baseline set of SQLi/XSS/path-traversal payloads through unmodified. + +## How to run it + +```bash +go run main.go serve +``` + +## Endpoint + +- `GET /search?q=` — echoes the query back + +## Modes + +The server supports two modes, toggled with the `--vulnerable` flag on the `serve` command (defaults to `true`): + +```bash +# vulnerable: payloads such as "' OR 1=1--", "" or "../../etc/passwd" pass through +go run main.go serve --vulnerable=true + +# fixed: the same baseline payload set is blocked with 403, including encoded variants +go run main.go serve --vulnerable=false +``` + +## Disclaimer + +The challenges provided in this repository are designed to be educational and for testing purposes only. Do not attempt to exploit vulnerabilities in systems or APIs without proper authorization. Always ensure that you have the necessary permissions to conduct security testing on any system or application. + +--- + +Learn more about API security at [Cerberauth](https://www.cerberauth.com/) diff --git a/challenges/proxy-waf-bypass/go.mod b/challenges/proxy-waf-bypass/go.mod new file mode 100644 index 0000000..f7fd37b --- /dev/null +++ b/challenges/proxy-waf-bypass/go.mod @@ -0,0 +1,13 @@ +module github.com/cerberauth/api-vulns-challenges/challenges/proxy-waf-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/proxy-waf-bypass/go.sum b/challenges/proxy-waf-bypass/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/challenges/proxy-waf-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/proxy-waf-bypass/main.go b/challenges/proxy-waf-bypass/main.go new file mode 100644 index 0000000..b040cce --- /dev/null +++ b/challenges/proxy-waf-bypass/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/cerberauth/api-vulns-challenges/challenges/proxy-waf-bypass/serve" + "github.com/cerberauth/api-vulns-challenges/common" +) + +func main() { + common.Execute(serve.RunServer) +} diff --git a/challenges/proxy-waf-bypass/serve/server.go b/challenges/proxy-waf-bypass/serve/server.go new file mode 100644 index 0000000..eb60ddd --- /dev/null +++ b/challenges/proxy-waf-bypass/serve/server.go @@ -0,0 +1,57 @@ +package serve + +import ( + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" +) + +// baselinePatterns is a naive, single-pass WAF rule set. It is intentionally +// easy to bypass with encoding tricks to demonstrate an inconsistent filter. +var baselinePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)('|--|;|\bunion\b|\bselect\b|\bor\b\s+1=1)`), // SQLi + regexp.MustCompile(`(?i)