Overview
I reported a subtle race condition in Google Cloud Build’s GitHub integration that could have allowed someone to bypass maintainer review when running pull request integrations tests.
Google Cloud Build is a managed CI/CD platform that integrates with third-party source code management systems like GitHub. Since CI/CD systems are essentially code execution as a service, access control becomes very important.
When a Google Cloud Build customer integrates with GitHub, they can configure a number of triggers - essentially events on GitHub that will trigger a Google Cloud Build execution. One of these triggers is on pull request. To reduce the risk of poisoned pipeline execution on public and inner-source repositories, Google Cloud Build has a “comment-control” feature that requires maintainers to create a comment in order to trigger a build from an untrusted contributor. I found that developers had baked a Time-of-Check-Time-of-Use (TOCTOU) vulnerability into this feature. Google has since mitigated the issue.
Cloud Build <-> GitHub Deep Dive
First, I’m going to start with a deep dive into Cloud Build and how it integrates with GitHub. I’ll also cover some fundamental knowledge on how most systems integrate with GitHub. This knowledge is key for testing systems that integrate with GitHub - I’ve seen so many issues in systems that integrate with GitHub. I believe the reason for this is that GitHub has a very unique security model, and securely building GitHub integrations requires understanding what GitHub’s security boundaries are and how Git works.
Cloud Build
Cloud Build is a managed, serverless CI/CD platform that allows users to perform software builds from connected Source Code Management (SCM) systems. It supports integrations with GitHub, BitBucket, GitLab, and Google’s own Cloud Source service - which is deprecated and closed to new customers.
Like other managed build services, Cloud Build supports secrets and running builds with privileged roles. Users might provide a build with credentials to additional repositories or IAM permissions to publish to build artifact registries.
Since builds can have credentials, it’s important to limit who can trigger a build. I’ll use GitHub as an example - what if a user can trigger a build by pushing code or creating a pull request, but the privileges required to push code are less than the privileges attached to the pipeline? That’s a privilege escalation vector.
Cloud Build offers a variety of configuration settings to help users control access to their Cloud Build jobs.
What the Fork
To understand how all of this works - you need some background knowledge on how Pull Requests on GitHub work.
On GitHub, a pull request is a request for the PR target to pull changes from a PR head branch. This branch can be within the repository (a feature branch PR), or a forked repository (Fork PR). For public repositories, anyone can fork a repository and create a fork pull request.
Some organizations follow an “InnerSource” model, where developers have read access to a large number of private repositories. If an organization has private forks enabled, then anyone with read access can create forks and fork pull requests.
They will not have write access to repositories that do not belong to their immediate team, but they can fork and create pull requests to other inner source repositories. Among organizations that follow an innersource model, pipeline access control is still important - otherwise a single compromised developer account could quickly escalate privileges.
Terminology:
Git and GitHub terminology can sometimes be confusing, and different terms map to the same thing.
- PR Head / PR Source: The PR head is the repository and branch containing newer changes that the PR creator wants to contribute into a repository. If I create a fork of a public repository and create a pull request, my repository is the PR head repository.
- PR Base / PR Target: The PR base is the repository and branch that a PR creator wants to add their contribution to.
- Merge Commit: Commit created by GitHub by merging the pull request head commit with commit at tip of the target branch. Timestamp generated by GitHub.
- Head Commit: Commit at the tip of the PR source - this is the last commit pushed by the external contributor.
- Push Timestamp: This is the timestamp that GitHub actually received new code in a branch.
- Commit Timestamp: This is the timestamp attached to a Git commit. For local commits, this is fully user controlled.
Cloud Build App and Hooks
Cloud Build uses a GitHub App to integrate with GitHub. GitHub has two flavors of applications: OAuth apps and GitHub Apps. The latter is newer and specific to GitHub. Applications can have app registered webhooks - this means when a user or organization installs an app they also register a set of webhooks defined by the app.
Webhooks are typically how apps receive information from GitHub. Events such as pushing code or creating a pull request will trigger a webhook. This webhook will transit GitHub’s backend system and travel over the internet to an address defined by the system.
GitHub Integration
When Cloud Build users connect their project to a GitHub repository, they can configure a variety of triggers to run builds. One of the triggers is the pull request trigger. When enabled, creation of a pull request or updates to a pull request head branch will trigger new Cloud Build jobs.
One common risk in CI/CD pipelines is Poisoned Pipeline Execution. To quote the OWASP entry:
Poisoned Pipeline Execution (PPE) risks refer to the ability of an attacker with access to source control systems - and without access to the build environment, to manipulate the build process by injecting malicious code/commands into the build pipeline configuration, essentially ‘poisoning’ the pipeline and running malicious code as part of the build process.
To prevent poisoned pipeline execution from pull requests, Cloud Build offers a “Comment Control” feature. By default, Cloud Build requires comment control if the pull request creator does not have write access to the repository through ownership or collaborator status.
The comment control feature requires a user with write access to the GitHub repository to trigger a Cloud Build execution by commenting /gcbrun
on a pull request.
An Old Suspect
If you’ve followed some of my work, you might remember I released a small sample code repository containing variants of an issue class called “Actions TOCTOU”. One of the variants I called out was the “Issue Comment TOCTOU”.
The primary reason we see the issue comment TOCTOU is that an Issue Comment event on a pull request will not contain the pull request SHA - only the pull request number. This holds true for the event in GitHub Actions as well as direct webhooks.
What does this mean for systems that need to operate on a SHA? They have to retrieve the SHA from the pull request using an API call. What happens if the PR head SHA changes after the webhook but before the API call?
The Vulnerability and Proof of Concept
At its core - the vulnerability in Cloud Build was that Cloud Build retrieved the latest commit SHA from GitHub after a maintainer commented /gcbrun
. This behavior created a race condition that a sufficiently motivated attacker could exploit.
Since Google Cloud Build jobs often have access to secrets, it meant that an attacker could create a legitimate pull request, convince the maintainer to run tests, and then immediately update their pull request with new code as soon as they see a /gcbrun
comment from a maintainer.
Environment Setup
To demonstrate this vulnerability, I created a Cloud Build project, connected it to a test repository, and selected the Pull request
trigger.
I also selected the strict comment control setting and configured the project to use a cloudbuild.yaml
file defined in the repository.
Winning The Race Condition
Unlike Actions race conditions, which attackers can easily win because workflow jobs take a few seconds to start, this race condition proved tighter.
To reliably win this race condition, I had to poll for the comment every 200ms or less - still very easy as far as race conditions go!
Race Condition Diagram
Creating Initial Pull Request
First, I created a “benign” pull request that just changed a README file. Note the commit 731f64a
!
As expected - the build does not run and notifies the user that a maintainer needs to trigger it by commenting /gcbrun
.
Preparing Payload
First, I created a “malicious” cloudbuild.yaml
file. This one simply contained a curl ping to my Burp collaborator domain.
|
|
Next, I cloned ActionsTOCTOU and modified the script to poll every 200ms instead of 500ms.
After patching the tool, I ran it to poll the pull request for a /gcbrun
command. Every 200ms, the script lists all comments on pull request 2 on VictimAccount/test
. As soon as it sees a comment starting with /gcbrun
, it commits the local file cloudbuild.yaml
to cloudbuild.yaml
in the AttackerAccount/test
repository on the test3
branch.
|
|
I used my VictimAccount to comment /gcbrun
. The script quickly detected the comment and pushed the malicious file up in a new commit.
Upon reviewing the Cloud Build dashboard, I saw that the build ran on commit b8ee164
, which came after the comment trigger.
This meant that the maintainer could not review the code.
Impact
Google’s payment amount surprised me. I suspect this was due to impact they identified to their repositories that use Cloud Build - I did call this out when reporting.
Google uses Cloud Build extensively on public repositories - most projects require comment control for external contributor PRs (it is the default setting).
With this vulnerability, an attacker could create a PR, convince a maintainer to run tests, and then quickly update their code to steal secrets / abuse the build execution role privileges.
Disclosure Timeline
- November 13, 2024 - Bug Reported
- December 22, 2024 - 🎉 Nice catch!
- January 28, 2025 - Awarded $30,000 Bounty
- June 12, 2025 - Asked Google if the issue was fixed yet.
- June 18, 2025 - Google marks issue as fixed.
- July 8th, 2025 - Provided draft of blog to Google
- July 14th, 2025 - Received feedback
- July 21st, 2025 - Published Blog
It was a great experience reporting this bug to Google!
They also had some kind words when providing draft feedback:
Thank you so much for identifying and reporting this issue. Your report has helped to improve the security of our products and keep our customers safe!
Fix Analysis
I was impressed with Google’s fix here - they didn’t just fix the race condition. Google made it harder for a human to accidentally approve a run on a bad commit pushed after they reviewed code, but prior to actually typing and sending the /gcbrun
trigger.
Fix Mechanism
Commit tied to Check
Google implemented a 5 second delay between the time a new commit is pushed and when is “triggerable”. This accounts for any delay between GitHub’s and GCP’s infrastructure, even in the off chance there are any delays that cause the webhook to arrive late (such as GitHub reliability events which happen a few times a year).
Once the maintainer comments /gcbrun
, Cloud Build runs the build on the most recent SHA that is at least 5 seconds old as seen by Cloud Build’s infrastructure.
Full SHA Requirement
One quirk of the 5 second delay technique is that it will show newer commits as pending, because the system associates the check with the previous commit if the commit was pushed within 5 seconds of the maintainer comment. This could cause a maintainer to think something went wrong and trigger it again. If they are not paying attention, they could inadvertently trigger a malicious build. Furthermore, there is a chance an attacker could push a new commit after the maintainer looks at the code, but before they type and send /gcbrun
.
To solve for these scenarios, Google Cloud Build will prompt the maintainer to specify the full SHA to trigger the run if there’s a recent commit after the last check.
Bypass Attempts
After Google fixed the issue, I spent time trying to bypass the fix. There were two things I looked at:
Sub-Second Race
The issue comment webhook event only has a 1-second timestamp granularity. If Google implemented a check to make sure the authoritative commit time did not exceed the webhook event time, then there is a chance that an attacker could bypass the fix by pushing a commit in the same second as the comment. It’s possible to reliably win the race with a bit of luck and clever commit construction.
Since Google tied each run to a pending check, this technique did not work.
Commit Stamp Forgery
Next, I tried bypassing it by setting the commit timestamp to a value in the past. Had Google fixed this by simply checking the commit time of the PR head, then an attacker could forge it by setting a specific date with Git.
Just like the previous attempt, this did not work.
Conclusion
This bug highlights the value of understanding the threat models and security boundaries of interconnected systems. The hard work here involved reading documentation from GitHub and Google. The vulnerability itself only took a few hours to confirm.
If you are building a system that integrates with GitHub - especially AI systems with Human-in-the-loop controls - then make sure all approval requirements map to immutable references. For developers - make sure your CI/CD pipelines are hardened even in an “Oops” event where a human runs tests on a malicious PR.
For bug hunters - always check timings on approvals. Race conditions like these often slip through the cracks because they require deep context to understand if they have security impact or not.
If you’ve read through all the way here - thank you! I hope you’ve found this write-up helpful.