前言:一個「幫我看看 repo」的接案邀請
接案平台上來了一個看似正常的全端案子:一個既有的 Next.js + Node.js 網站,要我接手完成。對方很客氣地寄來 GitHub repo 邀請,請我「先看看 homepage、評估報價與里程碑」。
這是再普通不過的接案流程。但有一個習慣救了我:任何陌生的 repo,我都先靜態審查,絕不先 npm install 跑起來。 這次掃下去,在一個名字無害到不行的檔案 server/lib/serverStartup.js 裡,我看到了這輩子最不想在「客戶專案」裡看到的東西——eval()。
順著這條線往下挖,是一套設計相當精巧的隱寫術(steganography)後門:惡意碼完全不在 .js 檔裡,而是藏在 21 個國旗 SVG 圖檔的註解中。這篇文章完整拆解它的三層結構、實際行為,以及背後那套針對開發者的社交工程攻擊。
⚠️ 本文所有惡意網域均以資安界慣例 defang(
example[.]dev)處理,程式碼片段皆為去活化的分析用途,不含可直接執行的下載指令。
攻擊全貌:社交工程 + 隱寫術 + 多階段載入
這類攻擊在業界稱為 Contagious Interview(假面試/假接案),核心不是去駭你的伺服器,而是騙你親手把惡意碼跑在自己的開發機上。一個 npm run dev 就中。
整條感染鏈長這樣:
flowchart TD
A[接案邀請<br/>GitHub repo] --> B[npm run dev<br/>啟動 server]
B --> C[env.js 載入時<br/>呼叫 runServerStartupLogs]
C --> D[serverStartup.js<br/>eval Check validation]
D --> E[validation 讀取<br/>21 個國旗 SVG 註解]
E --> F[串接 + base64 解碼<br/>= 載入器]
F --> G[回報主機 + VM 偵測<br/>拉取各竊取模組]
G --> M1[剪貼簿挾持<br/>clipper]
G --> M2[瀏覽器錢包<br/>+ 憑證竊取]
G --> M3[檔案 / 密鑰<br/>外洩模組]
G --> M4[socket.io<br/>遠端 shell RAT]
classDef bait fill:#d1ecf1,stroke:#17a2b8,color:#0c5460
classDef trap fill:#fff3cd,stroke:#ffc107,color:#856404
classDef evil fill:#f8d7da,stroke:#dc3545,color:#721c24
class A,B bait
class C,D,E,F,G trap
class M1,M2,M3,M4 evil
精巧之處有兩層。第一層是偽裝:每個檔案單獨看都「像正常程式」——檔名叫 serverStartup、validation,函式做的事看起來是「讀國旗檔做驗證」。第二層是模組化:載入器本身很小,真正的惡意能力被拆成數個可獨立拉取的竊取模組,這正是 Contagious Interview(針對開發者的假面試/假接案)攻擊家族的典型架構。只有把整條鏈串起來,才看得出它是一套完整的竊取工具包。
第一層:一行就是全部的 eval
先看觸發點。server/lib/env.js(負責載入環境變數,每次啟動必經)裡藏了一行呼叫:
function loadEnv() {
runServerStartupLogs() // 名字像在「印啟動 log」,實則是後門入口
// ... 後面才是真正載入 .env 的正常程式碼
}
順著 runServerStartupLogs 進到 serverStartup.js,核心只有三行:
function runServerStartupLogs() {
try {
eval(Check(validation())) // ← 整個後門的引爆點
} catch (err) {} // ← 空 catch 吞掉所有錯誤
}
為什麼 eval + 空 catch 是最大紅旗
這兩個特徵放在一起,可疑度趨近 100%:
| 特徵 | 正常程式 | 這段後門 |
|---|---|---|
eval 動態執行字串 | 幾乎不需要(現代框架都有替代方案) | 用來執行解碼後的惡意碼 |
空的 try/catch {} | 罕見,通常至少要 log | 確保惡意碼出錯時開發者「完全無感」 |
| 資料來源 | 設定檔、環境變數 | 一個叫 validation() 的函式 |
為什麼要這樣設計? 因為攻擊者知道,開發者第一次跑專案時若噴出一堆錯誤,一定會去查。空 catch 讓後門即使在某個平台跑失敗,也悄無聲息,不破壞網站正常運作,維持偽裝。
關鍵問題來了:validation() 回傳的那串字串,到底從哪來?
第二層:藏在國旗 SVG 裡的 payload
validation() 是整套手法最巧妙的一環。它的程式碼乾淨到你會直接跳過:
function validation() {
const dir = path.join(process.cwd(), "public", "flags")
const files = fs.readdirSync(dir)
.filter((f) => f.endsWith(".svg"))
.sort((a, b) => a.localeCompare(b, "en")) // 固定排序 = 固定拼接順序
const parts = []
for (const f of files) {
const raw = fs.readFileSync(path.join(dir, f), "utf8")
const m = raw.match(/<!--\s*([\s\S]*?)\s*-->/) // 抓 HTML 註解內容
parts.push(m ? m[1].trim() : "")
}
return parts.join("") // 串成一整段
}
它做的事是:讀取 public/flags/ 下每個國旗 SVG,用正則抓出 HTML 註解 <!-- ... --> 裡的內容,依檔名排序串接起來。 那些國旗檔開頭都長這樣:
<!-- ZnVuY3Rpb24gYTBfMHg0MGE1KCl7Y29uc3QgX3g0YmZh... (一長串 base64) -->
<svg xmlns="http://www.w3.org/2000/svg">...正常的國旗圖形...</svg>
為什麼藏在 SVG 註解裡?
這是整起事件最值得學的一課——它完美規避了「只看程式碼」的審查:
| 審查方式 | 結果 |
|---|---|
grep -r "eval|child_process" --include=*.js | 只掃到 serverStartup.js 一行,看似孤立 |
看 .js 原始碼 | 全部乾淨,沒有任何可疑字串 |
看 public/flags/*.svg | 「不就是國旗圖嗎?」直接略過 |
npm audit / 掃 package.json | 無惡意套件,驗不出來 |
核心洞見:程式碼審查不能只看程式碼,還要看「資料是怎麼被消費的」。 一個會 eval 的函式,配上一個會「讀檔案內容回傳字串」的函式,這兩者一接起來,任何「資料檔」都可能變成程式碼。SVG 本質是 XML,天生支援註解,圖檔還能正常顯示——是隱寫術的完美載體。
更陰險的是那個自製的 Check() 函式。它其實就是一個 base64 解碼器,但刻意不用 atob() 也不用 Buffer.from(x, 'base64'):
function Check(str) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
// ... 手刻 6-bit 位元搬移的 base64 解碼邏輯
}
為什麼要重造輪子? 為了規避關鍵字掃描。很多簡易的惡意碼偵測規則會抓 atob、Buffer.from(..., 'base64') 這類字串。手刻一個解碼器,掃描器就抓不到「這裡在做 base64 解碼」。
第三層:解碼後的真面目——一套竊取工具包
把 SVG 註解串起來、base64 解碼後,是一段約 36KB、用 a0_0x 字串陣列手法混淆的 JavaScript。一開始我以為它只是個剪貼簿小偷,但把內嵌的字串陣列逐一靜態還原後才發現:它是一個載入器,底下掛著至少四個獨立的竊取模組。
🔑 拆解的關鍵安全原則:解碼 ≠ 執行。 base64 解碼、
\x跳脫還原,本質上只是「把密碼信翻譯成看得懂的文字」。我全程只用fs.readFileSync把惡意碼當純文字字串讀進來、跑 regex 撈關鍵字,從不eval、不require它。真正危險的是把它「跑起來」。靜態分析時,讀取與搜尋永遠安全,執行才致命。
載入器啟動後先做兩件事:連向 C2(Command and Control,命令與控制伺服器)網域 controller.rightwidth[.]dev、帶著固定的受害者識別碼打卡,並安裝一個設成 ignore 的 unhandledRejection handler——確保任何模組出錯都不會讓 process crash 而引起注意。接著,它把以下四個模組散播出去:
模組 1:剪貼簿挾持(clipper)
一個 500ms 輪詢一次的迴圈,用 pbpaste(Mac)/ powershell Get-Clipboard(Win)讀取剪貼簿,內容一變就 POST 到 /api/service/makelog。為什麼盯著剪貼簿? 因為開發者複製貼上加密貨幣錢包位址、密碼、token 的頻率極高。clipper 會在你貼上「轉帳收款位址」的瞬間把它換成攻擊者的位址——位址又長又亂,幾乎沒人逐字核對。
模組 2:瀏覽器錢包與憑證竊取
這是最直接衝著錢來的模組。它寫死了約 25 個加密貨幣錢包瀏覽器擴充套件的 ID(包含 MetaMask nkbihfbeogaeaoehlefnkodbefgpgknn、Phantom 等),逐一掃描 Chrome、Brave、Edge、Opera 的 user data 目錄,打包以下資料上傳到 upload.rightwidth[.]dev:
- 各錢包擴充的
Local Extension Settings(含錢包狀態、有時含種子) - 瀏覽器的
Login Data、Web Data(存密碼與自動填入) - macOS 的
login.keychain-db(系統鑰匙圈)
模組 3:檔案與密鑰外洩
這個模組會走訪你的家目錄,比對一份超過 100 種副檔名與數十個敏感目錄的清單,命中就用 FormData 上傳。看一眼它的目標清單,就知道殺傷力有多廣:
| 類別 | 目標(節錄) | 為什麼致命 |
|---|---|---|
| 雲端 / 遠端憑證 | .ssh、.aws、.azure、.gnupg、.docker | 直接拿到你的伺服器與雲端帳號 |
| 密鑰 / 環境變數 | *.env*、*.pem、*.key、*.secret | DB 密碼、API key、私鑰一次打包 |
| AI 工具設定 | .claude、.cursor、.gemini、.windsurf | 這些目錄常存 AI 服務的 API key |
| Shell 紀錄 | .bash_history、.zsh_history | 你手打過的密碼、token 都在裡面 |
| 原始碼 / 文件 | *.ts、*.js、.git、*.pdf、*.docx、*.xlsx | 連客戶專案與商業文件一起偷 |
⚠️ 特別點名
.claude、.cursor、.gemini——攻擊者很清楚現在開發者機器上都裝了 AI 編碼工具,而那些設定檔往往明文存著 API key。這份清單是為 2025-2026 年的開發者量身打造的。
模組 4:socket.io 遠端 shell(RAT, Remote Access Trojan,遠端存取木馬)
最危險的一個。它先靜默執行 npm install socket.io-client(用 --loglevel silent --no-save 不留痕跡),連上攻擊者的 socket.io 伺服器後,註冊一個 command 事件——對方傳什麼指令,它就 exec() 什麼,再把結果回傳。這等於在你的開發機上開了一個全權限的反向 shell:攻擊者可以即時翻你的檔案、裝更多東西、橫向移動到你連線過的伺服器。
它還用 pidFile(寫在 ~/.npm/ 下、檔名偽裝成 npm)做「防重複執行」,確保同時只有一個 RAT 實例常駐。
攻擊者的技術手法全解
撇開惡意目的,這套程式在「規避偵測」上的工程是值得拆解的。以下三個手法最具代表性。
手法 1:字串陣列輪轉混淆
整段 payload 看不到任何明文字串,全部變成 a0_0x22f8db(0x17b) 這種呼叫。原理是把所有字串集中到一個陣列,再透過一個帶偏移量的存取函式取用:
function a0_0x3d3f(idx) {
idx = idx - 0x157 // 固定偏移,讓索引值對不上陣列真實位置
return stringArray[idx]
}
更狡猾的是開頭那個 IIFE(Immediately Invoked Function Expression,立即執行函式)自我保護迴圈:它用一串 parseInt 算出校驗和,必須等於某個魔術數字,否則就把陣列 push(shift()) 旋轉一格再算——陣列的初始順序是「錯的」,要靠這個迴圈轉到正確位置才能用。
為什麼這樣設計? 一來明文字串全消失,grep 任何關鍵字(child_process、pbpaste)都撲空;二來如果分析者想砍掉那段「看起來沒用」的校驗迴圈,字串陣列就停在錯誤的旋轉位置,整個程式取到的全是錯字串——這是一種反竄改設計。
手法 2:VM(Virtual Machine,虛擬機)與沙箱偵測
RAT 模組連線前會先「驗明正身」,確認自己不是跑在分析用的虛擬機裡:
| 平台 | 偵測指令 | 比對關鍵字 |
|---|---|---|
| Windows | wmic computersystem get model,manufacturer | virtualbox、vmware、microsoft corporation |
| macOS | system_profiler SPHardwareDataType | vmware、parallels、virtual |
| Linux | 讀 /proc/cpuinfo | hypervisor、kvm、xen、qemu |
偵測到 VM 時,它不會停手,而是在回報給 C2 的資料裡標記 (VM)。為什麼不直接退出? 因為攻擊者要的是「人工分流」——真人受害者(標記 (Local))優先處理,疑似沙箱的((VM))放著或餵假指令,避免在分析環境裡暴露完整行為。這也是為什麼用乾淨 VM 跑惡意樣本,常常看不到它的全貌。
手法 3:Living off the land(就地取材)與偽裝
整套攻擊幾乎不帶自己的執行檔,而是借用系統與生態圈裡「本來就該存在」的工具:
- 用系統內建的
curl、wmic、powershell、pbpaste完成下載與竊取 - 用
npm install socket.io-client把一個正當的開源套件變成 RAT 的通訊層 - 落地檔案取名
npm-compiler.log、pidFile 偽裝成npm、Windows 木馬叫DHCPSvc.exe/printSvc.exe——全都混在開發環境的正常雜訊裡
核心思路:能用環境裡現成的東西,就不要帶自己的。 自帶執行檔容易被防毒比對特徵碼;借用 curl 和 npm 套件則幾乎無法用「這支程式是不是惡意」來判斷,因為它們本來就無害。
這是誰幹的?——歸因到 Contagious Interview (G1052)
把這套 TTP(Tactics, Techniques and Procedures,戰術、技術與程序)拿去跟公開威脅情報比對,吻合度高到不像巧合。它對應的是一個有正式編號的威脅集團:Contagious Interview(又名 DeceptiveDevelopment、DEV#POPPER),MITRE ATT&CK 編號 G1052,被多家資安公司歸因到北韓相關行為者。
| 我觀察到的 | G1052 已記載的行為 |
|---|---|
| Fiverr 假接案「幫我看 repo」 | 假徵才/假面試誘餌 |
GitHub repo 裡的隱寫術 JS(SVG 藏 eval) | 「PolinRider」子行動:在公開 repo 植入混淆 JS 投放 BeaverTail |
socket.io 的 command handler = 反向 shell | Stage-2 RAT 以 Socket.IO 通訊 |
| 瀏覽器錢包 + 登入 DB 竊取 | BeaverTail 的錢包/憑證竊取 |
| 剪貼簿 + 檔案 + 密鑰收集 | InvisibleFerret 的竊取模組 |
| VM 偵測(wmic / system_profiler / cpuinfo) | 此集團已知的沙箱規避 |
這帶出一個容易被忽略的事實:你不是被某個獨立駭客挑中,而是撞進一條工業化的生產線。 光是 2025 年起,研究者就追蹤到 1,700+ 個與此集團相關的惡意套件,散布在 npm、PyPI、Go、Rust、Packagist。所以前面那條 git 作者線索(一個拋棄式或盜用的身份)追不到真人是正常的——對手是有國家資源的團隊。
🔍 這對你的意義: 歸因不是為了「抓兇手」,而是為了知道該怎麼防、該向誰通報。確認是 G1052 後,你就知道這是執法單位(FBI IC3、台灣 165)正在追的案子,你的樣本對他們有情報價值,而不是默默刪掉了事。
動手做:安全拆解可疑 payload 的 SOP(Standard Operating Procedure,標準作業程序)
理解原理之後,這裡給一套可以實際操作、且全程安全的靜態分析流程。核心心法只有一句:把惡意碼當「死字串」讀,永遠不要讓它「活過來」執行。
Step 1:掃描危險原語,建立第一印象
進到 repo 先別裝、別跑,直接 grep 高危關鍵字:
grep -rEn "eval\(|new Function|child_process|execSync|\.exec\(" \
--include="*.js" --include="*.ts" .
掃到 eval( 就要提高警覺,順著它的參數往回追來源。這次的案例會把你帶到 serverStartup.js 的 eval(Check(validation()))。
Step 2:別漏掉「資料檔」
grep 乾淨不代表安全——payload 可能藏在 assets 裡。檢查圖檔、JSON、設定檔有沒有異常大的註解或不該存在的 base64:
# 找出 SVG 裡的 HTML 註解(隱寫術常見藏身處)
grep -l "<!--" public/**/*.svg
# 看某個檔案註解內容有多長(正常國旗圖不會有上千字元的註解)
grep -oE "<!--.*-->" public/flags/us.svg | wc -c
Step 3:解碼,但「絕不 eval」
這是最關鍵、也最容易出錯的一步。要看 payload 內容,只做字串轉換——讀檔、base64 解碼、寫到另一個檔——而不是丟給 node 執行。下面這支小工具示範「安全解碼」的正確姿勢:
// decode.js —— 只做字串轉換,沒有 eval / require 惡意碼
const fs = require("fs")
// 1. 把可疑內容「當文字」讀進來
const b64 = extractCommentsFromSvgs("public/flags") // 你自己的擷取邏輯
// 2. base64 解碼 = 翻譯,不是執行
const decoded = Buffer.from(b64, "base64").toString("utf8")
// 3. 寫到檔案用編輯器看,千萬不要 eval(decoded)
fs.writeFileSync("/tmp/payload-readonly.txt", decoded)
console.log("已解碼,請用編輯器開啟檢視,不要執行它")
☠️ 唯一的紅線: 看到混淆碼很想「跑跑看輸出什麼」——這就是攻擊者賭你會犯的錯。
eval(decoded)、node payload.js、require('./payload')全都等於引爆。要觀察行為,請在斷網的拋棄式 VM 裡跑,不要在你的主力開發機上。
Step 4:撈 IOC(Indicator of Compromise,入侵指標)但不要連線驗證
從解碼後的文字裡用 regex 撈出網域、URL、端點,整理成 IOC 清單:
grep -oE "https?://[a-zA-Z0-9./_-]+|/api/[a-zA-Z/]+" /tmp/payload-readonly.txt | sort -u
撈到 rightwidth.dev 這類可疑網域時,不要手癢用瀏覽器或 curl 去戳。主動連線等於暴露你的 IP、打草驚蛇、甚至真的下載到執行檔。蒐證只需要「字串」,記錄下來、通報即可。
防禦:陌生 repo 的靜態審查心法
前面「動手做」是具體步驟,這裡收斂成三條能內化成肌肉記憶的判斷原則。
雷 1:先 clone,但「絕不先跑」
最大的錯誤就是拿到 repo 就 npm install && npm run dev。這套後門正是賭你會這麼做。安裝與啟動 = 把方向盤交給對方。 要跑,也只在拋棄式 VM/容器、無敏感資料、無錢包的沙箱環境裡跑。
雷 2:grep 是第一道篩網,不是結論
grep 危險原語能抓到「笨」的惡意碼,但這次的案例證明——grep 乾淨不代表安全。手刻 base64 解碼器、把 payload 藏進 SVG 註解,就是專門繞過關鍵字掃描的。掃完之後,還要往資料檔裡看。
雷 3:追蹤「資料如何變成程式碼」
這是最核心的一條。永遠問自己:這個 repo 裡,有沒有任何路徑能讓「資料檔的內容」流進「會執行字串的函式」? 一旦看到 eval、new Function()、vm.runInContext(),就往回追它的參數來源。只要來源是「讀檔案/讀網路」,就是高危。
好習慣 vs 壞習慣
| 情境 | ❌ 壞習慣 | ✅ 好習慣 |
|---|---|---|
| 拿到陌生 repo | 直接 npm install 跑跑看 | 先靜態審查,sandbox 才執行 |
看到乾淨的 .js | 「沒問題」就放行 | 連 public/、assets、設定檔一起看 |
| 解可疑 payload | 用 node 跑跑看輸出 | 只做字串解碼,永不 eval |
| 遇到可疑網域 | 用瀏覽器或 curl 點開看看 | 只記字串 IOC,絕不主動連線 |
| 中招後 | 只刪 repo 就當沒事 | 視為憑證外洩:換密碼、輪替金鑰、查錢包 |
⚠️ 萬一你已經
npm run dev跑過這類 repo,請當作憑證已外洩處理:輪替所有 SSH 金鑰與雲端 access key、改掉瀏覽器存的密碼、檢查加密貨幣錢包、把那台機器當成不可信。後門最不缺的就是耐心。
IOC(防禦指標)
若你也收到類似的接案/面試 repo,可比對以下指標(皆已 defang):
| 類型 | 指標 |
|---|---|
| C2 網域 | controller.rightwidth[.]dev、upload.rightwidth[.]dev、file.rightwidth[.]dev |
| 外洩端點 | /api/service/makelog、/api/service/process/、/upload、/cldbs |
| 落地檔名 | hostService.exe、DHCPSvc.exe、printSvc.exe、npm-compiler.log、~/.npm/ 下偽裝成 npm 的 pidFile |
| 惡意檔案 | server/lib/serverStartup.js、server/lib/validation.js、public/flags/*.svg(含 HTML 註解者) |
| 竊取目標 | .ssh、.aws、.azure、.gnupg、*.env*、*.pem、.claude、.cursor、.gemini、瀏覽器錢包擴充、login.keychain-db |
| 手法特徵 | SVG 註解隱寫術、手刻 base64、字串陣列輪轉混淆、VM 偵測標記、socket.io RAT、eval + 空 catch |
結語
這起事件最深的一課,不是某個 API 怎麼用,而是兩個認知。第一,安全審查的對象不是「程式碼」,而是「資料 + 程式碼的交互」——一段乾淨的 validation()、一批正常顯示的國旗圖、一行被空 catch 包住的 eval,單獨都無害,組起來就是載入器。第二,這不是一支病毒,而是一套針對開發者精心設計的竊取工具包:clipper 偷錢、模組偷錢包與雲端憑證、RAT 給對方一個反向 shell,連 .claude、.cursor 這些 AI 工具設定都在目標清單上。它賭的就是「開發者拿到 repo 會直接 npm run dev」。
對接案/遠端工作者而言,可執行的底線只有一條:陌生 repo 一律先靜態審查,要跑就進沙箱。 你的開發機裡有 SSH key、雲端憑證、瀏覽器 session、AI 工具的 API key、甚至錢包——它的價值,遠超過任何一個案子的報酬。
