Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

ChainDrop: con worm npm đi qua cổng chính, mang theo cả provenance

Tung Bui Thanh (TungDaDev)17 mins read

A metallic chain against a dark background

Một buổi sáng thứ Ba, bạn mở laptop, kéo code mới về, chạy npm install như hàng nghìn lần trước đó. Không ai đổi dependency nào. Lockfile chỉ nhích một dòng, kiểu flat-cache lên một bản patch, nằm lẫn giữa mấy chục dòng khác trong diff mà chẳng reviewer nào đọc kỹ. Terminal chạy xong, test xanh, bạn đi họp.

Lúc đó trong thư mục tạm của máy đã có một runtime Bun vừa được tải về, một đoạn JavaScript 727 KB đã bị làm rối đang quét ~/.ssh, .env, token của gh, thông tin cloud, và gửi tất cả đi dưới dạng đã mã hoá. Cái dependency kia có chữ ký provenance đàng hoàng, build từ GitHub Actions của chính dự án.

Đó là ChainDrop, chạy ngày 4/8/2026. Mình thấy vụ này đáng mổ kỹ, vì nó làm lung lay một niềm tin khá phổ biến: "package có provenance thì tạm yên tâm".

# chuyện gì đã xảy ra

ChainDrop là một biến thể của họ Shai-Hulud, loại worm npm đã xuất hiện từ các đợt trước. Microsoft gọi chiến dịch là ChainDrop và mô tả payload là biến thể "Mini Shai-Hulud". Nó là một bundle JavaScript bị làm rối nặng, chạy bằng Bun. Việc nó làm gồm ba bước lặp đi lặp lại: chạy lúc cài đặt, trộm token, rồi dùng token đó publish lại các package khác của nạn nhân.

Điểm xuất phát, theo StepSecurity, là việc kẻ tấn công chiếm tài khoản GitHub của maintainer dự án jaredwray/keyv. Chúng không đánh cắp token npm. Chúng push thẳng lên main, tạo GitHub Release, và để chính workflow release của dự án build rồi publish qua npm OIDC trusted publishing. Chỗ này quan trọng, mình quay lại ở phần provenance.

Dòng thời gian dưới đây lấy từ StepSecurity (giờ UTC, ngày 4/8/2026). Đây là nguồn chi tiết nhất về giờ giấc mình tìm được, các hãng khác không công bố mốc giờ tương đương nên chưa đối chiếu chéo được từng mốc.

giờ (UTC)sự kiện
09:02commit ee2681a push lên main của keyv, chèn payload và sửa logic release
09:04commit d8c850c cài hook cho IDE và AI agent vào repo
09:35keyv@6.0.0 được publish qua workflow release của dự án
09:38 đến 13:20làn sóng thứ hai: worm dùng credential vừa trộm để republish thêm 433 package
10:17cảnh báo công khai đầu tiên
10:39npm bắt đầu gỡ các bản độc
18:10cả 11 package "carrier" đã được trả về bản an toàn

Còn một chi tiết nữa từ Unit 42: hạ tầng được chuẩn bị từ rất sớm. Ba domain C2 được đăng ký ngày 22/5/2026, contract Ethereum dùng để xoay domain được deploy ngày 25/5. Tức là vụ keyv là ngày bấm nút, chứ không phải ngày bắt đầu chuẩn bị.

Về quy mô thì các nguồn không khớp nhau, và mình nghĩ nên nói thẳng thay vì chọn một con số cho đẹp:

nguồnthời điểmcon số
StepSecurity4/8, 18:10 UTC444 package, 2.212 version
Aikido5/8, 13:15 CESTít nhất 444 package, 1.381 version
BleepingComputer (dẫn Aikido, 4/8)lúc đăng bài, vụ còn diễn raít nhất 868 package, 1.381 version
Microsoft4/8hơn 400 package
CSA Singapore (AD-2026-009)6/8hơn 1.300 package version

