## https://sploitus.com/exploit?id=B265A4A6-887B-55F2-A0B7-038269601502
# CVE-2026-44590 - sherlock-project/sherlock CI - RCE via pull_request_target Injection โ Supply Chain Compromise
Discovered & reported by: [Astaruf](https://www.linkedin.com/in/lorenzoanastasi/)
Full writeup: [https://nstsec.com/en/posts/sherlock-rce-pull-request-target-cve-2026-44590/](https://nstsec.com/en/posts/sherlock-rce-pull-request-target-cve-2026-44590/)
Upstream advisory: [sherlock-project/sherlock GHSA advisory](https://github.com/sherlock-project/sherlock/security/advisories)
NVD entry: [https://nvd.nist.gov/vuln/detail/CVE-2026-44590](https://nvd.nist.gov/vuln/detail/CVE-2026-44590)
CVE Record: [https://vulners.com/cve/CVE-2026-44590](https://vulners.com/cve/CVE-2026-44590)
---
This repository contains the proof-of-concept for **CVE-2026-44590**, a command injection in the `validate_modified_targets.yml` GitHub Actions workflow of [sherlock-project/sherlock](https://github.com/sherlock-project/sherlock). Any GitHub user can open a pull request that triggers arbitrary command execution in the privileged CI context, exfiltrate the workflow's `GITHUB_TOKEN`, and auto-approve the malicious PR, all without any human interaction.
For the **full technical writeup** (root-cause analysis, exploitation walkthrough, impact discussion, and a chapter on what an attacker could do in real-world scenarios) see the blog post:
> [**nstsec.com/en/posts/sherlock-rce-pull-request-target-cve-2026-44590**](https://nstsec.com/en/posts/sherlock-rce-pull-request-target-cve-2026-44590/)
This README focuses exclusively on the **PoC script**: what it does, how to run it, and what to expect.
## About `poc.py`
`poc.py` is a single self-contained Python script (stdlib only) that automates the entire attack chain end-to-end:
- forks `sherlock-project/sherlock` if needed
- (optionally) rolls the fork's `master` back to the pre-fix commit so the bug can be reproduced even after the upstream fix
- spawns an OAST listener (`interactsh-client`)
- crafts and pushes a malicious PR branch
- triggers the vulnerable workflow
- extracts the `GITHUB_TOKEN` from the OAST callback (in `--mode exfil`) and decodes it in clear text
- uses the stolen token to auto-approve the same PR via the GitHub API
- prints a clear final verdict: `VULNERABILITY CONFIRMED` or `FIX VERIFIED`
- cleans up after itself (deletes the PoC branch, terminates `interactsh-client`)
There is exactly **one manual step** required (clicking GitHub's "I understand my workflows" banner the first time per fork), because no public API exists to dismiss it. The script detects this case and pauses with a clear prompt.
## Quick start
### Verify the upstream fix works (default behavior)
```bash
python3 poc.py --fork-owner
```
Forks the repo (if needed), syncs with upstream (patched master), opens a malicious PR, runs the attack chain, and reports `FIX VERIFIED` because the patched workflow blocks the payload before any shell command runs.
### Reproduce the original vulnerability
```bash
python3 poc.py --fork-owner --vulnerable
```
Same as above, but first rolls the fork's `master` back to the pre-fix commit (`271608fb`). Expected verdict: `VULNERABILITY CONFIRMED`.
### Demonstrate the full impact (token exfiltration + PR auto-approval)
```bash
python3 poc.py --fork-owner --vulnerable --mode exfil
```
The exfil payload dumps `git config --list` to the OAST and sleeps for 180 seconds to keep the workflow (and thus the `GITHUB_TOKEN`) alive. While the workflow is sleeping, the script extracts the token from the OAST log, decodes it, and immediately calls the GitHub API to approve the PR. The PR ends up approved by `github-actions[bot]`.
## Requirements
- **Python 3.8+** (no third-party dependencies, only stdlib)
- **`gh`** (GitHub CLI), authenticated:
```bash
gh auth login
```
- **`git`**
- **`interactsh-client`** (optional but recommended). When installed, the script auto-spawns it and verifies the callback in-script:
```bash
go install github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
```
If you prefer to use your own OAST endpoint (Burp Collaborator, oast.fun via web UI, requestbin, etc.), pass it with `--oast-url` and the auto-verification step will be skipped.
The script does **not** require a fork to exist beforehand, it creates one automatically.
## Modes
### `--mode harmless` (default)
The payload is a single `curl` POST with a static confirmation string. No secrets are read, no API calls are made, the only side effect is the OAST callback. Use this to confirm the vulnerability exists without exposing any credential.
### `--mode exfil`
The payload dumps `git config --list` (which contains the `GITHUB_TOKEN` base64-encoded under `http.https://github.com/.extraheader`) to the OAST, then sleeps for 180 seconds. The script then:
1. Polls the OAST log until the dump arrives.
2. Extracts the base64 blob with a regex.
3. Decodes it and prints the credential in clear text: `x-access-token:ghs_XXXXXXXX...`.
4. Strips the `x-access-token:` prefix and uses the raw token to call `POST /repos//pulls//reviews` with the standard approval payload (`{"event":"APPROVE","body":"All checks passed. LGTM!"}`).
5. The PR shows up as approved by `github-actions[bot]`, indistinguishable from legitimate CI automation.
Once the approval is recorded, the script skips the rest of the workflow's 180-second sleep, since the attack chain is complete and waiting for the runner to time out adds nothing.
## Options
| Flag | Description |
|---|---|
| `--fork-owner ` | **Required.** GitHub username that owns (or will own) the fork |
| `--fork-name ` | Fork repository name (default: `sherlock`) |
| `--oast-url ` | OAST endpoint receiving the callback. If omitted, the script auto-spawns `interactsh-client` and runs the in-script verdict |
| `--mode harmless\|exfil` | Payload type (default: `harmless`) |
| `--vulnerable` | Force-reset the fork's `master` to the pre-fix commit (`271608fb`) before running. Implies `--no-sync` |
| `--no-sync` | Skip syncing the fork with upstream (useful when testing a pinned commit) |
| `--base-branch ` | PR target branch on the fork (default: `master`) |
| `--keep-branch` | Do not delete the PoC branch after completion |
| `--no-poll` | Skip workflow run polling and exit after PR creation |
## Why the PR targets the fork, not the upstream repository
By design, the PoC opens the PR **from a branch on the fork to the `master` of the same fork**. It does not target `sherlock-project/sherlock` directly. There are two reasons for this.
### 1. Avoiding public disclosure of an exploit
A pull request on a public repository is visible to anyone. The diff stays indexed even after the PR is closed, and the GitHub Actions logs are accessible via the web UI. Opening a PR with a working command injection payload on the upstream repository would effectively publish a functional exploit before maintainers had a chance to ship a fix. Anyone watching the repository could copy the payload, swap the OAST callback for a malicious endpoint, and use it to exfiltrate the real `GITHUB_TOKEN`.
The PoC uses a fork-to-fork PR to keep the exploit out of public view while still demonstrating the bug end-to-end.
### 2. Reproducing the exact vulnerable behavior
When you fork `sherlock-project/sherlock`, the workflow file `validate_modified_targets.yml` is included in the fork. Opening a PR targeting the fork's `master` triggers the workflow under the fork's context, with a `GITHUB_TOKEN` issued for the fork. The mechanics are identical to the original attack:
- The `pull_request_target` trigger fires automatically
- The workflow runs in the base repository's context (in this case, the fork)
- The workflow has access to its own `GITHUB_TOKEN`
- `actions/checkout` writes the token into `.git/config` via the `http.https://github.com/.extraheader` setting
- The injected payload can extract or use the token
The only difference is the blast radius: the token belongs to the fork, not to `sherlock-project/sherlock`. The vulnerability is reproduced; the impact is contained.
## The one manual step
GitHub disables Actions on freshly created forks behind a UI banner:
> Workflows aren't being run on this forked repository
> Because this repository contained workflow files when it was forked, we have disabled them from running on this fork. Make sure you understand the configured workflows and their expected usage before enabling Actions on this repository.
There is **no public API** to dismiss this banner. The script detects this condition (fork was just created OR has zero historical workflow runs) and pauses with a clear prompt:
```
[*] ======================================================================
[*] MANUAL STEP REQUIRED
[*] ======================================================================
[*] Open this URL in a browser: https://github.com//sherlock/actions
[*] Click 'I understand my workflows, go ahead and enable them'.
[*] This is required only once per fresh fork (GitHub-imposed).
[*] ======================================================================
Press ENTER once you've enabled Actions on the fork...
```
After clicking the banner once on a given fork, the script will not prompt again on subsequent runs against the same fork.
## Sample output (vulnerable, exfil mode)
```
10:54:34 [>] No --oast-url provided, spawning interactsh-client
10:54:36 [+] Interactsh URL: https://abc...oast.pro
10:54:36 [*] Target fork: youruser/sherlock
10:54:36 [*] Payload mode: exfil
10:54:36 [>] Verifying fork
10:54:40 [+] Fork created
10:54:40 [+] Fork verified (parent: sherlock-project/sherlock)
[... manual prompt + ENTER ...]
10:54:56 [>] Enabling 'Actions can approve PRs' on fork (mirrors upstream setting)
10:54:56 [+] Setting enabled
10:54:56 [>] --vulnerable: rolling fork back to commit 271608fb
10:54:59 [+] Fork master rolled back to vulnerable commit
10:55:00 [+] Injected payload key into sherlock_project/resources/data.json
10:55:04 [+] PR opened: https://github.com/youruser/sherlock/pull/1
10:55:10 [+] Workflow run found: https://github.com/youruser/sherlock/actions/runs/...
10:55:10 [>] Polling OAST for token while workflow is alive
10:55:55 [+] Token captured: x-access-token:ghs_RL6tq56Kmr1USgAHsKeqQdfKc304ij36XOke
10:55:55 [>] Approving PR #1 on youruser/sherlock with stolen token
10:55:56 [+] PR approved by github-actions[bot]: state=APPROVED
10:55:56 [+] Review URL: https://github.com/youruser/sherlock/pull/1#pullrequestreview-...
10:55:56 [+] Attack chain complete, skipping the rest of the workflow run
10:55:56 [+] ======================================================================
10:55:56 [+] VULNERABILITY CONFIRMED: GITHUB_TOKEN exfiltrated
10:55:56 [+] Decoded credential: x-access-token:ghs_RL6tq56Kmr1USgAHsKeqQdfKc304ij36XOke
10:55:56 [+] PR auto-approved via API: state=APPROVED
10:55:56 [+] (Token is short-lived and tied to this workflow run.)
10:55:56 [+] ======================================================================
10:55:56 [>] Cleanup: deleting remote branch poc-cve-pr-target-...
10:55:58 [+] Done
```
## Sample output (patched, exfil mode)
```
10:18:00 [>] Polling OAST for token while workflow is alive
[... no token captured for the entire 180-second window ...]
10:21:00 [*] status=completed conclusion=failure
10:21:00 [+] ======================================================================
10:21:00 [*] FIX VERIFIED: no token exfiltrated from the runner
10:21:00 [*] No 'http.extraheader=AUTHORIZATION: basic ...' found in OAST log
10:21:00 [*] Either the injection was blocked, or persist-credentials: false
10:21:00 [*] kept the token out of the git config (defense in depth).
10:21:00 [+] ======================================================================
```
## Disclaimer
This repository is published for **educational and security research purposes only**. The vulnerability has been responsibly disclosed to the maintainers, fixed in upstream, and a CVE has been requested. Do not run this PoC against repositories you do not own or do not have explicit permission to test. The author is not responsible for any misuse.
## License
[MIT](LICENSE)