Megalodon GitHub Actions Secret Exfiltration Campaign
Megalodon added malicious GitHub Actions workflows to thousands of public repositories to collect environment variables, cloud credentials, source-control secrets, and runner tokens.
On this page 0% read
Executive Summary
StepSecurity reported Megalodon as a mass GitHub Actions secret-exfiltration campaign affecting 5,561 public repositories, with SafeDep publishing a dataset of 5,718 malicious commits. The campaign inserted disguised workflow files into repositories so GitHub Actions would execute attacker-controlled secret collection logic StepSecurity.
The payload collected environment variables, process environments, cloud credentials, SSH keys, Docker configuration, npm tokens, Kubernetes configs, Vault tokens, Terraform credentials, OIDC tokens, and source-code secrets before posting a compressed archive to 216[.]126[.]225[.]129:8443. Use the workflow-history, runner-egress, and downstream identity audit recipes below to determine which repositories executed the payload and which credential classes were exposed StepSecurity.
Key Facts
threat_type: "malicious GitHub Actions workflow injection"
ecosystem: "GitHub Actions"
registry: "GitHub repositories"
affected_packages:
- "not package-specific; repository workflow compromise"
malicious_versions: []
known_good_versions: []
fixed_or_safe_versions: []
execution_trigger: "GitHub Actions workflow execution after malicious workflow file is committed"
primary_impact: "mass CI/CD secret collection and exfiltration"
campaign_context: "May 2026 CI/CD supply-chain wave focused on direct runner execution and credential theft."
confidence: "medium"
canonical_source: "https://www.stepsecurity.io/blog/megalodon-mass-github-actions-secret-exfiltration-across-5-500-public-repositories"
last_verified: "2026-05-24"
Source Confidence & Evidence Mapping
- confirmed: StepSecurity reports 5,561 affected repositories and 5,718 malicious commits in a SafeDep-published dataset StepSecurity.
- confirmed: The campaign used malicious workflow files with names such as
SysDiagandOptimize-Buildto trigger GitHub Actions execution StepSecurity. - confirmed: The payload collected multiple classes of secrets and exfiltrated to
216[.]126[.]225[.]129:8443StepSecurity. - unclear: The dataset was not independently fetched in this local pass, so per-repository remediation status remains a collection gap.
Impact Determination
| Classification | Criteria | Required evidence | Required action | Closure condition |
|---|---|---|---|---|
| Confirmed compromise | Repository history contains the reported malicious workflow, commit hash, C2 endpoint, or payload content and the workflow executed. | Commit object, workflow file, Actions run metadata, runner logs, and network telemetry. | Remove the workflow, isolate affected runners, and rotate every credential class available to the run. | Malicious commits are reverted or removed, workflows are disabled, and downstream audits are clean. |
| Presumed exposed | A matching workflow exists and may have run with secrets, even if exfiltration telemetry is missing. | Workflow path, commit timestamp, runner assignment, permissions, and secret availability. | Rotate GitHub, cloud, package, container, SSH, Kubernetes, Vault, and Terraform credentials reachable from the job. | Credential owners confirm revocation of old material and no suspicious downstream writes are found. |
| Potentially exposed | Repository search finds suspicious workflow names, bot authors, archive creation, or the C2 IP but execution state is incomplete. | Code search hits, git log output, workflow run list, and runner telemetry availability. | Freeze suspicious workflow paths and collect missing run evidence before narrowing rotation. | Each hit is dispositioned as confirmed, presumed, or not exposed. |
| Not exposed | No matching workflow names, C2 endpoint, malicious hashes, or suspicious workflow additions exist in repository history. | Repository code search, git history search, and Actions run export. | Record the clean search and keep workflow ownership controls active. | Search artifacts are preserved with the date, repository list, and query terms. |
| Unknown | Repository history, Actions logs, or the public dataset comparison is unavailable. | Named evidence gaps and the repository population not yet searched. | Keep the repository in the investigation queue and apply conservative credential rotation for high-value projects. | Dataset comparison and workflow history are complete or the risk owner accepts the gap. |
Minimum Evidence To Collect
minimum_evidence:
- "Repository search results for `.github/workflows/SysDiag.yml`, `.github/workflows/Optimize-Build.yml`, reported hashes, and `216[.]126[.]225[.]129`."
- "Git commit metadata for any workflow additions and the author identity used."
- "GitHub Actions run metadata for malicious or suspicious workflows."
- "Runner process, archive creation, and egress telemetry."
- "Inventory of secrets and OIDC permissions available to each matching workflow run."
Timeline
- 2026-05-22 StepSecurity publishes Megalodon public analysis, citing 5,561 affected repositories and 5,718 malicious commits StepSecurity.
- 2026-05-24 This local feed split creates a standalone Megalodon article instead of grouping it into a weekly roundup.
What Happened
Megalodon did not need to compromise a package registry. It targeted repository automation directly by adding workflow files disguised as normal CI optimization or diagnostics. Once the workflow ran, the runner exposed a high-value environment: repository tokens, cloud credentials, deployment secrets, and process-level secrets.
StepSecurity’s writeup emphasizes the directness of the attack. A workflow file committed to a repository can run trusted automation without any application-code dependency update, which makes repository history and workflow governance critical evidence sources StepSecurity.
Technical Analysis
Initial Access
The public report focuses on malicious commits and affected repository count; it does not prove one universal initial access mechanism for every repository. Review commit authorship, branch protection, token scopes, and whether malicious workflow commits bypassed normal review.
Package or Artifact Tampering
The artifact is the GitHub Actions workflow file itself, not a package release. Reported workflow names include SysDiag and Optimize-Build, which are plausible enough to blend into routine automation StepSecurity.
Execution Trigger
Execution occurs when GitHub Actions runs the malicious workflow. Trigger conditions depend on the committed workflow, but the important defender point is that the malicious code executes inside the repository’s trusted CI context.
Payload Behavior
The payload collects environment variables, process environments, cloud credentials, SSH keys, Docker configuration, npm tokens, Kubernetes configs, Vault tokens, Terraform credentials, OIDC tokens, and source-code secrets. It then compresses and posts collected data to the C2 endpoint StepSecurity.
Exfiltration / C2
The reported exfiltration endpoint is 216[.]126[.]225[.]129:8443/collect. Any runner egress to that host and port should be treated as a high-confidence incident StepSecurity.
Propagation
Megalodon propagated operationally through many repository commits rather than through a self-replicating package payload. StepSecurity reports thousands of affected repositories and malicious commits, making source-control search and dataset comparison the primary scoping methods.
Obfuscation or Evasion
The campaign used benign-looking workflow names and CI-maintenance framing. This is effective because many repositories accept workflow changes as routine build hygiene unless workflow file review is explicitly protected.
Workflow Injection and Exfiltration Path
The following architectural flowchart details the Megalodon attack lifecycle, illustrating how a workflow injection event triggers automated secret harvesting inside the CI/CD runner and the subsequent egress path to the C2 server:
graph TD
classDef attacker fill:#f96,stroke:#333,stroke-width:2px;
classDef github fill:#9cf,stroke:#333,stroke-width:2px;
classDef runner fill:#fcf,stroke:#333,stroke-width:2px;
classDef c2 fill:#ff9,stroke:#333,stroke-width:2px;
Attacker[1. Attacker]:::attacker
GitRepo[2. Target GitHub Repo]:::github
GHActions[3. GitHub Actions CI/CD Engine]:::github
Runner[4. CI/CD Runner Environment]:::runner
Exfil[5. Exfiltration C2 <br/> 216.126.225.129:8443]:::c2
Attacker -- "Injects Malicious Commit <br/> (e.g. SysDiag / Optimize-Build)" --> GitRepo
GitRepo -- "Triggers Workflow Event <br/> (e.g. pull_request_target / push)" --> GHActions
GHActions -- "Spawns Runner with Secrets" --> Runner
subgraph Runner Context
Runner -- "1. Harvest Env Vars" --> Secrets[Credentials & Tokens]
Runner -- "2. Harvest SSH Keys" --> SSH[~/.ssh/*]
Runner -- "3. Harvest Cloud Keys" --> Cloud[AWS, Azure, GCP]
Runner -- "4. Harvest API Keys" --> API[NPM, Terraform, Vault]
Secrets & SSH & Cloud & API --> Archive[Compressed Secrets Archive]
end
Runner -- "Exfiltrates Archive (POST)" --> Exfil
Affected Assets and Blast Radius
affected_assets:
ecosystems:
- "GitHub Actions"
- "GitHub repositories"
packages: []
versions:
- "5,718 malicious commits reported by StepSecurity/SafeDep"
repositories:
- "5,561 public repositories reported by StepSecurity"
ci_cd_systems:
- "GitHub Actions"
container_images: []
developer_tools:
- "GitHub Actions"
- "repository workflow automation"
credentials_at_risk:
- "GitHub tokens"
- "GitHub Actions secrets"
- "OIDC tokens"
- "AWS credentials"
- "Azure credentials"
- "GCP credentials"
- "SSH private keys"
- "Docker registry credentials"
- "npm tokens"
- "Kubernetes configs"
- "Vault tokens"
- "Terraform credentials"
not_currently_known_to_affect:
- "Private repositories not represented in the public dataset, unless local audit finds matching commits or workflow files."
Indicators of Compromise
package_versions: []
files:
- ".github/workflows/SysDiag.yml"
- ".github/workflows/Optimize-Build.yml"
hashes:
- "1c9e803c80cc7fed000022d4c94f4b5bc2e90062"
- "7f6120bb10c870b9fde146961a18e5bf0b3d4401"
- "acac5a9854650c4ae2883c4740bf87d34120c038"
domains: []
urls:
- "hxxps://216[.]126[.]225[.]129:8443/collect"
ips:
- "216[.]126[.]225[.]129"
process_patterns:
- "workflow collects environment variables and credential files"
network_patterns:
- "HTTPS POST to 216[.]126[.]225[.]129:8443/collect"
provenance_signals:
- "workflow files named SysDiag or Optimize-Build added by unexpected commits"
- "commit authors such as [email protected] or [email protected]"
Detection and Hunting
Script: local repository and exported telemetry scope
#!/usr/bin/env python3
import os
import sys
import json
import subprocess
from pathlib import Path
ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
LOG_ROOT = os.environ.get("LOG_ROOT", "")
OUT = Path(os.environ.get("OUT", "hp-megalodon-github-actions-secret-exfiltration-scope"))
SINCE = "2026-05-24T00:00:00Z"
UNTIL = "2026-05-24T23:59:59Z"
PACKAGES = [
]
VERSIONS = [
]
FILES = [
".github/workflows/SysDiag.yml",
".github/workflows/Optimize-Build.yml",
]
DOMAINS = [
]
URLS = [
"https://216.126.225.129:8443/collect",
]
IPS = [
"216.126.225.129",
]
HASHES = [
"1c9e803c80cc7fed000022d4c94f4b5bc2e90062",
"7f6120bb10c870b9fde146961a18e5bf0b3d4401",
"acac5a9854650c4ae2883c4740bf87d34120c038",
]
PROCESS_PATTERNS = [
"workflow collects environment variables and credential files",
]
NETWORK_PATTERNS = [
"HTTPS POST to 216.126.225.129:8443/collect",
]
# Positive signal: repository, lockfile, artifact, process, or network telemetry contains one of the exact incident selectors above.
# Escalation: any match tied to a production build, CI run, deployed asset, or secret-bearing host moves the asset to presumed exposed.
OUT.mkdir(parents=True, exist_ok=True)
indicators_file = OUT / "indicators.txt"
# Collect unique indicators
indicators = set()
for group in [PACKAGES, VERSIONS, FILES, DOMAINS, URLS, IPS, HASHES, PROCESS_PATTERNS, NETWORK_PATTERNS]:
for val in group:
if val:
indicators.add(val)
with open(indicators_file, "w") as f:
for ind in sorted(indicators):
f.write(ind + "\n")
print(f"[+] Written unique selectors to {indicators_file}")
# Walk local directory
print(f"[+] Scanning directory: {ROOT} for selectors...")
matches = []
exclude_dirs = {"node_modules", "vendor", "dist", ".git"}
for root, dirs, filenames in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for filename in filenames:
filepath = Path(root) / filename
try:
content = filepath.read_text(errors="ignore")
for ind in indicators:
if ind in content:
matches.append(f"{filepath}: found '{ind}'")
except Exception:
pass
if matches:
(OUT / "repository-indicator-matches.txt").write_text("\n".join(matches) + "\n")
print(f"[!] Found {len(matches)} matches in codebase!")
# Optional Log Scanning
if LOG_ROOT and os.path.exists(LOG_ROOT):
print(f"[+] Scanning telemetry log directory: {LOG_ROOT}...")
log_matches = []
for root, _, filenames in os.walk(LOG_ROOT):
for filename in filenames:
filepath = Path(root) / filename
try:
content = filepath.read_text(errors="ignore")
for ind in indicators:
if ind in content:
log_matches.append(f"{filepath}: found '{ind}'")
except Exception:
pass
if log_matches:
(OUT / "exported-telemetry-indicator-matches.txt").write_text("\n".join(log_matches) + "\n")
print(f"[!] Found {len(log_matches)} matches in logs!")
if PACKAGES:
registry_dir = OUT / "registry"
registry_dir.mkdir(exist_ok=True)
print(f"[+] Wrote scope artifacts under {OUT}")
Downstream Abuse Audits
Script: GitHub organization run, release, secret, and workflow audit
#!/usr/bin/env python3
import os
import sys
import json
import subprocess
from pathlib import Path
if "ORG" not in os.environ:
print("ERROR: Set ORG environment variable to the GitHub organization to audit", file=sys.stderr)
sys.exit(1)
ORG = os.environ["ORG"]
SINCE = "2026-05-24T00:00:00Z"
UNTIL = "2026-05-24T23:59:59Z"
OUT = Path(os.environ.get("OUT", "hp-megalodon-github-actions-secret-exfiltration-github-audit"))
SELECTORS = [
".github/workflows/SysDiag.yml",
".github/workflows/Optimize-Build.yml",
"https://216.126.225.129:8443/collect",
"216.126.225.129",
"1c9e803c80cc7fed000022d4c94f4b5bc2e90062",
"7f6120bb10c870b9fde146961a18e5bf0b3d4401",
"acac5a9854650c4ae2883c4740bf87d34120c038",
]
# Positive signal: a workflow run, release, secret, key, package, or workflow change overlaps the exposure window and references an incident selector.
# Remediation trigger: unauthorized post-exposure write activity or a secret-bearing run matching an incident selector requires token revocation and downstream cloud/registry review.
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "runs").mkdir(exist_ok=True)
(OUT / "logs").mkdir(exist_ok=True)
(OUT / "repos").mkdir(exist_ok=True)
# 1. Write incident-selectors file
selectors_file = OUT / "incident-selectors.txt"
with open(selectors_file, "w") as sf:
for s in SELECTORS:
if s:
sf.write(s + "\n")
# 2. Get list of repos
print(f"[+] Fetching repositories for organization: {ORG}")
repo_res = subprocess.run(["gh", "repo", "list", ORG, "--limit", "1000", "--json", "nameWithOwner"], capture_output=True, text=True)
if repo_res.returncode != 0:
print(f"[-] Failed to fetch repos: {repo_res.stderr}", file=sys.stderr)
sys.exit(1)
repos = [r["nameWithOwner"] for r in json.loads(repo_res.stdout)]
for repo in repos:
safe_repo = repo.replace("/", "__")
print(f"[+] Auditing repository: {repo}")
# Check runs in the window
runs_res = subprocess.run([
"gh", "api", f"/repos/{repo}/actions/runs",
"-f", "per_page=100",
"-f", f"created=>={SINCE}",
"--paginate"
], capture_output=True, text=True)
if runs_res.returncode == 0:
try:
all_runs = json.loads(runs_res.stdout).get("workflow_runs", [])
filtered_runs = [r for r in all_runs if r["created_at"] <= UNTIL]
if filtered_runs:
with open(OUT / "runs" / f"{safe_repo}-runs.jsonl", "w") as rf:
for run in filtered_runs:
rf.write(json.dumps(run) + "\n")
# Fetch log dynamically
run_id = str(run["id"])
log_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--log"], capture_output=True, text=True)
if log_res.returncode == 0:
(OUT / "logs" / f"{safe_repo}-{run_id}.log").write_text(log_res.stdout)
# Fetch details
view_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--json", "databaseId,workflowName,headSha,event,createdAt,jobs"], capture_output=True, text=True)
if view_res.returncode == 0:
(OUT / "runs" / f"{safe_repo}-{run_id}.json").write_text(view_res.stdout)
except Exception as e:
print(f"[-] Error parsing runs for {repo}: {e}")
# Check releases in window
subprocess.run(["gh", "api", f"/repos/{repo}/releases", "-f", "per_page=100", "--paginate"], capture_output=True)
# Check repo secrets updated in window
subprocess.run(["gh", "api", f"/repos/{repo}/actions/secrets", "-f", "per_page=100", "--paginate"], capture_output=True)
# Check deploy keys
subprocess.run(["gh", "api", f"/repos/{repo}/keys", "-f", "per_page=100", "--paginate"], capture_output=True)
# Scan output directory for any indicator selector matches
print("[+] Scanning gathered telemetry for indicator matches...")
subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(selectors_file), str(OUT)], capture_output=False)
print(f"[+] Wrote GitHub audit artifacts under {OUT}")
Script: cloud OIDC and deployment credential follow-on audit
#!/usr/bin/env python3
import os
import json
import subprocess
from pathlib import Path
SINCE = "2026-05-24T00:00:00Z"
UNTIL = "2026-05-24T23:59:59Z"
OUT = Path(os.environ.get("OUT", "hp-megalodon-github-actions-secret-exfiltration-cloud-audit"))
AWS_REGIONS = os.environ.get("AWS_REGIONS", "us-east-1").split(",")
# Positive signal: token exchange or privileged write activity occurs in the exposure window from GitHub, CI/CD, package registry, or deployment automation identity.
# Remediation trigger: unexpected write, deploy, IAM, secret, or registry activity tied to an exposed CI/CD path requires trust-policy disablement and credential rotation.
OUT.mkdir(parents=True, exist_ok=True)
# 1. AWS CloudTrail Audit
print("[+] Querying AWS CloudTrail for Web Identity token exchanges...")
aws_events = []
for region in AWS_REGIONS:
res = subprocess.run([
"aws", "cloudtrail", "lookup-events",
"--region", region,
"--start-time", SINCE,
"--end-time", UNTIL,
"--lookup-attributes", "AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity",
"--output", "json"
], capture_output=True, text=True)
if res.returncode == 0:
try:
events = json.loads(res.stdout).get("Events", [])
for event in events:
ct = json.loads(event.get("CloudTrailEvent", "{}"))
ct["region"] = region
aws_events.append(ct)
except Exception as e:
print(f"[-] Error parsing AWS CloudTrail events: {e}")
if aws_events:
with open(OUT / "aws-assume-role-with-web-identity.jsonl", "w") as f:
for ev in aws_events:
f.write(json.dumps(ev) + "\n")
# Audit follow-on events for returned access keys
for ev in aws_events:
access_key = ev.get("responseElements", {}).get("credentials", {}).get("accessKeyId")
region = ev.get("region", "us-east-1")
if access_key:
print(f"[+] Enumerating AWS events for AccessKey: {access_key}")
f_res = subprocess.run([
"aws", "cloudtrail", "lookup-events",
"--region", region,
"--start-time", SINCE,
"--end-time", UNTIL,
"--lookup-attributes", f"AttributeKey=AccessKeyId,AttributeValue={access_key}",
"--output", "json"
], capture_output=True, text=True)
if f_res.returncode == 0:
try:
f_events = json.loads(f_res.stdout).get("Events", [])
with open(OUT / "aws-follow-on-api-calls.jsonl", "a") as ff:
for fe in f_events:
ff.write(fe.get("CloudTrailEvent", "{}") + "\n")
except Exception as e:
print(f"[-] Error writing follow-on events: {e}")
# 2. Azure Activity Log Audit
print("[+] Querying Azure activity logs...")
az_res = subprocess.run([
"az", "monitor", "activity-log", "list",
"--start-time", SINCE,
"--end-time", UNTIL,
"--query", "[?contains(operationName.value, 'write') || contains(operationName.value, 'delete') || contains(operationName.value, 'Microsoft.ManagedIdentity')]",
"-o", "json"
], capture_output=True, text=True)
if az_res.returncode == 0:
(OUT / "azure-write-delete-activity.json").write_text(az_res.stdout)
# 3. GCP Logging Audit
print("[+] Querying GCP Cloud Logging...")
gcp_filter = f'timestamp>="{SINCE}" AND timestamp<="{UNTIL}" AND (protoPayload.methodName="google.sts.v1.SecurityTokenService.ExchangeToken" OR protoPayload.methodName:"GenerateAccessToken" OR protoPayload.methodName:"CreateServiceAccountKey" OR protoPayload.methodName:"SetIamPolicy")'
gcp_res = subprocess.run([
"gcloud", "logging", "read", gcp_filter,
"--format", "json"
], capture_output=True, text=True)
if gcp_res.returncode == 0:
(OUT / "gcp-token-and-iam-activity.json").write_text(gcp_res.stdout)
print(f"[+] Wrote cloud audit artifacts under {OUT}")
Script: registry metadata and artifact audit
#!/usr/bin/env python3
import os
import json
import subprocess
from pathlib import Path
SINCE = "2026-05-24T00:00:00Z"
OUT = Path(os.environ.get("OUT", "hp-megalodon-github-actions-secret-exfiltration-registry-audit"))
PACKAGES = [
]
VERSIONS = [
]
# Positive signal: workflow files or extensions reference the affected action/extension names or versions.
# Remediation trigger: exposed secrets or OIDC federation policies must be immediately rotated.
OUT.mkdir(parents=True, exist_ok=True)
with open(OUT / "affected-versions.txt", "w") as av:
for version in VERSIONS:
if version:
av.write(version + "\n")
# 1. Search local workspace files for the affected actions/extensions
print("[+] Scanning workspace workflows for selectors...")
for file in Path(".").glob(".github/workflows/**/*.yml"):
subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(OUT / "affected-versions.txt"), str(file)])
# 2. HOW TO ROTATE EXPOSED GITHUB ACTIONS SECRETS:
# Overwrite compromised secrets with newly generated credentials:
# subprocess.run(["gh", "secret", "set", "COMPROMISED_SECRET_NAME", "--body", "my-new-secret-value", "--repo", "my-org/my-repo"])
# For organization-level secrets:
# subprocess.run(["gh", "secret", "set", "COMPROMISED_SECRET_NAME", "--org", "my-org", "--visibility", "private"])
# Revoke compromised OIDC federated trust credentials in AWS/GCP and redeploy the IAM trust policy:
# subprocess.run(["aws", "iam", "update-assume-role-policy", "--role-name", "my-role-name", "--policy-document", "file://new-clean-trust-policy.json"])
print(f"[+] Wrote registry audit artifacts under {OUT}")
Sources
- StepSecurity: Megalodon: Mass GitHub Actions Secret Exfiltration Across 5,500+ Public Repositories - Role: PRIMARY_RESEARCH - Impact: Documents affected repository and commit counts, workflow names, payload collection scope, C2 IP, and hunting pivots.
IOC Clipboard
7 IOCshttps://216.126.225.129:8443/collect hxxps://216[.]126[.]225[.]129:8443/collect 216.126.225.129 216[.]126[.]225[.]129 1c9e803c80cc7fed000022d4c94f4b5bc2e90062 1c9e803c80cc7fed000022d4c94f4b5bc2e90062 7f6120bb10c870b9fde146961a18e5bf0b3d4401 7f6120bb10c870b9fde146961a18e5bf0b3d4401 acac5a9854650c4ae2883c4740bf87d34120c038 acac5a9854650c4ae2883c4740bf87d34120c038 .github/workflows/SysDiag.yml .github/workflows/SysDiag.yml .github/workflows/Optimize-Build.yml .github/workflows/Optimize-Build.yml