10 月 29 號早上,一個叫 Ridham Parmar 的工程師打開 GitHub,發現自己的賬號在一夜之間往 9 個倉庫裏強推了 99 個分支。推送發生在凌晨 0 點 33 分到 0 點 49 分之間,那會兒他正在睡覺。每一筆提交都留着他的名字、他寫的提交信息和原來的日期。你上一次檢查自己的賬號有沒有替你推過你沒推過的東西,是什麼時候?
這個蠕蟲叫 PolinRider。被它打中的團隊叫 Improwised,他們在 10 月 31 號把整個經過寫了出來。我寫這篇,就是因爲他們肯把時間線原原本本貼出來,而不是藏起來,所以別人能順着事情發生的順序學到東西。
入侵時間線
事情是從 10 月 1 號開始的。那一天,他們那個開源答題應用的 20 個分支先被強推了一遍,推的人顯示的是一個同事的賬號。
到了 10 月 4 號,第二個賬號又往他們官網倉庫的 20 個分支強推了一遍。也是在同一天,一個外面來的研究員在公開 issue 裏報了這個惡意代碼,因爲他們公司的安全郵箱根本收不進他那封郵件。研究員隨後又提了一個 PR,想把惡意代碼刪掉,但是這個 PR 當天就被關了,沒有合進去。
10 月 8 號,第三個開發者克隆了官網倉庫,還跑了一下它的開發服務器,蠕蟲就這樣裝到了他的筆記本上。
到了 10 月 28 號,一次 GitHub CLI 登錄把一把能動所有倉庫的 token 放到了那臺已經被感染的筆記本上。第二天凌晨,蠕蟲就用這把 token 把那 99 個分支強推了一遍。
總共算下來,10 個倉庫裏的 139 個分支,都是從 3 個開發者的賬號推出去的,而蠕蟲在他們的倉庫裏待了 28 天。就他們目前所知,還沒有客戶的系統和數據受到影響。

蠕蟲留下的痕跡
139 個分支聽起來就是整件事,但是先別盯着這個數看。比起這個數,我讀完他們的文章之後忘不掉的,是這個蠕蟲留下的痕跡有多麼少。
它改的是每個分支上最新的一筆提交,改完再強推上去,而提交信息、作者和日期全都原樣留着。所以你打開 git log 去看,被改過的那筆提交跟原來那筆長得一模一樣。
提交日期在這裏一點用都沒有,因爲蠕蟲把原來的日期也留下來了。所以唯一可信的時鐘是服務器那邊的推送時間,得去 GitHub 的活動日誌和強推記錄裏查。

惡意代碼的藏身處
惡意代碼本身就藏在明處。它是一段混淆過的 JavaScript 代碼,接在一個本來就有的行尾後面,前面墊了幾百個空格。
它藏的地方,是項目在跑 install、dev、build 或者 test 的時候會加載的那幾個配置文件。文件名一共五個:svelte.config.js、postcss.config.js、nuxt.config.ts,還有 next.config.js 和 server.ts。
在編輯器或者 diff 界面裏看,這一行看着完全正常,除非你一路往右滾到底。
命令通道與常駐
它要聯繫的那個服務器地址,也不是一個能封掉的域名,因爲惡意代碼是從以太坊和 BNB 智能鏈的交易記錄裏讀出這個地址的,走的是公開的 RPC 服務,所以你提前沒東西可封。
它還騎在你每天要跑上百次的工具上。在一個開發者的筆記本上,惡意代碼把自己寫進了 npm 自己的 lib/cli.js。所以從那以後,每一次 npm 或者 npx 命令都會把它重新啓動一遍,那個開機自啓的工具也不例外。這就是爲什麼他們給團隊的第一條指令是先別再跑 npm。

CI 構建機
你的構建機器也會把這個東西跑起來,而這一層是大多數團隊從來沒想過的。惡意代碼就住在構建過程要加載的配置文件裏,所以只要有一次提交是被感染的,那一次的 CI 構建就會在構建機器上把它跑一遍。靜態站點的構建也一樣,因爲它加載的是同一批配置文件。
CI 就是你每次推送代碼之後,幫你構建和測試代碼的那臺機器。在這件事裏,CI 拿被改過的提交構建並且推送了兩個容器鏡像,其中一個裏面就裝着蠕蟲的遠程控制工具包。
那個鏡像最後沒在他們的集羣裏跑起來,因爲那個容器只用 nginx 提供靜態文件,但是他們還是把兩個鏡像都當成已經被污染處理,直接從 Docker Hub 上刪掉了。
他們的官網構建從 10 月 4 號開始就一直在加載被感染的配置文件。但是惡意代碼是衝着構建機器去的,不是衝着頁面去的,所以他們檢查了線上站點,裏面確實沒有。郵箱的憑證也很可能沒暴露,因爲這些憑證放在 Cloudflare Workers 裏當作運行時機密,而不是放在構建環境裏。
被波及的流水線機密
所以這件事的波及面比代碼本身大得多。因爲蠕蟲是騎着構建過程走的,而構建過程能看到你流水線裏的所有機密:鏡像倉庫的密碼、部署用的密鑰、放在 CI 變量裏的 API token。
只要你的構建跑過一次被感染的提交,那麼這些東西就全得輪換一遍,而且所有的鏡像和部署都要拿乾淨的提交重新構建。