Mình đoán phần lớn chênh lệch đến từ chuyện đơn vị đếm (package hay version) và thời điểm đếm, vì worm vẫn đang lan khi các hãng viết bài. Bài trên BleepingComputer có chỗ ghi "868 package" trong khi bản Aikido sau đó ghi 444, nên nếu bạn cần con số chính xác cho báo cáo nội bộ thì hãy dùng danh sách package/version của chính vendor mà công cụ của bạn đang dựa vào, đừng trích lại từ một bài tổng hợp.

# cơ chế: từ npm install tới token bị trộm

Mọi thứ bắt đầu từ một dòng trong package.json:

{
  "scripts": {
    "preinstall": "node setup.mjs"
  }
}

Đây là minh hoạ đơn giản hoá dựa trên mô tả của Aikido và Microsoft, không phải file gốc. Lifecycle script preinstall là tính năng bình thường của npm: chạy trước khi cài, không hỏi ai. Chính vì thế npm install một package bị nhiễm là đủ để mã độc chạy, bạn không cần import hay require gì cả.

setup.mjs làm vài việc nhỏ. Theo Elastic, nó kiểm tra máy có bun chưa, chưa có thì tải Bun 1.3.13 từ trang release chính thức trên GitHub, chạy payload, rồi xoá thư mục tạm. Unit 42 ghi thêm là payload thoát luôn nếu máy dùng locale tiếng Nga. Microsoft cho biết nó còn phân biệt máy dev với CI: trên máy dev nó tách ra chạy nền rồi để npm install kết thúc êm đẹp, còn trên CI nó bám lại trong job để với tới secret, credential của runner và quyền OIDC publish.

Danh sách thứ nó tìm khá dài, gộp từ Microsoft, Unit 42 và Aikido:

  • File và môi trường: .env, SSH key, shell history, toàn bộ biến môi trường, Terraform state, ví tiền mã hoá, cấu hình của các công cụ AI.
  • Token của công cụ: gh auth token, rồi gọi gcloud, az, azd để xin access token.
  • Cloud và hạ tầng: AWS, Kubernetes, HashiCorp Vault. Theo Microsoft, module không chỉ so khớp pattern trong file mà còn gọi API thật để kiểm tra token còn sống và lấy secret mà token đó đọc được.
  • npm và GitHub: kiểm tra token npm có quyền ghi không, có bypass 2FA không; kiểm tra scope token GitHub và liệt kê repo ghi được.
  • Trên GitHub Actions: đọc bộ nhớ của process Runner.Worker để lấy OIDC token (Unit 42).

Dữ liệu thu được được gom thành JSON, nén gzip, mã hoá AES-256-GCM, còn khoá AES được bọc bằng RSA-OAEP với public key của kẻ tấn công. Nghĩa là nếu bạn chặn được traffic cũng không đọc nổi nội dung, và các hãng nghiên cứu chỉ thấy được cái vỏ.

Còn gửi đi đâu thì đáng chú ý hơn. Trong mã không có domain cố định. Worm gọi một smart contract trên mạng chính Ethereum (địa chỉ 0xE1f2395ee43e45A1556EC6438a88c31B83493103) qua một loạt RPC công cộng để hỏi "domain hiện tại là gì", rồi POST dữ liệu lên https://<domain>/router. Kỹ thuật này người ta gọi là EtherHiding. Tác dụng của nó với kẻ tấn công: muốn đổi C2 chỉ cần ghi một transaction, còn bên phòng thủ không thể gỡ domain trong code ra khỏi hàng trăm package đã phát tán. Domain quan sát được lúc đầu là npm-cache[.]com; theo Unit 42, ngay trong ngày 4/8 contract đã xoay sang awqhnjewqjkl[.]icu.

Nếu kênh chính hỏng thì có hai đường lui: tìm commit trên GitHub chứa chuỗi đánh dấu, hoặc tạo repo công khai có description Shai-Hulud: Here We Go Again rồi commit kết quả đã mã hoá vào đó. Trong đường lui này token GitHub bị trộm được ghi vào commit message dưới dạng base64 hai lần (chỉ là encode, không phải mã hoá), nên các instance khác của worm lại nhặt được và dùng tiếp.

