Renamed forks of Baileys, the most widely used WhatsApp Web library for Node.js, keep turning up in npm malware feeds. They don't steal a password or a token. They use the bot owner's own signed-in WhatsApp session to follow channels the fork's author picked, quietly and without asking. OSV added four more on 16 October, which brings the malware records tied to Baileys to 240. On 17 October, 62 of those packages still served a version that OSV lists as malicious.
This post is for teams that run WhatsApp bots or integrations on Baileys, and for anyone who wants a clear example of a supply-chain attack that skips the usual install-time tricks. It covers how the forks work, what the OSV data shows through 17 October, why --ignore-scripts and release-age delays don't catch most of them, and a script that checks a project for the signs.
The short answer
The malicious Baileys forks run at runtime, not at install. Once the bot's WhatsApp session opens, injected code sends a FOLLOW request for one or more channels the author chose, often after a 30 to 120 second delay, sometimes fetching the list from GitHub and muting the channel afterwards. No install script is involved, so --ignore-scripts does nothing, and the median gap between an npm release and its OSV malware record was 18 days. Install only the official @whiskeysockets/baileys or baileys package, reject npm aliases for baileys and libsignal in your lockfile, check locked versions against OSV's malware records, and if you ran a fork, unlink that device from WhatsApp and pair a new session with the official package.
How the forks work
Baileys signs in to WhatsApp the way WhatsApp Web does, as a linked device. A paired session can send messages, join groups, follow channels and read chats as the account holder. SafeDep, which published a detailed account on 10 August, put the point plainly: whoever controls the session controls the account.
The official package has a newsletterFollow(jid) function, which the bot's own code can call. The forks call it, or the lower-level executeWMexQuery(..., QueryIds.FOLLOW), from code that runs on its own. OX Security's family description, attached to 76 of the OSV records, says the forced follow is the whole payload in the samples it covers, with no credential theft, no persistence and no remote code execution. Researchers have described several variants:
| Variant | What it adds | Reported by |
|---|---|---|
| Hard-coded channel | A connection.update handler follows a literal ...@newsletter ID when the connection opens | SafeDep, pkgwarden |
| Delayed or repeated | The same call behind a 30 to 120 second timer, or re-fired on an interval | pkgwarden |
| Encoded IDs | The channel ID stored as base64, base64 plus XOR, or a character-code array rebuilt at runtime | OX Security (OSV), pkgwarden |
| Remote list | The follow list fetched at runtime from a GitHub raw URL, so the author can change targets without publishing again. One variant also mutes each channel | SafeDep, pkgwarden |
| libsignal alias | The fork declares "libsignal": "npm:@someone/libsignal-node". The impersonator, when loaded, overwrites newsletter.js inside a real @whiskeysockets/baileys in node_modules | pkgwarden, OSV MAL-2026-13929 |
| Beyond the follow | One family posts a machine fingerprint to its author's server on every connect. Another opens a server-sent events stream and runs remote follow and react commands on the session | pkgwarden |
Two details make this family hard to judge from a package name. pkgwarden found that arming is per version: one fork was armed in one release, clean in the next five, and armed again later. And not every fork that follows a channel is hiding it. Some log the follow or say so in a comment. pkgwarden calls those grayware and blocks them by policy without filing them as malware. OSV's records cover the ones that conceal the behavior or go further.
What the data shows through 17 October
We counted every malicious-package record in OSV's npm data that names Baileys in the package name or in its analysis. The script reads OSV's public dump and, with --registry, asks npm whether each package still serves a version the record lists:
#!/usr/bin/env python3
"""Count OSV malicious-package records tied to Baileys forks, and how fast they arrived.
Reads OSV's npm dump (all.zip). With --registry, also asks the npm registry
whether each package still serves a version the record lists.
Usage: baileys_osv_stats.py npm-all.zip [--registry]
"""
import collections, concurrent.futures as cf, json, re, statistics, sys, urllib.parse, urllib.request, zipfile
from datetime import datetime
def ts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def records(path):
z = zipfile.ZipFile(path)
for n in z.namelist():
if n.startswith("MAL-"):
d = json.loads(z.read(n))
name = d["affected"][0]["package"]["name"]
text = d.get("details") or ""
if "baileys" in name.lower() or re.search(r"\bbaileys\b", text, re.I):
own = text.split("---\n_-= Per source")[0].strip()
yield d, name, own
def registry(rec):
d, name, _ = rec
url = "https://registry.npmjs.org/" + urllib.parse.quote(name, safe="@")
try:
j = json.load(urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "osv-stats"}), timeout=30))
except urllib.error.HTTPError:
return d, None
return d, j
def main(path, online):
recs = list(records(path))
by_month = collections.Counter(d["published"][:7] for d, _, _ in recs if d["published"] >= "2026")
analysed = [r for r in recs if len(r[2]) >= 80]
follow = [r for r in analysed if re.search(r"newsletter|channel|FOLLOW", r[2], re.I)]
jids = {j for d, _, _ in recs for j in re.findall(r"\b\d{15,20}@newsletter", d.get("details") or "")}
print(f"{len(recs)} records; {sum('baileys' in n.lower() for _, n, _ in recs)} with 'baileys' in the name")
print(f"{len(analysed)} carry a written analysis; {len(follow)} of those describe a forced channel follow")
print(f"{len(jids)} distinct channel IDs named in record text")
print("2026 records by OSV publish month:", " ".join(f"{k[5:]}:{v}" for k, v in sorted(by_month.items())))
if not online:
return
with cf.ThreadPoolExecutor(16) as ex:
reg = list(ex.map(registry, recs))
lags, still, latest = [], 0, 0
for d, j in reg:
vs = d["affected"][0].get("versions") or []
if not j or not vs:
continue
times = [j.get("time", {}).get(v) for v in vs if j.get("time", {}).get(v)]
if times:
lags.append((ts(d["published"]) - ts(min(times))).total_seconds() / 86400)
if j.get("dist-tags", {}).get("latest") == "0.0.1-security":
continue # npm has replaced the package with a security placeholder
still += any(v in j.get("versions", {}) for v in vs)
latest += j.get("dist-tags", {}).get("latest") in vs
print(f"npm publish to OSV record: median {statistics.median(lags):.1f} days over {len(lags)} records; "
f"{sum(l <= 1 for l in lags)} within 1 day, {sum(l <= 7 for l in lags)} within 7, {sum(l > 30 for l in lags)} over 30")
print(f"packages still serving a listed version: {still}; latest tag is a listed version: {latest}")
if __name__ == "__main__":
main(sys.argv[1], "--registry" in sys.argv)
$ curl -sO https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip
$ python3 baileys_osv_stats.py all.zip --registry
240 records; 160 with 'baileys' in the name
103 carry a written analysis; 102 of those describe a forced channel follow
55 distinct channel IDs named in record text
2026 records by OSV publish month: 01:2 02:8 03:23 04:2 05:21 06:3 07:12 08:26 09:62 10:77
npm publish to OSV record: median 18.2 days over 176 records; 22 within 1 day, 58 within 7, 63 over 30
packages still serving a listed version: 62; latest tag is a listed version: 38
The family is speeding up. Records published by month in 2026:
| Month | Records |
|---|---|
| January to June | 59 |
| July | 12 |
| August | 26 |
| September | 62 |
| October (to the 17th) | 77 |
The credits show how many hands are on it. Amazon Inspector is credited on 136 of the records and OX Security, which named the campaign PhantomSub, on 80. Rising Sun Solutions LLC is credited on 15, including two of the four added on 16 October, and pkgwarden, ReversingLabs and SafeDep have smaller shares. Of the 240 records, 103 carry a written analysis, and 102 of those describe the forced follow. Of the other 137, 103 carry only GitHub's generic malware text, which tells you to treat the machine as fully compromised, and 34 have no description at all. For a Baileys fork you haven't read, that's still the safe reading, because some variants do more than follow.
Takedown is uneven. On 17 October, 62 of the 240 packages still served at least one version that OSV lists as malicious. For 38 of them, the latest tag pointed at a listed version.
Why the usual npm defenses miss it
Install-script controls. npm install --ignore-scripts and pnpm's script allowlists stop code that runs during install. These forks have no install script. The code runs when the bot connects to WhatsApp, which is the one thing the bot is meant to do.
Release-age delays. A delay such as npm's min-release-age only helps if the malware is reported before the delay runs out. For the 176 records whose versions we could date on npm, 22 arrived within a day of the release, 58 within a week, and 63 took more than 30 days. A seven-day delay would have held back about a third of them. It's still worth having (our post on release-age cooldown policies covers the settings), but it isn't enough on its own here.
Static scanning by package name. The forks have many names and scopes. pkgwarden notes that a search for newsletterFollow misses forks that call the raw query, that the follow sometimes sits in a file named for something else, and that the libsignal patcher's payload was a string constant, so static scanners scored it clean. A lockfile that names @whiskeysockets/baileys can still hold a fork, if the entry is an npm alias.
Check your project
This script reads package-lock.json and node_modules. It flags baileys and libsignal entries that resolve to a different package, unofficial packages built on Baileys, locked versions with an OSV MAL- record, and static signs of a forced follow in Baileys-like code. It reads files only, never runs package code, and uses the Python standard library:
#!/usr/bin/env python3
"""Check a Node.js project for Baileys forks that act on your WhatsApp session.
1. package-lock.json: Baileys or libsignal entries that resolve to a different
package (npm aliases), and unofficial packages built on Baileys.
2. OSV.dev: every locked package checked for MAL- (malicious package) records.
3. node_modules: static signs of a forced channel follow in Baileys-like code:
hard-coded or encoded @newsletter IDs, FOLLOW calls next to connection
listeners, remote follow lists, and writes into another package's files.
Reads files only and never runs package code. Standard library only.
Usage: baileys_fork_check.py PROJECT_DIR [--offline]
"""
import base64, json, re, sys, urllib.request
from pathlib import Path
OFFICIAL = {"baileys", "@whiskeysockets/baileys", "libsignal"}
WATCH = re.compile(r"baileys|libsignal|wa-?web|wileys", re.I)
JID = re.compile(r"\b\d{15,20}@newsletter\b")
B64 = re.compile(r"['\"]([A-Za-z0-9+/]{24,}={0,2})['\"]")
CODES = re.compile(r"\[\s*((?:\d{2,3}\s*,\s*){10,}\d{2,3})\s*\]")
FOLLOW = re.compile(r"newsletterFollow\s*\(|QueryIds\.FOLLOW|newsletter_join")
LISTEN = re.compile(r"connection\.update|CB:success")
REMOTE = re.compile(r"https?://[^\s'\"`]+\.(?:json|js|txt)\b|raw\.githubusercontent\.com")
TAMPER = re.compile(r"node_modules.{0,200}(?:writeFileSync|copyFileSync)|(?:writeFileSync|copyFileSync).{0,200}node_modules", re.S)
def real_name(entry):
m = re.search(r"registry\.npmjs\.org/((?:@[^/]+/)?[^/]+)/-/", entry.get("resolved", ""))
return m.group(1).replace("%2f", "/").replace("%2F", "/") if m else None
def check_lock(root):
lock = root / "package-lock.json"
if not lock.exists():
return [], []
pkgs, out = json.loads(lock.read_text()).get("packages", {}), []
locked = []
for key, e in pkgs.items():
if not key.startswith("node_modules/") or e.get("link"):
continue
name = key.split("node_modules/")[-1]
real = real_name(e) or e.get("name") or name
locked.append((real, e.get("version")))
if real != name and (WATCH.search(name) or WATCH.search(real)):
out.append(("ALIAS", f"{name} is installed from {real}@{e.get('version')}; read that package before trusting it"))
elif WATCH.search(real) and real not in OFFICIAL:
out.append(("REVIEW", f"{real}@{e.get('version')} is not the official Baileys or libsignal package"))
return out, locked
def check_osv(locked):
out, q = [], [{"package": {"ecosystem": "npm", "name": n}, "version": v} for n, v in locked if v]
for i in range(0, len(q), 500):
req = urllib.request.Request("https://api.osv.dev/v1/querybatch", json.dumps({"queries": q[i:i + 500]}).encode(),
{"Content-Type": "application/json"})
res = json.load(urllib.request.urlopen(req, timeout=60))["results"]
for qq, r in zip(q[i:i + 500], res):
for v in r.get("vulns", []):
if v["id"].startswith("MAL-"):
out.append(("MALWARE", f"{qq['package']['name']}@{qq['version']} matches {v['id']}"))
return out
def decoded_jids(text):
found = set(JID.findall(text))
for s in B64.findall(text):
try:
found |= set(JID.findall(base64.b64decode(s + "==").decode("latin-1")))
except ValueError:
pass
for arr in CODES.findall(text):
found |= set(JID.findall("".join(chr(int(c)) for c in arr.split(",") if 31 < int(c) < 127)))
return found
def check_code(root):
out = []
for pj in sorted((root / "node_modules").rglob("package.json")):
pkg = pj.parent
if pkg.parent.name != "node_modules" and not pkg.parent.name.startswith("@"):
continue
try:
name = json.loads(pj.read_text()).get("name", pkg.name)
except ValueError:
continue
if not (WATCH.search(name) or (pkg / "lib/Socket/newsletter.js").exists()):
continue
for js in sorted(pkg.rglob("*.js")):
if "node_modules" in js.relative_to(pkg).parts:
continue
t, rel = js.read_text(errors="ignore"), f"{name}/{js.relative_to(pkg)}"
for j in sorted(decoded_jids(t)):
where = "in plain text" if j in t else "encoded"
out.append(("RISK", f"{rel}: channel ID {where}: {j}"))
if FOLLOW.search(t) and LISTEN.search(t):
out.append(("RISK", f"{rel}: FOLLOW call in a file that also handles connection events"))
if (FOLLOW.search(t) or "newsletter" in t) and REMOTE.search(t):
out.append(("RISK", f"{rel}: fetches a remote list near newsletter code: {REMOTE.search(t).group(0)[:80]}"))
if TAMPER.search(t):
out.append(("RISK", f"{rel}: writes into node_modules at runtime"))
return out
def main(argv):
root = Path(argv[1] if len(argv) > 1 else ".")
findings, locked = check_lock(root)
if locked and "--offline" not in argv:
findings += check_osv(locked)
if (root / "node_modules").is_dir():
findings += check_code(root)
for level, msg in findings:
print(f"{level:8} {msg}")
print(f"{len(locked)} locked package(s), {len(findings)} finding(s)")
return 1 if any(l in ("MALWARE", "ALIAS", "RISK") for l, _ in findings) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
We ran it on two fixtures. The first is a real lockfile for the official @whiskeysockets/baileys 7.0.0-rc14, generated with npm install --package-lock-only --ignore-scripts, plus the official package source. The second is a test fork: the same tree installed through an alias, with a placeholder channel ID added in base64, a libsignal alias whose index file has the patcher's shape, and a lockfile entry for one of the packages OSV listed on 16 October. Nothing in the fixture was downloaded from a malicious package:
$ python3 baileys_fork_check.py official-bot
95 locked package(s), 0 finding(s)
$ python3 baileys_fork_check.py fork-bot
ALIAS @whiskeysockets/baileys is installed from @example-bot/baileys@7.0.1; read that package before trusting it
ALIAS libsignal is installed from @example-scope/libsignal-node@2.0.0; read that package before trusting it
REVIEW @celestial-community/baileys@1.0.0 is not the official Baileys or libsignal package
MALWARE @celestial-community/baileys@1.0.0 matches MAL-2026-17438
RISK @example-bot/baileys/lib/Socket/newsletter.js: channel ID encoded: 000000000000000000@newsletter
RISK @example-bot/baileys/lib/Socket/newsletter.js: FOLLOW call in a file that also handles connection events
RISK @example-scope/libsignal-node/index.js: writes into node_modules at runtime
96 locked package(s), 7 finding(s)
The official tree produces no findings, which matters, because it also defines newsletterFollow and handles connection events in other files. The heuristics are deliberately narrow. They won't decode base64 plus XOR or javascript-obfuscator output, so treat a clean result for an unofficial fork as "not proven malicious", not as safe.
If you ran a fork
- Unlink the session. In WhatsApp, open Linked devices and log out the bot's device. The fork had full use of that session, and some variants talked to their author's servers.
- Delete the stored auth state (the folder used by
useMultiFileAuthStateor your own store), then pair again using the official package. - Review the account. Check followed channels, including muted ones, and unfollow what you didn't choose.
- Treat the host as exposed if you ran a version that phones home or takes remote commands, or one whose record carries only the generic text. Rotate secrets that the bot's machine could read.
- Pin the official package at an exact version and install with
npm ci, so the lockfile can't drift to a fork.
What to do
- Allow only
@whiskeysockets/baileysorbaileysin code review and CI. Both come from the WhiskeySockets repository, and between them they had over 900,000 downloads in the week to 12 October. - Reject npm aliases (
npm:specs) forbaileysandlibsignalinpackage.json, and check the lockfile, where the alias shows up as a different resolved package. - Check every locked version against OSV's malware records in CI, not only the CVE feed. The
MAL-records are where this family shows up. Our malicious npm package detection workflow covers the wider set of checks. - Keep a release-age delay for new versions, knowing it catches only the fast-reported part of this family.
- Read forks before you adopt them. If a fork offers a feature you need, read every file in the version you lock, not only
newsletter.js, and recheck when you upgrade.
The fair view
This isn't a breach in the usual sense. In the samples described, the harm is reach for someone's WhatsApp channel, gained with your account and without your consent. Many of the forks come from hobbyist bot authors promoting their own channels, and some say so in plain sight. It's fair to call those grayware, as pkgwarden does.
But the mechanism deserves attention beyond WhatsApp. The forks needed no stolen secret, no install hook and no exploit. They needed a library that holds a signed-in session and a user willing to install a renamed copy. Any client library that acts as a person, whether for chat, social or a browser profile, can be forked the same way. The control that works is the boring one: know which package you installed, from whom, at which exact version, and check it against the malware data before it ever gets the session.