Coordinated Disclosure Timeline
- 2023-12-18: Opened GHSA-mcph-m25j-8j63 through Private Vulnerability Reporting
- 2023-12-22: Fixed in 0102c07
Summary
The tj-actions/changed-files
workflow allows for command injection in changed filenames, allowing an attacker to execute arbitrary code and potentially leak secrets.
Project
changed-files
Tested Version
Details
Potential Actions command injection in output filenames (GHSL-2023-271
)
The changed-files
workflow returns the list of files changed in a commit or pull request.
const allChangedFiles = await getChangeTypeFiles({
inputs,
changedFiles: allFilteredDiffFiles,
changeTypes: [
ChangeTypeEnum.Added,
ChangeTypeEnum.Copied,
ChangeTypeEnum.Modified,
ChangeTypeEnum.Renamed
]
})
core.debug(`All changed files: ${JSON.stringify(allChangedFiles)}`)
await setOutput({
key: getOutputKey('all_changed_files', outputPrefix),
value: allChangedFiles.paths,
writeOutputFiles: inputs.writeOutputFiles,
outputDir: inputs.outputDir,
json: inputs.json,
shouldEscape: inputs.escapeJson
})
Even though there is a shouldEscape
option enabled by default, it only escapes "
for JSON values. The setOutput
function is defined as follows:
export const setOutput = async ({
key,
value,
writeOutputFiles,
outputDir,
json = false,
shouldEscape = false
}: {
key: string
value: string | string[] | boolean
writeOutputFiles: boolean
outputDir: string
json?: boolean
shouldEscape?: boolean
}): Promise<void> => {
let cleanedValue
if (json) {
cleanedValue = jsonOutput({value, shouldEscape})
} else {
cleanedValue = value.toString().trim()
}
core.setOutput(key, cleanedValue)
...
}
The jsonOutput
function is defined as follows:
export const jsonOutput = ({
value,
shouldEscape
}: {
value: string | string[] | boolean
shouldEscape: boolean
}): string => {
const result = JSON.stringify(value)
return shouldEscape ? result.replace(/"/g, '\\"') : result
}
This allows 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
.
Proof of Concept
In the case of a repository containing the following workflow, as detailed in changed-files
README:
name: CI
on:
pull_request:
branches:
- main
jobs:
changed_files:
runs-on: ubuntu-latest
name: Test changed-files
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Example 1
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v40
- name: List all changed files
run: |
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
echo "$file was changed"
done
- Submit a pull request to the repository with a new file injecting a command. For example
$(whoami).txt
would be a valid filename. - 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
step.
##[group]Run for file in $(whoami).txt; do
for file in $(whoami).txt; do
echo "$file was changed"
done
shell: /usr/bin/bash -e {0}
##[endgroup]
runner.txt was changed
Impact
This issue may lead to arbitrary command execution in the GitHub Runner.
Credit
This issue was discovered by @jsoref (Josh Soref) 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-271
in any communication regarding this issue.