Vòng lặp I → E và J → E là thứ biến một vụ chiếm tài khoản thành worm. Mỗi nạn nhân mới vừa là kho token, vừa là điểm phát tán mới.

# provenance: tờ giấy chứng nhận cho cái sai

Phần này mình thấy đáng suy nghĩ nhất.

Provenance (SLSA attestation, ký bằng Sigstore) trả lời câu hỏi: "tarball này được build từ commit nào, bởi workflow nào?". Nó không trả lời câu hỏi: "commit đó có được chủ dự án cho phép không?". StepSecurity tóm gọn thành một câu mà mình xin giữ nguyên: provenance chứng minh commit nào đã được build, không chứng minh được commit đó có hợp lệ hay không.

Với keyv@6.0.0, kẻ tấn công đã có quyền push, nên commit độc nằm trên main, release được tạo, workflow chạy, Sigstore ký. Mọi thứ khớp, chỉ có điều người bấm nút là kẻ gian. Đã vậy cấu hình trusted publishing còn xoá luôn nhu cầu có token npm dài hạn, vốn thường là thứ ta khuyên dùng để giảm rủi ro. Cái giảm là rủi ro lộ token. Còn rủi ro chiếm tài khoản maintainer thì vẫn nguyên.

Cũng cần nói cho đúng: không phải bản độc nào cũng có provenance. Theo Microsoft, phần lớn các bản trong làn sóng lan rộng không có commit, PR, tag hay release tương ứng, vì worm sửa tarball rồi publish thẳng bằng token npm đánh cắp được. Provenance hợp lệ chủ yếu thuộc nhóm đi qua workflow trusted publishing (nhóm keyv và một đường riêng tới @opensearch-project/opensearch theo Unit 42). Nên cái đáng nhớ là: có provenance thì chưa đủ để tin, còn thiếu provenance ở một package vốn trước giờ có thì lại là tín hiệu rất đáng nghi.

# ai bị ảnh hưởng

Nhóm "carrier" trực tiếp từ hệ sinh thái của jaredwray, liệt kê theo advisory của CSA Singapore, cộng với các bản an toàn mà StepSecurity gợi ý quay về:

packageversion bị nhiễmbản tốt gần nhất (theo StepSecurity)
keyv6.0.05.6.0
flat-cache6.1.246.1.23
file-entry-cache11.1.6chưa có số liệu, hãy tự tra
cacheable-request13.0.20chưa có số liệu, hãy tự tra
cacheable2.5.1chưa có số liệu, hãy tự tra
@cacheable/memory2.2.1chưa có số liệu, hãy tự tra
cache-manager7.2.10chưa có số liệu, hãy tự tra
@cacheable/node-cache3.1.2chưa có số liệu, hãy tự tra
@cacheable/utils2.5.1chưa có số liệu, hãy tự tra
@cacheable/net2.1.1chưa có số liệu, hãy tự tra
ecto5.0.1chưa có số liệu, hãy tự tra

Advisory của CSA chỉ liệt kê "package đáng chú ý" và dẫn sang Socket cho danh sách đầy đủ. Ngoài nhóm này còn hàng trăm package khác do worm lây, nhiều cái thuộc scope doanh nghiệp (StepSecurity nêu @servicetitan, @onereach, @or-sdk; BleepingComputer nhắc thêm Deliveroo, Ornikar, Picsart, Qlik). Đừng chỉ grep sáu cái tên ở trên rồi kết luận mình sạch. Với dependency gián tiếp thì keyv và flat-cache nằm trong cây của rất nhiều tool (ESLint dùng file-entry-cache chẳng hạn), nên khả năng bạn dính mà không biết là có thật.

# kiểm tra máy mình

Tất cả lệnh dưới đây chỉ đọc, không sửa gì. Chạy ở gốc project, và nhớ rằng chúng chỉ tìm dấu vết đã công bố, không chứng minh được máy sạch.

Trước hết, project có đang kéo các package trên không:

npm ls keyv flat-cache file-entry-cache cacheable-request cacheable cache-manager ecto --all
grep -nE '"node_modules/(keyv|flat-cache|file-entry-cache|cacheable|cache-manager|ecto)"' -A1 package-lock.json

