Coordinated Disclosure Timeline

Summary

TOCTOU (Time of Check Time of Use) .deploy approval bypass if no additional branch protections are enabled.

Project

Branch Deploy Action

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:

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

  1. Fork a repository that has a workflow using github/branch-deploy action. We have deployed a copy of github/entitlements-config for testing.
  2. Make a new branch and checkout it locally or to a codespace.
  3. Commit, push and make a pull request to the original repo with a change that is likely to be accepted.
  4. Make a change to a script that is running on deployment. In case of github/entitlements-config that would be script/deploy. For example echo "pwn". Commit it, but do not push yet.
  5. Modify the create_or_update_file function in the script to:
     os.chdir("path/to/the/local/pr/branch")
     os.system("git push")
    
  6. Setup the script and run. It will start monitoring for .deploy comment every 0.5 seconds.
  7. 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

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.