錯過的機會
他們其實有三次機會可以早點抓住它,而且他們都老老實實寫出來了。10 月 4 號,那個外面來的研究員就已經在公開 issue 裏,把他們 10 月 29 號才拼出來的大部分結論交到他們手裏了,整整早了 25 天。可是那個想刪掉惡意代碼的 PR,同一天就被關了。
到了 10 月 13 號,一個升級依賴的 PR 把惡意代碼從一個項目的一個分支裏刪掉了。但是沒有人接着問下去,這段代碼是怎麼進來的、是哪個賬號推的,還有哪些分支也有。他們自己對這次修復的說法是,它給他們爭取了 16 天。
你要是發現了被注入的代碼,那麼就別把它當成一個 bug 來修,而要把它當成一次事件來處理:先找到推它的那個賬號,然後再去找這個賬號還能推到的所有地方。
第一條規矩
這是我會給任何一個團隊立的第一條規矩。一筆提交掛着你的名字,不代表就是你寫的,因爲蠕蟲會把名字、提交信息和日期都原樣留下來。而主分支幹乾淨淨,也說明不了其他分支沒事,因爲蠕蟲會碰它能推到的每一個分支。
蠕蟲能在他們的倉庫裏待 28 天,是因爲該跟的線索一次都沒有人跟到底。10 月 4 號那次,關掉的是一個能刪掉惡意代碼的 PR。10 月 13 號那次,刪掉的是一個分支上的一段代碼。兩次都沒有人順着賬號往下跟。
自己動手的三項檢查
我自己這週也會從賬號這條線查起。第一件,查活動日誌,而不是提交日誌。去找團隊裏沒有人記得的強推記錄,而且要看推送時間,因爲提交日期是僞造的。
第二件,查那些特別長的行。去配置文件裏搜行尾墊了 150 個以上空格的代碼,還要搜惡意代碼留下的標記,也就是一個被設成 global.o=‘N-NNNN-du’ 這種樣子的全局變量。
第三件,查進程。看 npm 命令跑完之後,是不是還掛着一個脫離出來的 node -e 進程,還要看那些在你不認識的推送之後開始失敗的 CI 構建。
他們把一個只讀的掃描器開源了出來。它會檢查一臺機器上的每一個分支、標籤和暫存區,也會檢查進程和網絡連接。它不會運行它掃到的任何代碼。
本該早做的防範
而那些本來可以在第一天就攔住它的改動,都是些無聊的東西,所以才一直沒人去做。在默認分支和發佈分支上禁止強推。把審計日誌裏的強推事件接上告警,這樣一次大規模推送當天夜裏就會有人被叫起來,而不是等到第二天早上再發現。在受保護的分支上要求提交簽名。
把那些能推所有地方的長期 token 和 SSH 密鑰換掉,改成短期的、權限收得很窄的 token。拿不準的代碼只放在一次性的虛擬機裏跑,永遠別放在工作用的機器上,面試題和別人讓你「隨便看一眼」的倉庫也一樣算。
機器要是已經中了,就別在原地清理。先把它斷開,再從另一臺乾淨的機器上,把這臺機器存過的所有憑證都輪換一遍。最後把整臺機器重裝。
幕後歸因
知道是誰幹的,對查案的人有幫助,但是對你沒什麼用,因爲這個辦法換誰來抄都管用。這個蠕蟲是 OpenSourceMalware 團隊認定跟朝鮮有關的,而且跟「Contagious Interview」那種假面試的騙局活動連在一起。
我寫了 24 年代碼,這種事看得多了。這件事裏最讓我在意的,是每一次強推其實都記在 GitHub 的活動日誌裏,第一天夜裏就記下了,可是 28 天裏沒有人去翻過那個日誌。要是那些強推事件接了告警,10 月 1 號那天夜裏就會有人被叫起來。
要是這篇讓你去翻了自己的活動日誌,歡迎訂閱這個博客,或者先收藏起來。我會接着寫 AI、安全和開源這幾塊的實事,下一篇還是一個你能自己動手查的東西。
來源:Improwised 的事件文章 https://www.improwised.com/blog/polinrider-malware-infection-cleanup/ ;掃描器 https://github.com/Improwised/polinrider-scanner