Lệnh thứ hai in version ngay dòng dưới tên package, đối chiếu với bảng trên. Với pnpm-lock.yaml hay yarn.lock thì grep tên rồi nhìn version tương tự.

Tiếp theo, tìm file đã biết trong node_modules và hash của dropper:

find . \( -name 'Math_Symbol.js' -o -name 'math_init.js' \) -print 2>/dev/null
find . -name setup.mjs -not -path './.git/*' -exec shasum -a 256 {} + 2>/dev/null | grep -E '54dc7ea5|fd3ca400'

Hai hash dropper setup.mjs được ba hãng (Microsoft, Unit 42, Elastic) cùng công bố, bắt đầu bằng 54dc7ea5… và fd3ca400…. Hash đầy đủ nằm ở bài của Microsoft.

Rồi tới cơ chế persistence, thứ khiến máy có thể bị nhiễm lại dù bạn đã xoá package:

grep -rn "setup.mjs" .claude/settings.json .claude/setup.mjs .vscode/tasks.json .vscode/setup.mjs 2>/dev/null
git log --all --author='claude@users.noreply.github.com' --grep='chore: update config' --oneline
ls -d "${TMPDIR:-/tmp}"/bun-dl-* 2>/dev/null
ls ~/.local/bin/gh-token-monitor.sh \
   ~/Library/LaunchAgents/com.user.gh-token-monitor.plist \
   ~/.config/systemd/user/gh-token-monitor.service 2>/dev/null

Elastic ghi nhận commit do worm tạo có author claude và message chore: update config. Hook trong .claude/settings.json chạy ở sự kiện SessionStart của Claude Code, hook trong .vscode/tasks.json chạy khi mở folder trong VS Code. Nói cách khác, chỉ cần clone một repo đã bị ghi hook rồi mở bằng editor hoặc AI agent là có thể chạy lại payload, kể cả khi bạn chưa hề gõ npm install. Đây là điểm mình thấy mới và hơi khó chịu: bề mặt tấn công giờ gồm cả file cấu hình của công cụ AI.

Cuối cùng, tài khoản GitHub của bạn có repo lạ không:

gh repo list YOUR_USERNAME --limit 200 --json name,description \
  --jq '.[] | select((.description // "") | test("Shai-Hulud"))'

Về mạng, nếu có log DNS thì tìm truy vấn tới npm-cache.com, awqhnjewqjkl.icu, pypi-get.com, js-mirror.com. Unit 42 khuyên chặn ở tầng DNS và TLS SNI chứ không chặn IP, vì các domain này nằm sau địa chỉ Cloudflare dùng chung.

# nếu đã dính

Thứ tự việc làm quan trọng hơn bạn nghĩ.

Mọi nguồn đều đồng ý một điều: máy hoặc runner nào đã cài bản nhiễm thì coi như bị lộ toàn bộ credential nó chạm tới, kể cả khi sau đó package đã bị npm gỡ. Gỡ package khỏi registry không dọn được những gì đã chạy trên máy bạn.

  1. Cách ly máy hoặc runner đó. Với CI thì huỷ cache và image dùng chung, build lại từ baseline sạch.
  2. Kiểm tra và gỡ persistence (file .claude, .vscode, và gh-token-monitor nếu có) trước khi thu hồi token GitHub. StepSecurity cảnh báo thành phần token monitor sẽ chạy payload của kẻ tấn công khi token bị thu hồi, còn Microsoft mô tả nó có handler phá huỷ. Unit 42 thì cho biết các artifact cấp hệ điều hành này có trong mã nhưng họ chưa thấy nơi nào gọi tới. Ba nguồn hơi lệch nhau, mình chọn cách thận trọng: gỡ trước, thu hồi sau.
  3. Rotate từ một máy sạch: token npm (ưu tiên token automation có quyền ghi và bypass_2fa), GitHub PAT, SSH key, credential AWS/GCP/Azure, token Kubernetes, Vault, và cả API key của các dịch vụ AI.
  4. Rà log cloud và GitHub từ thời điểm cài trở đi: ai đăng nhập, repo nào có commit lạ, workflow nào mới (StepSecurity nhắc workflow tên "Run Copilot" và artifact format-results; Unit 42 nhắc .github/workflows/codeql_analysis.yml có toJSON(secrets)).
  5. Ghim lại version tốt, cài lại với npm ci --ignore-scripts.

