前言:一個「幫我看看 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

精巧之處有兩層。第一層是偽裝:每個檔案單獨看都「像正常程式」——檔名叫 serverStartupvalidation,函式做的事看起來是「讀國旗檔做驗證」。第二層是模組化:載入器本身很小,真正的惡意能力被拆成數個可獨立拉取的竊取模組,這正是 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 解碼邏輯
}

為什麼要重造輪子? 為了規避關鍵字掃描。很多簡易的惡意碼偵測規則會抓 atobBuffer.from(..., 'base64') 這類字串。手刻一個解碼器,掃描器就抓不到「這裡在做 base64 解碼」。


第三層:解碼後的真面目——一套竊取工具包

把 SVG 註解串起來、base64 解碼後,是一段約 36KB、用 a0_0x 字串陣列手法混淆的 JavaScript。一開始我以為它只是個剪貼簿小偷,但把內嵌的字串陣列逐一靜態還原後才發現:它是一個載入器,底下掛著至少四個獨立的竊取模組

🔑 拆解的關鍵安全原則:解碼 ≠ 執行。 base64 解碼、\x 跳脫還原,本質上只是「把密碼信翻譯成看得懂的文字」。我全程只用 fs.readFileSync 把惡意碼當純文字字串讀進來、跑 regex 撈關鍵字,從不 eval、不 require。真正危險的是把它「跑起來」。靜態分析時,讀取與搜尋永遠安全,執行才致命。

載入器啟動後先做兩件事:連向 C2(Command and Control,命令與控制伺服器)網域 controller.rightwidth[.]dev、帶著固定的受害者識別碼打卡,並安裝一個設成 ignoreunhandledRejection 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 DataWeb Data(存密碼與自動填入)
  • macOS 的 login.keychain-db(系統鑰匙圈)

模組 3:檔案與密鑰外洩

這個模組會走訪你的家目錄,比對一份超過 100 種副檔名與數十個敏感目錄的清單,命中就用 FormData 上傳。看一眼它的目標清單,就知道殺傷力有多廣:

類別目標(節錄)為什麼致命
雲端 / 遠端憑證.ssh.aws.azure.gnupg.docker直接拿到你的伺服器與雲端帳號
密鑰 / 環境變數*.env**.pem*.key*.secretDB 密碼、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_processpbpaste)都撲空;二來如果分析者想砍掉那段「看起來沒用」的校驗迴圈,字串陣列就停在錯誤的旋轉位置,整個程式取到的全是錯字串——這是一種反竄改設計。

手法 2:VM(Virtual Machine,虛擬機)與沙箱偵測

RAT 模組連線前會先「驗明正身」,確認自己不是跑在分析用的虛擬機裡:

平台偵測指令比對關鍵字
Windowswmic computersystem get model,manufacturervirtualboxvmwaremicrosoft corporation
macOSsystem_profiler SPHardwareDataTypevmwareparallelsvirtual
Linux/proc/cpuinfohypervisorkvmxenqemu

偵測到 VM 時,它不會停手,而是在回報給 C2 的資料裡標記 (VM)為什麼不直接退出? 因為攻擊者要的是「人工分流」——真人受害者(標記 (Local))優先處理,疑似沙箱的((VM))放著或餵假指令,避免在分析環境裡暴露完整行為。這也是為什麼用乾淨 VM 跑惡意樣本,常常看不到它的全貌

手法 3:Living off the land(就地取材)與偽裝

整套攻擊幾乎不帶自己的執行檔,而是借用系統與生態圈裡「本來就該存在」的工具:

  • 用系統內建的 curlwmicpowershellpbpaste 完成下載與竊取
  • npm install socket.io-client 把一個正當的開源套件變成 RAT 的通訊層
  • 落地檔案取名 npm-compiler.log、pidFile 偽裝成 npm、Windows 木馬叫 DHCPSvc.exeprintSvc.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 = 反向 shellStage-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.jseval(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.jsrequire('./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 裡,有沒有任何路徑能讓「資料檔的內容」流進「會執行字串的函式」? 一旦看到 evalnew Function()vm.runInContext(),就往回追它的參數來源。只要來源是「讀檔案/讀網路」,就是高危。

好習慣 vs 壞習慣

情境❌ 壞習慣✅ 好習慣
拿到陌生 repo直接 npm install 跑跑看先靜態審查,sandbox 才執行
看到乾淨的 .js「沒問題」就放行public/、assets、設定檔一起看
解可疑 payloadnode 跑跑看輸出只做字串解碼,永不 eval
遇到可疑網域用瀏覽器或 curl 點開看看只記字串 IOC,絕不主動連線
中招後只刪 repo 就當沒事視為憑證外洩:換密碼、輪替金鑰、查錢包

⚠️ 萬一你已經 npm run dev 跑過這類 repo,請當作憑證已外洩處理:輪替所有 SSH 金鑰與雲端 access key、改掉瀏覽器存的密碼、檢查加密貨幣錢包、把那台機器當成不可信。後門最不缺的就是耐心。


IOC(防禦指標)

若你也收到類似的接案/面試 repo,可比對以下指標(皆已 defang):

類型指標
C2 網域controller.rightwidth[.]devupload.rightwidth[.]devfile.rightwidth[.]dev
外洩端點/api/service/makelog/api/service/process//upload/cldbs
落地檔名hostService.exeDHCPSvc.exeprintSvc.exenpm-compiler.log~/.npm/ 下偽裝成 npm 的 pidFile
惡意檔案server/lib/serverStartup.jsserver/lib/validation.jspublic/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、甚至錢包——它的價值,遠超過任何一個案子的報酬。