PyPI spellcheckpy Typosquatting RAT Campaign
Attackers published typosquatted versions of the popular pyspellchecker library to deliver a Remote Access Trojan (RAT) hidden inside compressed Basque dictionary files.
On this page 0% read
Executive Summary
In January 2026, security researchers at Aikido Security discovered an evasive software supply chain attack campaign on the Python Package Index (PyPI) Aikido Security. Threat actors published two typosquatted packages, spellcheckerpy and spellcheckpy, designed to impersonate the highly popular and widely utilized spelling correction library pyspellchecker Aikido Security.
To bypass automated registry security sandboxes and static code analysis tools, the attackers hid a base64-encoded, zlib-compressed Python Remote Access Trojan (RAT) downloader inside a benign Basque language frequency dictionary resource file (resources/eu.json.gz) Aikido Security. In early iterations, the malware remained dormant to establish registry trust, but with the release of spellcheckpy version 1.2.0 on January 21, 2026, the threat actors enabled an import-time execution trigger inside the class constructor Aikido Security.
Once executed, the downloader establishes persistence, bypasses SSL verification, and beacons every 5 seconds to a malicious command-and-control (C2) server hosted on known bulletproof C2 infrastructure Aikido Security Halcyon. Use the package inventory, import-time execution, and downstream audit recipes below to determine whether the typosquats executed and which identities were reachable.
Key Facts
threat_type: typosquatting, malicious package, Remote Access Trojan (RAT), credential theft
ecosystem: pypi
registry: PyPI
affected_packages:
- "spellcheckerpy"
- "spellcheckpy"
malicious_versions:
- "spellcheckerpy@*"
- "[email protected]"
fixed_versions:
- "none"
safe_versions:
- "none (use pyspellchecker)"
exposure_window: 2026-01-20 to 2026-01-22
execution_trigger: Import-time execution (`WordFrequency.__init__`)
primary_impact: Remote Access Trojan (RAT) execution, credential theft, remote system access, files harvesting
known_iocs:
- "updatenet[.]work"
- "172.86.73[.]139"
- "https://updatenet[.]work/update1.php"
- "https://updatenet[.]work/settings/history.php"
- "dothebest[.]store"
- "FD429DEABE"
- "resources/eu.json.gz"
confidence: high
canonical_source: https://www.aikido.dev/blog/malicious-pypi-packages-spellcheckpy-and-spellcheckerpy-deliver-python-rat
Source Confidence & Evidence Mapping
- confirmed:
- The discovery of malicious
spellcheckpyandspellcheckerpypackages on PyPI that typosquattedpyspellcheckerAikido Security. - The payload evasion method of embedding zlib-compressed, base64-encoded Python scripts under the
"spellchecker"key inside Basque dictionary files (resources/eu.json.gz) Aikido Security. - The import-time trigger added to
spellcheckpyversion1.2.0on January 21, 2026, within theWordFrequency.__init__class constructor Aikido Security. - The active beaconing to C2 domain
updatenet[.]workat IP172.86.73[.]139using HTTPS POST requests Aikido Security. - An identical campaign discovered in November 2025 by HelixGuard under the package name
spellcheckerscommunicating with C2 domaindothebest[.]storeHelixGuard.
- The discovery of malicious
- likely:
- Threat actors targeted software developers working with spelling or linguistic modules to steal operational credentials (such as AWS keys, GitHub tokens, database logins) or cryptocurrency keys Aikido Security HelixGuard.
- The use of RouterHosting LLC (Cloudzy) as a bulletproof host chosen specifically for its low-verification signup policies and cryptocurrency payment options Halcyon.
- unclear:
- The exact geographic location or definitive group attribution of the threat actors, though the C2 infrastructure utilized is heavily correlated with Iranian and Russian threat actors Halcyon.
- The complete list of downstream victims, other than the registry-tracked download counts indicating approximately 1,000 installations Aikido Security.
- not_observed:
- Any self-propagating worm capabilities; the malware relies purely on targeted installation via typosquatting Aikido Security.
- Exploitation of software bugs or zero-day vulnerabilities; this attack is a social engineering-centric registry supply chain injection Aikido Security.
Impact Determination
| Classification | Criteria | Required evidence | Required action | Closure condition |
|---|---|---|---|---|
| Confirmed compromise | spellcheckpy==1.2.0 or related typosquat package is present and Python import decompresses and launches the hidden RAT loader or the reported process, file, or network indicators is observed. | Artifact inventory plus runtime telemetry showing Python import decompresses and launches the hidden RAT loader or listed C2/process/file indicators. | Isolate affected hosts or runners, preserve artifacts, and rotate reachable credentials from a clean environment. | Affected artifacts are removed, exposed credentials are replaced, and downstream audit modules show no suspicious follow-on use. |
| Presumed exposed | spellcheckpy==1.2.0 or related typosquat package was installed, pulled, imported, built, or executed during the exposure window, but telemetry cannot prove exfiltration. | Lockfile, package cache, workflow, image pull, extension inventory, build log, or deployment record tied to the exposure window. | Rebuild from clean artifacts and rotate credentials available to the affected environment. | Credential owners confirm revocation of old material and clean artifacts are deployed. |
| Potentially exposed | The package, workflow, image, extension, or module appears in dependency or deployment records, but Python install, import, or interpreter-startup execution is not established. | Manifest, lockfile, build, deployment, or endpoint records plus a named telemetry gap. | Collect the missing execution and telemetry evidence before narrowing scope. | Every hit is dispositioned as confirmed compromise, presumed exposed, or not exposed. |
| Not exposed | No affected version, artifact, mutable reference, or indicator appears in source, lockfiles, build outputs, deployments, package caches, or runtime telemetry. | Repository search, dependency inventory, build/deployment export, package cache query, and runtime telemetry query results. | Preserve the negative search output and keep the prevention controls active. | Search evidence covers developer endpoints, CI runners, production deployments, and package or image caches. |
| Unknown | Required inventory, build, endpoint, network, or audit telemetry is unavailable. | A gap statement naming unavailable systems, owners, and time windows. | Keep the asset in scope and make conservative rotation or rebuild decisions for high-value environments. | The missing evidence is recovered or the risk owner accepts residual uncertainty. |
Minimum Evidence To Collect
minimum_evidence:
- "Dependency, workflow, extension, image, or module inventory covering developer endpoints, CI runners, and production deployments."
- "Positive or negative search results for spellcheckpy==1.2.0, spellcheckerpy."
- "Execution evidence for Python import decompresses and launches the hidden RAT loader."
- "Process, file, DNS, proxy, firewall, or package-manager telemetry for listed indicators."
- "Inventory of credentials, tokens, deployment paths, and downstream systems reachable from exposed environments."
Timeline
- 2025-10-28T00:00:00Z The malicious C2 domain
updatenet[.]workis registered. Source: Aikido Security - 2025-11-19T00:00:00Z HelixGuard documents a functionally identical campaign involving the typosquatted package
spellcheckerscommunicating with C2 domaindothebest[.]store. Source: HelixGuard - 2026-01-20T00:00:00Z Aikido Security’s automated malware detection pipeline identifies initial dormant uploads of
spellcheckerpyandspellcheckpyon PyPI. Source: Aikido Security - 2026-01-21T00:00:00Z The threat actor publishes version
1.2.0ofspellcheckpy, switching the malware from dormant to active by embedding an execution trigger in the class constructor. Source: Aikido Security - 2026-01-22T00:00:00Z The packages are reported to PyPI security and removed from the registry after reaching approximately 1,000 downloads. Source: Aikido Security
- 2026-01-23T00:00:00Z Aikido Security publishes a detailed threat analysis detailing the RAT extraction and payload behavior. Source: Aikido Security
What Happened
In late January 2026, security analysts at Aikido Security flagged suspicious activity involving spelling correction libraries on PyPI Aikido Security. A threat actor had uploaded two packages—spellcheckerpy and spellcheckpy—which typosquatted the popular and legitimate pyspellchecker library Aikido Security.
To bypass standard registry scanning mechanisms that flag classic indicators like shell commands (subprocess.run(), eval(), exec()) in setup files, the attackers utilized a multi-stage, evasive deployment model Aikido Security. They first uploaded several “dormant” versions containing the obfuscated downloader code inside a Basque language frequency dictionary, but without active calls to execute it Aikido Security.
On January 21, 2026, the attackers published version 1.2.0 of spellcheckpy Aikido Security. This version modified the initialization routine of the WordFrequency class to compile and execute the base64-decoded dictionary data upon import Aikido Security. Consequently, any developer importing the library unwittingly triggered the download and execution of a fully featured Remote Access Trojan (RAT) Aikido Security.
The package was reported to PyPI administrators and removed on January 22, 2026, limiting the blast radius to roughly 1,000 installations Aikido Security. Subsequent research linked the campaign to a functionally identical incident documented by HelixGuard in November 2025 Aikido Security HelixGuard.
Technical Analysis
Initial Access
The primary entry point is typosquatting Aikido Security. The attackers relied on developers typing the wrong library name (spellcheckpy or spellcheckerpy instead of pyspellchecker) during local installation or manually writing their dependency files Aikido Security.
Package or Artifact Manipulation
The packages were structured similarly to the legitimate pyspellchecker project, mimicking standard linguistic modules Aikido Security. However, the file resources/eu.json.gz, which is legitimately used to store compressed Basque language word frequencies, was weaponized Aikido Security. The attackers injected a key named "spellchecker" containing a base64-encoded, compressed Python payload representing the first-stage downloader Aikido Security.
In early “dormant” releases, the loader file utils.py contained code to read the file but omitted execution routines, rendering it invisible to security monitors Aikido Security:
def test_file(filepath: PathOrStr, encoding: str, index: str):
# Reads compressed data but does not trigger execution
Execution Trigger
The execution trigger was flipped in version 1.2.0 of spellcheckpy Aikido Security. The threat actor modified the constructor function WordFrequency.__init__ Aikido Security. When a developer imports the module and instantiates the spelling checker, the system runs the constructor, which immediately compiles and executes the hidden script Aikido Security:
if eval(compile(base64.b64decode(test_file("eu", "utf-8", "spellchecker")).decode("utf-8"), ...)):
exec(szCode)
Payload Behavior
Once compiled and executed, the downloader performs the following sequences Aikido Security:
- SSL Disabling: It disables local SSL verification using
ssl._create_unverified_context()to ensure smooth connection routing Aikido Security. - Beaconing Loop: It establishes a persistent beaconing loop, executing an HTTPS POST request every 5 seconds to
https://updatenet[.]work/update1.phpAikido Security. - Telemetry Exfiltration: The POST payload carries a unique victim machine identifier, system metrics, and a hardcoded Campaign ID (
FD429DEABE) Aikido Security. - Encrypted Command Execution: The C2 server responds with encrypted commands, which the client decrypts using a 16-byte XOR key array (
03 06 02 01 06 00 04 07 00 01 09 06 08 01 02 05) and a secondary XOR key (0x7B) Aikido Security. The RAT supports full remote shell access, command execution, local file harvesting, and targeted credential theft Aikido Security.
Exfiltration / C2
domains:
- "updatenet[.]work"
ips:
- "172.86.73[.]139"
urls:
- "https://updatenet[.]work/update1.php"
- "https://updatenet[.]work/settings/history.php"
protocols:
- "HTTPS"
endpoints:
- "/update1.php"
- "/settings/history.php"
confidence: high
Propagation
The malicious code does not contain lateral propagation or worm-like replication capabilities Aikido Security. It relies entirely on developers manually importing the typosquatted package or pulling it via misconfigured automated dependency requirements Aikido Security.
Obfuscation or Evasion
- Steganographic Data Blending: Embedding base64-encoded, zlib-compressed payloads inside Basque frequency dictionary resource files which are structurally standard in spelling utilities Aikido Security.
- Dormant Upload Staging: Releasing multiple early versions of the package with the downloader code but no execution trigger, avoiding automated publish-time detection systems Aikido Security.
- Dynamic Code Compilation: Avoiding obvious dynamic evaluation keywords like
exec()oreval()directly in the primary package script and compiling strings dynamically inside standard class constructors (WordFrequency.__init__) Aikido Security.
Affected Assets and Blast Radius
affected_assets:
ecosystems:
- pypi
packages:
- spellcheckerpy
- spellcheckpy
versions:
- spellcheckerpy (all versions)
- spellcheckpy (all versions, trigger active in 1.2.0)
repositories: []
container_images: []
CI_CD_systems: []
developer_tools: []
environments:
- developer workstations
- CI runners
- build pipelines
- containers
- production systems
credentials_at_risk:
- environment variables
- SSH keys
- cloud provider credentials
- database connection keys
- API tokens
- cryptocurrency wallet keys
not_currently_known_to_affect:
- legitimate pyspellchecker users
- systems where spellcheckerpy or spellcheckpy was installed but the SpellChecker or WordFrequency class was never instantiated or imported (for versions < 1.2.0)
Indicators of Compromise
Domains
- value:
updatenet[.]work - value:
dothebest[.]store- source: https://helixguard.ai/blog/malicious-spellcheckers-2025-11-19/
- confidence: high
IPs
- value:
172.86.73[.]139
URLs
- value:
https://updatenet[.]work/update1.php - value:
https://updatenet[.]work/settings/history.php
Files
- value:
resources/eu.json.gz
Package Versions
- value:
[email protected] - value:
spellcheckerpy@*
Detection and Hunting
Script: local repository and exported telemetry scope
#!/usr/bin/env python3
import os
import sys
import json
import subprocess
from pathlib import Path
ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
LOG_ROOT = os.environ.get("LOG_ROOT", "")
OUT = Path(os.environ.get("OUT", "hp-spellcheckpy-typosquatting-rat-scope"))
SINCE = "2025-10-28T00:00:00Z"
UNTIL = "2026-01-23T23:59:59Z"
PACKAGES = [
"spellcheckerpy",
"spellcheckpy",
]
VERSIONS = [
"spellcheckerpy@*",
"[email protected]",
]
FILES = [
]
DOMAINS = [
"www.aikido.dev",
"eu.json.gz",
]
URLS = [
"https://www.aikido.dev/blog/malicious-pypi-packages-spellcheckpy-and-spellcheckerpy-deliver-python-rat",
"https://helixguard.ai/blog/malicious-spellcheckers-2025-11-19/",
"https://updatenet.work/update1.php`",
"https://updatenet.work/settings/history.php`",
]
IPS = [
"172.86.73.139",
]
HASHES = [
]
PROCESS_PATTERNS = [
]
NETWORK_PATTERNS = [
]
# Positive signal: repository, lockfile, artifact, process, or network telemetry contains one of the exact incident selectors above.
# Escalation: any match tied to a production build, CI run, deployed asset, or secret-bearing host moves the asset to presumed exposed.
OUT.mkdir(parents=True, exist_ok=True)
indicators_file = OUT / "indicators.txt"
# Collect unique indicators
indicators = set()
for group in [PACKAGES, VERSIONS, FILES, DOMAINS, URLS, IPS, HASHES, PROCESS_PATTERNS, NETWORK_PATTERNS]:
for val in group:
if val:
indicators.add(val)
with open(indicators_file, "w") as f:
for ind in sorted(indicators):
f.write(ind + "\n")
print(f"[+] Written unique selectors to {indicators_file}")
# Walk local directory
print(f"[+] Scanning directory: {ROOT} for selectors...")
matches = []
exclude_dirs = {"node_modules", "vendor", "dist", ".git"}
for root, dirs, filenames in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for filename in filenames:
filepath = Path(root) / filename
try:
content = filepath.read_text(errors="ignore")
for ind in indicators:
if ind in content:
matches.append(f"{filepath}: found '{ind}'")
except Exception:
pass
if matches:
(OUT / "repository-indicator-matches.txt").write_text("\n".join(matches) + "\n")
print(f"[!] Found {len(matches)} matches in codebase!")
# Optional Log Scanning
if LOG_ROOT and os.path.exists(LOG_ROOT):
print(f"[+] Scanning telemetry log directory: {LOG_ROOT}...")
log_matches = []
for root, _, filenames in os.walk(LOG_ROOT):
for filename in filenames:
filepath = Path(root) / filename
try:
content = filepath.read_text(errors="ignore")
for ind in indicators:
if ind in content:
log_matches.append(f"{filepath}: found '{ind}'")
except Exception:
pass
if log_matches:
(OUT / "exported-telemetry-indicator-matches.txt").write_text("\n".join(log_matches) + "\n")
print(f"[!] Found {len(log_matches)} matches in logs!")
if PACKAGES:
registry_dir = OUT / "registry"
registry_dir.mkdir(exist_ok=True)
for package in PACKAGES:
if not package: continue
safe_name = package.replace("/", "__")
print(f"[+] Querying pip index for {package}...")
res = subprocess.run(["python3", "-m", "pip", "index", "versions", package], capture_output=True, text=True)
if res.returncode == 0:
(registry_dir / f"pypi-{safe_name}-versions.txt").write_text(res.stdout)
subprocess.run(["python3", "-m", "pip", "download", "--no-deps", package, "-d", str(registry_dir)], capture_output=True)
print(f"[+] Wrote scope artifacts under {OUT}")
Downstream Abuse Audits
Script: GitHub organization run, release, secret, and workflow audit
#!/usr/bin/env python3
import os
import sys
import json
import subprocess
from pathlib import Path
if "ORG" not in os.environ:
print("ERROR: Set ORG environment variable to the GitHub organization to audit", file=sys.stderr)
sys.exit(1)
ORG = os.environ["ORG"]
SINCE = "2025-10-28T00:00:00Z"
UNTIL = "2026-01-23T23:59:59Z"
OUT = Path(os.environ.get("OUT", "hp-spellcheckpy-typosquatting-rat-github-audit"))
SELECTORS = [
"spellcheckerpy",
"spellcheckpy",
"spellcheckerpy@*",
"[email protected]",
"www.aikido.dev",
"eu.json.gz",
"https://www.aikido.dev/blog/malicious-pypi-packages-spellcheckpy-and-spellcheckerpy-deliver-python-rat",
"https://helixguard.ai/blog/malicious-spellcheckers-2025-11-19/",
"https://updatenet.work/update1.php`",
"https://updatenet.work/settings/history.php`",
"172.86.73.139",
]
# Positive signal: a workflow run, release, secret, key, package, or workflow change overlaps the exposure window and references an incident selector.
# Remediation trigger: unauthorized post-exposure write activity or a secret-bearing run matching an incident selector requires token revocation and downstream cloud/registry review.
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "runs").mkdir(exist_ok=True)
(OUT / "logs").mkdir(exist_ok=True)
(OUT / "repos").mkdir(exist_ok=True)
# 1. Write incident-selectors file
selectors_file = OUT / "incident-selectors.txt"
with open(selectors_file, "w") as sf:
for s in SELECTORS:
if s:
sf.write(s + "\n")
# 2. Get list of repos
print(f"[+] Fetching repositories for organization: {ORG}")
repo_res = subprocess.run(["gh", "repo", "list", ORG, "--limit", "1000", "--json", "nameWithOwner"], capture_output=True, text=True)
if repo_res.returncode != 0:
print(f"[-] Failed to fetch repos: {repo_res.stderr}", file=sys.stderr)
sys.exit(1)
repos = [r["nameWithOwner"] for r in json.loads(repo_res.stdout)]
for repo in repos:
safe_repo = repo.replace("/", "__")
print(f"[+] Auditing repository: {repo}")
# Check runs in the window
runs_res = subprocess.run([
"gh", "api", f"/repos/{repo}/actions/runs",
"-f", "per_page=100",
"-f", f"created=>={SINCE}",
"--paginate"
], capture_output=True, text=True)
if runs_res.returncode == 0:
try:
all_runs = json.loads(runs_res.stdout).get("workflow_runs", [])
filtered_runs = [r for r in all_runs if r["created_at"] <= UNTIL]
if filtered_runs:
with open(OUT / "runs" / f"{safe_repo}-runs.jsonl", "w") as rf:
for run in filtered_runs:
rf.write(json.dumps(run) + "\n")
# Fetch log dynamically
run_id = str(run["id"])
log_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--log"], capture_output=True, text=True)
if log_res.returncode == 0:
(OUT / "logs" / f"{safe_repo}-{run_id}.log").write_text(log_res.stdout)
# Fetch details
view_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--json", "databaseId,workflowName,headSha,event,createdAt,jobs"], capture_output=True, text=True)
if view_res.returncode == 0:
(OUT / "runs" / f"{safe_repo}-{run_id}.json").write_text(view_res.stdout)
except Exception as e:
print(f"[-] Error parsing runs for {repo}: {e}")
# Check releases in window
subprocess.run(["gh", "api", f"/repos/{repo}/releases", "-f", "per_page=100", "--paginate"], capture_output=True)
# Check repo secrets updated in window
subprocess.run(["gh", "api", f"/repos/{repo}/actions/secrets", "-f", "per_page=100", "--paginate"], capture_output=True)
# Check deploy keys
subprocess.run(["gh", "api", f"/repos/{repo}/keys", "-f", "per_page=100", "--paginate"], capture_output=True)
# Scan output directory for any indicator selector matches
print("[+] Scanning gathered telemetry for indicator matches...")
subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(selectors_file), str(OUT)], capture_output=False)
print(f"[+] Wrote GitHub audit artifacts under {OUT}")
Script: cloud OIDC and deployment credential follow-on audit
#!/usr/bin/env python3
import os
import json
import subprocess
from pathlib import Path
SINCE = "2025-10-28T00:00:00Z"
UNTIL = "2026-01-23T23:59:59Z"
OUT = Path(os.environ.get("OUT", "hp-spellcheckpy-typosquatting-rat-cloud-audit"))
AWS_REGIONS = os.environ.get("AWS_REGIONS", "us-east-1").split(",")
# Positive signal: token exchange or privileged write activity occurs in the exposure window from GitHub, CI/CD, package registry, or deployment automation identity.
# Remediation trigger: unexpected write, deploy, IAM, secret, or registry activity tied to an exposed CI/CD path requires trust-policy disablement and credential rotation.
OUT.mkdir(parents=True, exist_ok=True)
# 1. AWS CloudTrail Audit
print("[+] Querying AWS CloudTrail for Web Identity token exchanges...")
aws_events = []
for region in AWS_REGIONS:
res = subprocess.run([
"aws", "cloudtrail", "lookup-events",
"--region", region,
"--start-time", SINCE,
"--end-time", UNTIL,
"--lookup-attributes", "AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity",
"--output", "json"
], capture_output=True, text=True)
if res.returncode == 0:
try:
events = json.loads(res.stdout).get("Events", [])
for event in events:
ct = json.loads(event.get("CloudTrailEvent", "{}"))
ct["region"] = region
aws_events.append(ct)
except Exception as e:
print(f"[-] Error parsing AWS CloudTrail events: {e}")
if aws_events:
with open(OUT / "aws-assume-role-with-web-identity.jsonl", "w") as f:
for ev in aws_events:
f.write(json.dumps(ev) + "\n")
# Audit follow-on events for returned access keys
for ev in aws_events:
access_key = ev.get("responseElements", {}).get("credentials", {}).get("accessKeyId")
region = ev.get("region", "us-east-1")
if access_key:
print(f"[+] Enumerating AWS events for AccessKey: {access_key}")
f_res = subprocess.run([
"aws", "cloudtrail", "lookup-events",
"--region", region,
"--start-time", SINCE,
"--end-time", UNTIL,
"--lookup-attributes", f"AttributeKey=AccessKeyId,AttributeValue={access_key}",
"--output", "json"
], capture_output=True, text=True)
if f_res.returncode == 0:
try:
f_events = json.loads(f_res.stdout).get("Events", [])
with open(OUT / "aws-follow-on-api-calls.jsonl", "a") as ff:
for fe in f_events:
ff.write(fe.get("CloudTrailEvent", "{}") + "\n")
except Exception as e:
print(f"[-] Error writing follow-on events: {e}")
# 2. Azure Activity Log Audit
print("[+] Querying Azure activity logs...")
az_res = subprocess.run([
"az", "monitor", "activity-log", "list",
"--start-time", SINCE,
"--end-time", UNTIL,
"--query", "[?contains(operationName.value, 'write') || contains(operationName.value, 'delete') || contains(operationName.value, 'Microsoft.ManagedIdentity')]",
"-o", "json"
], capture_output=True, text=True)
if az_res.returncode == 0:
(OUT / "azure-write-delete-activity.json").write_text(az_res.stdout)
# 3. GCP Logging Audit
print("[+] Querying GCP Cloud Logging...")
gcp_filter = f'timestamp>="{SINCE}" AND timestamp<="{UNTIL}" AND (protoPayload.methodName="google.sts.v1.SecurityTokenService.ExchangeToken" OR protoPayload.methodName:"GenerateAccessToken" OR protoPayload.methodName:"CreateServiceAccountKey" OR protoPayload.methodName:"SetIamPolicy")'
gcp_res = subprocess.run([
"gcloud", "logging", "read", gcp_filter,
"--format", "json"
], capture_output=True, text=True)
if gcp_res.returncode == 0:
(OUT / "gcp-token-and-iam-activity.json").write_text(gcp_res.stdout)
print(f"[+] Wrote cloud audit artifacts under {OUT}")
Script: registry metadata and artifact audit
#!/usr/bin/env python3
import os
import json
import subprocess
from pathlib import Path
SINCE = "2025-10-28T00:00:00Z"
OUT = Path(os.environ.get("OUT", "hp-spellcheckpy-typosquatting-rat-registry-audit"))
PACKAGES = [
"spellcheckerpy",
"spellcheckpy",
]
VERSIONS = [
"spellcheckerpy@*",
"[email protected]",
]
# Positive signal: registry metadata, package tarballs, or cached artifacts contain the exact affected package/version values.
# Remediation trigger: any internal package cache, build artifact, or deployment using these package/version values requires exposure scoping.
OUT.mkdir(parents=True, exist_ok=True)
with open(OUT / "affected-versions.txt", "w") as av:
for version in VERSIONS:
if version:
av.write(version + "\n")
# 1. Audit PyPI dependencies in project files
print("[+] Scanning PyPI dependency files...")
for file in ["requirements.txt", "poetry.lock", "Pipfile.lock", "pyproject.toml", "setup.py"]:
if Path(file).exists():
subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(OUT / "affected-versions.txt"), file])
# 2. Query registry metadata and download packages for local analysis
packages_dir = OUT / "packages"
metadata_dir = OUT / "metadata"
packages_dir.mkdir(exist_ok=True)
metadata_dir.mkdir(exist_ok=True)
for package in PACKAGES:
if not package: continue
print(f"[+] Querying pip index for {package}...")
res = subprocess.run(["python3", "-m", "pip", "index", "versions", package], capture_output=True, text=True)
if res.returncode == 0:
(metadata_dir / f"{package}-versions.txt").write_text(res.stdout)
subprocess.run(["python3", "-m", "pip", "download", "--no-deps", package, "-d", str(packages_dir)], capture_output=True)
# 3. HOW TO REVOKE AND ROTATE EXPOSED PYPI PUBLISHING TOKENS:
# PyPI does not support token revocation via CLI. Follow these exact steps:
# 1. Log in to https://pypi.org/manage/account/
# 2. Scroll to the "API tokens" section and click "Remove" on any compromised tokens.
# 3. Generate a new API token limited to the specific project scope.
# 4. Update your CI/CD secrets using the GitHub CLI:
# subprocess.run(["gh", "secret", "set", "PYPI_API_TOKEN", "--body", "pypi-AgEIcHlwaS5vcm...", "--repo", "my-org/my-repo"])
print(f"[+] Wrote registry audit artifacts under {OUT}")
Sources
- Aikido Security. Role: PRIMARY_RESEARCH Impact: Detailed the discovery of the spellcheckpy typosquatting campaign, payload extraction, and import-time execution trigger in version 1.2.0.
- HelixGuard. Role: PRIMARY_RESEARCH Impact: Documented the November 2025 typosquatted campaign under the package name
spellcheckerscommunicating with C2 domaindothebest[.]storeusing an identical codebase. - Halcyon. Role: ENRICHMENT_DATA Impact: Published the “Cloudzy with a Chance of Ransomware” investigation exposing RouterHosting LLC (Cloudzy) as an Iranian-linked C2P provider heavily utilized by APTs and ransomware groups.
- The Hacker News. Role: SECONDARY_ANALYSIS Impact: Aggregated reporting of the typosquatted package takedown timeline on PyPI.
IOC Clipboard
6 IOCswww.aikido.dev www[.]aikido[.]dev eu.json.gz eu[.]json[.]gz https://www.aikido.dev/blog/malicious-pypi-packages-spellcheckpy-and-spellcheckerpy-deliver-python-rat hxxps://www[.]aikido[.]dev/blog/malicious-pypi-packages-spellcheckpy-and-spellcheckerpy-deliver-python-rat https://helixguard.ai/blog/malicious-spellcheckers-2025-11-19/ hxxps://helixguard[.]ai/blog/malicious-spellcheckers-2025-11-19/ https://updatenet.work/update1.php` hxxps://updatenet[.]work/update1[.]php` https://updatenet.work/settings/history.php` hxxps://updatenet[.]work/settings/history[.]php`