SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

npm 供應鏈攻擊如何入侵 VPS 上的 Node 應用程式

了解 npm 供應鏈攻擊如何透過惡意 patch 版本、postinstall 指令碼與 typosquat 進入 VPS 上的 Node 應用程式,以及固定 lockfile 版本的部署做法。

伺服器上的 npm 供應鏈攻擊是什麼

npm 供應鏈攻擊會透過你選擇安裝的套件進入伺服器。這不涉及開放連接埠,也不需要執行漏洞利用。npm(Node package manager)會安裝程式碼,而安裝程式碼就會執行程式碼。因此,一個小型 Node 應用程式可能會拉取數百個你從未閱讀過的套件,而其中任何一個套件都可能在一小時後發布新版本。

你的部署程序會取得惡意版本,因為安裝命令要求符合條件的最新版本。接著,該程式碼會以執行安裝程序之帳號的權限執行。以下所有內容都源自這兩句話。

以下依照單一人員將單一 Node 應用程式部署到單一 VPS 時的常見程度排列。大型公司不會採用這種順序,因為大型公司通常有內部 registry、審查團隊,以及公開 registry 的 mirror。你只有一個部署腳本。

形式 1:維護者帳戶遭入侵並發布修補程式

npm registry 不允許任何人變更已存在版本的內容。因此,攻擊者即使透過釣魚取得維護者帳戶,或竊取 publish token,也無法改寫 4.18.2。他們會發布 4.18.3

請查看你的 package.json。像 "express": "^4.18.2" 這樣的設定並不代表版本 4.18.2。插入號表示「此版本以上的任何 4.x 版本」,而 ~4.18.2 表示「任何 4.18.x 版本」。npm install 會在執行當下解析這個版本範圍。因此,同一個 git commit 若在同一個下午部署兩次,可能會安裝兩組不同的程式碼。這個差異就是攻擊面。即使你的機器沒有遭入侵,也可能因此受到影響。

惡意版本通常會被回報並下架,但下架是在使用者安裝之後才發生。在這段期間部署的主機,磁碟上已經留有該程式碼。每次執行時都解析版本範圍的 pipeline,會自動進入這段期間。它每週可能發生數次,而且不需要任何人事先決定。

情境 2:安裝指令碼以執行部署的使用者身分執行

套件的 package.json 可以在其 scripts 區塊中宣告 preinstallinstallpostinstallprepare。npm 會在安裝期間執行這些指令碼。它們不在沙箱中執行,也沒有人審查。這些 shell 指令會以輸入安裝命令之使用者的身分執行,工作目錄是該使用者的家目錄,並具備該使用者的網路存取權限,以及該 shell 的完整環境。

因此,實用的問題不是套件能做什麼,而是該使用者能讀取什麼。在一般部署主機上,答案包括存放 registry token 的 ~/.npmrc、用作 SSH(secure shell)部署金鑰的 ~/.ssh/id_ed25519~/.aws/credentials~/.docker/config.json,以及 shell 中每個 exported variable;DATABASE_URL 通常就存放在其中。

這類 payload 不需要持久化,也不需要權限提升。它只要讀取幾個檔案,透過 HTTPS 傳送到主機,然後以狀態碼 0 結束。你不會看到任何內容,因為 npm 預設會隱藏 install script 的輸出。關閉這項功能,查看實際執行的內容:

npm ci --foreground-scripts

foreground-scripts 會與 npm process 共用 standard input、output 和 error,因此 build script 會將輸出顯示在你的終端機中,而不是寫入 npm 在安裝成功時會捨棄的 buffer。

形態 3:typosquat,以及你沒有完全輸入正確的名稱

typosquat 是使用接近熱門套件名稱發布的套件,等待有人輸入錯誤或貼上錯誤的安裝命令。其作用點在命令,而不是程式碼,因此 lockfile 在這裡無法提供協助:你只要加入一次錯誤名稱,之後 lockfile 就會如實將它固定下來。