# bài học cho cách mình cài dependency

Phần này là ý kiến cá nhân, dựa trên những gì các hãng khuyến nghị và trade-off mình thấy.

Tắt lifecycle script mặc định. Cả Aikido, Microsoft và Elastic đều cho thấy điểm vào duy nhất của đợt này là preinstall. Với phần lớn package thuần JS, việc script chạy lúc cài là thừa. Đặt ignore-scripts=true trong .npmrc của project và CI, rồi cho phép riêng những package thật sự cần build native (esbuild, sharp, kiểu vậy). Elastic cho biết npm 12 chặn preinstall theo mặc định; mình chưa tự kiểm chứng điều này nên bạn hãy đọc release note trước khi dựa vào. Trade-off: một số package sẽ hỏng âm thầm vì thiếu bước postinstall, nên cần một vòng thử trên CI trước khi bật toàn team.

Lockfile là hàng rào, nhưng chỉ khi dùng npm ci. npm install có thể nhích version theo range trong package.json, npm ci thì bám đúng lockfile. Review diff lockfile nghe cực nhưng với dependency trực tiếp thì đáng làm; với cây gián tiếp thì cần công cụ hỗ trợ.

Thêm độ trễ trước khi nhận bản mới. Cảnh báo công khai đầu tiên xuất hiện lúc 10:17 UTC, chỉ 42 phút sau khi keyv@6.0.0 ra đời, và npm bắt đầu gỡ lúc 10:39. Một cooldown vài ngày cho bản mới (Microsoft nhắc tới min-release-age, Elastic gọi là soak period) sẽ bỏ qua gần như toàn bộ cửa sổ đó. Cái giá là bản vá bảo mật cũng đến chậm hơn, nên thường phải có đường tắt cho advisory khẩn.

Token ngắn hạn, quyền hẹp. Trusted publishing tốt hơn token dài hạn, nhưng như đã thấy, nó chỉ dời điểm yếu sang tài khoản maintainer. Việc cần làm cùng nhau: bật 2FA cho mọi tài khoản npm và GitHub, tránh token bypass_2fa, bật branch protection và bắt buộc PR review cho nhánh release, đặt protected environment có người duyệt cho job publish. Nếu workflow release của bạn cho phép push thẳng main rồi tạo Release là tự động publish, thì khoảng cách giữa "tài khoản bị chiếm" và "bản độc lên registry" chỉ còn vài phút.

Máy dev và CI nên nghèo secret. Worm chỉ trộm được thứ có sẵn trên máy. Credential cloud dài hạn nằm trong ~/.aws, token GitHub scope rộng, .env chứa key production: càng ít thì thiệt hại càng nhỏ. Dùng SSO với session ngắn, OIDC cho CI thay vì secret tĩnh, và egress allowlist cho runner (runner build không có lý do gì nói chuyện được với một domain .icu lạ).

Coi thư mục cấu hình của editor và agent là code. .claude/settings.json và .vscode/tasks.json thực thi lệnh. Một PR sửa hai file này nên được review kỹ như PR sửa workflow CI. Một chi tiết nhỏ: commit giả danh "AI assistant" mang message chore: update config rất dễ lọt mắt người đang vội.

Về chuyện ai đứng sau: trong các bài kỹ thuật mình đọc, không bài nào quy kết được cho một nhóm cụ thể, nên bài này bỏ qua phần attribution.

Nếu chỉ làm một việc sau khi đọc xong, hãy mở .npmrc của project quan trọng nhất và thêm ignore-scripts=true, rồi cho CI chạy thử một lần. Sau đó đọc tiếp bài của StepSecurity và Unit 42 bên dưới, hai bài có nhiều chi tiết kỹ thuật nhất.

Tham khảo:


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.