</>Học Dev
Bài học

Tuần 1 - Ngày 4: SQL Injection, SSRF, IDOR

Tuần 1 – Ngày 4

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 SQLdata 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

IMDSv1(legacy)Request:GEThttp://169.254.169.254/latest/meta-data/...Response:credentialJSONBtcHTTPclientnàotrênEC2cũnggiđưc,kcSSRF
IMDSv2(recommended)Step1Gettoken(PUT):PUThttp://169.254.169.254/latest/api/tokenHeader:X-aws-ec2-metadata-token-ttl-seconds:21600returns:<token>Step2Usetoken:GEThttp://169.254.169.254/latest/meta-data/...Header:X-aws-ec2-metadata-token:<token>VìsaochngSSRF:-CnPUTrequestđasSSRFchcóGET-CnsetcustomheaderSSRFquaURLkhôngsetđưcheader-TokencóTTLkhônglưutrvĩnhvin

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 admin
  • http://internal-jenkins:8080/ — Jenkins
  • file:///etc/passwd — local file read (nếu library support file://)

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:  &lt;script&gt;                 (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

  1. 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.

  2. 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.254 trả 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.

  3. 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 :id thà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.

  4. 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.4 lúc check, rồi trả 169.254.169.254 lúc connect.
    • Open redirect chain: allowed.com/redirect?to=evil.com → server fetch allowed.com → bị redirect → fetch evil.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.

  5. Bạn build admin endpoint /admin/users chỉ 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


Tiếp theo: Ngày 5 — Security Headers và CSP