會攻擊團隊而非個人的變體是 dependency confusion。你的內部套件名稱為 billing-utils,並存放在私有 registry。若公有 registry 上不存在名為 billing-utils 的套件,任何人都能發布一個。npm 會針對未加 scope 的名稱,向預設公有 registry 解析,因此公有版本可能會優先被採用。解法是使用你擁有的 scope,並為該 scope 設定 registry 對應,如 .npmrc

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

現在 @yourorg/billing-utils 只會從該主機取得,因為系統會先查詢 scope 對 registry 的對應,再查詢預設 registry。未加 scope 的內部名稱沒有對應,因此不受保護。

在加入任何新相依套件之前,請檢查套件本身,而不是只看下載量徽章:

npm view some-lib repository.url maintainers time.created time.modified

上個月才建立、且發布者帳戶無法與任何公開 repository 建立關聯的套件,與已有 6 年歷史的套件具有不同的風險。這兩項資訊都不能證明套件一定安全或不安全,但都很容易檢查。

形態 4:套件擁有者悄然變更的相依套件

維護者會交接套件。有人精疲力竭,陌生人主動提供協助,發布權限轉移,但相依於該套件的專案完全不會收到任何通知。沒有任何內容遭到入侵。你在 2021 年授予的信任,如今已由另一個人持有。

這是最緩慢、也最難偵測的形態,而且沒有任何命令能直接回答這個問題。以下兩點可以縮小範圍。採用套件前,先使用上方的 npm view 指令檢查誰具備發布權限。接著,當你實際相依的套件發生變更時,閱讀差異內容:

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

第一種形式只會列出變更的檔案名稱,因此每次你關注的套件升級時都能快速執行。如果修補版本修改建置指令碼、在套件根目錄新增檔案,或編輯 scripts 區塊,應在該版本部署到伺服器前完整閱讀差異內容。

使用已提交的 lockfile 搭配 npm ci 建置

package-lock.json 記錄套件樹中每個套件的確切版本、來源 URL、每個 tarball 的 sha512 完整性雜湊,以及需要該套件的套件。請將它提交至版本控制。這是唯一能指出實際測試內容的檔案。

接著使用 npm ci 安裝。在不是開發者筆記型電腦的任何機器上,都不要使用 npm install

npm ci --omit=dev --ignore-scripts

npm cinpm install 的差異在這裡都很重要。它要求 lockfile 必須存在。開始前會移除現有的 node_modules,因此先前部署留下的檔案不會帶入這次部署。它不會寫入 package.json 或 lockfile,因此安裝不會暗中將版本更新。若 lockfile 與 package.json 不一致,它會直接顯示錯誤並結束,而不是自行解決差異。

這項錯誤是功能,不是困擾。它表示相依性變更必須以經過他人審查的提交內容進入,而不是在 02:00 部署時意外產生。

每次抓取檔案時都會檢查完整性雜湊。若 tarball 的位元組與記錄的雜湊不符,安裝會顯示 code EINTEGRITY 並失敗,而不是解開該檔案。請準確理解這項機制的作用:它能證明收到的檔案就是 lockfile 鎖定的檔案;這與使用 checksum 驗證下載提供相同的保證,限制也相同。它無法說明鎖定的版本在發布時是否含有惡意內容。

關於 --omit=dev,有一項細節需要注意:這些套件仍會被解析,也仍會寫入 lockfile,只是不會放到磁碟上。磁碟上的套件越少,安裝腳本就越少,執行階段載入的程式碼也越少,因此值得使用。但這不會將相依性從套件樹中移除。

將安裝腳本視為程式碼,並了解如何拒絕執行

您可以停用安裝腳本。將以下內容放入專案的 .npmrc,並與 lockfile 一同提交:

ignore-scripts=true
save-exact=true

ignore-scripts=true 會阻止 npm 執行相依套件宣告的腳本。save-exact=true 會讓 npm install some-lib1.4.2 寫入 package.json,而不是寫入 ^1.4.2,因此解析範圍不會意外進入 manifest。

這會造成部分功能失效,因此啟用前應先了解影響。會編譯原生 addon 或下載預先編譯二進位檔的套件,通常會在安裝腳本中執行這些工作。停用腳本後,安裝程序本身仍會成功,但稍後執行時才會失敗,並顯示無法載入 binding file 的模組。解決方式是建立允許清單:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> 會執行該套件的 build scripts。這樣您就能逐一決定要允許哪些套件,而不是對數百個您永遠不會見面的陌生套件一律授予執行權限。

