Back to all posts

cat posts/4837-hijacked-github-accounts-your-azure-oidc-trust-audit.md --category "Security & Compliance" --views 8

4,837 hijacked GitHub accounts: your Azure OIDC trust audit

The wfx campaign used 4,837 hijacked GitHub accounts to probe 61,764 repositories, ran a credential stealer in at least 1,806 of them, and planted federated identity credentials on Entra app registrations that survive every secret rotation. Enumerate every federated trust across app registrations and managed identities, hunt the deleted workflow runs, rotate in the right order, and scope GitHub OIDC subjects to protected environments.

  • --author By Falak Mahmood
  • --date November 12, 2026
  • --read 13 min read
  • --views 8 views

Between 10 November at 12:45 UTC and 11 November at 15:00 UTC, 4,837 compromised GitHub accounts opened probe pull requests across 61,764 repositories. At least 1,806 of those repositories ran a 1,500-line credential stealer inside GitHub Actions, and the payload confirmed exfiltration before reverting its own changes and deleting the workflow run logs. Those are Aikido Security's figures, published on 12 November. SafeDep's own sweep, which ran until 04:36 UTC on 12 November, counted 8,093 repositories with malicious commits and noted the campaign was still active at that point. The number that should worry an Azure team is not any of those. It is the one in the persistence section of Aikido's write-up: with a Microsoft Graph token in hand, the stealer adds new federated identity credentials, named wfx-ci-…, to your Entra app registrations, pointing back at the repository it just compromised. Rotating every secret you own does nothing about that. The trust has to be found and deleted.

What happened in 40 hours

The campaign ran in three stages. First, each hijacked account opened a pull request titled "docs: dependency audit notes" from a branch called wfx-docs. That PR was a write-access probe: if the account could push a branch, the attacker knew it could push a workflow. Aikido counts 10,431 of those PRs landing in repositories the compromised user did not own, through collaborator or organisation membership. As of publication, 92% of the probe PRs were still open, which tells you how little of this was noticed.

Second came the payload, in one of three shapes. SafeDep's breakdown of the 8,093 affected repositories:

VariantRepositoriesHow it runs
Injection into an existing workflow7,309A commit titled "ci: refresh build cache" appends roughly 1,500 lines to a workflow you already trust. Runs on the next trigger.
npm postinstall hook771Fires on npm install, which means it runs on developer laptops as well as runners.
New throwaway workflow file16Needs an initial push to trigger, so it was the least used.

Third, cleanup. Commits titled "ci: restore" and "ci: cleanup" reverted the injected lines, and the workflow run logs were deleted within a minute of execution. If you look at a run list and the numbering has gaps, that is the trace. Two published packages carried the payload: digitalelvis/neuronai-studio 3.14.3 on Packagist and a tagged version of the Go module onuragtas/docker-env, though in both cases the malicious code did not execute during a normal install.

Aikido notes an overlap in compromised accounts with October's GhostAction waves, which hit about 1,900 accounts and 30,000 repositories, but different infrastructure and tooling. Their reading is credential resale rather than the same operator. Either way, the accounts were already stolen before this started. Nothing in GitHub was exploited. Access was simply used.

What the stealer takes, and why the Azure part is different

The collection list is long and predictable until you reach the cloud sections. The script grabs GitHub Actions secrets, repository variables, the GITHUB_TOKEN and Actions runtime tokens. It sweeps environment variables whose names match token, secret, aws, azure, gcp and a list of AI providers. It reads credential files from the standard paths for AWS, Azure, GCP, Docker, Kubernetes, npm, PyPI, Git, SSH, GPG, Vault and Terraform. It copies shell histories, including MySQL and PostgreSQL histories, and dumps process memory from runner services. On self-hosted runners it also reads instance metadata, Docker state and systemd units. If you read our LLMjacking post last week, note that OpenAI, Anthropic, Gemini and Hugging Face tokens are explicitly on the list. A runner that holds an Azure OpenAI key in an environment variable just handed it over.

Then it escalates. For Azure, Aikido describes two moves. The first is token exchange: the payload takes the OIDC token GitHub minted for the workflow, or any client secret it found, exchanges it for an ARM token and enumerates subscriptions and Key Vaults. The second is FOCI replay, "replaying cached Azure CLI refresh tokens against first-party Microsoft client IDs to get Graph, ARM and Key Vault tokens." FOCI, the Family of Client IDs, lets a refresh token issued to one first-party Microsoft client be redeemed by another. A developer or a self-hosted runner that ran az login leaves that refresh token in the local MSAL cache. The stealer turns one cached login into Graph access.

Graph access is what makes the persistence possible. A federated identity credential on an Entra app registration is a trust statement: "accept a token from issuer X whose subject claim is Y, and treat it as this application." The whole point of the design is that no secret exists to rotate. That is why we recommend it over client secrets for CI, and the recommendation stands. But it also means an attacker who can write to the app registration can add a trust that survives every rotation you perform, because there is nothing to rotate. The credential they add points at the repository they control, so any future workflow run there mints a GitHub OIDC token that Entra will happily exchange for your application's access token. Aikido's own summary: this "bypasses secret rotation."

