Sáng thứ Hai, bạn nhận email khẩn cấp: “Database bị xóa sạch, 50,000 thông tin khách hàng bị đánh cắp!”. Kiểm tra log, bạn thấy một dòng truy vấn lạ: SELECT * FROM products WHERE id = '1' UNION SELECT username, password FROM users--'. Chỉ một dấu ' mà người dùng gõ vào ô tìm kiếm đã kéo cả bảng users ra ngoài — đó là SQL Injection.
Injection không dừng ở SQL. Command Injection cho phép chạy lệnh hệ thống, còn XSS đánh cắp session của người dùng. Bài viết này dựng lại từng đòn tấn công bằng Node.js chạy được thật — bạn tự curl vào server để thấy dữ liệu rò ra — rồi vá lần lượt. Mỗi ví dụ là một file Node hoàn chỉnh: lưu lại và chạy bằng node.
1. Injection là gì và tại sao nguy hiểm?
Injection xảy ra khi ứng dụng ghép dữ liệu người dùng thẳng vào một câu lệnh — SQL query, shell command, hay HTML — mà không tách bạch đâu là lệnh, đâu là dữ liệu. Khi ranh giới đó biến mất, một chuỗi input được viết khéo sẽ thoát ra khỏi vùng “dữ liệu” và trở thành “lệnh” mà server ngoan ngoãn thực thi.
Trong OWASP Top 10, Injection xếp hạng A03:2021 — từng giữ vị trí số 1 giai đoạn 2010–2017, nay đứng sau Broken Access Control. Dù không còn top 1, đây vẫn là một trong những lỗ hổng phổ biến và có sức phá hoại lớn nhất, vì gốc rễ của nó lặp lại ở khắp nơi: lập trình viên tin dữ liệu người dùng.
1.1. Các loại Injection phổ biến
Bài viết đi qua ba loại thường gặp nhất trong một ứng dụng web:
- SQL Injection — thay đổi câu truy vấn database (mục 2)
- Command Injection — chạy lệnh hệ điều hành (mục 3)
- XSS — chèn JavaScript độc hại vào trang (mục 4)
Ba loại khác nhau về bề mặt, nhưng chung một nguyên nhân và chung một hướng phòng thủ.
1.2. Điểm chung của mọi cuộc tấn công Injection
View Mermaid diagram code
flowchart TD
Start([Kẻ tấn công]) --> Input[Nhập dữ liệu độc hại]
Input --> Check{App có tách<br/>lệnh và dữ liệu?}
Check -->|Không| Inject[Input trở thành lệnh<br/>trong query/shell/HTML]
Check -->|Có| Block[Bị chặn: input chỉ là dữ liệu]
Inject --> Execute[Server thực thi mã của kẻ tấn công]
Execute --> Impact[Đánh cắp data · Xóa database · Chiếm server]
style Inject fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
style Execute fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
style Impact fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
style Block fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50Nguyên nhân gốc rễ: ứng dụng nối trực tiếp input vào câu lệnh. Hướng phòng thủ gốc rễ: tách bạch lệnh và dữ liệu — prepared statement cho SQL, spawn với arguments riêng cho shell, output encoding cho HTML. Bốn mục dưới đây đều xoay quanh đúng ý tưởng đó.
2. SQL Injection - Lỗ hổng nguy hiểm nhất
SQL Injection cho phép kẻ tấn công đổi câu truy vấn database, dẫn tới đánh cắp toàn bộ dữ liệu, xóa table, hoặc bypass authentication. Đây là loại injection dễ khai thác và hậu quả nặng nhất, nên ta dựng nó chạy thật trước.
2.1. Cách SQL Injection hoạt động
Giả sử một ô tìm kiếm sản phẩm ghép thẳng id từ URL vào câu SQL. Đây là file đầy đủ (npm install express, Node ≥ 22.5 cho node:sqlite), có sẵn hai route: /search-unsafe ghép chuỗi (dính lỗi) và /search-safe dùng prepared statement (bản vá, phân tích ở mục 2.4). Lưu thành sql-injection.js:
▶ Chạy thử ở máy bạn: injection/sql-injection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const express = require("express");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE products (id INTEGER, name TEXT);
INSERT INTO products VALUES (1, 'Ao thun'), (2, 'Quan jean'), (3, 'Non');
CREATE TABLE users (id INTEGER, username TEXT, password TEXT);
INSERT INTO users VALUES (1, 'admin', 's3cr3t');
`);
const app = express();
// ❌ Ghép chuỗi trực tiếp — input đóng được dấu nháy rồi viết thêm SQL
app.get("/search-unsafe", (req, res) => {
const sql = `SELECT id, name FROM products WHERE id = '${req.query.id}'`;
res.json({ query: sql, rows: db.prepare(sql).all() });
});
// ✅ Prepared statement — '?' là dữ liệu, không bao giờ là cấu trúc query
app.get("/search-safe", (req, res) => {
const rows = db
.prepare("SELECT id, name FROM products WHERE id = ?")
.all(req.query.id);
res.json({ rows });
});
app.listen(4002);Chạy node sql-injection.js, rồi gọi bằng curl -G (để tự URL-encode dấu nháy và khoảng trắng). Với input bình thường, mọi thứ đúng như mong đợi:
1
curl -s -G http://localhost:4002/search-unsafe --data-urlencode "id=1"1
2
3
4
{
"query": "SELECT id, name FROM products WHERE id = '1'",
"rows": [{ "id": 1, "name": "Ao thun" }]
}Bây giờ gửi 1' OR '1'='1. Dấu ' đóng chuỗi giá trị, phần OR '1'='1' biến điều kiện thành luôn đúng:
1
curl -s -G http://localhost:4002/search-unsafe --data-urlencode "id=1' OR '1'='1"1
2
3
4
5
6
7
8
{
"query": "SELECT id, name FROM products WHERE id = '1' OR '1'='1'",
"rows": [
{ "id": 1, "name": "Ao thun" },
{ "id": 2, "name": "Quan jean" },
{ "id": 3, "name": "Non" }
]
}Toàn bộ bảng products trả về. Trường query in kèm cho thấy chính xác câu SQL mà kẻ tấn công đã viết lại. Đây là bản chất của mọi SQLi: input không còn là dữ liệu, nó đã trở thành một phần cấu trúc của query.
2.2. Nâng cấp: UNION lôi cả bảng khác ra ngoài
OR '1'='1' mới chỉ trả về nhiều dòng của cùng một bảng. UNION SELECT nguy hiểm hơn — nó gắn kết quả của một query thứ hai (trên bảng khác) vào chung output. Vẫn server đó, gửi 1' UNION SELECT username, password FROM users--:
1
curl -s -G http://localhost:4002/search-unsafe --data-urlencode "id=1' UNION SELECT username, password FROM users--"1
2
3
4
5
6
7
{
"query": "SELECT id, name FROM products WHERE id = '1' UNION SELECT username, password FROM users--'",
"rows": [
{ "id": 1, "name": "Ao thun" },
{ "id": "admin", "name": "s3cr3t" }
]
}Dòng cuối — {"id":"admin","name":"s3cr3t"} — là username và password lấy thẳng từ bảng users, hiển thị lẫn vào danh sách sản phẩm. Hai chi tiết khiến đòn này thành công:
- Số cột phải khớp.
productstrả 2 cột (id,name), nênUNION SELECTcũng phải chọn đúng 2 cột. Kẻ tấn công dò số cột này bằngORDER BYhoặcUNION SELECT NULL, NULL. --comment out phần đuôi. Dấu'thừa mà server nối vào cuối query bị--biến thành comment, nên câu SQL vẫn hợp lệ.
2.3. Luồng tấn công SQL Injection
View Mermaid diagram code
sequenceDiagram
participant Attacker as Kẻ Tấn Công
participant Server as Web Server
participant DB as Database
Attacker->>Server: GET /search?id=1' UNION SELECT username,password FROM users--
Note over Server: Ghép chuỗi thẳng vào query
Server->>DB: SELECT ... WHERE id='1' UNION SELECT username,password FROM users--'
DB-->>Server: products + cả username/password
Server-->>Attacker: Trả về dữ liệu nhạy cảm
Note over Attacker,DB: Có thể nâng cấp: blind dò từng bit,<br/>second-order, bypass login2.4. Phòng tránh SQL Injection
Giải pháp 1: Prepared statement (khuyến nghị)
Cách chữa gốc là không bao giờ nối input vào câu SQL. Prepared statement gửi cấu trúc query và tham số tới database như hai thứ tách biệt: dấu ? là chỗ điền dữ liệu, và dữ liệu đó không bao giờ được diễn giải thành SQL. Route /search-safe trong file ở mục 2.1 chính là bản vá đó — db.prepare("... WHERE id = ?").all(req.query.id). Gửi đúng payload đã kéo cả bảng products ra:
1
2
3
4
5
curl -s -G http://localhost:4002/search-safe --data-urlencode "id=1' OR '1'='1"
# {"rows":[]}
curl -s -G http://localhost:4002/search-safe --data-urlencode "id=1"
# {"rows":[{"id":1,"name":"Ao thun"}]}Lần này 1' OR '1'='1 trả về []. Database đi tìm sản phẩm có id bằng đúng chuỗi ký tự "1' OR '1'='1" — không có, nên không dòng nào khớp. Toàn bộ payload được coi là một giá trị, không phải SQL. Với database thật (mysql2, pg…), dấu ? (hay $1) cũng là placeholder cho tham số theo đúng cơ chế này.
Giải pháp 2: ORM
ORM sinh prepared statement giúp bạn, nên hầu hết truy vấn thông thường an toàn mặc định. Chạy thử với Prisma trên một MySQL thật — dựng nhanh bằng Docker (chờ vài chục giây để MySQL khởi động xong):
1
2
docker run --name blog-mysql -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=shop -p 3306:3306 -d mysql:8
docker exec blog-mysql sh -c 'until mysql -uroot -psecret -e "SELECT 1" >/dev/null 2>&1; do sleep 2; done' # chờ MySQL nhận đúng mật khẩu rootPrisma 7 đưa connection URL ra khỏi schema nên cần ba file. schema.prisma:
1
2
3
4
5
6
7
8
9
10
11
12
13
generator client {
provider = "prisma-client-js"
output = "./generated"
}
datasource db {
provider = "mysql"
}
model Product {
id Int @id
name String
}prisma.config.ts — nơi Prisma 7 đọc connection URL:
1
2
3
4
5
6
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "schema.prisma",
datasource: { url: process.env.DATABASE_URL! },
});sql-prisma.js — truyền adapter MySQL vào PrismaClient:
▶ Chạy thử ở máy bạn: injection/prisma/sql-prisma.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { PrismaMariaDb } = require("@prisma/adapter-mariadb");
const { PrismaClient } = require("./generated/client");
const adapter = new PrismaMariaDb(process.env.DATABASE_URL);
const prisma = new PrismaClient({ adapter });
async function main() {
await prisma.product.deleteMany();
await prisma.product.createMany({
data: [
{ id: 1, name: "Ao thun" },
{ id: 2, name: "Quan jean" },
{ id: 3, name: "Non" },
],
});
// id được truyền như tham số, không ghép chuỗi
const product = await prisma.product.findUnique({ where: { id: 1 } });
console.log("findUnique(id=1):", JSON.stringify(product));
await prisma.$disconnect();
}
main();Cài dependencies, sinh client rồi chạy:
1
2
3
4
5
6
7
8
npm install @prisma/client @prisma/adapter-mariadb
npm install -D prisma
export DATABASE_URL="mysql://root:secret@127.0.0.1:3306/shop"
npx prisma db push # tạo bảng Product từ schema
npx prisma generate # sinh client vào ./generated
node sql-prisma.js
# findUnique(id=1): {"id":1,"name":"Ao thun"}findUnique sinh prepared statement dưới nền: id đi vào query như tham số, không bao giờ ghép chuỗi. Các ORM phổ biến trong hệ Node: Prisma, Sequelize, TypeORM, Drizzle. Lưu ý ORM vẫn hở nếu bạn dùng lối “raw query” và tự nối chuỗi (prisma.$queryRawUnsafe, sequelize.query với string interpolation) — lúc đó bạn quay lại đúng lỗ hổng ban đầu.
Giải pháp 3: Validate input đúng kiểu
Prepared statement chống được injection rồi, nhưng validate thêm giúp loại bỏ input vô nghĩa sớm và rẻ. Lưu thành sql-validate.js (Node ≥ 22.5 cho node:sqlite):
▶ Chạy thử ở máy bạn: injection/sql-validate.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE products (id INTEGER, name TEXT);
INSERT INTO products VALUES (1, 'Ao thun'), (2, 'Quan jean'), (3, 'Non');
`);
function getProduct(productId) {
if (!/^\d+$/.test(productId)) {
throw new Error("Invalid product ID");
}
return db
.prepare("SELECT id, name FROM products WHERE id = ?")
.all(Number(productId));
}
console.log("Hợp lệ (id=1) :", JSON.stringify(getProduct("1")));
try {
getProduct("1 OR 1=1");
} catch (e) {
console.log("Bị chặn (1 OR 1=1) :", e.message);
}Chạy node sql-validate.js:
1
2
Hợp lệ (id=1) : [{"id":1,"name":"Ao thun"}]
Bị chặn (1 OR 1=1) : Invalid product IDInput sai kiểu bị chặn ngay, trước cả khi chạm database. Nhưng validate là lớp bổ sung, không thay thế prepared statement — có những trường (tên, địa chỉ, comment) buộc phải nhận ký tự tự do, và ở đó chỉ prepared statement mới cứu được bạn.
Giải pháp 4: Least privilege cho DB user
Nếu account mà app dùng để kết nối database chỉ có đúng quyền cần thiết, thì kể cả khi SQLi lọt qua, thiệt hại cũng bị giới hạn. Thử ngay trên container blog-mysql đã dựng ở Giải pháp 2. Dùng root để tạo bảng và một user riêng cho app — chỉ cấp SELECT, INSERT, UPDATE, không hơn:
1
2
3
4
5
6
7
docker exec -e MYSQL_PWD=secret -i blog-mysql mysql -uroot shop <<'SQL'
CREATE TABLE products (id INT, name VARCHAR(50));
INSERT INTO products VALUES (1, 'Ao thun'), (2, 'Quan jean');
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'app_pass';
GRANT SELECT, INSERT, UPDATE ON shop.products TO 'app_user'@'localhost';
SQLGiờ đăng nhập bằng app_user — đúng bộ quyền mà app dùng hằng ngày. SELECT chạy bình thường:
1
docker exec -e MYSQL_PWD=app_pass blog-mysql mysql -uapp_user shop -e "SELECT * FROM products;"1
2
3
id name
1 Ao thun
2 Quan jeanNhưng một payload phá hoại như DROP TABLE bị chặn ngay ở tầng database, vì app_user không hề có quyền đó:
1
docker exec -e MYSQL_PWD=app_pass blog-mysql mysql -uapp_user shop -e "DROP TABLE products;"1
ERROR 1142 (42000) at line 1: DROP command denied to user 'app_user'@'localhost' for table 'products'Đây là lớp phòng thủ cuối cùng, không thay cho prepared statement. Kể cả khi một câu SQLi lọt qua ở đâu đó, một account không có quyền DROP (hay DELETE, hay đọc bảng users) khiến kẻ tấn công chỉ làm được đúng những gì app thật sự cần — thiệt hại bị đóng khung trong đúng bộ quyền tối thiểu bạn đã cấp.
Lưu ý: tự escape chuỗi không đủ an toàn
Cám dỗ lớn nhất là tự “làm sạch” input rồi vẫn nối chuỗi. Đừng. Escape thủ công cực dễ sai — quên một dấu nháy, xử lý nhầm một ký tự, hoặc quên rằng số không cần nháy nên id = 1 OR 1=1 vẫn lọt. Thứ tự ưu tiên rõ ràng: prepared statement > ORM > (đừng bao giờ dừng ở) escape thủ công.
3. Command Injection - Khi hacker chạy lệnh trên server
Command Injection cho phép kẻ tấn công thực thi lệnh hệ điều hành tùy ý — đọc file, tải malware, mở reverse shell — dẫn tới chiếm toàn quyền server. Nó xuất hiện mỗi khi ứng dụng đưa input vào một shell command.
3.1. Kịch bản: một cái ping tool
Một tính năng “ping thử một host” là ví dụ kinh điển. File đầy đủ dưới đây (npm install express) có /ping-unsafe ghép chuỗi qua exec (dính lỗi) và /ping-safe dùng spawn (bản vá, phân tích ở mục 3.4). Lưu thành command-injection.js:
▶ Chạy thử ở máy bạn: injection/command-injection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const express = require("express");
const { exec, spawn } = require("child_process");
const app = express();
// ❌ exec ghép chuỗi rồi giao cho /bin/sh — dấu ; được hiểu là ngăn cách lệnh
app.get("/ping-unsafe", (req, res) => {
exec(`ping -c 1 ${req.query.host}`, (err, stdout, stderr) => {
res.type("text").send(stdout + stderr);
});
});
// ✅ spawn tách command khỏi arguments — không qua shell
app.get("/ping-safe", (req, res) => {
const ping = spawn("ping", ["-c", "1", req.query.host]);
let out = "";
ping.stdout.on("data", (d) => (out += d));
ping.stderr.on("data", (d) => (out += d));
ping.on("close", () => res.type("text").send(out));
});
app.listen(4001);Với host=127.0.0.1 thì đúng như tên gọi. Nhưng exec chạy chuỗi qua /bin/sh, nên dấu ; trong input được shell hiểu là “hết lệnh này, chạy lệnh tiếp”. Gửi 127.0.0.1;whoami:
1
2
node command-injection.js
curl -s "http://localhost:4001/ping-unsafe?host=127.0.0.1;whoami"1
2
3
4
5
6
7
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.023 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.023/0.023/0.023/0.000 ms
paoloDòng paolo cuối cùng là output của whoami — một lệnh thứ hai mà server không hề định chạy.
Đọc được username mới chỉ là khởi đầu. Vẫn lỗ hổng đó, đổi whoami thành cat /etc/passwd là đọc luôn nội dung file trên server. Lần này input có dấu cách và / nên phải encode, dùng --data-urlencode thay vì nhét thẳng vào query string:
1
curl -s -G http://localhost:4001/ping-unsafe --data-urlencode "host=127.0.0.1;cat /etc/passwd"1
2
3
4
5
6
7
8
9
10
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.025/0.025/0.025/0.000 ms
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
...
paolo:x:1000:1000:Paolo:/home/paolo:/usr/bin/zsh
sshd:x:980:65534:sshd user:/run/sshd:/usr/sbin/nologinTừ whoami sang đọc file tùy ý chỉ khác nhau ở lệnh đứng sau dấu ;. Giới hạn duy nhất là quyền của user đang chạy Node — cùng cơ chế này còn cho phép tải malware về hay mở reverse shell.
3.2. Phòng tránh Command Injection
Tốt nhất là đừng chạy shell command nào cả. Nhiều tác vụ đã có thư viện thuần Node thay thế — thư viện lo phần gọi hệ thống bên trong, còn bạn chỉ truyền dữ liệu, không bao giờ chạm tới shell. Với bài toán ping, thư viện ping làm đúng vậy. Lưu thành command-ping-lib.js (npm install express ping):
▶ Chạy thử ở máy bạn: injection/command-ping-lib.js
1
2
3
4
5
6
7
8
9
10
11
const express = require("express");
const ping = require("ping");
const app = express();
app.get("/ping", async (req, res) => {
const result = await ping.promise.probe(req.query.host);
res.json({ host: result.host, alive: result.alive, time: result.time });
});
app.listen(4010);Chạy node command-ping-lib.js. Với host hợp lệ, kết quả đúng như mong đợi:
1
curl -s "http://localhost:4010/ping?host=127.0.0.1"1
{ "host": "127.0.0.1", "alive": true, "time": 0.031 }Giờ gửi lại payload chèn lệnh 127.0.0.1;whoami:
1
curl -s "http://localhost:4010/ping?host=127.0.0.1;whoami"1
{ "host": "127.0.0.1;whoami:", "alive": false, "time": "unknown" }whoami không chạy. ping.promise.probe coi nguyên chuỗi 127.0.0.1;whoami là một hostname để resolve — nó không tồn tại nên alive là false. Không có shell nào diễn giải dấu ;, nên cũng không có lệnh thứ hai.
4. XSS (Cross-Site Scripting) - Đánh cắp thông tin user
XSS cho phép kẻ tấn công chèn JavaScript vào trang; đoạn script chạy trong trình duyệt người dùng khác, dưới đúng origin của bạn, nên đọc được cookie, session và hành động thay mặt họ. Khác SQLi/Command Injection nhắm vào server, XSS nhắm vào người dùng.
4.1. Ba loại XSS
XSS có ba loại, nhưng cả ba cùng một nguyên nhân — input chưa được encode khi render — và cùng một cách vá. Bài này chỉ dựng chạy thật loại nguy hiểm nhất là Stored XSS; Reflected và DOM-based tra cứu thêm khi cần:
Reflected XSS — server trả thẳng payload từ URL về response ngay trong request đó; kẻ tấn công phát tán link độc cho từng nạn nhân.
DOM-based XSS — không đụng tới server: JavaScript phía client tự đưa input vào DOM qua sink như
innerHTML(ví dụelement.innerHTML = "Welcome " + location.hash).
Stored XSS (nguy hiểm nhất) — payload được lưu vào database (một comment, một profile) rồi hiển thị lại cho mọi người xem trang: chèn một lần, ảnh hưởng tất cả người dùng.
▶ Chạy thử ở máy bạn: injection/xss-stored.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Lưu thành `xss-stored.js` (`npm install express`, Node ≥ 22.5)
const express = require("express");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE comments (id INTEGER PRIMARY KEY, text TEXT)");
function escapeHtml(text) {
const map = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post("/comment", (req, res) => {
db.prepare("INSERT INTO comments (text) VALUES (?)").run(req.body.comment);
res.type("text").send("Đã lưu comment\n");
});
// ❌ Render thẳng comment, không encode — script trong DB chạy trên trình duyệt nạn nhân
app.get("/posts-unsafe", (req, res) => {
const comments = db.prepare("SELECT text FROM comments").all();
res.type("html").send(comments.map((c) => `<p>${c.text}</p>`).join(""));
});
// ✅ Encode khi render — ký tự HTML hiển thị như text thường
app.get("/posts-safe", (req, res) => {
const comments = db.prepare("SELECT text FROM comments").all();
res
.type("html")
.send(comments.map((c) => `<p>${escapeHtml(c.text)}</p>`).join(""));
});
app.listen(4006);Chạy node xss-stored.js, lưu một comment chứa payload rồi xem lại trang:
1
2
curl -s -X POST http://localhost:4006/comment --data-urlencode 'comment=<img src=x onerror="steal()">'
curl -s http://localhost:4006/posts-unsafe1
<p><img src=x onerror="steal()"></p>Câu INSERT đã dùng prepared statement — chống được SQL Injection, nhưng không liên quan tới XSS. Lỗ hổng nằm ở bước render ${c.text} không encode: payload <img ... onerror> lọt nguyên vào HTML, và với một onerror thật thì cookie của mọi người xem bài bay đi.
4.2. Luồng tấn công Stored XSS
View Mermaid diagram code
sequenceDiagram
participant Attacker as Kẻ Tấn Công
participant Server as Web Server
participant DB as Database
participant Victim as Nạn Nhân
Attacker->>Server: POST /comment (payload chứa script)
Server->>DB: Lưu comment (mã độc nằm im trong DB)
Victim->>Server: GET /posts (xem bài viết)
Server->>DB: SELECT comments
DB-->>Server: Comment có script
Server-->>Victim: HTML chứa <script>
Note over Victim: Trình duyệt chạy script (same-origin)
Victim->>Attacker: Gửi cookie/session tới attacker.com4.3. Phòng tránh XSS
Giải pháp 1: Encode HTML output
Gốc của XSS là các ký tự < > " ' được trình duyệt hiểu là cú pháp HTML. Encode chúng thành HTML entity (<, >, …) khiến trình duyệt hiển thị như text thường, không còn là thẻ. Route /posts-safe trong xss-stored.js ở mục 4.1 chạy mỗi comment qua escapeHtml trước khi render. Đọc lại chính comment <img onerror> đã lưu ở mục 4.1, giờ qua route an toàn:
1
curl -s http://localhost:4006/posts-safe1
<p><img src=x onerror="steal()"></p>< đã thành < và " thành ", nên trình duyệt in ra dòng chữ <img src=x onerror="steal()"> như text thay vì tạo thẻ <img> và chạy onerror.
Giải pháp 2: Template engine tự động escape
Thực tế bạn hiếm khi tự viết escapeHtml — hầu hết template engine và framework tự escape khi nội suy biến. Chỉ cần biết đâu là cú pháp escape, đâu là raw:
1
2
3
4
5
<!-- EJS — an toàn (auto-escape) -->
<h1>Search: <%= query %></h1>
<!-- EJS — NGUY HIỂM (raw output) -->
<h1>Search: <%- query %></h1>1
2
3
4
5
6
7
// React — an toàn, tự escape mọi biến nội suy
function Search({ query }) {
return <h1>Kết quả cho: {query}</h1>;
}
// React — NGUY HIỂM
<div dangerouslySetInnerHTML={{ __html: userInput }} />;Quy tắc: mọi cú pháp có chữ “raw”, “html”, hay “dangerously” đều bỏ qua lớp escape — chỉ dùng khi input đã được sanitize (xem Giải pháp 4).
Giải pháp 3: Content Security Policy
CSP là lớp chặn cuối: script lạ có lọt được vào HTML thì browser vẫn không chạy nó, vì nguồn của script không nằm trong danh sách bạn khai báo. Cách cấu hình chi tiết nằm ở bài Ngăn Chặn XSS Với CSP.
Nhìn lại cả ba, chúng là một lỗ hổng mặc ba bộ đồ: ứng dụng để dữ liệu người dùng tràn từ vùng “dữ liệu” sang vùng “lệnh”. Và cách chữa cũng chỉ một ý tưởng, hiện ra dưới ba hình hài — prepared statement, spawn với arguments rời, output encoding — tất cả đều đang dựng lại ranh giới giữa lệnh và dữ liệu. Không có viên đạn bạc; hãy dựng nhiều lớp và đừng bao giờ tin input. Mọi ví dụ trong bài đều chạy được — hãy tự dựng lên, curl thử, và thấy dữ liệu rò ra trước khi nó rò trên production của bạn.