若要查看目前授予的範圍有多大,請要求 npm 列出清單:

npm query ":attr(scripts, [postinstall])"

這會列出已安裝樹狀結構中所有包含 postinstall script 的套件。對典型應用程式而言,清單通常比預期短,這正是允許清單實用的原因。

將建置與處理網路流量的程序分開

deploy 使用者需要寫入 node_modules。處理 HTTP 請求的程序不需要寫入。如果兩者使用相同帳號,安裝期間執行的程式碼就能改寫服務使用者的程式碼,執行階段的程式碼也同樣可以改寫。

將兩者分開。使用一個使用者進行建置,使用另一個使用者提供服務,並讓服務使用者只能讀取服務目錄:

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

接著讓 systemd 強制執行這項限制。建立 /etc/systemd/system/nodeapp.service

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

ProtectSystem=strict 會將此服務使用的整個檔案系統掛載為唯讀,但 /dev/proc/sys 以及你在 ReadWritePaths 中列出的項目除外。因此,應用程式嘗試寫入 node_modules 時會因 EROFS: read-only file system 失敗。你可以在自己的日誌中重現這個結果,約需一分鐘。NoExecPaths 會處理可寫入的上傳目錄:服務可以在其中寫入檔案,但 kernel 會拒絕執行這些檔案。此選項需要 systemd 249 或更新版本,而 Ubuntu 24.04 隨附 255。

此 unit file 有兩個常見陷阱。第一,不要加入 MemoryDenyWriteExecute=yes。它會出現在大多數 systemd 強化設定清單中,但會阻止 Node 啟動,因為 V8 會在執行階段將 JavaScript 編譯為機器碼,並需要同時可寫入與可執行的記憶體頁面。第二,請從 command -v node 取得 ExecStart 路徑。如果使用 version manager 安裝 Node,它會位於 deploy 使用者的 home directory 下;ProtectHome=yes 接著會對服務隱藏該目錄,導致 unit 立即以 status=203/EXEC 失敗,日誌也會顯示找不到可執行檔。

不要只相信檔案內容,請檢查實際結果:

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

systemd-analyze security 會列出每項強化設定及其暴露程度,讓你查看哪些設定仍使用預設值。touch 應因 Permission denied 而失敗,因為 nodeapp 不擁有 current 下的任何項目。如果執行成功,表示檔案擁有權設定錯誤,而 systemd 設定正悄悄掩蓋這個問題。

關於 EnvironmentFile,systemd 會在降權至 User=nodeapp 前以 root 身分讀取它,因此該檔案可以使用 mode 600 設定為 root:root。應用程式仍會收到這些變數。任何能以 nodeapp 身分取得 shell 的人,仍可從 /proc/<pid>/environ 讀取這些變數;因此這只能保護靜態儲存的 secret,無法保護執行中的程序。

讓部署憑證離開建置環境

安裝腳本會繼承環境。這項事實應該決定建置位置。

最安全的做法是在 production server 以外的地方建置,再將完成的目錄複製過去。此時,建置機器只需保存 read-only registry token,不應保存其他憑證。不應有 SSH deploy key、cloud access key、database password 或 container registry login。

npm token create --read-only

read-only token 可以抓取套件,但不能發布套件。若該 token 從建置環境遭竊,損失僅限於他人能下載 public packages。

如果必須在伺服器上建置,請以 deploy 使用者執行,並使用刻意限縮的環境。將 runtime secrets 保存於 /etc/nodeapp/env,讓 deploy 無法讀取。相同原則也適用於自行託管的建置自動化:自託管的 GitHub Actions runner 會保存 token,並在每個 job 中執行任意發布的程式碼,因此在小型部署環境中,它是價值最高的機器。任何不是由你撰寫、卻能取得完整環境的程式,都應歸入同一類別。因此,讓 secret 離開 AI agent 的環境,本質上就是中間換成另一個程式的相同問題。

固定版本或納入無法稽核的套件

