Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
Home/Blog/Seven PyPI Packages Caught Dropping Bun Malware via Hidden .pth Files
Threat ResearchJune 8, 202616 min read

Seven PyPI Packages Caught Dropping Bun Malware via Hidden .pth Files

Seven PyPI packages hide malware in .pth files. The payload runs on every Python startup, steals credentials, and hides itself with a one-shot guard.

O
O3 Security Team
Seven PyPI Packages Caught Dropping Bun Malware via Hidden .pth Files

A .pth file is a Python path configuration file. It belongs in site-packages. It is not supposed to run code. But Python's site module executes any line in a .pth file that starts with 'import ', via exec(), on every single interpreter startup. Attackers found this years ago. Seven packages on PyPI are exploiting it right now: five with runtime-confirmed payload execution, one with the same payload but no confirmed download, and one separate high-sophistication campaign.

[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

On June 8, 2026, we detected seven malicious PyPI packages (eight versions in total) across two separate campaigns, all abusing this mechanism. The Bun dropper campaign targeting bioinformatics packages is part of the Shai-Hulud / Miasma PyPI wave: the same malware family (attributed to the threat actor TeamPCP) behind the Shai-Hulud 2.0 npm worm and the LiteLLM compromise. The PyPI branch is sometimes called Hades by researchers tracking the infrastructure. Below is the full technical breakdown: the exact payloads, the IOCs, the affected package versions, and what each campaign does once it lands on a machine.

How Python executes .pth files

When the Python interpreter starts, Lib/site.py iterates every .pth file in your site-packages directories. For each line beginning with 'import ' (space required), it calls exec(line, {'__file__': sitedir}). This runs before any user code. It runs during pip install operations. It runs when you type python --version. There is no way to opt out short of passing -S to disable the site module entirely, which breaks virtually all real-world Python environments.

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

Campaign 1: The Bun dropper in bioinformatics packages

Five legitimate bioinformatics packages were injected with an identical .pth payload. Same obfuscation, same one-shot guard, same download URL, same C2 IP. This is one actor targeting the scientific Python ecosystem.

The .pth payload (verbatim)

Every affected package contains a file named <package>-setup.pth in its wheel. The content is a single line:

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop

Deobfuscated, the execution flow is:

  1. Check if /tmp/.bun_ran exists. If yes, exit immediately (one-shot guard).
  2. Detect CPU architecture via platform.machine() — maps x86_64 to x64 for the download URL.
  3. Download https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64.zip to /tmp/bun.zip using urllib.request.urlretrieve.
  4. Extract the bun binary from the zip to /tmp/bun via unzip.
  5. chmod 0o755 the binary to make it executable.
  6. Write /tmp/.bun_ran to mark execution as complete. Future startups skip all of the above.

The second stage (what bun actually runs after being extracted) was not captured in the sandbox because the payload writes .bun_ran before executing the JS stage, causing it to skip on subsequent sandbox runs. The env_access events recorded during sandbox execution (3 per package) indicate the second stage reads environment variables, consistent with credential harvesting.

Confirmed IOCs from sandbox runtime

The sandbox confirmed the following network activity across all five packages:

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop

Six packages carry the identical .bun_ran .pth payload. Five had the live Bun download confirmed in our runtime sandbox (the HTTP GET to GitHub plus the TCP connection to 20.207.73.82). One, bramin, carries the exact same payload but the outbound download was not observed during analysis. Every one of these is a distinct package name and version you should remove on sight.

[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

Runtime-confirmed compromised packages

These five packages had the live Bun download confirmed at runtime. If you have any of these exact versions installed, treat the machine as compromised and rotate credentials immediately.

[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop

Why the Bun runtime

Bun uses its own native APIs (Bun.gunzipSync(), Bun.file(), Bun.write()) that do not exist in Node.js. A second-stage JavaScript payload that calls these will crash immediately under node. EDR signatures and behavioral rules written for Node.js process trees do not match a bun process. The binary name is bun, not node or python, so process-name-based detections miss it. The download comes from oven-sh's official GitHub releases page, not an attacker-controlled domain.

Shai-Hulud / Miasma / Hades: how this attack moves

This is not a typosquat where someone publishes a fake package and waits. The .pth Bun dropper is part of the Shai-Hulud / Miasma malware family, a self-propagating supply chain attack framework attributed to TeamPCP. Researchers tracking the infrastructure call the PyPI branch Hades. The goal is not to infect one machine. It is to steal the credentials that let the attacker publish the next round of malicious packages and keep the chain going.

The fact that five legitimate, established bioinformatics packages were hit at once with byte-identical payloads tells you the entry point was not the code. It was the publisher. When an attacker (TeamPCP in prior campaigns compromised hundreds of npm packages the same way) takes over a single maintainer account through a phished password, a leaked PyPI token, or a reused credential, they can push backdoored patch releases across that maintainer's entire portfolio in one burst. To anyone watching the registry, it looks like a routine round of version bumps.

Here is the lifecycle we see, stage by stage:

  1. Account takeover. The attacker gains publish access to a maintainer account through a stolen or phished PyPI token, a leaked CI secret, or a reused password.
  2. Mass portfolio publish. Backdoored patch versions are released across the maintainer's whole package set at once. Each carries the same <package>-setup.pth file. The version jump looks normal.
  3. Silent install hook. On the victim's next Python startup (or the next unrelated pip install), site.py executes the .pth line before any application code runs. No import of the package is required.
  4. Second-stage download. The .pth loader pulls the Bun runtime from GitHub's official releases and runs a JavaScript payload under bun, sidestepping Node.js and Python-tuned detections.
  5. Multi-ecosystem credential harvest. The second stage scrapes the environment and developer machine for npm, PyPI, GitHub, and cloud (AWS, GCP, Azure) tokens, plus CI/CD secrets and AI tool sessions.
  6. Propagation. Those stolen publishing tokens are used to backdoor the victim's own packages and push them to the registry. The compromise cascades from maintainer to maintainer.
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

The choice of GitHub releases for the Bun download and GitHub itself as an exfiltration and staging channel is deliberate. Traffic to github.com is allowlisted almost everywhere. Pulling a real, signed binary from oven-sh's release page and routing stolen data back through GitHub repositories or API calls means the malicious activity hides inside traffic that security tools are trained to trust.

Full attack flow: from pip install to credential exfiltration

The attack is a six-stage chain. Each stage is designed to look unremarkable to the tool watching it.

[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

How this attack evades every layer of your security stack

This campaign was not caught by the tools most organizations rely on. Each evasion is deliberate and targets a specific detection class.

Layer 1: Static scanners see nothing dangerous

Standard SCA and SAST tools parse setup.py, pyproject.toml, and __init__.py. They do not parse .pth files. The malicious .pth line is a single-line Python expression with no function names that appear in malware signature databases. The payload itself is not in the .pth file at all: the .pth loads a Bun bootstrap, which downloads an AES-256-GCM encrypted JavaScript blob at runtime. There is nothing for a static scanner to match against.

The JavaScript stealer uses five obfuscation layers in sequence:

  1. Outer eval() wrapper with character-code array decoding.
  2. AES-128-GCM decryption of embedded blobs written to a random /tmp/p*.js path.
  3. Rotated string table with a custom PBKDF2/SHA256 decoder.
  4. AES-256-GCM plus gzip compression on the main stealer logic.
  5. Runtime-only decryption key derived from environment values, so the payload cannot be decrypted without executing it.
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

Layer 2: Runtime sandboxes get a clean run

The payload checks the environment before doing anything visible. If any of these conditions are true, it exits silently:

  • Russian locale is set (LANG, LC_ALL, or LANGUAGE contains ru_RU or similar).
  • StepSecurity harden-runner is detected in the environment, indicating an instrumented CI sandbox.
  • Decoy token prefixes are present: GitHub ghp_AAAA, npm npm_AAAA, Anthropic sk-ant-AAAA, CircleCI, or AWS dummy values. The payload validates real token shapes before exfiltrating.
  • /tmp/.bun_ran already exists, meaning a prior analysis run already triggered the one-shot guard.

The result: a sandbox that runs the package once, checks for outbound traffic, and marks it clean will see exactly nothing. The payload will not fire in most automated analysis pipelines.

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop

Layer 3: AI code scanners are targeted directly

This is the most novel evasion in the campaign. The immunity-agent package (Campaign 2) embeds adversarial text in its source files specifically to manipulate AI-powered code review tools. The approach works on two levels.

First, zero-width Unicode characters (ZWSP U+200B, ZWNJ U+200C, ZWJ U+200D, BOM U+FEFF) are inserted mid-token in dangerous function names and string literals. A human reading the file sees normal-looking code. A syntax highlighter renders it normally. But an LLM tokenizer splits the token at the invisible character boundary, producing a different token sequence that does not match the model's learned patterns for malicious code.

Second, the policy template files contain strings that read as instructions to an LLM rather than as code. When an AI scanner ingests the file, these strings are processed as natural language prompts embedded in the context window. The recovered warden/default_policy.yaml contains lines such as:

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

The exfiltration path for the Bun dropper campaign also exploits AI tool trust. Stolen credentials are posted to https://api.anthropic.com/v1/api (a nonexistent Anthropic endpoint). Traffic to api.anthropic.com looks like normal AI API usage to network monitoring tools. Organizations that allowlist AI vendor traffic for developer productivity create a free exfiltration channel.

[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop

Campaign 2: immunity-agent, a different and more dangerous actor

immunity-agent is not the same campaign. Different payload structure, different attacker sophistication, different target. Where Campaign 1 is a quiet dropper, immunity-agent is a full implant.

The .pth payload

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop

The silent exception swallow (except Exception: pass) is deliberate. If the warden module is not on the path yet or raises any error, execution continues silently. No crash, no traceback, no indication anything ran.

What warden._post_install does

Static analysis of the package contents found the following critical signals across immunity_agent-1.6.0/supplychain/ioc.py and supporting files:

[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

The dead drop resolver pattern (filev2.getsession.org) means the actual C2 IP is not hardcoded in the package. The attacker can rotate infrastructure without publishing a new package version. The zipapp at /tmp/transformers.pyz is a self-contained Python application disguised as a legitimate Hugging Face artifact that runs the main payload without leaving obvious imports in the package's own source.

[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop

Indicators of Compromise

Check these on any machine that may have installed an affected package.

[@portabletext/react] Unknown block type "code", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "table", specify a component for it in the `components.types` prop

Why most scanners missed this

Standard SCA tools index package metadata and known CVE databases. They do not execute packages. The .pth mechanism is not a CVE: it is a documented Python feature (CPython Lib/site.py, stable since Python 3.3). MITRE added it as ATT&CK T1546.018 in v16 (2024), but detection rule coverage is still sparse.

  • The bioinformatics packages are legitimate, widely-used projects. Name-based typosquat detection does not fire.
  • .pth files are path configuration, not source code. Most scanners only parse setup.py, pyproject.toml, and __init__.py.
  • The download URL is github.com/oven-sh/bun, a trusted domain on virtually every allowlist.
  • The Bun binary has no established malware signatures in EDR products.
  • The .bun_ran one-shot guard means the payload executes once and never appears in subsequent runtime traces.
  • immunity-agent uses zero-width Unicode characters in source files to hide code from code review and syntax highlighters.
[@portabletext/react] Unknown block type "relatedLink", specify a component for it in the `components.types` prop
[@portabletext/react] Unknown block type "relatedLink", specify a component for it in the `components.types` prop

Detection and response

If you installed any of the affected packages listed above, assume the payload ran. The .bun_ran guard means it executed once and stopped logging itself.

  1. Run the IOC check commands above. The presence of /tmp/.bun_ran is definitive proof the dropper executed.
  2. Rotate all credentials that were in environment variables on the affected machine: AWS keys, GitHub tokens, cloud service accounts, AI tool auth tokens.
  3. Check /etc/systemd/system/ for unexpected service files (immunity-agent campaign).
  4. Audit .claude/settings.json and .cursor/mcp.json for unexpected modifications.
  5. Remove the malicious package versions and upgrade to a clean version if one is available, or remove the package entirely until the maintainer confirms a fix.
  6. Add a detection rule for .pth file creation in site-packages directories. Elastic ships a prebuilt rule for MITRE T1546.018.
  7. Alert on bun or bun.exe processes spawned with python as a parent.
[@portabletext/react] Unknown block type "pullquote", specify a component for it in the `components.types` prop

See your full attack chain.
Code, build, runtime. One platform.