Coordinated Disclosure Timeline
- 2025-01-30: Reported through GitHub’s Private Vulnerability Reporting (PVR).
- 2025-03-14: v10.4.0 with
deployment_confirmation
feature released.
Summary
TOCTOU (Time of Check Time of Use) .deploy
approval bypass if no additional branch protections are enabled.
Project
Tested Version
v10 and possibly earlier.
Details
TOCTOU .deploy
bypass (GHSL-2025-038
)
Branch Deploy Action is an IssueOps action by GitHub which allows maintainers to manage deployments pull requests by commenting with a set of commands. Below is one of the official examples how the action is typically used:
name: branch-deploy
on:
issue_comment:
types: [created]
# Permissions needed for reacting and adding comments for IssueOps commands
permissions:
pull-requests: write
deployments: write
contents: write
checks: read
statuses: read
jobs:
deploy:
name: deploy
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request }} # only run on pull request comments
steps:
# The branch-deploy Action
- name: branch-deploy
id: branch-deploy
uses: github/branch-deploy@vX.X.X
# If the branch-deploy Action was triggered, checkout our branch
- uses: actions/checkout@v4
with:
ref: ${{ steps.branch-deploy.outputs.sha }}
# If the branch-deploy Action was triggered, run the noop deployment (i.e. '.noop')
- name: noop deploy
if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' }}
run: <do-your-noop-deployment> # this could be anything you want
# If the branch-deploy Action was triggered, run the real deployment (i.e. '.deploy')
- name: deploy
if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop != 'true' }}
run: <do-your-deployment> # this could be anything you want
All the examples check out the pull request files (it is obviously necessary for the functionality), which may lead to pwn request vulnerability.
The action is designed with security in mind and has the following features:
- If
allow_forks
input is set tofalse
(the default istrue
), it doesn’t allow deployments from forks. - If
commit_verification
is set totrue
(the default isfalse
), it verifies commit signatures. - It checks if the user giving
.deploy
command haswrite/admin
permissions. - It has TOCTOU defenses - if a commit is pushed after
.deploy
command is given, but before the deployment happens it stops with a warning. - If the pull request branch is not up to date with the main branch, i.e. the hosting workflow was changed in the main branch, it also stops with a warning.
However the statement that issue_comment
is safer than pull_request
is misleading. While pull_request
allows a contributor to execute their version of the workflow (i.e. it runs not from the main branch, but from the merge of the pull request), it is safest option because it doesn’t have access to secrets, runs with read-only permissions and is not susceptible to cache poisoning. Nevertheless it is obvious that IssueOps requires elevated permissions to do the job.
While on Dec 5, 2024 all github/branch-deploy
action usage examples were updated to use SHA, which should prevent TOCTOU, it is still vulnerable to TOCTOU when deployments from forks are allowed (by default or explicitly). The old pattern of using steps.branch-deploy.outputs.ref
is even easier to exploit.
The github/branch-deploy
action grabs the latest SHA of pull request head in prechecks
function, but there is still a not so small window of opportunity to push to the pull request head after .deploy
approval, but before the check. It is quite reliable if automated as you can see in the PoC below.
To prevent this race condition the action checks if the date/time of the comment is later than the commit with the SHA in question:
const comment_created_at = context.payload.comment.created_at
core.debug(`comment_created_at: ${comment_created_at}`)
// fetch the timestamp that the commit was authored (format: "2024-10-21T19:10:24Z" - String)
const commit_created_at = commit.author.date
core.debug(`commit_created_at: ${commit_created_at}`)
// check to ensure that the commit was authored before the comment was created
if (isTimestampOlder(comment_created_at, commit_created_at)) {
return {
message: `### ⚠️ Cannot proceed with deployment\n\nThe latest commit is not safe for deployment. It was authored after the trigger comment was created.`,
status: false,
isVerified: isVerified
}
Unfortunately the commit date is information the attacker controls.
Please note, that the attack won’t work if the repository has branch protection rules enabled which require pull request approvals. This is because the instant that an attacker pushes a malicious commit, it dismisses PR approvals, and then the Action will reject the .deploy
as the pull request being deployed is “unsafe”. Additionally, pushing a new commit also wipes out CI check status for the pull request and that will also cause the deploy to be rejected.
However this is only documented under Here are some additional security best practices to consider
and is not required.
PoC
- Fork a repository that has a workflow using
github/branch-deploy
action. We have deployed a copy of github/entitlements-config for testing. - Make a new branch and checkout it locally or to a codespace.
- Commit, push and make a pull request to the original repo with a change that is likely to be accepted.
- Make a change to a script that is running on deployment. In case of
github/entitlements-config
that would bescript/deploy
. For exampleecho "pwn"
. Commit it, but do not push yet. - Modify the
create_or_update_file
function in the script to:os.chdir("path/to/the/local/pr/branch") os.system("git push")
- Setup the script and run. It will start monitoring for
.deploy
comment every 0.5 seconds. - Once one of the maintainers comments on the pull request with
.deploy
the script pushes the commit. Since the date of the commit is older than the date of the comment it passes the safety check.
Impact
The bypass may lead to an arbitrary code execution by attacker in the context of GitHub workflow running with contents: write
permissions. Even in permissions are restricted, since issue_comment
runs in a context of the main branch, it allows for cache poisoning if the repository uses it in any workflow. This typically leads to full repository, its releases and secrets compromise.
To make it more stealthy the attacker may immediately remove the malicious commit from their fork and force push changes. Since the workflow has pull-requests: write
permissions (to comment with the status) the attacker may automate comment editing to make it look like nothing has happened.
Resources
- IssueOops: Security pitfalls with
issue_comment
trigger - A TOCTOU script by Adnan Khan
- Cache poisoning attack explained
- Pwn request
Credit
This issue was discovered and reported by GHSL team member @JarLob (Jaroslav Lobačevski).
Contact
You can contact the GHSL team at securitylab@github.com
, please include a reference to GHSL-2025-038
in any communication regarding this issue.