固定版本的相依套件,除非提交新的變更,否則版本不會改變。整個相依樹已可透過提交 lockfile 達成這點。但有兩種情況需要額外處理。

第一種是傳遞相依套件。你無法控制自己的相依套件依賴哪些套件。overridespackage.json 中會強制整個相依樹使用指定版本:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

加入後執行一次 npm install,讓 lockfile 記錄結果,然後提交這兩個檔案。

第二種是你無法稽核、也無法移除的套件。將它納入專案管理。npm pack 會下載 registry 實際提供的確切 tarball,而 file: 相依套件則會從你的副本安裝:

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

現在 tarball 已存在於你的 repository 中,不會在你未察覺的情況下變更。但你也必須永久負責更新它。因此,這項做法適合你不得不繼續使用的小型、已停止維護套件,不適合用於 web framework。

此外,還有一段不需額外成本的觀察期:

npm install --before=2026-08-01

before 選項會使用指定日期當天或之前發布的版本重建相依樹。重新整理相依套件時,將日期設為一或兩週前,即可避開惡意版本已上線但尚未通報的期間。這是粗略的工具,因為它也會暫緩真正的安全性修補。使用它來解析版本範圍、查看變更內容,然後提交 lockfile。

我如何知道實際部署的是哪個版本?

git 中的 lockfile 只表示應該安裝的內容。磁碟上的檔案才表示實際已安裝的內容。只有後者是證據。

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls 會讀取 node_modules,因此回報實際存在於磁碟上的內容,而不是 lockfile 設定的內容。node -e 行會依路徑讀取已安裝的 manifest。即使套件的 exports 欄位禁止子路徑匯入,這種方式仍可運作,並且只會輸出一個版本,不會附帶樹狀結構。

至於比較的另一端,請讀取 git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

將這兩項資訊永久連結,方法是把 commit 放入部署版面。將版本釋出至 /srv/nodeapp/releases/<short commit sha>,再以 symlink 讓 /srv/nodeapp/current 指向該版本。如此一來,「目前正在執行哪個版本」的答案就是 readlink /srv/nodeapp/current;即使是在 03:00,由未執行部署的人員查看,也能取得相同資訊。

最後,確認 registry 願意為哪些內容背書:

npm audit signatures

這會驗證已安裝樹狀結構中套件的 registry 簽章,也會驗證具備 provenance attestation 的套件。Provenance 會將已發布的 tarball 連結至產生該檔案的公開 continuous integration (CI) 建置,因此,經驗證的 attestation 表示你可以將程式碼追溯至某個 commit,而不是某台不明筆電。這項涵蓋範圍並不普遍,因此缺少 attestation 應解讀為「沒有資訊」,而不是「套件不良」。

遭遇有問題的 release 部署到伺服器後的處置方式

從實際執行的內容,以及執行時使用的使用者帳號開始,逐步向外確認。

如果程式碼在安裝期間執行,請假設 build user 可讀取的所有內容都已外洩。輪換 registry token、該 home directory 中的 SSH keys、cloud credentials,以及該 shell 曾 export 的所有 secret。輪換是唯一誠實的處置方式,因為你無法證明某個檔案未曾被讀取。

如果程式碼在 runtime 期間以受限制的 service account 執行,可觸及的範圍會小得多:應用程式自己的 environment variables,以及其 network access 能夠觸及的內容。這正是 在 VPS 上以 unprivileged users 執行服務 的完整理由。這無法防止 compromise,但能決定 compromise 可取得主機的多大範圍,以及重新啟動後是否仍會存續。

接著重新建置,不要進行清理。刪除 node_modules,在 package.json 中將受影響的 package 固定在有問題的版本以下,執行 npm install 一次以更新 lockfile,提交變更,然後使用 npm ci 部署。不要在原有 tree 上直接修復。你無法完整列舉 install script 曾修改的內容。

也要記錄事件範圍:第一個可能拉取該版本的 deploy,以及移除該版本的 deploy。這個範圍能告訴你應該讀取哪些自己的 logs;而只有在 release 以 commit 命名時,才能回答這個問題。

哪些措施都無法解決的問題

