Mục tiêu học tập
- Hiểu 4 loại SQL Injection: Union-based, Error-based, Boolean-based blind, Time-based blind
- Viết code an toàn với prepared statement / parameterized query trong Node, Python, Go
- Hiểu SSRF và vai trò của AWS Instance Metadata Service (IMDSv1 vs IMDSv2)
- Hiểu IDOR (Insecure Direct Object Reference) và cách fix với authorization check
- Nắm các kỹ thuật bypass attacker thường dùng để vượt qua filter ngây thơ
1. SQL Injection — Khi user input thành SQL code
SQL Injection xảy ra khi user input được nối vào câu lệnh SQL mà không escape/parameterize → attacker tiêm cú pháp SQL của riêng họ.
Ví dụ kinh điển
// VULNERABLE
app.get('/login', (req, res) => {
const { user, pass } = req.query;
const sql = `SELECT * FROM users WHERE username='${user}' AND password='${pass}'`;
db.query(sql, (err, rows) => {
if (rows.length > 0) res.send('Login OK');
else res.send('Fail');
});
});
Attacker gửi:
?user=admin'--&pass=anything
SQL thực tế:
SELECT * FROM users WHERE username='admin'--' AND password='anything'
↑
-- comment, AND password bị ignore
→ Login với username admin, không cần password.
Impact thực tế
Capital One (2019) 100M+ customers leak
TalkTalk (2015) 156K customers, £77M loss
Sony Pictures (2011) 77M PSN accounts
Heartland Payment (2008) 134M credit cards
Tất cả đều có SQL Injection làm entry point hoặc escalation.
2. Các loại SQL Injection
Union-based — Lấy data từ bảng khác
Khi response trả về kết quả query, attacker dùng UNION SELECT để merge data bảng khác vào.
GET /products?id=1
SQL: SELECT name, price FROM products WHERE id=1
Attacker: ?id=1 UNION SELECT username, password FROM users--
SQL: SELECT name, price FROM products WHERE id=1
UNION SELECT username, password FROM users--
Response: [name="iPhone", price=999], [name="admin", price="$2y$10$..."]
↑
Password hash của admin
Error-based — Database error leak data
Attacker dụ DB throw error có chứa data trong message:
-- MySQL
?id=1 AND (SELECT 1 FROM (SELECT COUNT(*),CONCAT(version(),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)y)
-- Error message:
-- "Duplicate entry '8.0.30-MySQL1' for key '<group_key>'"
-- ↑ data leaked qua error
Mitigation phụ: app KHÔNG trả stack trace / DB error message cho client (return generic 500).
Boolean-based Blind — Hỏi yes/no
Khi response không hiển thị data, attacker dùng câu hỏi true/false để infer:
?id=1 AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a'
→ Page load bình thường → đúng, ký tự đầu là 'a'
?id=1 AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='b'
→ Page khác → sai
Lặp lại cho mỗi ký tự, mỗi vị trí → recover từng chữ password
Chậm nhưng vẫn dùng được. Tools như sqlmap tự động hoá hàng nghìn request.
Time-based Blind — Dùng delay làm tín hiệu
Khi response giống nhau hoàn toàn, attacker dùng SLEEP để phân biệt:
?id=1 AND IF((SELECT SUBSTRING(password,1,1) FROM users WHERE id=1)='a', SLEEP(5), 0)
-- Nếu response trễ 5s → ký tự đầu là 'a'
Cực kỳ chậm (1 ký tự = 1 request 5s), nhưng vẫn dump được full DB nếu attacker kiên nhẫn.
3. Prepared Statement / Parameterized Query — Solution chuẩn
Tách câu lệnh SQL và data thành hai phần. DB engine biết phần nào là code, phần nào là literal — input không bao giờ được parse như SQL.
Node.js — mysql2
// VULNERABLE
const sql = `SELECT * FROM users WHERE username='${user}'`;
// SECURE — parameterized
const [rows] = await db.execute(
'SELECT * FROM users WHERE username = ? AND password = ?',
[user, pass]
);
Node.js — pg (PostgreSQL)
const { rows } = await pool.query(
'SELECT * FROM users WHERE username = $1 AND password = $2',
[user, pass]
);
Python — psycopg2 / psycopg3
# VULNERABLE
cursor.execute(f"SELECT * FROM users WHERE username='{user}'")
# SECURE
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(user, pass)
)
Lưu ý:
%sở đây không phải Python string formatting — là placeholder của psycopg.
Python — SQLAlchemy ORM
# SECURE — ORM tự parameterize
user = session.query(User).filter(User.username == username).first()
# Hoặc raw với :param
result = session.execute(
text("SELECT * FROM users WHERE username = :u"),
{"u": username}
)
Go — database/sql
// VULNERABLE
query := fmt.Sprintf("SELECT * FROM users WHERE username='%s'", user)
db.Query(query)
// SECURE
rows, err := db.Query(
"SELECT * FROM users WHERE username = $1 AND password = $2",
user, pass,
)
ORM mặc định an toàn — nhưng cẩn thận raw query
// Prisma — SECURE
const user = await prisma.user.findFirst({
where: { username: userInput }
});
// Prisma — VULNERABLE nếu dùng $queryRawUnsafe
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE name='${userInput}'`);
// Prisma — SECURE với template tag
await prisma.$queryRaw`SELECT * FROM users WHERE name=${userInput}`;
// ↑ tagged template tự parameterize
4. Những thứ KHÔNG parameterize được
Prepared statement chỉ parameterize literal values. Những thứ này không thể bind tham số:
- Tên column, tên table
ORDER BY <column>LIMIT <n>(một số DB)- Toàn bộ
IN (...)list size
Nếu phải dynamic những phần này, whitelist từ giá trị cho phép:
const ALLOWED_SORT = ['name', 'price', 'created_at'];
const sortCol = ALLOWED_SORT.includes(req.query.sort) ? req.query.sort : 'created_at';
const order = req.query.order === 'desc' ? 'DESC' : 'ASC';
const sql = `SELECT * FROM products ORDER BY ${sortCol} ${order} LIMIT ?`;
db.query(sql, [pageSize]);
5. Server-Side Request Forgery (SSRF)
SSRF xảy ra khi server fetch URL/resource do user cung cấp, mà không validate đích đến. Attacker buộc server gọi internal endpoint không reachable từ internet.
Pattern dễ bị SSRF
- Feature "import URL" (avatar, document, RSS)
- Webhook caller
- "Preview link" sinh OG meta
- PDF/screenshot generator render URL user nhập
- File proxy / image resizer
Classic exploit — AWS metadata service
EC2 instance có service nội bộ trả về IAM credential:
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>
Response:
{
"AccessKeyId": "AKIA...",
"SecretAccessKey": "...",
"Token": "...",
"Expiration": "..."
}
Attacker khai thác SSRF:
POST /api/screenshot
{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/web-role" }
→ Server fetch → trả về credential trong response → attacker dùng AWS CLI với credential đó → full AWS account compromise.
Đây là Capital One 2019 breach — SSRF + IMDSv1 + over-permissioned IAM role.
IMDSv1 vs IMDSv2
Bắt buộc IMDSv2 trên mọi EC2/ASG mới. AWS hiện cho phép disable IMDSv1 ở account level.
Internal network access
SSRF cũng dùng để scan/access internal services:
http://localhost:6379/— Redis (không auth thường thấy)http://10.0.0.5:9200/— Elasticsearch adminhttp://internal-jenkins:8080/— Jenkinsfile:///etc/passwd— local file read (nếu library supportfile://)
Mitigation SSRF
1. Whitelist URL/scheme/host
const ALLOWED_HOSTS = ['cdn.example.com', 'api.partner.com'];
const ALLOWED_SCHEMES = ['https:'];
function isUrlSafe(rawUrl) {
try {
const u = new URL(rawUrl);
if (!ALLOWED_SCHEMES.includes(u.protocol)) return false;
if (!ALLOWED_HOSTS.includes(u.hostname)) return false;
return true;
} catch {
return false;
}
}
2. Block private IP ranges + metadata IP
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'), # AWS/Azure/GCP metadata + link-local
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'), # IPv6 ULA
]
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
try:
ip = socket.gethostbyname(parsed.hostname)
except socket.gaierror:
return False
ip_obj = ipaddress.ip_address(ip)
return not any(ip_obj in net for net in BLOCKED)
3. DNS rebinding protection
Resolve hostname một lần rồi connect bằng IP đã verify — tránh attacker dùng DNS trả IP khác giữa lúc check và lúc connect:
import requests
ip = socket.gethostbyname(hostname)
if not is_safe(ip):
raise SSRFError()
# Connect bằng IP, set Host header thủ công
resp = requests.get(f"https://{ip}/path", headers={"Host": hostname}, verify=False)
4. Network-level chống đỡ
- Disable IMDSv1, force IMDSv2
- EC2 metadata hop limit = 1 (chống container access metadata)
- VPC egress: chỉ cho outbound đến whitelist
- IAM role nguyên tắc least privilege
6. Insecure Direct Object Reference (IDOR)
IDOR xảy ra khi app reference object qua identifier (ID, filename) mà không verify user có quyền access object đó.
Classic example
// VULNERABLE
app.get('/api/users/:id/profile', authMiddleware, async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // ← không check user.id === req.user.id
});
User A login (/api/users/100/profile trả về của mình). Đổi URL thành /api/users/101/profile → trả về data của user B. Không cần bypass auth, chỉ thay số.
Real-world example
- Snapchat 2014 — phone number lookup bằng cách brute force numeric ID
- Facebook page admin role qua page ID + IDOR
- Optus 2022 (Úc) — 10M customer leak qua API endpoint không check authorization, ID đoán được
Patterns dễ IDOR
/api/orders/123(numeric, đoán được)/files/download?path=user_456_invoice.pdf/api/messages/abc-123/read(UUID nhưng không check ownership)- GraphQL
query { user(id: "X") { email } }
Fix #1 — Authorization check
app.get('/api/users/:id/profile', authMiddleware, async (req, res) => {
const targetId = req.params.id;
// Authorization: chỉ chính chủ hoặc admin
if (targetId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await User.findById(targetId);
res.json(user);
});
Fix #2 — Query có scope theo user
Thay vì lấy bằng ID rồi check sau, query đã scope sẵn:
// Tốt hơn — không thể leak vì query filter
const order = await Order.findOne({
where: { id: orderId, userId: req.user.id }
});
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
Trả 404 thay vì 403 để không leak "object có tồn tại" — phòng enumeration.
Fix #3 — Dùng opaque identifier
/api/orders/123
↓ thay bằng UUID hoặc random token
/api/orders/d4f8e2b1-...
UUID khó đoán → giảm risk brute force enumeration. Nhưng không thay thế authorization check — UUID lộ là IDOR vẫn xảy ra. Defense in depth: dùng UUID + check ownership.
Fix #4 — Capability-based URL
URL chứa token capabilities, không dùng raw ID:
/api/download?token=eyJhbGciOiJIUzI1NiJ9...
↑ JWT chứa fileId + userId + expiry, signed
Token có signature nên user không sửa được. Hết hạn nhanh (5 phút), one-time use.
7. Common Bypass Tricks
Attacker thử các kỹ thuật này để bypass filter ngây thơ:
Encoding tricks
Original: '<script>alert(1)</script>'
URL enc: %3Cscript%3Ealert(1)%3C/script%3E
Double: %253Cscript%253E (decode 1 lần → %3C... → server decode lần 2)
Unicode: <script>
HTML enc: <script> (server decode HTML entity)
Mitigation: chỉ decode 1 lần, validate sau khi decode hoàn tất.
Case variation
SELECT → SeLeCt, sELECT, /*!50000SELECT*/ (MySQL inline comment)
script → ScRiPt
Mitigation: filter không phân biệt hoa thường + dùng output encoding, không blacklist.
Filter regex bypass
// VULNERABLE — strip "<script>"
input.replace(/<script>/gi, '');
// Bypass: <scr<script>ipt> → sau khi replace còn lại "<script>"
Mitigation: không cố "sanitize" bằng regex blacklist — dùng library proper (DOMPurify).
Path traversal
?file=../../etc/passwd
?file=..%2F..%2Fetc%2Fpasswd
?file=....//....//etc/passwd (strip "../" một lần → "../../")
Mitigation: path.normalize rồi verify path nằm trong allowed directory:
const safePath = path.resolve(BASE_DIR, req.query.file);
if (!safePath.startsWith(BASE_DIR + path.sep)) {
return res.status(400).send('Bad path');
}
Null byte injection
?file=secret.txt%00.jpg
Một số language (C, PHP cũ) cắt string ở null byte. Mitigation: reject input chứa \x00.
IDOR via numeric vs string ID
/api/users/123 → forbid
/api/users/123/ → bypass? (trailing slash route khác middleware)
/api/users/0123 → leading zero?
/api/users/123e2 → "12300" parsed as float (JS!)
Mitigation: chuẩn hoá input, dùng route framework chặt, ID parse strict.
HTTP method override
Form chỉ cho GET/POST. Để bypass:
POST /api/users/123
X-HTTP-Method-Override: DELETE
Backend dùng header này → thực thi DELETE
Mitigation: không enable method override trừ khi cần; nếu cần thì authz check áp dụng cho method effective.
8. Câu hỏi ôn tập
-
Tại sao prepared statement an toàn hơn string concatenation, ngay cả khi bạn escape input cẩn thận?
Xem đáp án
Prepared statement gửi câu lệnh SQL và parameter trên hai kênh tách biệt đến DB engine. DB compile SQL trước, sau đó bind parameter — parameter không bao giờ được parse như SQL syntax. Escape thủ công luôn có risk: quên một edge case (Unicode normalization, charset encoding, special syntax theo từng DB engine), một bypass nhỏ là đủ. Prepared statement biến vấn đề thành structural thay vì textual — không có cách nào để input thoát ra ngoài context literal.
-
SSRF khai thác IMDSv1 hoạt động như thế nào? Vì sao IMDSv2 chống được?
Xem đáp án
IMDSv1: bất kỳ HTTP GET nào tới
169.254.169.254trả về IAM credential. SSRF chỉ cần buộc server gửi 1 GET request → đọc được response → exfil credential.IMDSv2 yêu cầu 2 bước: (1) PUT request lấy session token, (2) GET với header
X-aws-ec2-metadata-token. SSRF thường chỉ có GET đơn giản — không gửi PUT, không set custom header (vì attacker chỉ control URL). Hơn nữa IMDSv2 có hop limit (default 1) chặn container/proxy access.Capital One breach 2019 = SSRF + IMDSv1 + role permission quá rộng. AWS đã có cách buộc IMDSv2 ở organization level.
-
App của bạn cho user download invoice qua
/api/invoices/:id/pdf. Bạn đã có middleware verify JWT. Đủ chưa?Xem đáp án
Chưa đủ — đó chỉ là authentication (biết user là ai), chưa có authorization (user có quyền vào invoice này không). Đây là IDOR điển hình: User A đăng nhập đổi
:idthành ID invoice của User B → download được.Fix: query có scope
Invoice.findOne({ id, userId: req.user.id }). Nếu không tìm thấy → 404. Hoặc check ownership rõ ràng trước khi serve file.Đây là OWASP A01 #1 trong Top 10 2021.
-
Whitelist URL có an toàn 100% chống SSRF không?
Xem đáp án
Không 100%, nhưng là biện pháp tốt nhất. Một số bypass:
- DNS rebinding: attacker control domain
evil.com, DNS trả1.2.3.4lúc check, rồi trả169.254.169.254lúc connect. - Open redirect chain:
allowed.com/redirect?to=evil.com→ server fetchallowed.com→ bị redirect → fetchevil.com. - Subdomain takeover trong whitelist domain.
Mitigation bổ sung: resolve DNS một lần, connect bằng IP đã verify, không follow redirect (hoặc verify từng hop), block private IP range cứng sau khi resolve. Defense in depth: kết hợp whitelist + IP block + IMDSv2 + network egress control.
- DNS rebinding: attacker control domain
-
Bạn build admin endpoint
/admin/userschỉ admin truy cập. Frontend đã ẩn link này với user thường. Đủ chưa?Xem đáp án
Không đủ — frontend chỉ là UI; user thường vẫn có thể gửi request HTTP trực tiếp (Postman, curl, DevTools). Đây là OWASP A01 — "missing function level access control".
Fix: server bắt buộc check role. Mỗi route admin phải có middleware:
function requireAdmin(req, res, next) { if (req.user.role !== 'admin') return res.status(403).end(); next(); } app.use('/admin', authMiddleware, requireAdmin);Quy tắc: client là untrusted. Mọi authorization decision phải làm ở server. Ẩn UI chỉ là UX, không phải bảo mật.
Bài tập thực hành
# 1. SQLi với DVWA hoặc Juice Shop
docker run --rm -p 3000:3000 bkimminich/juice-shop
# Vào "Score Board", tìm "Login Admin" challenge
# Thử ' OR 1=1-- trong field email
# 2. Thử sqlmap
docker run --rm -it parrotsec/sqlmap \
-u "http://target.com/products?id=1" --batch --dbs
# 3. Kiểm tra IMDS version trên EC2
# IMDSv1 (chỉ work nếu instance vẫn cho phép v1):
curl http://169.254.169.254/latest/meta-data/
# IMDSv2 (recommended):
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/
# Disable IMDSv1 trên instance:
aws ec2 modify-instance-metadata-options \
--instance-id i-xxx \
--http-tokens required
# 4. Audit IDOR trong app của bạn
# - Liệt kê mọi route có :id, :uuid trong URL
# - Với mỗi route, đọc handler: có check ownership không?
# - Viết test: user A login, gọi resource của user B → expect 403/404
# 5. SSRF defense — viết hàm is_safe_url cho Python/Node
# (xem code ví dụ ở phần 5)
# Test với input:
# http://169.254.169.254/ → reject
# http://10.0.0.5/ → reject
# http://example.com/ → allow
# file:///etc/passwd → reject
Tài liệu tham khảo chính thức
- OWASP SQL Injection Prevention
- OWASP SSRF Prevention
- OWASP IDOR
- AWS IMDSv2 documentation
- PortSwigger Web Security Academy — free lab thực hành
Tiếp theo: Ngày 5 — Security Headers và CSP