diff --git a/.github/scripts/.bash_history b/.github/scripts/.bash_history index a024fc1c6..120d5bd14 100644 --- a/.github/scripts/.bash_history +++ b/.github/scripts/.bash_history @@ -347,7 +347,7 @@ rm -rf jdk-18_linux-x64_bin.deb git rebase -i main git rebase -i master git stash -export tempPassword="mO5vAFh3aK4tBr54zX8P9BS8LpT96gJWcKL5r0yZxhE=" +export tempPassword="xlhyzFAFKJnjmzPtnM+q9ezt0xiZO5seUT+f4t/46SY=" mvn run tempPassword k6 npx k6 diff --git a/.gitignore b/.gitignore index dcc1ec6e2..17cdaceaa 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ src/main/resources/executables/wrongsecrets-dotnet* k8s/challenge53/executables/wrongsecrets-challenge53-c k8s/challenge53/executables/wrongsecrets-challenge53-c* +# Challenge 62 +challenge62-key.json + # Node JS js/node/ js/node_modules/ diff --git a/.lycheeignore b/.lycheeignore index f32f92a92..a6303773e 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -33,3 +33,6 @@ https://github.com/topics/secrets-detection # Helm docs are flaky in CI (connection resets) https://helm.sh/docs/intro/install/ + +# Google Docs require authentication and always return 401 to link checkers +https://docs.google.com/document/* diff --git a/Dockerfile b/Dockerfile index 921aa7884..5d72b1dc8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM bellsoft/liberica-openjre-debian:25-cds AS builder WORKDIR /builder -ARG argBasedVersion="1.13.1" +ARG argBasedVersion="1.13.2alpha1" COPY --chown=wrongsecrets target/wrongsecrets-${argBasedVersion}-SNAPSHOT.jar application.jar RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted @@ -19,6 +19,10 @@ ENV DOCKER_ENV_PASSWORD="This is it" ENV AZURE_KEY_VAULT_ENABLED=false ENV CHALLENGE59_SLACK_WEBHOOK_URL=$challenge59_webhook_url ENV WRONGSECRETS_MCP_SECRET=MCPStolenSecret42! +ARG GOOGLE_SERVICE_ACCOUNT_KEY="if_you_see_this_configure_the_google_service_account_properly" +ARG GOOGLE_DRIVE_DOCUMENT_ID="1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs" +ENV GOOGLE_SERVICE_ACCOUNT_KEY=$GOOGLE_SERVICE_ACCOUNT_KEY +ENV GOOGLE_DRIVE_DOCUMENT_ID=$GOOGLE_DRIVE_DOCUMENT_ID ENV SPRINGDOC_UI=false ENV SPRINGDOC_DOC=false ENV BASTIONHOSTPATH="/home/wrongsecrets/.ssh" diff --git a/Dockerfile.web b/Dockerfile.web index 66f49d8e2..7e8dc84c2 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -1,5 +1,5 @@ -FROM jeroenwillemsen/wrongsecrets:1.13.1-no-vault -ARG argBasedVersion="1.13.1-no-vault" +FROM jeroenwillemsen/wrongsecrets:1.13.2alpha1-no-vault +ARG argBasedVersion="1.13.2alpha1-no-vault" ARG spring_profile="without-vault" ARG CANARY_URLS="http://canarytokens.com/terms/about/s7cfbdakys13246ewd8ivuvku/post.jsp,http://canarytokens.com/terms/about/y0all60b627gzp19ahqh7rl6j/post.jsp" ARG CTF_ENABLED=false @@ -39,6 +39,10 @@ ENV default_aws_value_challenge_11=$CHALLENGE_11_VALUE ENV BASTIONHOSTPATH="/home/wrongsecrets/.ssh" ENV PROJECTSPECPATH="/var/helpers/project-specification.mdc" ENV funnybunny="This is a funny bunny" +ARG GOOGLE_SERVICE_ACCOUNT_KEY="if_you_see_this_configure_the_google_service_account_properly" +ARG GOOGLE_DRIVE_DOCUMENT_ID="1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs" +ENV GOOGLE_SERVICE_ACCOUNT_KEY=$GOOGLE_SERVICE_ACCOUNT_KEY +ENV GOOGLE_DRIVE_DOCUMENT_ID=$GOOGLE_DRIVE_DOCUMENT_ID # Keep memory usage within Heroku dyno limits (512MB dyno). # Hard cap heap to 250M, metaspace to 60M, disable expensive GC, exit on OOM immediately. ENV JAVA_TOOL_OPTIONS="-Xmx250M -Xms128M -XX:MetaspaceSize=40M -XX:MaxMetaspaceSize=60M -XX:CompressedClassSpaceSize=32M -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof" diff --git a/README.md b/README.md index 96751134b..849bafe60 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,12 @@ docker run -p 8080:8080 -p 8090:8090 ghcr.io/owasp/wrongsecrets/wrongsecrets-mas ⚠️ **Warning**: This is a development version built from the latest master branch and may contain experimental features or instabilities. **πŸ“ Note on Ports:** -- Port **8080**: Main application (challenges 0-61) +- Port **8080**: Main application (challenges 0-62) - Port **8090**: MCP server (required for Challenge 60) +**πŸ“ Note on Challenge 62 (Google Drive MCP):** +Challenge 62 requires a Google Service Account to be configured for full functionality. See [docs/CHALLENGE62_GOOGLE_DRIVE_SETUP.md](docs/CHALLENGE62_GOOGLE_DRIVE_SETUP.md) for setup instructions. Without configuration, the challenge will show a placeholder message. + Now you can try to find the secrets by means of solving the challenge offered at the links below
all the links for docker challenges (click triangle to open the block). @@ -218,6 +221,7 @@ Now you can try to find the secrets by means of solving the challenge offered at - [localhost:8080/challenge/challenge-59](http://localhost:8080/challenge/challenge-59) - [localhost:8080/challenge/challenge-60](http://localhost:8080/challenge/challenge-60) - [localhost:8080/challenge/challenge-61](http://localhost:8080/challenge/challenge-61) +- [localhost:8080/challenge/challenge-62](http://localhost:8080/challenge/challenge-62)
Note that these challenges are still very basic, and so are their explanations. Feel free to file a PR to make them look @@ -246,7 +250,7 @@ If you want to host WrongSecrets on Railway, you can do so by deploying [this on ## Basic K8s exercise -_Can be used for challenges 0-6, 8, 12-43, 48-61_ +_Can be used for challenges 0-6, 8, 12-43, 48-62_ ### Minikube based @@ -341,7 +345,7 @@ This is because if you run the start script again it will replace the secret in ## Cloud Challenges -_Can be used for challenges 0-61_ +_Can be used for challenges 0-62_ **READ THIS**: Given that the exercises below contain IAM privilege escalation exercises, never run this on an account which is related to your production environment or can influence your account-over-arching diff --git a/aws/k8s/secret-challenge-vault-deployment.yml b/aws/k8s/secret-challenge-vault-deployment.yml index b0d721059..e33a21282 100644 --- a/aws/k8s/secret-challenge-vault-deployment.yml +++ b/aws/k8s/secret-challenge-vault-deployment.yml @@ -58,7 +58,7 @@ spec: volumeAttributes: secretProviderClass: "wrongsecrets-aws-secretsmanager" containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-k8s-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-k8s-vault imagePullPolicy: IfNotPresent name: secret-challenge command: ["/bin/sh"] diff --git a/azure/k8s/secret-challenge-vault-deployment.yml.tpl b/azure/k8s/secret-challenge-vault-deployment.yml.tpl index db20ac7d4..e93aaf17f 100644 --- a/azure/k8s/secret-challenge-vault-deployment.yml.tpl +++ b/azure/k8s/secret-challenge-vault-deployment.yml.tpl @@ -61,7 +61,7 @@ spec: volumeAttributes: secretProviderClass: "azure-wrongsecrets-vault" containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-k8s-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-k8s-vault imagePullPolicy: IfNotPresent name: secret-challenge command: ["/bin/sh"] diff --git a/docs/CHALLENGE62_GOOGLE_DRIVE_SETUP.md b/docs/CHALLENGE62_GOOGLE_DRIVE_SETUP.md new file mode 100644 index 000000000..c0343c3d0 --- /dev/null +++ b/docs/CHALLENGE62_GOOGLE_DRIVE_SETUP.md @@ -0,0 +1,220 @@ +# Challenge 62: Google Service Account Setup Guide + +This guide explains how to configure Challenge 62, which demonstrates privilege escalation via an MCP (Model Context Protocol) server using a Google Service Account to access restricted Google Drive documents. + +## Overview + +Challenge 62 shows how an MCP server configured with an overly-privileged Google Service Account allows callers to read Google Drive documents they are not directly authorized to access. The service account acts as a privilege escalation proxy. + +## Runtime Behavior Notes + +- The challenge answer is parsed from document content between `` and ``. +- The parsed answer is cached once in `Challenge62` and reused for answer validation. +- `Challenge62McpController` caches Drive documents to reduce repeated API calls. +- Cache policy: always retain the configured default document (`GOOGLE_DRIVE_DOCUMENT_ID`) plus up to 20 additional document ids. + +## Prerequisites + +- A Google Cloud project +- Owner or Editor role on the Google Cloud project (to create service accounts) +- A Google Drive document containing a secret + +## Step 1: Create a Google Cloud Project (if needed) + +If you don't have a Google Cloud project: + +```bash +gcloud projects create YOUR_PROJECT_ID --name="WrongSecrets Challenge 62" +gcloud config set project YOUR_PROJECT_ID +``` + +## Step 2: Enable the Google Drive API + +```bash +gcloud services enable drive.googleapis.com +``` + +## Step 3: Create a Service Account + +```bash +gcloud iam service-accounts create wrongsecrets-challenge62 \ + --display-name="WrongSecrets Challenge 62 Drive Reader" \ + --description="Service account for WrongSecrets Challenge 62 - demonstrates MCP privilege escalation" +``` + +## Step 4: Create and Download a Service Account Key + +```bash +gcloud iam service-accounts keys create challenge62-key.json \ + --iam-account=wrongsecrets-challenge62@YOUR_PROJECT_ID.iam.gserviceaccount.com +``` + +**⚠️ Security Warning**: Service account key files are sensitive credentials. Handle them carefully: +- Do not commit key files to version control +- Delete the key file after encoding it +- Rotate keys regularly + +## Step 5: Create a Google Drive Document with the Secret + +1. Go to [Google Drive](https://drive.google.com) and create a new Google Doc +2. Add your challenge secret as the document content (e.g., `my_wrongsecrets_challenge62_answer`) + - Recommended format: `my_wrongsecrets_challenge62_answer` +3. Note the document ID from the URL: + - URL format: `https://docs.google.com/document/d/DOCUMENT_ID/edit` + - Copy the `DOCUMENT_ID` part + +## Step 6: Share the Document with the Service Account + +Share the Google Drive document with the service account's email address: + +1. Open the document in Google Drive +2. Click **Share** +3. Add the service account email: `wrongsecrets-challenge62@YOUR_PROJECT_ID.iam.gserviceaccount.com` +4. Set the permission to **Viewer** +5. Click **Send** + +Alternatively, use the Drive API via the CLI: +```bash +# Get the document ID from the URL +DOCUMENT_ID="your_document_id_here" +SA_EMAIL="wrongsecrets-challenge62@YOUR_PROJECT_ID.iam.gserviceaccount.com" + +# Share using the Drive API (requires OAuth2 token) +curl -X POST "https://www.googleapis.com/drive/v3/files/${DOCUMENT_ID}/permissions" \ + -H "Authorization: Bearer $(gcloud auth print-access-token)" \ + -H "Content-Type: application/json" \ + -d "{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"${SA_EMAIL}\"}" +``` + +## Step 7: Encode the Service Account Key + +Base64-encode the service account key file: + +```bash +# On Linux/macOS: +SERVICE_ACCOUNT_KEY_B64=$(base64 -w 0 challenge62-key.json) + +# On macOS (if the above doesn't work): +SERVICE_ACCOUNT_KEY_B64=$(base64 -i challenge62-key.json | tr -d '\n') + +echo "Your base64-encoded key (use this as GOOGLE_SERVICE_ACCOUNT_KEY):" +echo "${SERVICE_ACCOUNT_KEY_B64}" +``` + +## Step 8: Configure WrongSecrets + +Set the following environment variables when **running** WrongSecrets. These must be provided at container start time β€” do **not** bake real credentials into the image via `--build-arg`, as that embeds them in the image layer history. + +| Variable | Description | Default (placeholder) | Example override | +|----------|-------------|----------------------|-----------------| +| `GOOGLE_SERVICE_ACCOUNT_KEY` | Base64-encoded service account JSON key | `if_you_see_this_configure_the_google_service_account_properly` | `eyJ0eXBlIjoic2VydmljZV9hY2...` | +| `GOOGLE_DRIVE_DOCUMENT_ID` | Google Drive document ID | `1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs` | your document id | +| `WRONGSECRETS_MCP_GOOGLEDRIVE_SECRET` | *(optional)* Static override β€” skips live Drive fetch | *(none β€” live fetch used)* | `my_wrongsecrets_challenge62_answer` | + +> **Why runtime-only?** +> The `Dockerfile` and `Dockerfile.web` ship harmless placeholder defaults via `ENV`. Real credentials should only be injected at `docker run` time so they never appear in image layers or build logs. + +### Running with Docker (explicit values) + +```bash +export SERVICE_ACCOUNT_KEY_B64=$(base64 -i challenge62-key.json | tr -d '\n') +export DOCUMENT_ID="your_document_id_here" + +docker run -p 8080:8080 -p 8090:8090 \ + -e GOOGLE_SERVICE_ACCOUNT_KEY="${SERVICE_ACCOUNT_KEY_B64}" \ + -e GOOGLE_DRIVE_DOCUMENT_ID="${DOCUMENT_ID}" \ + ghcr.io/owasp/wrongsecrets/wrongsecrets:latest-no-vault +``` + +### Running with Docker (inherit from host shell) + +If the variables are already exported in your shell, pass them through without a value β€” Docker inherits from the host: + +```bash +export GOOGLE_SERVICE_ACCOUNT_KEY="${SERVICE_ACCOUNT_KEY_B64}" +export GOOGLE_DRIVE_DOCUMENT_ID="your_document_id_here" + +docker run -p 8080:8080 -p 8090:8090 \ + -e GOOGLE_SERVICE_ACCOUNT_KEY \ + -e GOOGLE_DRIVE_DOCUMENT_ID \ + ghcr.io/owasp/wrongsecrets/wrongsecrets:latest-no-vault +``` + +### Running with Docker using an env file + +Create a `.env` file (add it to `.gitignore`): + +```bash +GOOGLE_SERVICE_ACCOUNT_KEY= +GOOGLE_DRIVE_DOCUMENT_ID= +``` + +Then run: + +```bash +docker run -p 8080:8080 -p 8090:8090 \ + --env-file .env \ + ghcr.io/owasp/wrongsecrets/wrongsecrets:latest-no-vault +``` + +### Running with Spring Boot (local development) + +Set environment variables in your shell before running: + +```bash +export GOOGLE_SERVICE_ACCOUNT_KEY="${SERVICE_ACCOUNT_KEY_B64}" +export GOOGLE_DRIVE_DOCUMENT_ID="your_document_id" +./mvnw spring-boot:run +``` + +Or add them to a **local-only** properties file that is not committed to version control: + +```properties +# application-local.properties (keep out of git) +GOOGLE_SERVICE_ACCOUNT_KEY= +GOOGLE_DRIVE_DOCUMENT_ID= +``` + +## Step 9: Clean Up the Key File + +After encoding the key, delete the local key file: + +```bash +rm challenge62-key.json +``` + +## Using the Default OWASP Document (for testing) + +The default document ID configured in the application is the OWASP WrongSecrets Google Drive document: +- Document: https://docs.google.com/document/d/1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs/edit + +To use this document, your service account must have been granted read access to it by the OWASP WrongSecrets maintainers. For your own deployment, we recommend creating your own document as described above. + +## Security Notes + +1. **This is intentionally insecure for educational purposes**: In a real system, you should always authenticate and authorize MCP callers before granting access to external resources. + +2. **Least Privilege**: The service account used in this challenge demonstrates what happens when you violate least privilege. In production, ensure service accounts only have the minimum permissions necessary. + +3. **Never use production credentials**: Do not use service accounts that have access to production data for this challenge. + +4. **Key rotation**: Regularly rotate service account keys to limit the window of exposure if a key is compromised. + +## Verification + +After configuration, verify the challenge works by calling the MCP endpoint: + +```bash +curl -s -X POST http://localhost:8080/mcp62 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_google_drive_document","arguments":{}}}' +``` + +The response should contain the document content with your secret. + +## Tests and Code References + +- Main challenge logic: `src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62.java` +- MCP controller and cache logic: `src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpController.java` +- Challenge tests: `src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62Test.java` +- MCP controller tests: `src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpControllerTest.java` diff --git a/docs/VERSION_MANAGEMENT.md b/docs/VERSION_MANAGEMENT.md index fa8ee2809..4421e5ff1 100644 --- a/docs/VERSION_MANAGEMENT.md +++ b/docs/VERSION_MANAGEMENT.md @@ -12,9 +12,9 @@ The project maintains version consistency between: ## Version Schema ``` -pom.xml version: 1.13.1-SNAPSHOT -Dockerfile version: 1.13.1 -Dockerfile.web version: 1.13.1-no-vault +pom.xml version: 1.13.2alpha1-SNAPSHOT +Dockerfile version: 1.13.2alpha1 +Dockerfile.web version: 1.13.2alpha1-no-vault ``` ## Automated Solutions diff --git a/fly.toml b/fly.toml index e00de8bb9..ed7992806 100644 --- a/fly.toml +++ b/fly.toml @@ -8,7 +8,7 @@ app = "wrongsecrets" primary_region = "ams" [build] - image = "docker.io/jeroenwillemsen/wrongsecrets:1.13.1-no-vault" + image = "docker.io/jeroenwillemsen/wrongsecrets:1.13.2alpha1-no-vault" [env] K8S_ENV = "Fly(Docker)" diff --git a/gcp/k8s/secret-challenge-vault-deployment.yml.tpl b/gcp/k8s/secret-challenge-vault-deployment.yml.tpl index b7e67c57f..f2224cc23 100644 --- a/gcp/k8s/secret-challenge-vault-deployment.yml.tpl +++ b/gcp/k8s/secret-challenge-vault-deployment.yml.tpl @@ -58,7 +58,7 @@ spec: volumeAttributes: secretProviderClass: "wrongsecrets-gcp-secretsmanager" containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-k8s-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-k8s-vault imagePullPolicy: IfNotPresent name: secret-challenge command: ["/bin/sh"] diff --git a/js/index.js b/js/index.js index 93b4aa90b..38b9fd784 100644 --- a/js/index.js +++ b/js/index.js @@ -1,5 +1,5 @@ function secret() { - var password = "svbdToA=" + 9 + "PW50" + 6 + "IPk=" + 2 + "st9I" + 7; + var password = "H1mDyoE=" + 9 + "Xk4x" + 6 + "QMs=" + 2 + "XtKn" + 7; return password; } diff --git a/k8s/challenge53/secret-challenge53-sidecar.yml b/k8s/challenge53/secret-challenge53-sidecar.yml index 90d889f00..b94bccd53 100644 --- a/k8s/challenge53/secret-challenge53-sidecar.yml +++ b/k8s/challenge53/secret-challenge53-sidecar.yml @@ -21,7 +21,7 @@ spec: runAsGroup: 2000 fsGroup: 2000 containers: - - image: jeroenwillemsen/wrongsecrets-challenge53:1.13.1 + - image: jeroenwillemsen/wrongsecrets-challenge53:1.13.2alpha1 name: secret-challenge-53 imagePullPolicy: IfNotPresent resources: @@ -45,7 +45,7 @@ spec: command: ["/bin/sh", "-c"] args: - cp /home/wrongsecrets/* /shared-data/ && exec /home/wrongsecrets/start-on-arch.sh - - image: jeroenwillemsen/wrongsecrets-challenge53-debug:1.13.1 + - image: jeroenwillemsen/wrongsecrets-challenge53-debug:1.13.2alpha1 name: sidecar imagePullPolicy: IfNotPresent command: ["/bin/sh", "-c", "while true; do ls /shared-data; sleep 10; done"] diff --git a/k8s/challenge53/secret-challenge53.yml b/k8s/challenge53/secret-challenge53.yml index 8cbcfc314..a70cf333f 100644 --- a/k8s/challenge53/secret-challenge53.yml +++ b/k8s/challenge53/secret-challenge53.yml @@ -21,7 +21,7 @@ spec: runAsGroup: 2000 fsGroup: 2000 containers: - - image: jeroenwillemsen/wrongsecrets-challenge53:1.13.1 + - image: jeroenwillemsen/wrongsecrets-challenge53:1.13.2alpha1 name: secret-challenge-53 imagePullPolicy: IfNotPresent resources: diff --git a/k8s/secret-challenge-deployment.yml b/k8s/secret-challenge-deployment.yml index 02dda9537..6f48112a3 100644 --- a/k8s/secret-challenge-deployment.yml +++ b/k8s/secret-challenge-deployment.yml @@ -28,7 +28,7 @@ spec: runAsGroup: 2000 fsGroup: 2000 containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-no-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-no-vault imagePullPolicy: IfNotPresent name: secret-challenge ports: diff --git a/k8s/secret-challenge-vault-deployment.yml b/k8s/secret-challenge-vault-deployment.yml index 1552082fe..d4a74442a 100644 --- a/k8s/secret-challenge-vault-deployment.yml +++ b/k8s/secret-challenge-vault-deployment.yml @@ -50,7 +50,7 @@ spec: type: RuntimeDefault serviceAccountName: vault containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-k8s-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-k8s-vault imagePullPolicy: IfNotPresent name: secret-challenge command: ["/bin/sh"] diff --git a/okteto/k8s/secret-challenge-ctf-deployment.yml b/okteto/k8s/secret-challenge-ctf-deployment.yml index a6e6f538f..f6e2877d8 100644 --- a/okteto/k8s/secret-challenge-ctf-deployment.yml +++ b/okteto/k8s/secret-challenge-ctf-deployment.yml @@ -28,7 +28,7 @@ spec: runAsGroup: 2000 fsGroup: 2000 containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-no-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-no-vault name: secret-challenge-ctf imagePullPolicy: IfNotPresent securityContext: diff --git a/okteto/k8s/secret-challenge-deployment.yml b/okteto/k8s/secret-challenge-deployment.yml index ae9d2da45..1bf9883ea 100644 --- a/okteto/k8s/secret-challenge-deployment.yml +++ b/okteto/k8s/secret-challenge-deployment.yml @@ -28,7 +28,7 @@ spec: runAsGroup: 2000 fsGroup: 2000 containers: - - image: jeroenwillemsen/wrongsecrets:1.13.1-no-vault + - image: jeroenwillemsen/wrongsecrets:1.13.2alpha1-no-vault name: secret-challenge imagePullPolicy: IfNotPresent securityContext: diff --git a/pom.xml b/pom.xml index 9cfc94c6f..89a3164c4 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ org.owasp wrongsecrets - 1.13.1-SNAPSHOT + 1.13.2alpha1-SNAPSHOT OWASP WrongSecrets Examples with how to not use secrets diff --git a/src/main/java/org/owasp/wrongsecrets/SecurityConfig.java b/src/main/java/org/owasp/wrongsecrets/SecurityConfig.java index 11905095a..7af1b73d4 100644 --- a/src/main/java/org/owasp/wrongsecrets/SecurityConfig.java +++ b/src/main/java/org/owasp/wrongsecrets/SecurityConfig.java @@ -51,7 +51,11 @@ private void configureCsrf(HttpSecurity http) throws Exception { http.csrf( csrf -> csrf.ignoringRequestMatchers( - "/canaries/tokencallback", "/canaries/tokencallbackdebug", "/token", "/mcp")); + "/canaries/tokencallback", + "/canaries/tokencallbackdebug", + "/token", + "/mcp", + "/mcp62")); } private void configureBasicAuthentication(HttpSecurity http, List auths) diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge60McpController.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge60McpController.java index fe7f1cf28..b2903ca02 100644 --- a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge60McpController.java +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge60McpController.java @@ -140,6 +140,11 @@ private Map handleExecuteCommand(Object id, Map // expose when an attacker runs a command like "env" or "printenv" String envOutput = System.getenv().entrySet().stream() + .filter( + e -> + !e.getKey() + .equals("GOOGLE_SERVICE_ACCOUNT_KEY")) // we don't want to waste challenge62 + // ;-). .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("\n")); diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62.java new file mode 100644 index 000000000..0c4578914 --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62.java @@ -0,0 +1,124 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import com.google.common.base.Strings; +import lombok.extern.slf4j.Slf4j; +import org.owasp.wrongsecrets.challenges.Challenge; +import org.owasp.wrongsecrets.challenges.Spoiler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Challenge demonstrating how an MCP server with an overly-privileged Google Service Account can be + * used to escalate privileges and access Google Drive documents that the caller is not authorized + * to read directly. + */ +@Component +@Slf4j +public class Challenge62 implements Challenge { + + private static final String SECRET_START_TAG = ""; + private static final String SECRET_END_TAG = ""; + private static final String DEFAULT_PLACEHOLDER = + "if_you_see_this_configure_the_google_service_account_properly"; + private static final String DEFAULT_DOCUMENT_ID = "1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs"; + + private final String configuredGoogleDriveSecret; + private final String documentId; + private final Challenge62McpController challenge62McpController; + // Holds the normalized answer value used by spoiler()/answerCorrect(). + // Normalization extracts the value between and once at load time. + private String cachedResolvedSecret = ""; + private boolean cachedResolvedSecretLoaded; + + @Autowired + public Challenge62( + @Value( + "${WRONGSECRETS_MCP_GOOGLEDRIVE_SECRET:if_you_see_this_configure_the_google_service_account_properly}") + String configuredGoogleDriveSecret, + @Value("${GOOGLE_DRIVE_DOCUMENT_ID:1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs}") + String documentId, + Challenge62McpController challenge62McpController) { + this.configuredGoogleDriveSecret = configuredGoogleDriveSecret; + this.documentId = documentId; + this.challenge62McpController = challenge62McpController; + } + + Challenge62(String configuredGoogleDriveSecret) { + this(configuredGoogleDriveSecret, DEFAULT_DOCUMENT_ID, null); + } + + @Override + public Spoiler spoiler() { + return new Spoiler(resolveSecret()); + } + + @Override + public boolean answerCorrect(String answer) { + if (Strings.isNullOrEmpty(answer)) { + return false; + } + + String resolvedSecret = resolveSecret(); + return !Strings.isNullOrEmpty(resolvedSecret) && resolvedSecret.equals(answer.trim()); + } + + /** + * Extracts the value between {@code } and {@code }. + * + *

Returns {@code null} when tags are missing or malformed. + */ + private String extractSecretValue(String resolvedSecret) { + if (Strings.isNullOrEmpty(resolvedSecret)) { + return null; + } + + int secretStart = resolvedSecret.indexOf(SECRET_START_TAG); + if (secretStart < 0) { + return null; + } + + int valueStart = secretStart + SECRET_START_TAG.length(); + int secretEnd = resolvedSecret.indexOf(SECRET_END_TAG, valueStart); + if (secretEnd < 0) { + return null; + } + + return resolvedSecret.substring(valueStart, secretEnd).trim(); + } + + /** + * Resolves and caches the challenge answer once. + * + *

If {@code WRONGSECRETS_MCP_GOOGLEDRIVE_SECRET} is explicitly configured, that value is used + * as source content and normalized to the tagged secret. Otherwise, the value is loaded from the + * configured Google Drive document through {@link Challenge62McpController} and then normalized. + */ + private String resolveSecret() { + if (cachedResolvedSecretLoaded) { + return cachedResolvedSecret; + } + + if (!DEFAULT_PLACEHOLDER.equals(configuredGoogleDriveSecret)) { + cachedResolvedSecret = Strings.nullToEmpty(extractSecretValue(configuredGoogleDriveSecret)); + cachedResolvedSecretLoaded = true; + return cachedResolvedSecret; + } + + if (challenge62McpController == null) { + return configuredGoogleDriveSecret; + } + + try { + log.info("Attempting to read secret from Google Drive document with ID: {}", documentId); + String loadedDocument = challenge62McpController.readGoogleDriveDocument(documentId); + cachedResolvedSecret = Strings.nullToEmpty(extractSecretValue(loadedDocument)); + cachedResolvedSecretLoaded = true; + return Strings.isNullOrEmpty(cachedResolvedSecret) + ? configuredGoogleDriveSecret + : cachedResolvedSecret; + } catch (Exception ignored) { + return configuredGoogleDriveSecret; + } + } +} diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpController.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpController.java new file mode 100644 index 000000000..3eaec6d97 --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpController.java @@ -0,0 +1,370 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.ServiceAccountCredentials; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * MCP (Model Context Protocol) server endpoint for Challenge 62. Demonstrates how an MCP server + * configured with an overly-privileged Google Service Account allows privilege escalation: a caller + * without direct access to a Google Drive document can use the MCP tool to read it through the + * service account's elevated permissions. + * + *

The service account credentials are provided via the {@code GOOGLE_SERVICE_ACCOUNT_KEY} + * environment variable (base64-encoded JSON key file content). The Google Drive document ID to read + * is configured via {@code GOOGLE_DRIVE_DOCUMENT_ID}. + */ +@Slf4j +@RestController +public class Challenge62McpController { + + private static final String JSONRPC_VERSION = "2.0"; + private static final String DEFAULT_KEY_PLACEHOLDER = + "if_you_see_this_configure_the_google_service_account_properly"; + private static final int MAX_ADDITIONAL_CACHED_DOCUMENTS = 20; + private static final String DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; + private static final String DRIVE_EXPORT_URL = + "https://www.googleapis.com/drive/v3/files/%s/export?mimeType=text/plain"; + + private final String serviceAccountKeyBase64; + private final String documentId; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + // Cache for fetched Google Drive documents. The configured default document is pinned. + private final Map documentCache; + // Access-order tracker for non-default document ids to support bounded eviction. + private final LinkedHashMap additionalDocumentCacheOrder; + + @Autowired + public Challenge62McpController( + @Value( + "${GOOGLE_SERVICE_ACCOUNT_KEY:if_you_see_this_configure_the_google_service_account_properly}") + String serviceAccountKeyBase64, + @Value("${GOOGLE_DRIVE_DOCUMENT_ID:1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs}") + String documentId) { + this(serviceAccountKeyBase64, documentId, createDefaultRestTemplate(), new ObjectMapper()); + } + + Challenge62McpController( + String serviceAccountKeyBase64, + String documentId, + RestTemplate restTemplate, + ObjectMapper objectMapper) { + this.serviceAccountKeyBase64 = serviceAccountKeyBase64; + this.documentId = documentId; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.documentCache = new ConcurrentHashMap<>(); + this.additionalDocumentCacheOrder = new LinkedHashMap<>(16, 0.75f, true); + } + + private static RestTemplate createDefaultRestTemplate() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(10)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return new RestTemplate(factory); + } + + @PostMapping( + value = "/mcp62", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Map handleMcpRequest(@RequestBody Map request) { + String method = (String) request.get("method"); + Object id = request.get("id"); + log.info("Challenge62 MCP request received for method: {}", sanitizeForLog(method)); + + return switch (method) { + case "initialize" -> buildInitializeResponse(id); + case "tools/list" -> buildToolsListResponse(id); + case "tools/call" -> handleToolCall(id, request); + default -> buildErrorResponse(id, -32601, "Method not found: " + method); + }; + } + + private Map buildInitializeResponse(Object id) { + return buildResponse( + id, + Map.of( + "protocolVersion", + "2024-11-05", + "serverInfo", + Map.of("name", "wrongsecrets-googledrive-mcp-server", "version", "1.0.0"), + "capabilities", + Map.of("tools", Map.of()), + "instructions", + "This MCP server provides access to Google Drive documents using a configured service" + + " account. Use the read_google_drive_document tool to fetch document contents.")); + } + + private Map buildToolsListResponse(Object id) { + return buildResponse( + id, + Map.of( + "tools", + List.of( + Map.of( + "name", + "read_google_drive_document", + "description", + "Read the contents of a Google Drive document using the configured service" + + " account credentials. The service account has read access to documents" + + " that may not be accessible to the caller directly.", + "inputSchema", + Map.of( + "type", + "object", + "properties", + Map.of( + "document_id", + Map.of( + "type", + "string", + "description", + "The Google Drive document ID to read (optional, uses configured" + + " default if not provided)")), + "required", + List.of()))))); + } + + @SuppressWarnings("unchecked") + private Map handleToolCall(Object id, Map request) { + Map params = (Map) request.get("params"); + if (params == null) { + return buildErrorResponse(id, -32602, "Missing params"); + } + String toolName = (String) params.get("name"); + Map arguments = (Map) params.get("arguments"); + return switch (toolName) { + case "read_google_drive_document" -> handleReadGoogleDriveDocument(id, arguments); + default -> buildErrorResponse(id, -32602, "Unknown tool: " + toolName); + }; + } + + private Map handleReadGoogleDriveDocument( + Object id, Map arguments) { + String docId = + (arguments != null && arguments.get("document_id") != null) + ? (String) arguments.get("document_id") + : documentId; + + log.info( + "Challenge62 MCP read_google_drive_document called for document: {}", + sanitizeForLog(docId)); + + if (DEFAULT_KEY_PLACEHOLDER.equals(serviceAccountKeyBase64)) { + log.warn( + "Challenge62: GOOGLE_SERVICE_ACCOUNT_KEY is not configured. " + + "Set this environment variable to a base64-encoded service account JSON key."); + return buildResponse( + id, + Map.of( + "content", + List.of( + Map.of( + "type", + "text", + "text", + "Google Service Account is not configured. " + + "Set the GOOGLE_SERVICE_ACCOUNT_KEY environment variable to a " + + "base64-encoded service account JSON key file.")))); + } + + try { + String documentContent = readGoogleDriveDocument(docId); + return buildResponse( + id, Map.of("content", List.of(Map.of("type", "text", "text", documentContent)))); + } catch (Exception e) { + log.error("Challenge62: Failed to read Google Drive document: {}", e.getMessage()); + return buildErrorResponse(id, -32603, "Failed to read document: " + e.getMessage()); + } + } + + /** + * Reads a Google Drive document using the configured service account credentials. + * + *

Caching behavior: + * + *

    + *
  • The configured default document ({@code GOOGLE_DRIVE_DOCUMENT_ID}) is always cached and + * never evicted. + *
  • At most 20 additional document ids are cached using access-order eviction. + *
  • When reading any non-default document, the configured default document is ensured to be + * cached alongside it. + *
+ * + * @param docId the Google Drive document ID + * @return the plain text content of the document + * @throws Exception if the document cannot be read + */ + String readGoogleDriveDocument(String docId) throws Exception { + ensureConfiguredDocumentCached(docId); + + String cachedDocument = documentCache.get(docId); + if (cachedDocument != null) { + recordDocumentAccess(docId); + return cachedDocument; + } + + String documentContent = fetchGoogleDriveDocument(docId); + cacheDocument(docId, documentContent); + return documentContent; + } + + private void ensureConfiguredDocumentCached(String requestedDocId) throws Exception { + if (documentId.equals(requestedDocId) || documentCache.containsKey(documentId)) { + return; + } + + String configuredDocumentContent = fetchGoogleDriveDocument(documentId); + cacheDocument(documentId, configuredDocumentContent); + } + + String fetchGoogleDriveDocument(String docId) throws Exception { + log.info("fetchGoogleDriveDocument called for docId: {}", sanitizeForLog(docId)); + String accessToken = getServiceAccountAccessToken(); + String exportUrl = String.format(DRIVE_EXPORT_URL, docId); + + HttpHeaders headers = new HttpHeaders(); + /** + * Ensures the configured default document is cached when non-default documents are requested. + */ + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = + restTemplate.exchange(exportUrl, HttpMethod.GET, entity, String.class); + String body = response.getBody(); + return body != null ? body.trim() : ""; + } catch (RestClientException e) { + log.error( + "Challenge62: Failed to export Google Drive document {}: {}", docId, e.getMessage()); + throw new Exception("Unable to read document from Google Drive: " + e.getMessage(), e); + } + } + + /** Adds a document to the bounded cache and evicts only from the non-default document set. */ + private void cacheDocument(String docId, String documentContent) { + documentCache.put(docId, documentContent); + if (documentId.equals(docId)) { + return; + } + + synchronized (additionalDocumentCacheOrder) { + additionalDocumentCacheOrder.put(docId, Boolean.TRUE); + if (additionalDocumentCacheOrder.size() > MAX_ADDITIONAL_CACHED_DOCUMENTS) { + String evictedDocId = additionalDocumentCacheOrder.keySet().iterator().next(); + additionalDocumentCacheOrder.remove(evictedDocId); + documentCache.remove(evictedDocId); + } + } + } + + /** Updates access order for non-default cached documents. */ + private void recordDocumentAccess(String docId) { + if (documentId.equals(docId)) { + return; + } + + synchronized (additionalDocumentCacheOrder) { + if (additionalDocumentCacheOrder.containsKey(docId)) { + additionalDocumentCacheOrder.get(docId); + } + } + } + + /** + * Obtains an OAuth2 access token for the configured Google Service Account. + * + * @return the OAuth2 access token string + * @throws Exception if the token cannot be obtained + */ + private String getServiceAccountAccessToken() throws Exception { + try { + byte[] keyBytes = Base64.getDecoder().decode(serviceAccountKeyBase64); + validateServiceAccountJson(keyBytes); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.fromStream(new ByteArrayInputStream(keyBytes)); + ServiceAccountCredentials scopedCredentials = + (ServiceAccountCredentials) + credentials.createScoped(Collections.singletonList(DRIVE_SCOPE)); + scopedCredentials.refreshIfExpired(); + return scopedCredentials.getAccessToken().getTokenValue(); + } catch (IllegalArgumentException e) { + throw new Exception("Invalid base64 encoding for GOOGLE_SERVICE_ACCOUNT_KEY", e); + } + } + + /** + * Validates that the decoded bytes represent a valid JSON object with the expected service + * account fields. This guards against injection of arbitrary content. + * + * @param keyBytes the decoded service account JSON bytes + * @throws Exception if the JSON is invalid or missing required fields + */ + private void validateServiceAccountJson(byte[] keyBytes) throws Exception { + try { + JsonNode root = objectMapper.readTree(new String(keyBytes, StandardCharsets.UTF_8)); + if (!root.isObject()) { + throw new Exception("Service account key must be a JSON object"); + } + if (!root.has("type") || !"service_account".equals(root.get("type").asText())) { + throw new Exception("Invalid service account key: missing or incorrect 'type' field"); + } + if (!root.has("client_email") || !root.has("private_key")) { + throw new Exception( + "Invalid service account key: missing required fields (client_email, private_key)"); + } + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new Exception("Service account key is not valid JSON", e); + } + } + + private String sanitizeForLog(String input) { + if (input == null) { + return null; + } + return input.replaceAll("[\r\n\u0085\u2028\u2029]", "_"); + } + + private Map buildResponse(Object id, Object result) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", JSONRPC_VERSION); + response.put("id", id); + response.put("result", result); + return response; + } + + private Map buildErrorResponse(Object id, int code, String message) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", JSONRPC_VERSION); + response.put("id", id); + response.put("error", Map.of("code", code, "message", message)); + return response; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4962e40c0..05f762184 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -77,6 +77,9 @@ challenge49pin=NDQ0NDQ= challenge49ciphertext=k800mdwu8vlQoqeAgRMHDQ== CHALLENGE59_SLACK_WEBHOOK_URL=YUhSMGNITTZMeTlvYjI5cmN5NXpiR0ZqYXk1amIyMHZjMlZ5ZG1salpYTXZWREV5TXpRMU5qYzRPUzlDTVRJek5EVTJOemc1THpGaE1tSXpZelJrTldVMlpqZG5PR2c1YVRCcU1Xc3liRE50Tkc0MWJ6WndDZz09 WRONGSECRETS_MCP_SECRET=if_you_see_this_please_call_the_mcp_endpoint +WRONGSECRETS_MCP_GOOGLEDRIVE_SECRET=if_you_see_this_configure_the_google_service_account_properly +GOOGLE_SERVICE_ACCOUNT_KEY=if_you_see_this_configure_the_google_service_account_properly +GOOGLE_DRIVE_DOCUMENT_ID=1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs mcp.server.port=8090 DOCKER_SECRET_CHALLENGE51=Fald';alksAjhdna'/ management.endpoint.health.probes.enabled=true diff --git a/src/main/resources/challenges/challenge-62/challenge-62.snippet b/src/main/resources/challenges/challenge-62/challenge-62.snippet new file mode 100644 index 000000000..2fd08388e --- /dev/null +++ b/src/main/resources/challenges/challenge-62/challenge-62.snippet @@ -0,0 +1,54 @@ +
+

