5 minutes
Hunting vulnerabilities in pipelines
CI/CD pipelines are a crucial part of software development lifecycle. They allow you to automate building, testing and deploying your projects. And now in today’s left shifting landscape, security has become an integral part of these pipelines. We utilize various security tools within these pipelines to ensure that our applications are secure.
But what about the security of the pipelines themselves? Can these pipelines even be vulnerable?
Unfortunately, the answer is yes. In this write-up I will discuss how I discovered a vulnerability within the pipelines used by my team.
Understanding the vulnerable pipeline
My organization uses GitHub Actions to test all our projects and automate numerous manual processes. One such process is backporting pull requests (PRs) to different versions of a project.
name: Pull Request Backporting
on:
pull_request_target:
types: [closed, labeled] 1️⃣
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
compute-targets: 2️⃣
if: ${{ github.event.pull_request.state == 'closed' && github.event.pull_request.merged }}
runs-on: ubuntu-latest
outputs:
target-branches: ${{ steps.set-targets.outputs.targets }}
env:
LABELS: ${{ toJSON(github.event.pull_request.labels) }}
steps:
- name: Set target branches
id: set-targets
uses: kiegroup/kie-ci/.ci/actions/parse-labels@main 4️⃣
with:
labels: ${LABELS}
backporting:
if: ${{ github.event.pull_request.state == 'closed' && github.event.pull_request.merged && needs.compute-targets.outputs.target-branches != '[]' }}
name: "[${{ matrix.target-branch }}] - Backporting"
runs-on: ubuntu-latest
needs: compute-targets
strategy:
matrix:
target-branch: ${{ fromJSON(needs.compute-targets.outputs.target-branches) }}
fail-fast: false
steps:
- name: Backporting
uses: kiegroup/kie-ci/.ci/actions/backporting@main 5️⃣
with:
target-branch: ${{ matrix.target-branch }}
The workflow configuration used for backporting PRs
1️⃣
After merging a pull request, we apply labels with a specific prefix to indicate which branches require the same changes. This triggers the workflow.
2️⃣
The compute-target
job extracts these branches using the parse-label
action ( see 4️⃣
).
3️⃣
The extracted branches from 2️⃣
are passed to the backporting
job which utilizes the backporting
action (see 5️⃣
) to create pull requests across all the extracted branches.
The vulnerability
Can you spot the problem here? Hint: It is not related to the use of pull_request_target
workflow trigger.
Surprised? I was too. The immediate red flag that I noticed was the use of pull_request_target
as it can be risky if not used properly (see GitHub’s advisory on it). However, we were in fact using it safely in this case since we didn’t check out the code and run any scripts.
The problem lies within the parse-label
action. It is vulnerable to a script injection attack when constructing a label that the action recognizes. This allows attackers to execute arbitrary code during the workflow run.
The action can be exploited by creating labels named in the following pattern:
LABEL_PREFIX-$(COMMAND_TO_EXECUTE)
So an example label for this exploit would be backport-$(curl www.google.com)
which would result in making a call to google.com
name: 'Parse Labels'
description: 'Extract target branches or refs from labels'
inputs:
labels:
description: "List of label objects to be parsed, e.g., github.event.pull_request.labels"
required: true
label-prefix:
description: "Extract targets from labels matching this provided prefix"
default: "backport-" 5️⃣
required: false
outputs:
targets:
description: "Extracted targets"
value: ${{ steps.extract-targets.outputs.targets }}
runs:
using: 'composite'
steps:
- name: Fetch labels 1️⃣
id: fetch-labels
shell: bash
run: |
echo "Labels retrieved below"
echo "${{ inputs.labels }}"
4️⃣ filtered_labels="$(echo ${{ inputs.labels }} | jq -c 'map(select(.name | startswith("${{ inputs.label-prefix }}")))')"
echo "filtered_labels = ${filtered_labels}"
echo "FILTERED_LABELS=${filtered_labels}" >> $GITHUB_ENV
- name: Extract targets 2️⃣
id: extract-targets
shell: bash
run: |
5️⃣ targets="$(echo $FILTERED_LABELS | jq -c '[.[] | .name | sub("${{ inputs.label-prefix }}"; "")]')"
6️⃣ echo "targets=$(echo $targets)" >> $GITHUB_OUTPUT
# The vulnerabile step
- name: Printing extracted targets 3️⃣
shell: bash
run: echo "Extracted target branches ${{ steps.extract-targets.outputs.targets }}"
The workflow configuration for the parse-label action
1️⃣
The Fetch labels
step iterates through all the labels taken from github.event.pull_request.labels
and extracts any label with a name starting with the specified prefix (see 4️⃣
). In this case, it was the specified prefix is the default value - backport-
(see 5️⃣
). Each label is represented by an object with the following fields:
{
"color": string,
"default": boolean,
"description": string,
"id": number,
"node_id": string,
"url": string,
"name": string,
}
2️⃣
Next, the Extract targets
step extracts out the name
field from the filtered labels and removes the prefix from it (see 5️⃣
). It then stores these extracted targets as a GitHub step output (see 6️⃣
).
3️⃣
Finally, our injected script is executed in the Printing extracted targets
step. Here the workflow simply echoes the extracted targets, which in our example would be $(curl www.google.com)
. Thus, what actually ends up getting executed in this step is:
echo "Extracted target branches $(curl www.google.com)"
Bash then interprets this as executing curl www.google.com
and then printing “Extracted target branches” along with result of the curl
command.
Impact
Arbitrary code execution can lead to significant damage. However, the impact of this vulnerability was relatively limited because not all users can create new labels. To create labels for any repository within our organization, a certain level of privilege is required.
Fix
The fix was rather simple - use an intermediate environment variable. This approach is the recommended solution by GitHub. With this method, the value of the expression ${{ steps.extract-targets.outputs.targets }}
is stored in memory as a variable, and doesn’t interact with the script generation process.
- name: Printing extracted targets
env:
# set untrusted input to intermediate env variable
TARGETS: ${{ steps.extract-targets.outputs.targets }}
shell: bash
run: echo "Extracted target branches $TARGETS"
The faulty step after it was fixed
Preventative Measures
Looking back, the only proactive step we could have taken to prevent pushing a vulnerable workflow like this one was to manually follow GitHub’s guidelines on hardening workflows. However, as the complexity of the workflow grows, maintaining this approach becomes increasingly challenging.
I attempted to find scanners that could help us identify such issues, but unfortunately, I couldn’t find any suitable ones. I did come across some experimental CodeQL queries, but they were not comprehensive enough.
The absence of such security tools and the presence of vulnerabilities like this one, indicate the necessity to explore tools specifically designed to detect vulnerabilities in pipeline configurations.
Conclusion
In conclusion, securing your application alone is not sufficient. It is equally crucial to secure your CI/CD platforms. The vulnerability discovered in this pipeline highlights the need for implementing robust security measures for these platforms, just as we do for all our applications.
1028 Words
2023-06-20 00:00