← All hunts high TLP:CLEAR Part 1 of 2

ChainDrop: NPM Worm Endpoint and CI Runner Activity

An intruder has infected an npm package and triggered a preinstall hook that uses the Bun runtime to harvest credentials from the filesystem and CI runner process memory.

Based on research by Unit 42 2026-09-20 12 steps · 5 queries T1003.001 T1059.003 T1105 T1195.002 T1552.001 T1555

Brief

Background and Source

Recent research by Unit 42 titled ChainDrop: Inside a Self-Propagating npm Worm (https://unit42.paloaltonetworks.com/chaindrop-npm-worm-analysis/) details a sophisticated threat targeting the JavaScript ecosystem. The worm spreads by trojanizing common npm packages and hijacking the preinstall lifecycle hook. It uses the Bun runtime to execute its dropper, allowing it to bypass some standard Node.js instrumentation and monitoring.

How the Hunt Flows

The hunt begins with a scoping phase. A query scans the software inventory for hostnames running specific trojanized npm packages, such as keyv or cacheable-request. This establishes a baseline of potentially affected hosts but does not confirm infection on its own.

Next, the hunt looks for execution evidence in two parallel paths. The first path searches process activity for command lines referencing the setup.mjs dropper or internal state variables used to prevent recursion. The second path identifies rare instances of the Bun runtime. Because Bun is less common than Node.js in many enterprise environments, stack-counting its execution helps find the worm's beachhead on workstations or runners.

After identifying execution, the hunt pivots to assess impact. It examines file activity to find instances where Node or Bun processes access sensitive files like .env, .npmrc, or SSH keys. For CI environments, the hunt looks for command lines that indicate the worm is reading the Linux proc filesystem specifically to scrape memory from the GitHub Actions worker process.

Finally, the hunt provides a triage step where an analyst confirms the chain from the infected package to the final credential harvest. If confirmed, the playbook includes an action to isolate the host and remediate specific files in the .claude or .vscode directories.

Blind Spots

Short-lived CI runners pose the highest risk to this hunt. If the worm executes on a runner that is destroyed before its process and file logs are shipped to a central sink, the hunt will not see the activity. Additionally, the detection of memory scraping relies on the presence of specific procfs strings in command-line arguments. If the adversary uses a compiled binary that interacts with memory directly without shell helpers, this signal disappears.

In this series

Steps

  1. Affected npm packages in inventory

    Query · scoping

    Identify hosts that have the trojanized npm packages installed in their development environments.

    reads hb_software_inventorysql
    SELECT device_hostname, package_name, package_version, install_path FROM hb_software_inventory WHERE package_type = 'npm' AND instr(',' || '{{infected_packages}}' || ',', ',' || package_name || ',') > 0

    What a hit looks like. A list of hostnames and paths where the vulnerable packages are installed. Silence indicates the packages are not present in the reported inventory.

  2. Dropper and lifecycle hook execution

    Query · detection candidate

    Detect the execution of setup.mjs or the specific environment variable used by the worm to prevent recursion.

    reads hb_process_activitysql
    SELECT device_hostname, process_cmd_line, user_name, time FROM hb_process_activity WHERE ('{{scope_hosts}}' = '' OR instr(',' || '{{scope_hosts}}' || ',', ',' || device_hostname || ',') > 0) AND (LOWER(process_cmd_line) LIKE '%setup.mjs%' OR LOWER(process_cmd_line) LIKE '%math_init.js%' OR LOWER(process_cmd_line) LIKE '%_node_runtime_init%') AND time >= datetime('now', '-{{lookback_days}} days')

    What a hit looks like. Process command lines referencing the dropper file or the worm's internal state variable. This confirms the malicious hook fired.

  3. Rare Bun runtime execution

    Query · baseline

    Identify hosts running the Bun runtime, which the worm uses as a portable dropper to evade standard Node instrumentation.

    reads hb_process_activitysql
    SELECT LOWER(process_name) AS process_name, COUNT(DISTINCT device_hostname) AS host_count, MIN(time) AS first_seen FROM hb_process_activity WHERE LOWER(process_name) LIKE '%bun%' AND time >= datetime('now', '-{{lookback_days}} days') GROUP BY process_name HAVING host_count <= 3

    What a hit looks like. Anomalous execution of the Bun binary. Since it is less common than Node.js in many environments, stack-counting helps find the beachhead.

  4. Evaluate initial execution

    Agent triage

    Assess whether the inventory finds and process patterns together indicate a ChainDrop dropper execution.

  5. Credential file harvest

    Query · triage

    Identify instances where Node or Bun processes access sensitive files like .env, .npmrc, or SSH keys.

    reads hb_file_activitysql
    SELECT device_hostname, file_path, process_name, time FROM hb_file_activity WHERE ('{{scope_hosts}}' = '' OR instr(',' || '{{scope_hosts}}' || ',', ',' || device_hostname || ',') > 0) AND (LOWER(file_path) LIKE '%.env%' OR LOWER(file_path) LIKE '%.npmrc%' OR LOWER(file_path) LIKE '%.git-credentials%' OR LOWER(file_path) LIKE '%/.ssh/%') AND (LOWER(process_name) LIKE '%node%' OR LOWER(process_name) LIKE '%bun%') AND time >= datetime('now', '-{{lookback_days}} days')

    What a hit looks like. File activity showing the worm's runtime accessing credential stores. Silence suggests no broad sweep occurred or was captured.

  6. CI Runner process memory scraping

    Query · enrichment

    Detect the worm accessing the memory of the GitHub Actions Runner worker to steal OIDC tokens.

    reads hb_process_activitysql
    SELECT device_hostname, process_cmd_line, user_name, time FROM hb_process_activity WHERE ('{{scope_hosts}}' = '' OR instr(',' || '{{scope_hosts}}' || ',', ',' || device_hostname || ',') > 0) AND (LOWER(process_cmd_line) LIKE '%runner.worker%' AND (LOWER(process_cmd_line) LIKE '%/proc/%/maps%' OR LOWER(process_cmd_line) LIKE '%/proc/%/mem%')) AND time >= datetime('now', '-{{lookback_days}} days')

    What a hit looks like. Command lines showing Python or Shell helpers reading the Linux proc filesystem for the GitHub runner process. This is a high-confidence indicator of ChainDrop impact on CI infrastructure.

  7. Confirm credential theft and impact

    Agent triage

    Combine the early execution context with the later harvesting patterns to confirm a full intrusion.

  8. Route on verdict

    Decision

    Determine whether to contain the host based on the severity of the findings.

  9. Isolate and remediate host

    Response action

    Stop further exfiltration and remove the worm's artifacts.

  10. Analyst review

    Analyst task

    Verify the findings and initiate a full incident response if the worm's activity is confirmed.

  11. Close out

    Analyst task

    Finalize the hunt when no malicious activity is found.

Coverage

Scenario coverage

StageCoveredHow, or why not
Trojanized npm Package Installation
T1195.002
Yes affected-npm-packages
NPM Preinstall Hook Execution
T1059.003
Yes dropper-execution
Bun Runtime Dropper and Payload Execution
T1105
Yes bun-prevalence
Filesystem and Environment Credential Harvest
T1552.001 · T1555
Yes credential-sweep
CI Runner Process Memory Scraping
T1003.001
Yes memory-scraping
Persistence via VS Code and Claude Code Hooks
T1546
Out of scope Belongs to another part of the 'ChainDrop: Inside a Self-Propagating npm Worm' series.
Ethereum Smart Contract C2 Resolution
T1102
Out of scope Belongs to another part of the 'ChainDrop: Inside a Self-Propagating npm Worm' series.
Worm Propagation via Package Republishing
T1534 · T1574.006
Out of scope Belongs to another part of the 'ChainDrop: Inside a Self-Propagating npm Worm' series.

Blind spots

  • Needs EDR memory-access telemetry or Sysmon for Linux with Event ID 10. Without memory-access logs, we rely on the command-line presence of procfs strings, which the adversary could obfuscate or perform via a binary that does not use a shell helper. It would answer Whether the Runner.Worker memory was successfully read by a non-standard process..
  • Needs Real-time process logs from ephemeral containers. Ephemeral CI runners may not persist logs to a central sink in time for the hunt to see the hook execution if the job finishes quickly. It would answer If the worm executed on a runner that was destroyed before logs were shipped..

Parameters & data

Parameters

ParameterTypeDefaultWhat it is
infected_packageslist[string]keyv, cacheable-requestNPM packages known to have been trojanized by ChainDrop.
lookback_daysnumber14Days of history to examine.
scope_hostslist[host]Optional list of hostnames to focus the hunt on; leave empty for fleet-wide.

Telemetry

SourceCategoryTelemetry
Endpoint telemetry (hb_ surfaces)endpointendpoint

Source

Download hunt.md Definition (JSON) An open hunt.md file; it runs anywhere that reads the format.
---
analysis: A simple detection rule on the npm package version is easily bypassed by
  the worm's propagation. This hunt pivots between software inventory, process runtime
  prevalence (Bun), and subsequent behavioral indicators like file harvest and proc-fs
  scraping to find the worm even after packages rotate.
blind_spots:
- id: limited-process-memory-visibility
  question: Whether the Runner.Worker memory was successfully read by a non-standard
    process.
  requires: EDR memory-access telemetry or Sysmon for Linux with Event ID 10
  risk: Without memory-access logs, we rely on the command-line presence of procfs
    strings, which the adversary could obfuscate or perform via a binary that does
    not use a shell helper.
  stage: ci-runner-memory-access
- id: short-lived-runners
  question: If the worm executed on a runner that was destroyed before logs were shipped.
  requires: Real-time process logs from ephemeral containers
  risk: Ephemeral CI runners may not persist logs to a central sink in time for the
    hunt to see the hook execution if the job finishes quickly.
  stage: npm-lifecycle-hook-execution
coverage:
- stage: npm-supply-chain-compromise
  status: covered
  steps:
  - affected-npm-packages
- stage: npm-lifecycle-hook-execution
  status: covered
  steps:
  - dropper-execution
- stage: portable-runtime-dropper
  status: covered
  steps:
  - bun-prevalence
- stage: credential-and-environment-sweep
  status: covered
  steps:
  - credential-sweep
- stage: ci-runner-memory-access
  status: covered
  steps:
  - memory-scraping
- reason: 'Belongs to another part of the ''ChainDrop: Inside a Self-Propagating npm
    Worm'' series.'
  stage: developer-tooling-persistence
  status: out_of_scope
- reason: 'Belongs to another part of the ''ChainDrop: Inside a Self-Propagating npm
    Worm'' series.'
  stage: blockchain-c2-routing
  status: out_of_scope
- reason: 'Belongs to another part of the ''ChainDrop: Inside a Self-Propagating npm
    Worm'' series.'
  stage: automated-package-propagation
  status: out_of_scope
guardrails:
  claims: no_unsupported
  evidence: citation_required
  missing_data: not_benign
  telemetry: untrusted
hunt:
  applicability: campaign-specific
  handoff: keep-as-periodic-hunt
  justification: ChainDrop is a high-velocity supply chain attack targeting the root
    of developer trust; identifying its presence on workstations and runners is essential
    to prevent large-scale credential theft.
  methodology: model-assisted
  trigger: intel-report
hypothesis: An intruder has infected an npm package and triggered a preinstall hook
  that uses the Bun runtime to harvest credentials from the filesystem and CI runner
  process memory.
labels:
- hunt
- attack.t1195.002
- attack.t1059.003
- attack.t1105
- attack.t1552.001
- attack.t1555
- attack.t1003.001
name: 'ChainDrop: NPM Worm Endpoint and CI Runner Activity'
parameters:
  infected_packages:
    default:
    - keyv
    - cacheable-request
    description: NPM packages known to have been trojanized by ChainDrop.
    from:
      kind: article
      observed: '2026-08-06'
      ref: unit42-chaindrop
    type: list[string]
  lookback_days:
    default: '14'
    description: Days of history to examine.
    type: number
  scope_hosts:
    default: []
    description: Optional list of hostnames to focus the hunt on; leave empty for
      fleet-wide.
    type: list[host]
provenance:
  authors:
  - name: Huntbase hunt generation
    org: huntbase.io
  generated:
    by: huntbase-hunt-generation
    from: https://unit42.paloaltonetworks.com/chaindrop-npm-worm-analysis/
    gates:
    - dry-run
    - lint
    - critic
    model: hb_google/gemini-3-flash-preview
rationale: Prioritize development workstations and CI/CD runner environments where
  npm packages are frequently installed and updated.
references:
- name: 'ChainDrop: Inside a Self-Propagating npm Worm'
  url: https://unit42.paloaltonetworks.com/chaindrop-npm-worm-analysis/
related:
- hunt: chaindrop-persistence-and-propagation
  reason: This hunt identifies the initial execution and harvest; persistence in VS
    Code and Claude Code, and automated propagation, are handled in the follow-on
    hunt.
  relation: out-of-scope-alternative
scenario:
  stages:
  - name: Trojanized npm Package Installation
    observables:
    - registry.npmjs.org
    - keyv
    - cacheable-request
    slug: npm-supply-chain-compromise
    tactic: initial-access
    techniques:
    - T1195.002
  - name: NPM Preinstall Hook Execution
    observables:
    - preinstall
    - node setup.mjs
    slug: npm-lifecycle-hook-execution
    tactic: execution
    techniques:
    - T1059.003
  - name: Bun Runtime Dropper and Payload Execution
    observables:
    - setup.mjs
    - math_init.js
    - Bun 1.3.13
    - Oven GitHub repository
    - _NODE_RUNTIME_INIT=1
    slug: portable-runtime-dropper
    tactic: execution
    techniques:
    - T1105
  - name: Filesystem and Environment Credential Harvest
    observables:
    - .env
    - .git-credentials
    - .netrc
    - SSH keys
    - npm tokens
    - GitHub tokens
    - Kubernetes service-account tokens
    slug: credential-and-environment-sweep
    tactic: credential-access
    techniques:
    - T1552.001
    - T1555
  - name: CI Runner Process Memory Scraping
    observables:
    - Runner.Worker
    - /proc/pid/maps
    - /proc/pid/mem
    - OIDC tokens
    slug: ci-runner-memory-access
    tactic: credential-access
    techniques:
    - T1003.001
  - name: Persistence via VS Code and Claude Code Hooks
    observables:
    - .vscode/tasks.json
    - .claude/settings.json
    - Environment Setup
    - SessionStart
    - .claude/math_init.js
    - com.user.gh-token-monitor
    - gh-token-monitor.service
    slug: developer-tooling-persistence
    tactic: persistence
    techniques:
    - T1546
  - name: Ethereum Smart Contract C2 Resolution
    observables:
    - Ethereum transaction
    - blockchain-based C2 resolution
    slug: blockchain-c2-routing
    tactic: command-and-control
    techniques:
    - T1102
  - name: Worm Propagation via Package Republishing
    observables:
    - 'Shai-Hulud: Here We Go Again'
    - .github/workflows/codeql_analysis.yml
    - npm:registry.npmjs.org
    - release-drafter.yml
    - /opensearch-js
    slug: automated-package-propagation
    tactic: lateral-movement
    techniques:
    - T1534
    - T1574.006
  summary: ChainDrop is a self-propagating npm worm that infects developer environments
    via trojanized package lifecycle hooks to harvest cloud credentials, SSH keys,
    and CI runner secrets. It persists through cross-linked VS Code and AI tool configurations
    and uses Ethereum smart contracts for C2 routing to spread by republishing infected
    versions of legitimate packages.
series:
  index: 1
  slug: chaindrop-inside-a-self-propagating-npm-worm
  title: 'ChainDrop: Inside a Self-Propagating npm Worm'
  total: 2
severity: high
targets:
  analyst:
    name: Tier-2 analyst
    role: analyst
  endpoint:
    category: endpoint
    name: Endpoint telemetry (hb_ surfaces)
    telemetry:
    - endpoint
  hunter:
    agent: true
    name: Hunt agent
tlp: clear
type: investigation
---


# ChainDrop: NPM Worm Endpoint and CI Runner Activity

ChainDrop is a self-propagating worm that spreads by trojanizing npm packages and hijacking the preinstall lifecycle hook. It uses a legitimate portable runtime, Bun, as an execution vehicle to evade standard Node.js instrumentation. Once running, the worm sweeps the filesystem for developer credentials and scrapes GitHub Actions runner memory for OIDC tokens and runner secrets. This hunt focuses on the initial execution of the worm on developer endpoints and CI runners. It identifies affected hosts via software inventory, detects the presence of the Bun-based dropper, and corroborates the activity by looking for unauthorized access to sensitive credential files and Linux process memory associated with CI agents.

## affected-npm-packages
<!-- Affected npm packages in inventory -->
Identify hosts that have the trojanized npm packages installed in their development environments.

```sqlite target=endpoint role=scoping params=(infected_packages=infected_packages)
~~~yaml
expected: A list of hostnames and paths where the vulnerable packages are installed.
  Silence indicates the packages are not present in the reported inventory.
reads:
- device_hostname
- package_name
- package_version
- install_path
silence: not_evidence_of_absence
source: hb_software_inventory
verified: dry-run
verified_at: '2026-09-20'
~~~
SELECT device_hostname, package_name, package_version, install_path FROM hb_software_inventory WHERE package_type = 'npm' AND instr(',' || '{{infected_packages}}' || ',', ',' || package_name || ',') > 0
```

## early-parallel
<!-- Early execution and runtime check -->
parallel:
- → dropper-execution
- → bun-prevalence
join: → triage-initial

## dropper-execution
<!-- Dropper and lifecycle hook execution -->
Detect the execution of setup.mjs or the specific environment variable used by the worm to prevent recursion.

```sqlite target=endpoint role=detection-candidate params=(lookback_days=lookback_days, scope_hosts=scope_hosts)
~~~yaml
expected: Process command lines referencing the dropper file or the worm's internal
  state variable. This confirms the malicious hook fired.
reads:
- device_hostname
- process_cmd_line
- user_name
- time
silence: not_evidence_of_absence
source: hb_process_activity
verified: dry-run
verified_at: '2026-09-20'
~~~
SELECT device_hostname, process_cmd_line, user_name, time FROM hb_process_activity WHERE ('{{scope_hosts}}' = '' OR instr(',' || '{{scope_hosts}}' || ',', ',' || device_hostname || ',') > 0) AND (LOWER(process_cmd_line) LIKE '%setup.mjs%' OR LOWER(process_cmd_line) LIKE '%math_init.js%' OR LOWER(process_cmd_line) LIKE '%_node_runtime_init%') AND time >= datetime('now', '-{{lookback_days}} days')
```

## bun-prevalence
<!-- Rare Bun runtime execution -->
Identify hosts running the Bun runtime, which the worm uses as a portable dropper to evade standard Node instrumentation.

```sqlite target=endpoint role=baseline params=(lookback_days=lookback_days)
~~~yaml
baseline:
  compare: first_seen
  window: '{{lookback_days}}d'
expected: Anomalous execution of the Bun binary. Since it is less common than Node.js
  in many environments, stack-counting helps find the beachhead.
prevalence:
  by: device_hostname
  key:
  - process_name
  rare_below: 3
reads:
- process_name
- device_hostname
- time
silence: not_evidence_of_absence
source: hb_process_activity
verified: dry-run
verified_at: '2026-09-20'
~~~
SELECT LOWER(process_name) AS process_name, COUNT(DISTINCT device_hostname) AS host_count, MIN(time) AS first_seen FROM hb_process_activity WHERE LOWER(process_name) LIKE '%bun%' AND time >= datetime('now', '-{{lookback_days}} days') GROUP BY process_name HAVING host_count <= 3
```

## triage-initial
<!-- Evaluate initial execution -->
```agent target=hunter
cite: required
context:
- affected-npm-packages
- dropper-execution
- bun-prevalence
max_iterations: 3
objective: Determine if the setup.mjs dropper or Bun runtime was triggered on hosts
  that also contain the trojanized npm packages.
success_criteria: A list of hosts with confirmed execution, citing command lines and
  package inventory.
tools:
- endpoint
```

## impact-parallel
<!-- Follow-on impact triage -->
parallel:
- → credential-sweep
- → memory-scraping
join: → final-assessment

## credential-sweep
<!-- Credential file harvest -->
Identify instances where Node or Bun processes access sensitive files like .env, .npmrc, or SSH keys.

```sqlite target=endpoint role=triage params=(lookback_days=lookback_days, scope_hosts=scope_hosts)
~~~yaml
expected: File activity showing the worm's runtime accessing credential stores. Silence
  suggests no broad sweep occurred or was captured.
reads:
- device_hostname
- file_path
- process_name
- time
silence: not_evidence_of_absence
source: hb_file_activity
verified: dry-run
verified_at: '2026-09-20'
~~~
SELECT device_hostname, file_path, process_name, time FROM hb_file_activity WHERE ('{{scope_hosts}}' = '' OR instr(',' || '{{scope_hosts}}' || ',', ',' || device_hostname || ',') > 0) AND (LOWER(file_path) LIKE '%.env%' OR LOWER(file_path) LIKE '%.npmrc%' OR LOWER(file_path) LIKE '%.git-credentials%' OR LOWER(file_path) LIKE '%/.ssh/%') AND (LOWER(process_name) LIKE '%node%' OR LOWER(process_name) LIKE '%bun%') AND time >= datetime('now', '-{{lookback_days}} days')
```

## memory-scraping
<!-- CI Runner process memory scraping -->
Detect the worm accessing the memory of the GitHub Actions Runner worker to steal OIDC tokens.

```sqlite target=endpoint role=enrichment params=(lookback_days=lookback_days, scope_hosts=scope_hosts)
~~~yaml
expected: Command lines showing Python or Shell helpers reading the Linux proc filesystem
  for the GitHub runner process. This is a high-confidence indicator of ChainDrop
  impact on CI infrastructure.
reads:
- device_hostname
- process_cmd_line
- user_name
- time
silence: not_evidence_of_absence
source: hb_process_activity
verified: dry-run
verified_at: '2026-09-20'
~~~
SELECT device_hostname, process_cmd_line, user_name, time FROM hb_process_activity WHERE ('{{scope_hosts}}' = '' OR instr(',' || '{{scope_hosts}}' || ',', ',' || device_hostname || ',') > 0) AND (LOWER(process_cmd_line) LIKE '%runner.worker%' AND (LOWER(process_cmd_line) LIKE '%/proc/%/maps%' OR LOWER(process_cmd_line) LIKE '%/proc/%/mem%')) AND time >= datetime('now', '-{{lookback_days}} days')
```

## final-assessment
<!-- Confirm credential theft and impact -->
```agent target=hunter
cite: required
context:
- triage-initial
- credential-sweep
- memory-scraping
max_iterations: 5
objective: Analyze the file access and memory scraping activity in the context of
  the initial dropper execution to verify if ChainDrop successfully harvested credentials.
success_criteria: A per-host verdict of malicious if the chain from infected package
  to file or memory harvest is complete.
tools:
- endpoint
```

## route-on-verdict
<!-- Route on verdict -->
if~: "the final-assessment verdict is malicious for at least one host" (confidence: high, judge=hunter)
then: → isolate-and-remediate
indeterminate: → analyst-review
unavailable: → analyst-review (blind_spot: limited-process-memory-visibility)
else: → close-out

## isolate-and-remediate
<!-- Isolate and remediate host -->
```action target=endpoint
~~~yaml
approval: required
~~~
Isolate the endpoint, terminate the Bun and Node processes identified by the agent, and delete the malicious files: .claude/math_init.js, .claude/settings.json, .claude/setup.mjs, .vscode/setup.mjs, and .vscode/tasks.json.
```
→ analyst-review

## analyst-review
<!-- Analyst review -->
```manual target=analyst
Review the cited evidence. Confirm if Bun was used to touch .env or SSH files. Inspect CI runner logs for matching setup.mjs activity. Rotate all developer tokens, npm credentials, and SSH keys discovered in the sweep.
```
→ close-out

## close-out
<!-- Close out -->
```manual target=analyst
Document the absence of ChainDrop activity in the current window and record any tuning notes for the Bun prevalence query.
```
→ end

Run it

Take this hunt into your environment.

Open it in Huntbase to run every step against your own connections, with Scout weighing the evidence and your analysts in command. Or take the open hunt.md file anywhere that reads the format.

Machine-drafted by huntbase-hunt-generation using hb_google/gemini-3-flash-preview, gated by dry-run, lint, critic, then reviewed by a person.