πŸ”‘ MCP Server with Google Service Account

+

An MCP (Model Context Protocol) server is running alongside this application. It exposes a read_google_drive_document tool that uses a Google Service Account to read a restricted document β€” even if you are not directly authorized to access it.

+ +
+

⚠️ The MCP server is reachable via /mcp62.

+ +

Step 1 β€” Discover what tools the MCP server exposes:

+
curl -s -X POST http://localhost:8080/mcp62 \
+  -H 'Content-Type: application/json' \
+  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
+ + + +

Step 2 β€” Call read_google_drive_document to read the restricted document:

+
curl -s -X POST http://localhost:8080/mcp62 \
+  -H 'Content-Type: application/json' \
+  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_google_drive_document","arguments":{}}}'
+ + + +

πŸ’‘ The document content contains the secret. Submit the secret content as your answer.

+
+
+ + + + diff --git a/src/main/resources/explanations/challenge62.adoc b/src/main/resources/explanations/challenge62.adoc new file mode 100644 index 000000000..a5c4ecfc6 --- /dev/null +++ b/src/main/resources/explanations/challenge62.adoc @@ -0,0 +1,44 @@ +=== MCP Server Privilege Escalation via Google Service Account + +The Model Context Protocol (MCP) allows AI assistants and agents to use tools and access external services. When an MCP server is configured with a Google Service Account that has broader permissions than the calling user, it creates a privilege escalation vulnerability: anyone who can call the MCP tool gains access to resources they are not directly authorized to access. + +This challenge demonstrates a realistic scenario where a developer has built an MCP server to help an AI assistant access internal Google Drive documents. The service account used by the MCP server has read access to a restricted document β€” but the MCP server does not verify that the caller is authorized to access it. + +**Your goal:** + +1. **An MCP server is running on this application** accessible via the `/mcp62` endpoint +2. **The server exposes a `read_google_drive_document` tool** that uses a Google Service Account +3. **The service account has read access to a Google Drive document** containing the secret +4. **The tool does not check whether the caller is authorized** to access that document + +**How to interact with the MCP server:** + +First, discover the available tools: + +[source,bash] +---- +curl -s -X POST http://localhost:8080/mcp62 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +---- + +Then, call the `read_google_drive_document` tool to retrieve the document contents: + +[source,bash] +---- +curl -s -X POST http://localhost:8080/mcp62 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_google_drive_document","arguments":{}}}' +---- + +**** +πŸ’‘ *Note:* + +The server reads a Google Drive document using a configured service account. The document's content contains the secret you need to submit as your answer. The key security insight is that the MCP server's service account has privileged access that neither you nor your AI assistant would normally have β€” but the MCP server grants it implicitly to anyone who calls its tools. +**** + +**Implementation details (for maintainers):** + +- The challenge answer is extracted from content between `` and ``. +- Extraction is done once at secret load time and then cached for answer validation. +- The MCP controller caches fetched documents, always retaining the configured default document plus up to 20 additional document ids. diff --git a/src/main/resources/explanations/challenge62_hint.adoc b/src/main/resources/explanations/challenge62_hint.adoc new file mode 100644 index 000000000..1e76446fb --- /dev/null +++ b/src/main/resources/explanations/challenge62_hint.adoc @@ -0,0 +1,40 @@ +=== Hint for Challenge 62 + +This challenge demonstrates how an MCP server with an overly-privileged Google Service Account enables privilege escalation. + +**Where to look:** + +1. **An MCP server is running at `/mcp62`** on the main application port +2. **The MCP server exposes a `read_google_drive_document` tool** that uses a Google Service Account to read a document +3. **You do not need valid Google credentials** β€” the service account is already configured in the application + +**Step-by-step approach:** + +First, list the tools the MCP server offers: + +[source,bash] +---- +curl -s -X POST http://localhost:8080/mcp62 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +---- + +Then call `read_google_drive_document` to read the document: + +[source,bash] +---- +curl -s -X POST http://localhost:8080/mcp62 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_google_drive_document","arguments":{}}}' +---- + +**What to look for:** + +- The response contains the contents of a Google Drive document +- The document contains the secret answer for this challenge +- The answer validator uses the value between `` and `` from that content +- Notice that you accessed a document you are not directly authorized to read β€” the MCP server's service account provided that access + +**Maintainer note:** The MCP controller keeps a bounded document cache to reduce repeated Drive API calls. + +**Remember:** This is an example of privilege escalation through an MCP server. The service account has more permissions than you (the caller) would have, and the MCP server does not enforce caller-level access controls. diff --git a/src/main/resources/explanations/challenge62_reason.adoc b/src/main/resources/explanations/challenge62_reason.adoc new file mode 100644 index 000000000..1849bbb6d --- /dev/null +++ b/src/main/resources/explanations/challenge62_reason.adoc @@ -0,0 +1,43 @@ +=== Why Challenge 62 Matters: MCP Privilege Escalation via Google Service Account + +**The Problem:** + +MCP (Model Context Protocol) servers are increasingly used to give AI assistants access to external services and data sources. When these servers are configured with service accounts or API keys that have broader permissions than the calling user, they create a dangerous privilege escalation vector. + +This challenge demonstrates the **Principle of Least Privilege** violation: the MCP server's Google Service Account has access to a restricted Google Drive document, but the server grants that access to *any caller* without verifying whether the caller is authorized to see the document. + +**The Real-World Scenario:** + +1. A developer builds an MCP server to help an internal AI assistant access team documents in Google Drive +2. The service account is granted read access to a sensitive document +3. The developer does not implement any caller authentication or authorization checks on the MCP tools +4. Anyone who discovers the MCP endpoint can now read documents they are not supposed to access β€” effectively using the service account as a privilege escalation proxy + +**Why This Is Dangerous:** + +- **Privilege escalation**: Callers gain the service account's permissions, not their own +- **No audit trail**: Access via the MCP tool may not be logged with the caller's identity +- **Broad attack surface**: Any MCP client (legitimate or malicious) can invoke the tool +- **AI-assisted exploitation**: AI agents connected to this MCP server will execute the tool automatically when instructed, amplifying the risk + +**How to Fix:** + +1. **Authenticate MCP callers**: Require an authentication token on the MCP endpoint and verify caller identity before executing tools +2. **Authorize at the tool level**: Check whether the authenticated caller is permitted to access the requested resource before calling the service account +3. **Scope service account permissions narrowly**: The service account should have the minimum permissions necessary for legitimate use cases only +4. **Audit all tool invocations**: Log the caller identity along with every tool call so access can be reviewed +5. **Prefer impersonation over shared credentials**: Use caller-specific credentials or token exchange instead of a single shared service account + +**WrongSecrets implementation notes:** + +- Challenge answer parsing uses tagged content (`...`) and caches the extracted value to optimize its memory footprint. +- MCP document retrieval uses a bounded cache to limit repeated outbound requests while preserving the configured default document. +- Relevant tests: + - `Challenge62Test` + - `Challenge62McpControllerTest` + +**References:** + +- https://modelcontextprotocol.io/specification/2024-11-05/[MCP Specification] +- https://owasp.org/www-project-top-ten/[OWASP Top 10] +- https://cloud.google.com/iam/docs/best-practices-service-accounts[Google Service Account Best Practices] diff --git a/src/main/resources/templates/about.html b/src/main/resources/templates/about.html index 33cc7f990..fc917652d 100644 --- a/src/main/resources/templates/about.html +++ b/src/main/resources/templates/about.html @@ -48,7 +48,7 @@
🎯 Learning Objectives
The list below is generated with `mvn license:add-third-party`
    -
  • Lists of 378 third-party dependencies.
  • +
  • Lists of 382 third-party dependencies.
  • (Eclipse Public License - v 2.0) (GNU Lesser General Public License) Logback Classic Module (ch.qos.logback:logback-classic:1.5.32 - http://logback.qos.ch/logback-classic)
  • (Eclipse Public License - v 2.0) (GNU Lesser General Public License) Logback Core Module (ch.qos.logback:logback-core:1.5.32 - http://logback.qos.ch/logback-core)
  • (The MIT License (MIT)) Microsoft Azure Java Core Library (com.azure:azure-core:1.57.1 - https://github.com/Azure/azure-sdk-for-java)
  • @@ -161,32 +161,36 @@
    🎯 Learning Objectives
  • (The Apache Software License, Version 2.0) micrometer-core (io.micrometer:micrometer-core:1.16.3 - https://github.com/micrometer-metrics/micrometer)
  • (The Apache Software License, Version 2.0) micrometer-jakarta9 (io.micrometer:micrometer-jakarta9:1.16.3 - https://github.com/micrometer-metrics/micrometer)
  • (The Apache Software License, Version 2.0) micrometer-observation (io.micrometer:micrometer-observation:1.16.3 - https://github.com/micrometer-metrics/micrometer)
  • -
  • (Apache License, Version 2.0) Netty/Buffer (io.netty:netty-buffer:4.1.130.Final - https://netty.io/netty-buffer/)
  • -
  • (Apache License, Version 2.0) Netty/Codec (io.netty:netty-codec:4.1.130.Final - https://netty.io/netty-codec/)
  • +
  • (Apache License, Version 2.0) Netty/Buffer (io.netty:netty-buffer:4.2.10.Final - https://netty.io/netty-buffer/)
  • +
  • (Apache License, Version 2.0) Netty/Codec (io.netty:netty-codec:4.2.10.Final - https://netty.io/netty-codec/)
  • (Apache License, Version 2.0) Netty/Codec/Base (io.netty:netty-codec-base:4.2.10.Final - https://netty.io/netty-codec-base/)
  • (Apache License, Version 2.0) Netty/Codec/Classes/Quic (io.netty:netty-codec-classes-quic:4.2.10.Final - https://netty.io/netty-codec-classes-quic/)
  • (Apache License, Version 2.0) Netty/Codec/Compression (io.netty:netty-codec-compression:4.2.10.Final - https://netty.io/netty-codec-compression/)
  • -
  • (Apache License, Version 2.0) Netty/Codec/DNS (io.netty:netty-codec-dns:4.1.130.Final - https://netty.io/netty-codec-dns/)
  • -
  • (Apache License, Version 2.0) Netty/Codec/HTTP (io.netty:netty-codec-http:4.1.130.Final - https://netty.io/netty-codec-http/)
  • -
  • (Apache License, Version 2.0) Netty/Codec/HTTP2 (io.netty:netty-codec-http2:4.1.130.Final - https://netty.io/netty-codec-http2/)
  • +
  • (Apache License, Version 2.0) Netty/Codec/DNS (io.netty:netty-codec-dns:4.2.10.Final - https://netty.io/netty-codec-dns/)
  • +
  • (Apache License, Version 2.0) Netty/Codec/HTTP (io.netty:netty-codec-http:4.2.10.Final - https://netty.io/netty-codec-http/)
  • +
  • (Apache License, Version 2.0) Netty/Codec/HTTP2 (io.netty:netty-codec-http2:4.2.10.Final - https://netty.io/netty-codec-http2/)
  • (Apache License, Version 2.0) Netty/Codec/Http3 (io.netty:netty-codec-http3:4.2.10.Final - https://netty.io/netty-codec-http3/)
  • +
  • (Apache License, Version 2.0) Netty/Codec/Marshalling (io.netty:netty-codec-marshalling:4.2.10.Final - https://netty.io/netty-codec-marshalling/)
  • (Apache License, Version 2.0) Netty/Codec/Native/Quic (io.netty:netty-codec-native-quic:4.2.10.Final - https://netty.io/netty-codec-native-quic/)
  • -
  • (Apache License, Version 2.0) Netty/Codec/Socks (io.netty:netty-codec-socks:4.1.130.Final - https://netty.io/netty-codec-socks/)
  • -
  • (Apache License, Version 2.0) Netty/Common (io.netty:netty-common:4.1.130.Final - https://netty.io/netty-common/)
  • -
  • (Apache License, Version 2.0) Netty/Handler (io.netty:netty-handler:4.1.130.Final - https://netty.io/netty-handler/)
  • -
  • (Apache License, Version 2.0) Netty/Handler/Proxy (io.netty:netty-handler-proxy:4.1.130.Final - https://netty.io/netty-handler-proxy/)
  • -
  • (Apache License, Version 2.0) Netty/Resolver (io.netty:netty-resolver:4.1.130.Final - https://netty.io/netty-resolver/)
  • -
  • (Apache License, Version 2.0) Netty/Resolver/DNS (io.netty:netty-resolver-dns:4.1.130.Final - https://netty.io/netty-resolver-dns/)
  • -
  • (Apache License, Version 2.0) Netty/Resolver/DNS/Classes/MacOS (io.netty:netty-resolver-dns-classes-macos:4.1.130.Final - https://netty.io/netty-resolver-dns-classes-macos/)
  • -
  • (Apache License, Version 2.0) Netty/Resolver/DNS/Native/MacOS (io.netty:netty-resolver-dns-native-macos:4.1.130.Final - https://netty.io/netty-resolver-dns-native-macos/)
  • -
  • (The Apache Software License, Version 2.0) Netty/TomcatNative [BoringSSL - Static] (io.netty:netty-tcnative-boringssl-static:2.0.74.Final - https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static/)
  • -
  • (The Apache Software License, Version 2.0) Netty/TomcatNative [OpenSSL - Classes] (io.netty:netty-tcnative-classes:2.0.74.Final - https://github.com/netty/netty-tcnative/netty-tcnative-classes/)
  • -
  • (Apache License, Version 2.0) Netty/Transport (io.netty:netty-transport:4.1.130.Final - https://netty.io/netty-transport/)
  • -
  • (Apache License, Version 2.0) Netty/Transport/Classes/Epoll (io.netty:netty-transport-classes-epoll:4.1.130.Final - https://netty.io/netty-transport-classes-epoll/)
  • -
  • (Apache License, Version 2.0) Netty/Transport/Classes/KQueue (io.netty:netty-transport-classes-kqueue:4.1.130.Final - https://netty.io/netty-transport-classes-kqueue/)
  • -
  • (Apache License, Version 2.0) Netty/Transport/Native/Epoll (io.netty:netty-transport-native-epoll:4.1.130.Final - https://netty.io/netty-transport-native-epoll/)
  • -
  • (Apache License, Version 2.0) Netty/Transport/Native/KQueue (io.netty:netty-transport-native-kqueue:4.1.130.Final - https://netty.io/netty-transport-native-kqueue/)
  • -
  • (Apache License, Version 2.0) Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.1.130.Final - https://netty.io/netty-transport-native-unix-common/)
  • +
  • (Apache License, Version 2.0) Netty/Codec/Protobuf (io.netty:netty-codec-protobuf:4.2.10.Final - https://netty.io/netty-codec-protobuf/)
  • +
  • (Apache License, Version 2.0) Netty/Codec/Socks (io.netty:netty-codec-socks:4.2.10.Final - https://netty.io/netty-codec-socks/)
  • +
  • (Apache License, Version 2.0) Netty/Common (io.netty:netty-common:4.2.10.Final - https://netty.io/netty-common/)
  • +
  • (Apache License, Version 2.0) Netty/Handler (io.netty:netty-handler:4.2.10.Final - https://netty.io/netty-handler/)
  • +
  • (Apache License, Version 2.0) Netty/Handler/Proxy (io.netty:netty-handler-proxy:4.2.10.Final - https://netty.io/netty-handler-proxy/)
  • +
  • (Apache License, Version 2.0) Netty/Resolver (io.netty:netty-resolver:4.2.10.Final - https://netty.io/netty-resolver/)
  • +
  • (Apache License, Version 2.0) Netty/Resolver/DNS (io.netty:netty-resolver-dns:4.2.10.Final - https://netty.io/netty-resolver-dns/)
  • +
  • (Apache License, Version 2.0) Netty/Resolver/DNS/Classes/MacOS (io.netty:netty-resolver-dns-classes-macos:4.2.10.Final - https://netty.io/netty-resolver-dns-classes-macos/)
  • +
  • (Apache License, Version 2.0) Netty/Resolver/DNS/Native/MacOS (io.netty:netty-resolver-dns-native-macos:4.2.10.Final - https://netty.io/netty-resolver-dns-native-macos/)
  • +
  • (The Apache Software License, Version 2.0) Netty/TomcatNative [BoringSSL - Static] (io.netty:netty-tcnative-boringssl-static:2.0.75.Final - https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static/)
  • +
  • (The Apache Software License, Version 2.0) Netty/TomcatNative [OpenSSL - Classes] (io.netty:netty-tcnative-classes:2.0.75.Final - https://github.com/netty/netty-tcnative/netty-tcnative-classes/)
  • +
  • (Apache License, Version 2.0) Netty/Transport (io.netty:netty-transport:4.2.10.Final - https://netty.io/netty-transport/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Classes/Epoll (io.netty:netty-transport-classes-epoll:4.2.10.Final - https://netty.io/netty-transport-classes-epoll/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Classes/io_uring (io.netty:netty-transport-classes-io_uring:4.2.10.Final - https://netty.io/netty-transport-classes-io_uring/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Classes/KQueue (io.netty:netty-transport-classes-kqueue:4.2.10.Final - https://netty.io/netty-transport-classes-kqueue/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Native/Epoll (io.netty:netty-transport-native-epoll:4.2.10.Final - https://netty.io/netty-transport-native-epoll/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Native/io_uring (io.netty:netty-transport-native-io_uring:4.2.10.Final - https://netty.io/netty-transport-native-io_uring/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Native/KQueue (io.netty:netty-transport-native-kqueue:4.2.10.Final - https://netty.io/netty-transport-native-kqueue/)
  • +
  • (Apache License, Version 2.0) Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.2.10.Final - https://netty.io/netty-transport-native-unix-common/)
  • (The Apache License, Version 2.0) OpenCensus (io.opencensus:opencensus-api:0.31.1 - https://github.com/census-instrumentation/opencensus-java)
  • (The Apache License, Version 2.0) OpenCensus (io.opencensus:opencensus-contrib-http-util:0.31.1 - https://github.com/census-instrumentation/opencensus-java)
  • (Apache 2.0) perfmark:perfmark-api (io.perfmark:perfmark-api:0.27.0 - https://github.com/perfmark/perfmark)
  • @@ -315,7 +319,7 @@
    🎯 Learning Objectives
  • (The Apache Software License, Version 2.0) Dependency-Check Core (org.owasp:dependency-check-core:12.2.0 - https://github.com/dependency-check/DependencyCheck.git/dependency-check-core)
  • (The Apache Software License, Version 2.0) Dependency-Check Maven Plugin (org.owasp:dependency-check-maven:12.2.0 - https://github.com/dependency-check/DependencyCheck.git/dependency-check-maven)
  • (The Apache Software License, Version 2.0) Dependency-Check Utils (org.owasp:dependency-check-utils:12.2.0 - https://github.com/dependency-check/DependencyCheck.git/dependency-check-utils)
  • -
  • (The MIT License) Project Lombok (org.projectlombok:lombok:1.18.42 - https://projectlombok.org)
  • +
  • (The MIT License) Project Lombok (org.projectlombok:lombok:1.18.44 - https://projectlombok.org)
  • (MIT-0) reactive-streams (org.reactivestreams:reactive-streams:1.0.4 - http://www.reactive-streams.org/)
  • (The MIT License) semver4j (org.semver4j:semver4j:5.8.0 - https://github.com/semver4j/semver4j)
  • (MIT) JUL to SLF4J bridge (org.slf4j:jul-to-slf4j:2.0.17 - http://www.slf4j.org)
  • @@ -374,10 +378,10 @@
    🎯 Learning Objectives
  • (Apache License, Version 2.0) spring-cloud-starter (org.springframework.cloud:spring-cloud-starter:5.0.1 - https://projects.spring.io/spring-cloud)
  • (Apache License, Version 2.0) Spring Cloud Starter Vault Config (org.springframework.cloud:spring-cloud-starter-vault-config:5.0.1 - https://cloud.spring.io/spring-cloud-vault/)
  • (Apache License, Version 2.0) Spring Cloud Vault Configuration Integration (org.springframework.cloud:spring-cloud-vault-config:5.0.1 - https://github.com/spring-cloud/spring-cloud-vault/spring-cloud-vault-config)
  • -
  • (Apache License, Version 2.0) spring-security-config (org.springframework.security:spring-security-config:7.0.3 - https://spring.io/projects/spring-security)
  • +
  • (Apache License, Version 2.0) spring-security-config (org.springframework.security:spring-security-config:7.0.4 - https://spring.io/projects/spring-security)
  • (Apache License, Version 2.0) spring-security-core (org.springframework.security:spring-security-core:7.0.3 - https://spring.io/projects/spring-security)
  • (Apache License, Version 2.0) spring-security-crypto (org.springframework.security:spring-security-crypto:7.0.3 - https://spring.io/projects/spring-security)
  • -
  • (Apache License, Version 2.0) spring-security-web (org.springframework.security:spring-security-web:7.0.3 - https://spring.io/projects/spring-security)
  • +
  • (Apache License, Version 2.0) spring-security-web (org.springframework.security:spring-security-web:7.0.4 - https://spring.io/projects/spring-security)
  • (Apache License, Version 2.0) Spring Vault Core (org.springframework.vault:spring-vault-core:4.0.1 - https://projects.spring.io/spring-vault/spring-vault-core/)
  • (MIT) Testcontainers :: JUnit Jupiter Extension (org.testcontainers:junit-jupiter:1.21.4 - https://java.testcontainers.org)
  • (BSD-3-Clause) ThreeTen backport (org.threeten:threetenbp:1.7.0 - https://www.threeten.org/threetenbp)
  • @@ -394,35 +398,35 @@
    🎯 Learning Objectives
  • (BSD 2-Clause) github-buttons (org.webjars.npm:github-buttons:2.14.1 - https://www.webjars.org)
  • (Common Public 1.0) pecoff4j (org.whitesource:pecoff4j:0.0.2.1 - https://github.com/whitesource/pecoff4j-maven)
  • (Apache License, Version 2.0) SnakeYAML (org.yaml:snakeyaml:2.5 - https://bitbucket.org/snakeyaml/snakeyaml)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Annotations (software.amazon.awssdk:annotations:2.42.4 - https://aws.amazon.com/sdkforjava/core/annotations)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Clients :: Apache (software.amazon.awssdk:apache-client:2.42.4 - https://aws.amazon.com/sdkforjava/http-clients/apache-client)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Auth (software.amazon.awssdk:auth:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: AWS Core (software.amazon.awssdk:aws-core:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: AWS Json Protocol (software.amazon.awssdk:aws-json-protocol:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: AWS Query Protocol (software.amazon.awssdk:aws-query-protocol:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Checksums (software.amazon.awssdk:checksums:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Checksums SPI (software.amazon.awssdk:checksums-spi:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Endpoints SPI (software.amazon.awssdk:endpoints-spi:2.42.4 - https://aws.amazon.com/sdkforjava/core/endpoints-spi)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth (software.amazon.awssdk:http-auth:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth AWS (software.amazon.awssdk:http-auth-aws:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth Event Stream (software.amazon.awssdk:http-auth-aws-eventstream:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth SPI (software.amazon.awssdk:http-auth-spi:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Client Interface (software.amazon.awssdk:http-client-spi:2.42.4 - https://aws.amazon.com/sdkforjava/http-client-spi)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Identity SPI (software.amazon.awssdk:identity-spi:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: Json Utils (software.amazon.awssdk:json-utils:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Metrics SPI (software.amazon.awssdk:metrics-spi:2.42.4 - https://aws.amazon.com/sdkforjava/core/metrics-spi)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Clients :: Netty Non-Blocking I/O (software.amazon.awssdk:netty-nio-client:2.42.4 - https://aws.amazon.com/sdkforjava/http-clients/netty-nio-client)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Profiles (software.amazon.awssdk:profiles:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: Protocol Core (software.amazon.awssdk:protocol-core:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Regions (software.amazon.awssdk:regions:2.42.4 - https://aws.amazon.com/sdkforjava/core/regions)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Retries (software.amazon.awssdk:retries:2.42.4 - https://aws.amazon.com/sdkforjava/core/retries)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Retries API (software.amazon.awssdk:retries-spi:2.42.4 - https://aws.amazon.com/sdkforjava/core/retries-spi)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: SDK Core (software.amazon.awssdk:sdk-core:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Services :: AWS Simple Systems Management (SSM) (software.amazon.awssdk:ssm:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Services :: AWS STS (software.amazon.awssdk:sts:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Third Party :: Jackson-core (software.amazon.awssdk:third-party-jackson-core:2.42.4 - https://aws.amazon.com/sdkforjava)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Utilities (software.amazon.awssdk:utils:2.42.4 - https://aws.amazon.com/sdkforjava/utils)
  • -
  • (Apache License, Version 2.0) AWS Java SDK :: Utils Lite (software.amazon.awssdk:utils-lite:2.42.4 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Annotations (software.amazon.awssdk:annotations:2.42.13 - https://aws.amazon.com/sdkforjava/core/annotations)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Clients :: Apache (software.amazon.awssdk:apache-client:2.42.13 - https://aws.amazon.com/sdkforjava/http-clients/apache-client)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Auth (software.amazon.awssdk:auth:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: AWS Core (software.amazon.awssdk:aws-core:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: AWS Json Protocol (software.amazon.awssdk:aws-json-protocol:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: AWS Query Protocol (software.amazon.awssdk:aws-query-protocol:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Checksums (software.amazon.awssdk:checksums:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Checksums SPI (software.amazon.awssdk:checksums-spi:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Endpoints SPI (software.amazon.awssdk:endpoints-spi:2.42.13 - https://aws.amazon.com/sdkforjava/core/endpoints-spi)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth (software.amazon.awssdk:http-auth:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth AWS (software.amazon.awssdk:http-auth-aws:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth Event Stream (software.amazon.awssdk:http-auth-aws-eventstream:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth SPI (software.amazon.awssdk:http-auth-spi:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Client Interface (software.amazon.awssdk:http-client-spi:2.42.13 - https://aws.amazon.com/sdkforjava/http-client-spi)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Identity SPI (software.amazon.awssdk:identity-spi:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: Json Utils (software.amazon.awssdk:json-utils:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Metrics SPI (software.amazon.awssdk:metrics-spi:2.42.13 - https://aws.amazon.com/sdkforjava/core/metrics-spi)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: HTTP Clients :: Netty Non-Blocking I/O (software.amazon.awssdk:netty-nio-client:2.42.13 - https://aws.amazon.com/sdkforjava/http-clients/netty-nio-client)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Profiles (software.amazon.awssdk:profiles:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: Protocol Core (software.amazon.awssdk:protocol-core:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Regions (software.amazon.awssdk:regions:2.42.13 - https://aws.amazon.com/sdkforjava/core/regions)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Retries (software.amazon.awssdk:retries:2.42.13 - https://aws.amazon.com/sdkforjava/core/retries)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Retries API (software.amazon.awssdk:retries-spi:2.42.13 - https://aws.amazon.com/sdkforjava/core/retries-spi)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: SDK Core (software.amazon.awssdk:sdk-core:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Services :: AWS Simple Systems Management (SSM) (software.amazon.awssdk:ssm:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Services :: AWS STS (software.amazon.awssdk:sts:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Third Party :: Jackson-core (software.amazon.awssdk:third-party-jackson-core:2.42.13 - https://aws.amazon.com/sdkforjava)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Utilities (software.amazon.awssdk:utils:2.42.13 - https://aws.amazon.com/sdkforjava/utils)
  • +
  • (Apache License, Version 2.0) AWS Java SDK :: Utils Lite (software.amazon.awssdk:utils-lite:2.42.13 - https://aws.amazon.com/sdkforjava)
  • (Apache License, Version 2.0) AWS Event Stream (software.amazon.eventstream:eventstream:1.0.1 - https://github.com/awslabs/aws-eventstream-java)
  • (The Apache Software License, Version 2.0) Jackson-core (tools.jackson.core:jackson-core:3.0.4 - https://github.com/FasterXML/jackson-core)
  • (The Apache Software License, Version 2.0) jackson-databind (tools.jackson.core:jackson-databind:3.0.4 - https://github.com/FasterXML/jackson)
  • diff --git a/src/main/resources/wrong-secrets-configuration.yaml b/src/main/resources/wrong-secrets-configuration.yaml index 7529c4fed..976e6f087 100644 --- a/src/main/resources/wrong-secrets-configuration.yaml +++ b/src/main/resources/wrong-secrets-configuration.yaml @@ -947,3 +947,17 @@ configurations: category: *secrets ctf: enabled: true + + - name: Challenge 62 + short-name: "challenge-62" + sources: + - class-name: "org.owasp.wrongsecrets.challenges.docker.Challenge62" + explanation: "explanations/challenge62.adoc" + hint: "explanations/challenge62_hint.adoc" + reason: "explanations/challenge62_reason.adoc" + ui-snippet: "challenges/challenge-62/challenge-62.snippet" + environments: *all_envs + difficulty: *normal + category: *ai + ctf: + enabled: true diff --git a/src/test/java/org/owasp/wrongsecrets/SecurityConfigTest.java b/src/test/java/org/owasp/wrongsecrets/SecurityConfigTest.java index b0d4c70bc..5e4fe89c7 100644 --- a/src/test/java/org/owasp/wrongsecrets/SecurityConfigTest.java +++ b/src/test/java/org/owasp/wrongsecrets/SecurityConfigTest.java @@ -69,4 +69,13 @@ void shouldAllowToGetAuthValuesWithBasicAuth() throws Exception { .with(httpBasic(challenge37BasicAuth.username(), challenge37BasicAuth.password()))) .andExpect(status().isOk()); } + + @Test + void shouldAllowMcp62WithoutCsrfToken() throws Exception { + mvc.perform( + post("/mcp62") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}")) + .andExpect(status().isOk()); + } } diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpControllerTest.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpControllerTest.java new file mode 100644 index 000000000..e2f8a9f95 --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62McpControllerTest.java @@ -0,0 +1,225 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; + +class Challenge62McpControllerTest { + + private static final String DEFAULT_KEY = + "if_you_see_this_configure_the_google_service_account_properly"; + private static final String DEFAULT_DOC_ID = "1PlZkwEd7GouyY4cdOxBuczm6XumQeuZN31LR2BXRgPs"; + + @Test + void initializeShouldReturnServerInfo() { + var controller = + new Challenge62McpController( + DEFAULT_KEY, DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()); + Map request = Map.of("jsonrpc", "2.0", "id", 1, "method", "initialize"); + + Map response = controller.handleMcpRequest(request); + + assertThat(response).containsKey("result"); + @SuppressWarnings("unchecked") + Map result = (Map) response.get("result"); + assertThat(result).containsKey("serverInfo"); + @SuppressWarnings("unchecked") + Map serverInfo = (Map) result.get("serverInfo"); + assertThat(serverInfo.get("name")).isEqualTo("wrongsecrets-googledrive-mcp-server"); + } + + @Test + void toolsListShouldExposeReadGoogleDriveDocumentTool() { + var controller = + new Challenge62McpController( + DEFAULT_KEY, DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()); + Map request = Map.of("jsonrpc", "2.0", "id", 1, "method", "tools/list"); + + Map response = controller.handleMcpRequest(request); + + @SuppressWarnings("unchecked") + Map result = (Map) response.get("result"); + @SuppressWarnings("unchecked") + List> tools = (List>) result.get("tools"); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).get("name")).isEqualTo("read_google_drive_document"); + } + + @Test + void readDocumentShouldReturnNotConfiguredMessageWhenKeyIsDefault() { + var controller = + new Challenge62McpController( + DEFAULT_KEY, DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()); + Map request = + Map.of( + "jsonrpc", + "2.0", + "id", + 2, + "method", + "tools/call", + "params", + Map.of("name", "read_google_drive_document", "arguments", Map.of())); + + Map response = controller.handleMcpRequest(request); + + @SuppressWarnings("unchecked") + Map result = (Map) response.get("result"); + @SuppressWarnings("unchecked") + List> content = (List>) result.get("content"); + assertThat(content.get(0).get("text").toString()) + .contains("Google Service Account is not configured"); + } + + @Test + void unknownMethodShouldReturnError() { + var controller = + new Challenge62McpController( + DEFAULT_KEY, DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()); + Map request = Map.of("jsonrpc", "2.0", "id", 1, "method", "unknown/method"); + + Map response = controller.handleMcpRequest(request); + + assertThat(response).containsKey("error"); + @SuppressWarnings("unchecked") + Map error = (Map) response.get("error"); + assertThat(error.get("code")).isEqualTo(-32601); + } + + @Test + void unknownToolShouldReturnError() { + var controller = + new Challenge62McpController( + DEFAULT_KEY, DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()); + Map request = + Map.of( + "jsonrpc", + "2.0", + "id", + 2, + "method", + "tools/call", + "params", + Map.of("name", "nonexistent_tool", "arguments", Map.of())); + + Map response = controller.handleMcpRequest(request); + + assertThat(response).containsKey("error"); + @SuppressWarnings("unchecked") + Map error = (Map) response.get("error"); + assertThat(error.get("code")).isEqualTo(-32602); + } + + @Test + void readDocumentShouldCallGoogleDriveApiWhenKeyIsConfigured() throws Exception { + var restTemplate = mock(RestTemplate.class); + Map request = + Map.of( + "jsonrpc", + "2.0", + "id", + 2, + "method", + "tools/call", + "params", + Map.of( + "name", + "read_google_drive_document", + "arguments", + Map.of("document_id", "testDocId"))); + + // Override the key check by using a non-default key + var controllerWithKey = + new Challenge62McpController("dGVzdA==", DEFAULT_DOC_ID, restTemplate, new ObjectMapper()) { + @Override + String readGoogleDriveDocument(String docId) { + return "secret_from_google_drive_document"; + } + }; + + Map response = controllerWithKey.handleMcpRequest(request); + + @SuppressWarnings("unchecked") + Map result = (Map) response.get("result"); + @SuppressWarnings("unchecked") + List> content = (List>) result.get("content"); + assertThat(content.get(0).get("text")).isEqualTo("secret_from_google_drive_document"); + } + + @Test + void readGoogleDriveDocumentShouldUseCacheForConfiguredDocument() throws Exception { + var fetchCount = new AtomicInteger(); + var controller = + new Challenge62McpController( + "dGVzdA==", DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()) { + @Override + String fetchGoogleDriveDocument(String docId) { + fetchCount.incrementAndGet(); + return "cached_secret_for_" + docId; + } + }; + + String firstRead = controller.readGoogleDriveDocument(DEFAULT_DOC_ID); + String secondRead = controller.readGoogleDriveDocument(DEFAULT_DOC_ID); + + assertThat(firstRead).isEqualTo("cached_secret_for_" + DEFAULT_DOC_ID); + assertThat(secondRead).isEqualTo("cached_secret_for_" + DEFAULT_DOC_ID); + assertThat(fetchCount.get()).isEqualTo(1); + } + + @Test + void readGoogleDriveDocumentShouldAlsoCacheConfiguredDocumentWhenReadingOtherDocument() + throws Exception { + var fetchCount = new AtomicInteger(); + var controller = + new Challenge62McpController( + "dGVzdA==", DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()) { + @Override + String fetchGoogleDriveDocument(String docId) { + fetchCount.incrementAndGet(); + return "cached_secret_for_" + docId; + } + }; + + String otherDocumentRead = controller.readGoogleDriveDocument("doc-1"); + String configuredDocumentRead = controller.readGoogleDriveDocument(DEFAULT_DOC_ID); + String secondOtherDocumentRead = controller.readGoogleDriveDocument("doc-1"); + + assertThat(otherDocumentRead).isEqualTo("cached_secret_for_doc-1"); + assertThat(configuredDocumentRead).isEqualTo("cached_secret_for_" + DEFAULT_DOC_ID); + assertThat(secondOtherDocumentRead).isEqualTo("cached_secret_for_doc-1"); + assertThat(fetchCount.get()).isEqualTo(2); + } + + @Test + void readGoogleDriveDocumentShouldCacheOnlyTwentyAdditionalDocuments() throws Exception { + var fetchCount = new AtomicInteger(); + var controller = + new Challenge62McpController( + "dGVzdA==", DEFAULT_DOC_ID, mock(RestTemplate.class), new ObjectMapper()) { + @Override + String fetchGoogleDriveDocument(String docId) { + fetchCount.incrementAndGet(); + return "cached_secret_for_" + docId; + } + }; + + controller.readGoogleDriveDocument(DEFAULT_DOC_ID); + for (int index = 1; index <= 20; index++) { + controller.readGoogleDriveDocument("doc-" + index); + } + + controller.readGoogleDriveDocument("doc-1"); + controller.readGoogleDriveDocument("doc-21"); + controller.readGoogleDriveDocument(DEFAULT_DOC_ID); + controller.readGoogleDriveDocument("doc-2"); + + assertThat(fetchCount.get()).isEqualTo(23); + } +} diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62Test.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62Test.java new file mode 100644 index 000000000..9a225657c --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge62Test.java @@ -0,0 +1,49 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.owasp.wrongsecrets.challenges.Spoiler; + +class Challenge62Test { + + @Test + void spoilerShouldReturnExtractedConfiguredSecret() { + var challenge = new Challenge62("before my_google_drive_secret_42 after"); + assertThat(challenge.spoiler()).isEqualTo(new Spoiler("my_google_drive_secret_42")); + } + + @Test + void answerCorrectShouldReturnTrueForCorrectAnswer() { + var challenge = new Challenge62("before my_google_drive_secret_42 after"); + assertThat(challenge.answerCorrect("my_google_drive_secret_42")).isTrue(); + } + + @Test + void answerCorrectShouldReturnFalseForIncorrectAnswer() { + var challenge = new Challenge62("before my_google_drive_secret_42 after"); + assertThat(challenge.answerCorrect("wronganswer")).isFalse(); + assertThat(challenge.answerCorrect("")).isFalse(); + assertThat(challenge.answerCorrect(null)).isFalse(); + } + + @Test + void answerCorrectShouldTrimWhitespace() { + var challenge = new Challenge62("before my_google_drive_secret_42 after"); + assertThat(challenge.answerCorrect(" my_google_drive_secret_42 ")).isTrue(); + } + + @Test + void answerCorrectShouldReturnFalseWhenResolvedSecretHasNoSecretTags() { + var challenge = new Challenge62("my_google_drive_secret_42"); + assertThat(challenge.answerCorrect("my_google_drive_secret_42")).isFalse(); + } + + @Test + void defaultValueShouldBeUsedWhenNotConfigured() { + var challenge = + new Challenge62("if_you_see_this_configure_the_google_service_account_properly"); + assertThat(challenge.spoiler().solution()) + .isEqualTo("if_you_see_this_configure_the_google_service_account_properly"); + } +}