Coordinated Disclosure Timeline

Summary

The tj-actions/verify-changed-files workflow allows for command injection in changed filenames, potentially allowing an attacker to leak secrets.

Project

verify-changed-files

Tested Version

v16.1.1

Details

Potential Actions command injection in output filenames (GHSL-2023-275)

The verify-changed-files workflow returns the list of files changed in a commit or pull request.

if [[ -n "$INPUT_FILES_PATTERN_FILE" ]]; then
  TRACKED_FILES=$(git diff --diff-filter=ACMUXTR --name-only | { grep -x -E -f "$INPUT_FILES_PATTERN_FILE" || true; } | awk -v d="|" '{s=(NR==1?s:s d)$0}END{print s}')

  # Find untracked changes
  # shellcheck disable=SC2086
  UNTRACKED_OR_IGNORED_FILES=$(git status $GIT_STATUS_EXTRA_ARGS | awk '{print $NF}' | { grep -x -E -f "$INPUT_FILES_PATTERN_FILE" || true; } | awk -v d="|" '{s=(NR==1?s:s d)$0}END{print s}')

  # Find unstaged deleted files
  UNSTAGED_DELETED_FILES=$(git ls-files --deleted | { grep -x -E -f "$INPUT_FILES_PATTERN_FILE" || true; } | awk -v d="|" '{s=(NR==1?s:s d)$0}END{print s}')
else
  TRACKED_FILES=$(git diff --diff-filter=ACMUXTR --name-only | awk -v d="|" '{s=(NR==1?s:s d)$0}END{print s}')

  # Find untracked changes
  # shellcheck disable=SC2086
  UNTRACKED_OR_IGNORED_FILES=$(git status $GIT_STATUS_EXTRA_ARGS | awk '{print $NF}' | awk -v d="|" '{s=(NR==1?s:s d)$0}END{print s}')

  # Find unstaged deleted files
  UNSTAGED_DELETED_FILES=$(git ls-files --deleted | awk -v d="|" '{s=(NR==1?s:s d)$0}END{print s}')
fi

Given that there is no sanitization being applied before appending to $GITHUB_OUTPUT, it allows for filenames to contain special characters such as ; and ` (backtick) which can be used by an attacker to take over the GitHub Runner if the output value is used in a raw fashion (thus being directly replaced before execution) inside a run block. By running custom commands an attacker may be able to steal secrets such as GITHUB_TOKEN if triggered on other events than pull_request. For example on push.

if [[ -n "$CHANGED_FILES" ]]; then
  echo "Found uncommited changes"

  CHANGED_FILES=$(echo "$CHANGED_FILES" | awk '{gsub(/\|/,"\n"); print $0;}' | awk -v d="$INPUT_SEPARATOR" '{s=(NR==1?s:s d)$0}END{print s}')

  echo "files_changed=true" >> "$GITHUB_OUTPUT"
  echo "changed_files=$CHANGED_FILES" >> "$GITHUB_OUTPUT"
  ...

Proof of Concept

In the case of a repository containing the following steps, as detailed in verify-changed-files README:

- name: Verify Changed files
  uses: tj-actions/verify-changed-files@v16
  id: verify-changed-files

- name: List all changed files tracked and untracked files
  run: |
    echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
  1. Submit a pull request to the repository with a new file injecting a command. For example $(whoami).txt would be a valid filename.
  2. Upon approval of the workflow (triggered by the pull request), the action will get executed and the malicious pull request filename will flow into the List all changed files tracked and untracked files step.
##[group]Run echo "Changed files: $(whoami).txt"
  echo "Changed files: $(whoami).txt"
shell: /usr/bin/bash -e {0}
##[endgroup]
Changed files: runner.txt

Impact

This issue may lead to arbitrary command execution in the GitHub Runner.

Credit

This issue was discovered and reported by GHSL team member @jorgectf (Jorge Rosillo).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-275 in any communication regarding this issue.