vatt'ghern jaskier's ballads

一位 LinkedIn 上的招募者開出 lead engineer 職缺,聊了幾天、丟來一個公開 GitHub repo,請你看看「deprecated Node modules」的問題。後門不在那些 modules 裡,而在 app/test/index.js 約第 225 行、夾在註解掉的測試之間的一段 payload;只要你跑下 npm install,它就自己醒來。

藏在 npm prepare 裡的後門

是一場把社交工程接到供應鏈攻擊上的故事,作者把整個過程按時間順序寫了下來——重點不在「有人寫了惡意程式」,而在於這條鏈每一節都借用了你日常工作裡最不設防的習慣:相信 LinkedIn 的招募訊息、相信公開 repo 能用肉眼 review、相信 npm install 只是把套件抓下來。攻擊者要的不是你的 review 結論,而是你「為了 review 而先把環境裝起來」這個動作。

把這條鏈拆開看,五個環節環環相扣,但只有其中一節是受害者真正能親手切斷的。下面這個 stepper 把五步攤平——逐步點過去,看每一步把信任交了出去多少、又留下什麼可以反悔的窗口。

click each step to walk the attack chain · 5 steps

社交工程 → 供應鏈:信任逐節交出 LinkedIn DM 假招募者 GitHub repo 請你 review npm install 唯一可阻斷 prepare 自動觸發 payload 下載並執行 第 3 步 npm install 是唯一在你手裡的閘門

幾則訊息換來的一次 review 邀請

起點是 LinkedIn 上一位招募者,自稱是一家加密貨幣新創的人,說他們有個壞掉的 proof-of-concept,正在找 lead engineer 來修。作者引用得很白:那是「broken proof-of-concept they needed a lead engineer for」。這套說法把兩個誘因綁在一起——一個職缺(你願意花時間)、一個技術問題(你願意動手看 code)——而且都落在你平常就會做的事情範圍內。

接著是幾天的對話。沒有催促、沒有匯款、沒有任何傳統詐騙的紅旗,只是把對話拉長到足以建立「這是個正常招募流程」的印象。作者後來查證,這位招募者的 LinkedIn 身分是盜用一位藝術記者的真實檔案;而 repo 裡的 39 個 commit 掛在一位真實開發者名下,那位開發者表示自己「he'd never worked for them」。借來的身分撐起了整條鏈的可信度:你看到的不是一個空殼帳號,而是有臉、有履歷、有 commit 歷史的人。

建立信任之後,請求才出現,而且請求本身極其無害:她丟來一個公開的 GitHub repo,請作者「check out the deprecated Node modules issue」。注意這個 framing 的精巧——它把你的注意力導向 dependency(那些 deprecated modules),暗示問題出在第三方套件;它要的是一個技術判斷,正好是 senior engineer 最樂意提供的東西。你以為自己在審的是別人寫的依賴,真正在等你的,是 repo 自己的 install 流程。

這個故事裡攻擊者完全沒做的事情,比他做的更值得想。沒有要你點不明連結、沒有要你輸入帳密、沒有附件、沒有要你關掉防毒、沒有任何一步看起來像釣魚。傳統的安全意識訓練教你辨認的紅旗,這條鏈一個都沒踩。它的高明之處正在於,每一步都精準落在「一個 senior engineer 在正常工作日會做的事」的範圍內——回招募訊息、clone 一個公開 repo、為了看懂它而把依賴裝起來。攻擊面不是某個漏洞,而是你的職業習慣本身;防毒軟體看不到「你決定信任這個 repo 到願意在自己機器上 install 它」這個念頭。

社交工程裡有一條反直覺的規律:越是讓你動用專業判斷的請求,越容易繞過你的戒心。一個叫你輸入密碼的訊息會觸發警報,因為它要的是你被反覆警告過的東西;一個叫你「幫忙看看這段 code」的請求卻會點燃相反的反應——它把你放進專家的位置,你的注意力立刻被導向「這段 deprecated module 到底哪裡寫壞了」這個技術問題,而不是「我為什麼要相信丟 code 給我的這個人」。review 這個動作本身帶著一種職業自尊:被請去 review,等於被認定有能力評斷別人的 code,沒有人會在這種時刻先懷疑邀請的真偽。攻擊者要的不是你疏忽,而是你認真——你越認真想看懂那個 repo,就越可能把它裝起來跑。