Two facts from Microsoft's documentation shape the hunt. An application or user-assigned managed identity can hold at most 20 federated identity credentials, so the list is short enough to read by eye. And adding one requires the account to be an owner of the application or hold Application Administrator, Cloud Application Administrator, Global Administrator or Hybrid Identity Administrator. If a cached CLI refresh token belonged to anyone with those rights, assume the attacker had them too.

The audit, in order

1. Find the footprints on GitHub

Search every organisation you own for the indicators. The PR title is "docs: dependency audit notes". The branches are wfx-docs and chore/ followed by eight lowercase alphanumerics. The commit messages are "ci: refresh build cache", "chore: add ci helper", "chore: add dependency audit workflow", "ci: restore" and "ci: cleanup". Exfiltration went to port 8080 on 194.150.166.244, 18.143.168.96 and 153.75.82.159, and a fifth of the payloads used Telegram only. The payload hash is 89695abb392d5cfd4972672961e644bb46baa98aa1ed7173c2a2816c762735cf.

Then check for the thing that was deleted. The organisation audit log keeps 180 days of events and supports an action filter, so query it for workflow run deletions over the campaign window:

action:workflows.delete_workflow_run created:2026-11-10..2026-11-12

Any hit in a repository that does not routinely prune runs is a confirmed execution, not a probe. Treat every secret that workflow could see as stolen. Aikido also saw forged status checks used to get past branch protection, so review protection bypass events in the same log.

2. Enumerate every federated trust in the tenant

Federated identity credentials live in two places: app registrations (Entra) and user-assigned managed identities (ARM). Pull both. This needs rights to read applications and to read the identities, which an auditor should already hold.

# App registrations
for app in $(az ad app list --all --query "[].id" -o tsv); do
  az ad app federated-credential list --id "$app" \
    --query "[].{app:'$app', name:name, issuer:issuer, subject:subject}" -o tsv
done > fic-apps.tsv

# User-assigned managed identities, every subscription
for sub in $(az account list --query "[].id" -o tsv); do
  az identity list --subscription "$sub" --query "[].{n:name, g:resourceGroup}" -o tsv |
  while read -r n g; do
    az identity federated-credential list --subscription "$sub" \
      --identity-name "$n" --resource-group "$g" \
      --query "[].{mi:'$n', name:name, issuer:issuer, subject:subject}" -o tsv
  done
done > fic-mi.tsv

grep -i "wfx" fic-apps.tsv fic-mi.tsv

A wfx-ci name is the easy case. The harder review is the subject column. For GitHub the issuer is https://token.actions.githubusercontent.com and the subject takes one of three forms: repo:org/repo:environment:name, repo:org/repo:ref:refs/heads/branch, or repo:org/repo:pull_request. Every subject should name an organisation and repository you own and a branch or environment you recognise. A subject that references a personal account, a fork, or a repository you have never heard of is the backdoor, whatever its name says. Microsoft's docs are explicit that the name field is immutable and the description is never validated, so neither is evidence of anything.

Delete anything you cannot explain, from both stores:

az ad app federated-credential delete --id <app-object-id> --federated-credential-id <name>
az identity federated-credential delete --identity-name <mi> --resource-group <rg> --name <name>

3. Read the Entra audit log for the same window

If the tenant streams to Log Analytics, the application-management category records credential changes on app registrations. Look for anything touching federated credentials since 10 November, and look at who did it. A service principal or a developer account adding a credential at 03:00 to an application it does not own is the signal.

AuditLogs
| where TimeGenerated >= datetime(2026-11-10)
| where Category == "ApplicationManagement"
| where tostring(TargetResources) has "federatedIdentityCredentials"
    or OperationName has "federated"
| project TimeGenerated, OperationName, Result,
          Actor = tostring(InitiatedBy.user.userPrincipalName),
          ActorApp = tostring(InitiatedBy.app.displayName),
          Target = tostring(TargetResources[0].displayName)

Pair it with the service principal sign-in log. Microsoft groups service principal sign-ins by principal, status, IP address and resource, which is convenient here: a deployment identity that has only ever signed in from GitHub's runner ranges and suddenly shows a new IP and a Key Vault resource is your exchanged token in use.

4. Rotate what rotation actually fixes

With the trusts cleaned, rotate in this order. GitHub Actions secrets and variables in every repository that executed the payload. Client secrets and certificates on any app registration those workflows used. Key Vault secrets the workflow identity could read, since the stealer enumerated vaults. Then the refresh tokens: for every human account whose Azure CLI cache sat on an affected self-hosted runner or developer machine, revoke sign-in sessions through Microsoft Graph so the cached FOCI token dies. That last step is the one teams skip, and it is the one the FOCI pivot depends on.