lockfile 不會讓相依套件變得安全。它會將接受該相依套件的時點記錄為有日期、經過審查的決策,而不是部署時附帶產生的結果。上述每項做法都完成相同的轉換:將意外改為選擇。

npm audit 在這裡不是防禦措施。它會將你的相依套件樹與已回報漏洞的資料庫比對,因此只能找出已公開且已命名的問題。供應鏈攻擊在整個有效生命週期內都可能沒有名稱。對已知的舊漏洞執行 npm audit,但不要期待它能發現 4 小時前才發布的版本中的問題。

減少相依套件數量的幫助勝過本指南中的任何工具,但這也是最不受歡迎的建議。每個你未加入的套件,都代表少了一個可能代你遭到釣魚攻擊的發布者,也少了一個不會以 deploy user 身分執行的安裝指令碼。

這些問題也不只存在於 npm。同樣的 4 種情況適用於 PyPI、RubyGems、container image 以及你的 distribution package manager。npm 最容易出現這些問題,因為其相依套件樹最深,而且預設會執行安裝指令碼。周邊機器中有多少部分需要由你負責防禦,取決於它的執行位置;這也是VPS hosting 是否安全這個更廣泛問題的一部分。

FAQ

npm ci 能防止 npm 套件遭到入侵嗎?

它能防止版本在你不知情的情況下變更。npm ci 會完全依照 package-lock.json 的記錄進行安裝,使用 sha512 完整性雜湊值檢查每個 tarball;如果 package.json 與 lockfile 不一致,會直接回報錯誤並結束,而不是自行解決差異。這無法表示鎖定的版本是否安全。如果你提交的 lockfile 鎖定了惡意版本,npm ci 每次都會在你管理的每台伺服器上如實安裝該版本。

我應該為所有項目設定 ignore-scripts=true 嗎?

先設定,再建立 allowlist。在專案的 .npmrc 中啟用 ignore-scripts=true,可停止相依套件的安裝指令碼,移除惡意套件直接取得部署使用者憑證的途徑。需要編譯原生 addon 或下載預先建置二進位檔的套件,確實需要執行自己的指令碼;停用指令碼後,這些套件會在執行階段因缺少 binding 檔案而失敗,而不是在安裝時失敗。執行 npm ci --ignore-scripts,再針對少數你決定信任的套件執行 npm rebuild <package>npm query ":attr(scripts, [postinstall])" 會顯示實際需要允許的套件數量。

如何確認伺服器實際安裝了套件的哪個版本?

讀取磁碟上的內容,不要只看 lockfile。npm ls <package> 會回報 node_modules 中目前存在的內容,node -e "console.log(require('./node_modules/<package>/package.json').version)" 則只輸出版本字串。git 中的 lockfile 回答的是另一個問題,也就是原本應該安裝的版本;比較這兩者正是其用途。將部署目錄命名為 git commit,可在數個月後仍保留這兩項資訊,方便你進行確認。

npm audit 能找出供應鏈攻擊嗎?

不能。npm audit 會將你的相依性樹與已回報漏洞的資料庫比對,因此只能找出已公開並取得識別碼的問題。惡意版本在發布後的數小時或數天內可能尚未被回報,但這正是安裝它最可能造成影響的期間。npm audit signatures 是更實用的指令:它會驗證已安裝相依性樹中的 registry 簽章,並在發布者提供時檢查來源證明,藉此確認 tarball 來自公開建置流程,而不是未知機器。

如果攻擊發生在安裝階段,為什麼仍要以非特權使用者執行應用程式?

因為這兩種失敗的影響範圍不同,而你必須同時防範兩者。安裝階段的程式碼會以部署使用者身分執行,因此可以讀取該使用者的 SSH 金鑰、registry token 與雲端憑證。執行階段的程式碼則以服務帳號身分執行;搭配 User=nodeappProtectSystem=strict,並且不在磁碟上保存憑證時,它能讀取的範圍只會到應用程式本身的環境與其資料庫。分離使用者帳號也表示提供網路流量服務的程序無法改寫 node_modules,因此執行階段遭到入侵後,會在下一次重新啟動時失效,而不會永久保留。