那位招募者也沒有急。從第一則 LinkedIn 訊息到丟出 repo,中間隔了好幾天的對話,這段鋪陳不是浪費時間,而是攻擊的一部分。詐騙的紅旗多半是「急迫」——限時、催款、再不處理帳號就停用。這條鏈反其道而行,用一段不疾不徐、像模像樣的招募流程,把「這人有問題」的直覺一點一點磨平。等到請求出現時,你心裡建立的已經不是「陌生人丟連結給我」,而是「我面試到一半的這家公司請我看個技術問題」。框架一旦從詐騙換成求職,整個風險評估的基準線都跟著移位了。

後門藏在註解掉的測試裡,網址拆成片段拼出來

惡意程式碼放在 app/test/index.js,整個檔案約 250 行,payload 出現在第 225 行附近——用作者的話說,「hiding in plain sight between commented-out tests」,夾在一堆註解掉的測試之間。這個位置的選擇是有算計的:review 一個 repo 時,測試檔通常是最不被認真讀的部分,而「被註解掉的程式碼」更是大腦會自動略過的視覺噪音。真正在跑的那幾行,被刻意泡在一片看起來無關緊要的死碼裡。

更狡猾的是那個 callback 網址:它沒有以完整字串出現在檔案裡,而是拆成幾個變數片段,等執行時才拼起來。grep 一個可疑網域、肉眼掃一個 string literal,這些手段全部失效,因為磁碟上根本不存在那串完整網址。下面這個 widget 把片段攤開,照原始順序點,看那串 https://rest-icon-handler.store/icons/77 怎麼一節節長出來。

click fragments in order to reassemble the callback URL · 6 fragments

按原始順序:protocol → separator → subdomain → domain → path → token。磁碟上沒有任何一行寫著完整網址。

這六個片段在原始碼裡只是六個平凡的字串變數,分散在程式各處,單獨看不會觸動任何掃描規則。直到執行期,它們才被串成 https://rest-icon-handler.store/icons/77。網域名稱「rest-icon-handler」與路徑「/icons/」也都偽裝成某種正常的圖示資源服務,連拼出來之後第一眼也不像 C2。靜態掃描看字串、人腦看 string literal,這招同時躲過了兩者。

這段 payload 怎麼被叫起來?檔案本身不會自己跑——它得被 require。串接點在 app/index.js,那裡有一行 const test = require('./test')。表面上像是把測試模組載進來,實際上 require() 一執行就會把 app/test/index.js 整個 module 的 top-level 程式碼跑一遍,那段拼網址、發請求的 payload 就在這個 top-level 執行。剩下的唯一問題是:誰來跑 app/index.js

這裡藏著一個很多人對 CommonJS module 的誤解:require('./test') 不只是「把某個函式拉進來等我呼叫」,而是會把那個檔案當成一支腳本,從頭到尾立刻執行一次,只是把最後 module.exports 的東西交還給你。也就是說,payload 不需要被任何人「呼叫」——它寫在 module 的最外層,require 的那一瞬間就跑掉了。藏在註解測試之間、又掛在 top-level 的這個組合,等於把惡意行為偽裝成「載入一個無關緊要的測試模組」這種日常到不行的動作。整條鏈到這裡都還是被動的:要有人去 require app/index.js,這些才會發生。

值得把這個語意講清楚,因為它正是這段 payload 不需要任何函式呼叫就能跑起來的根本原因。在 CommonJS 裡,一個 module 的本體就是它的 top-level 程式碼;require 第一次解析某個檔案時,會建立一個 module 物件、執行整個檔案的最外層敘述、把 module.exports 上掛的東西記進快取,之後再 require 同一個路徑就直接回傳快取,不再重跑。攻擊者利用的就是「第一次 require=執行整個檔案」這半步。payload 不是寫成一個 function malicious() 等人去叫——那樣需要 app/index.js 真的呼叫它,多一個可被 review 抓到的環節。它直接裸寫在 module 最外層,於是「載入」與「執行」在 require('./test') 這一行就合而為一。對 review 的人來說,const test = require('./test') 看起來只是把一個測試模組接進來備用,沒有任何一行寫著「執行惡意程式」;真正的執行,藏在 require 這個動詞的語意裡。

真正的引信是 package.json 裡的 prepare

答案是 npm install 自己。package.json 裡掛了一個 prepare lifecycle script,它最終會跑 node app/index.js。關鍵機制在於:prepare 會在 npm install 之後自動執行——你不需要 npm start、不需要 npm run 任何東西、不需要真的去跑那個 proof-of-concept。光是為了 review 而把 dependency 裝起來,引信就點著了。