The npm postinstall variant changes the blast radius. It ran wherever npm install ran, so a developer who pulled an affected branch on a laptop with an az login session is in scope. Our rotation-without-outages guide covers the mechanics; the ordering above is what changes when the attacker may still hold a trust.

5. Self-hosted runners on Azure

The payload reads instance metadata on self-hosted runners. On an Azure VM that means the managed identity endpoint, and a runner VM with a system-assigned identity carrying Contributor on a subscription gives the attacker that role for free, no exchange required. If your runners live on Azure (we ran the numbers in our self-hosted runner cost post), check three things: the runner VM has no managed identity it does not need, the runners are ephemeral so a cached login cannot outlive a job, and the VM's network security group blocks outbound traffic to anything but GitHub and your package sources. The three exfiltration IPs would have been dropped at the NSG.

Hardening that would have blunted it

  • Scope subjects to environments, and protect the environments. A federated credential whose subject is repo:org/repo:environment:production only matches jobs that declare that environment, and GitHub environment protection rules can restrict which branches may deploy to it and require a reviewer. An injected workflow on a feature branch never gets a token Entra will accept. Branch-scoped subjects on main are the next best thing; pull_request subjects should be rare and reviewed.
  • Grant id-token: write to the deploy job only. The permission exists per job. A workflow that sets it at the top level hands every step, including an injected one, the ability to mint an OIDC token for exchange.
  • Keep the default GITHUB_TOKEN read-only. The restricted default gives read access to contents and packages. Workflows that need more should ask for it per job.
  • Require approval for all external contributors. It is the strictest of GitHub's three fork-approval options. It would not have stopped a hijacked collaborator, but 10,431 of the PRs came through collaborator or organisation access that the owners could have narrowed.
  • Deny federated credential creation where it should never happen. Microsoft's considerations page ships an Azure Policy example that denies the Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials type outright. Apply it to subscriptions where managed identities exist but should never be federated, which for most estates is nearly all of them.
  • Narrow who can add credentials to app registrations. App ownership is the quiet privilege here. A developer who owns a deployment app registration can add a federated credential to it, so can anyone who steals that developer's cached token. Keep ownership with a small group and alert on the audit event.

If you run Azure DevOps instead

This campaign targeted GitHub, but the persistence trick is identical for Azure Pipelines. A workload identity federation service connection is a federated credential on an app registration or managed identity, with a subject of the form sc://organisation/project/connection-name. The enumeration script above finds those too. Microsoft is also retiring the Azure DevOps issuer, https://vstoken.dev.azure.com, on 1 July 2027 in favour of the Entra issuer, so every flagged connection must be converted before then anyway. Do the inventory once and use it for both jobs: match each credential to a service connection that exists, and delete the ones that do not.

The Swedish and EU angle

Three obligations turn this from an engineering cleanup into a reportable event for many Swedish organisations.

NIS2 incident clocks. If a workflow in scope of Sweden's cybersäkerhetslag ran the payload, the exfiltration of cloud credentials is a candidate significant incident. The NIS2 timeline is a 24-hour early warning and a 72-hour incident notification to the supervisory authority. The deleted workflow logs do not reset that clock; the GitHub audit log entry for the deletion is your timestamp.

GDPR Article 33. Repository snapshots and Key Vault contents were in the collection list. If either held personal data, or if the stolen tokens could reach systems that do, the 72-hour notification to IMY applies from the moment you become aware. "We are still assessing" is a valid first notification; silence is not.

Supplier chains. Swedish public-sector buyers increasingly require that suppliers disclose CI/CD compromise affecting delivered code. If a repository that builds something you deliver to a myndighet executed the payload, the forged-status-check detail matters: the attacker could have merged code past branch protection. Verify the main branch history for the window before you certify a release clean.

What to do today

  • Search all organisations for the PR title, branch names and commit messages, and query the audit log for workflow run deletions since 10 November.
  • Run the federated credential enumeration across app registrations and managed identities. Delete any credential whose subject you cannot map to a repository, branch or environment you own.
  • Rotate Actions secrets, app secrets and Key Vault secrets for affected workflows, then revoke sign-in sessions for any account whose CLI cache was reachable.
  • Strip unneeded managed identities from runner VMs and block outbound traffic from runners to anything but GitHub and your package feeds.
  • Move GitHub federated credentials to environment-scoped subjects with protection rules, and apply the deny policy for managed identity federation where it has no business existing.
  • Open the NIS2 and GDPR assessment in parallel with the technical work if any payload execution is confirmed.

Secretless CI remains the right design. It just moves the asset you have to protect from a string in a vault to a trust record in Entra, and a trust record is only safe if someone reads the list.

subscribe # the AI news that matters, minus the noise

Book a Call

Sources

Tags

Related posts