這是整條鏈最該被記住的一格。前面四步——假招募者、借來的身分、公開 repo、註解裡的 payload——全都是為了把你誘導到一個你以為安全的動作前面。npm install 在每個人的肌肉記憶裡都是「先裝再說」的第一步,它前面不會有確認對話框,跑完也不會留下「我剛剛執行了任意程式」的感覺。下面這個 scrubber 把 npm install 觸發的 lifecycle script 排在時間軸上,看 prepare 落在哪一格、什麼時候點著引信。

drag the handle along the install timeline · 5 hook positions

npm install 期間的 lifecycle hook 觸發順序

為什麼 prepare 特別危險?它的設計初衷其實是給套件作者用的——在把套件 publish 出去之前自動做 build(編譯 TypeScript、產生 dist 等)。但 npm 為了讓「從 git 安裝」或「本地 install」也能拿到 build 好的產物,讓 preparenpm install 後也會跑。於是一個本來服務於發布流程的 hook,變成了任何人 install 你的 repo 時都會替你執行任意命令的入口。下面這張表把幾個常見的 lifecycle script 並排,看哪些會在純粹的 install 動作下自動點火。

要理解這個 hook 的脾氣,得先分清它兩種觸發情境。當你是套件「作者」、在自己的專案根目錄跑 npm install 時,prepare 會跑——這是設計上要的,讓你 commit 前就把 dist build 好。當別人把你發到 registry 的套件當成一般 dependency 裝下來時,prepare 不會跑,因為 build 產物已經包在 tarball 裡了。問題出在中間那種情境:直接從一個 git repo 或本地路徑安裝。npm 這時拿不到預先 build 好的產物,於是把該 repo 當成「作者本人」對待,prepare 照跑不誤。而 clone 下來一個公開 repo 再 npm install,正正落在這個情境裡。你以為自己是來「審查別人套件」的旁觀者,npm 卻把你當成這個專案的維護者,忠實地替你執行了 prepare

這三種安裝情境裡,prepare 跑不跑、引信點不點,差別全在你是用哪種方式把東西裝進來。下面這個切換器把三種情境並排——點一下,看 prepare 在哪種裝法下會替你自動執行。

toggle the install scenario · does prepare fire? · 3 cases

同一個 prepare,三種裝法,結果不同 作者在自己的專案根目錄 install prepare 會跑

把三種情境並排,prepare 的設計初衷與被濫用的縫隙就一目了然:它對「作者」與「從 git/本地裝的人」一視同仁,而 review 一個公開 repo 的你,在 npm 眼裡就是後者。這也是為什麼 prepare 比它的近親 postinstall 更值得警惕。多數人聽過「npm 套件可以在 postinstall 裡藏惡意程式」這個警告,看到 package.json 裡有 postinstall 多少會多看一眼。prepare 的知名度低得多,很多開發者甚至不知道它會在 install 後自動跑,下意識把它歸到「跟 publish 有關、與我 install 無關」那一類。攻擊者選的就是這份認知落差:機制和 postinstall 幾乎一樣危險,卻更不容易在 review 時引起注意。一個只掃 postinstall 的人,會直接漏掉這條引信。

lifecycle script npm install 會自動跑? 原本的用途
preinstall會(依賴安裝前)安裝前的準備工作
install原生模組編譯等安裝步驟
postinstall會(安裝後)安裝後的收尾、產生設定
prepare會(install 之後)publish 前 build;本案的引信
start / test不會(要手動 npm run啟動、跑測試

這張表畫出的界線正是攻擊者下手的地方:前四個 hook 都在「你只是想把東西裝起來」時自動執行,最後一行的 starttest 才需要你主動下指令。攻擊者把引信掛在自動那一側,於是「我只是裝個依賴看看 code」與「我執行了攻擊者的程式」之間,沒有任何你會注意到的分界。postinstall 是更廣為人知的同類陷阱,這次換成了 prepare——機制一樣,知名度低一點,更容易在 review 時被放過。

換個角度看,這也解釋了為什麼「review 一個 repo」這件事在直覺上比實際上安全。在 GitHub 網頁上讀 code、在 diff 視窗裡看 pull request,這些動作純粹是讀取,不會執行任何東西;危險的是把 repo 拉到本機、跑一次 install 的那一步。問題在於,後者在大多數人的工作流裡是前者的自然延伸——你讀了幾分鐘覺得「得跑起來才看得懂」,於是 git clonenpm install 一氣呵成。攻擊者要的就是這個延伸動作。把「在瀏覽器裡讀」和「在我的 shell 裡 install」拆成兩段、中間插一個明確的信任判斷,是這類攻擊唯一便宜又有效的防線。

它差點就成功了,因為阻斷點在最不設防的那一步

這場攻擊最後沒有得逞,但作者並不把這歸功於什麼高明的防禦。他寫得很坦白:「on a more tired or rushed day, I could easily have run npm install before thinking it through」——換到更累或更趕的一天,他很可能會先跑 npm install,才想清楚這件事。能擋下來,靠的是當天剛好有餘裕停下來想一下,而不是任何工具或流程。這句話比任何威脅情報都更值得釘在牆上:你的防線是「那天你有沒有空多想三秒」,這不該是一條防線。

把整條鏈倒過來看,能反制的環節其實寥寥可數。假招募者攔不住——身分是真的,只是被盜用了;公開 repo 攔不住——它本來就該被 review;註解裡的 payload 與拆片段的網址,肉眼掃描與 grep 都會放過。整條鏈唯一落在受害者手裡、又最容易被略過的閘門,就是第三步那個 npm install。一旦跑下去,後面 preparerequire → 拼網址 → 下載執行全是自動的,沒有第二個確認點。

具體能做什麼?把 npm install --ignore-scripts 變成 review 不明 repo 時的預設——它會跳過所有 lifecycle script,讓你先看 code 再決定要不要讓它跑。把「review 別人的 repo」與「在我自己的機器上 install 它的依賴」當成兩件不同信任等級的事:前者可以隨手做,後者等同把執行權交出去。如果非裝不可,丟進一個沒有 SSH key、沒有 token、沒有環境變數的拋棄式容器或 VM 裡,讓 prepare 點著的引信燒在一個沒東西可偷的空房間。

這裡有個值得拆開的細節:--ignore-scripts 真正買到的,是把「裝依賴」與「執行這個 repo 的程式」這兩件被 npm 預設綁在一起的事重新拆開。跳過 lifecycle script 之後,你拿到的是一棵裝好的 node_modules,但 preparepostinstall 那些 hook 全都沒跑——引信還沒點。這時你才有從容看 package.jsonscripts 區塊到底掛了什麼、那些 hook 指向哪個檔案的餘裕。你也可以把這個設定寫進 .npmrcignore-scripts=true)變成整台機器的預設,再對信得過的專案個別開啟,等於把預設從「自動執行」翻轉成「明示同意才執行」。代價是某些靠原生模組編譯的套件會少了它要的安裝步驟,得手動補——這個摩擦是刻意的,它逼你在每一次「讓陌生程式跑起來」之前停一下。

--ignore-scripts 之外能設的閘門擺在一起,會看得更清楚:沒有一道是萬靈丹,但它們各自把鏈上某一節變得更難偷渡。下面這張表把幾項防線並排,標出它各擋住攻擊鏈的哪一步、以及代價落在哪裡。

defensive measures vs. where each one cuts the chain

防線 擋住鏈上哪一步 代價/限制
--ignore-scripts 切斷第 4 步 prepare 自動點火——裝好依賴但不跑任何 hook 靠原生模組編譯的套件得手動補安裝步驟
先讀 scripts install 前先看 package.json 的 scripts 區塊,揪出 prepare/postinstall 指向哪 只擋寫在 package.json 裡的 hook,攔不住 require 進去才跑的 payload
拋棄式容器 就算引信點著,也燒在沒有 key/token/env 的空房間 建容器有摩擦;攻擊者仍可能偵測沙箱而靜默
review 分層 切斷第 3 步——把「在瀏覽器讀」與「在我 shell 裡 install」當兩件事 靠習慣維持,累或趕的一天最先被跳過

這張表攤開後,有一件事很難迴避:四道防線沒有一道是工具預設就替你開好的,全都得你主動選擇開啟,而它們要對抗的,恰恰是「先裝再說」這個不費腦力的預設。--ignore-scripts 與容器擋的是「引信點著之後」,先讀 scripts 與 review 分層擋的是「你決定 install 之前」;真正穩的做法是前後各放一道,而不是賭某一道夠強。而這場攻擊最後沒得逞,靠的並不是上面任何一道——作者當天兩手空空,只是剛好有餘裕停下來多想三秒。把這幾道閘門設成預設,等於不再把安危押在「那天你有沒有空多想」這件最靠不住的事情上。

The lessonnpm install 不是「把套件抓下來」,而是「在我的機器上執行陌生人寫的程式」;當你為了 review 一個來路不明的 repo 而裝它的依賴,--ignore-scripts 是你在那條鏈上唯一還握在手裡的閘門。