Bảo mật LLM cơ bản: vẽ bản đồ mối đe doạ trước khi viết code

- Published on
- /19 mins read/
Một team làm xong con chatbot chăm sóc khách hàng. Nó đọc được lịch sử đơn hàng, tra được tài liệu nội bộ, gửi được email xác nhận. Demo trơn tru, sếp gật gù. Hôm trước ngày lên production, một anh bên vận hành hỏi bâng quơ: "Lỡ khách gửi một cái email có chèn lệnh, bảo con bot gửi danh sách khách hàng ra ngoài thì sao?"
Không ai trả lời được. Không phải vì team kém, mà vì chưa ai ngồi vẽ ra bức tranh: hệ thống có gì quý, ai muốn lấy, và họ chui vào bằng đường nào.
Bên bảo mật gọi bức tranh đó là threat model. Với web app truyền thống thì đây là việc quen thuộc. Với ứng dụng LLM, mình thấy rất nhiều team bỏ qua, phần vì nghĩ chỉ cần system prompt dặn kỹ là đủ, phần vì chưa biết vẽ thế nào khi "code" và "dữ liệu" trộn lẫn vào nhau. Bài này nói về cách vẽ. Phần các lớp phòng thủ chi tiết (Dual-LLM, Tool Broker, guardrails) mình đã viết ở bài bảo mật LLM và OWASP Top 10, nên ở đây chỉ nhắc tới khi cần.
# cái khó gốc rễ: không có ranh giới giữa lệnh và dữ liệu
SQL injection được giải quyết từ lâu bằng một ý tưởng rất đơn giản: tách câu lệnh ra khỏi dữ liệu. Prepared statement gửi câu SQL đi một đường, tham số đi một đường khác, database không bao giờ nhầm tham số là lệnh.
LLM không có hai đường. System prompt của bạn, câu hỏi của người dùng, nội dung một trang web model vừa đọc, kết quả trả về từ tool, tất cả bị ghép thành một chuỗi token duy nhất. Các nhà cung cấp model có cố đánh dấu vai trò (system, user, tool) và dạy model ưu tiên hướng dẫn của system hơn, nhưng đó là thứ model học để tôn trọng, không phải thứ được cưỡng chế như một ranh giới kỹ thuật. Model vẫn đọc mọi chữ, và một đoạn chữ đủ khéo nằm trong cái PDF người dùng upload vẫn có thể được nó coi là lệnh.
Simon Willison đặt tên cho hiện tượng này là prompt injection từ năm 2022. Bốn năm sau, vẫn chưa ai có cách chặn tuyệt đối. Các bộ lọc, classifier phát hiện injection đều chặn được phần lớn, nhưng trong bảo mật, chặn được 99% nghĩa là kẻ tấn công chỉ cần thử 100 lần.
Hệ quả cho người thiết kế: đừng xây hệ thống dựa trên giả định "model sẽ không bị lừa". Hãy giả định model đã bị lừa, rồi hỏi: lúc đó nó làm được gì? Toàn bộ phần còn lại của bài là cách trả lời câu hỏi này một cách có hệ thống.
# bước 1: liệt kê thứ đáng bảo vệ
Lấy giấy bút ra, liệt kê những thứ mà mất đi thì bạn mất ngủ. Với ứng dụng LLM, danh sách thường có: dữ liệu người dùng (lịch sử chat, file upload, thông tin cá nhân), dữ liệu nội bộ mà model đọc được qua RAG hay tool, bí mật của hệ thống như API key và token, quyền hành động (gửi email, ghi database, chạy code, tạo đơn, chuyển tiền), tiền (mỗi request đều tốn token), và uy tín.
Uy tín nghe hơi mơ hồ, nhưng một tấm ảnh chụp màn hình chatbot của hãng bạn nói bậy hoặc hứa bán xe với giá một đô la có thể lan nhanh hơn bất kỳ thông cáo báo chí nào.
Danh sách này quyết định thứ tự ưu tiên. Một chatbot FAQ chỉ đọc tài liệu công khai và không có tool thì gần như chẳng có gì để mất ngoài uy tín. Một agent có quyền đọc email và gửi email thì khác hẳn.
# bước 2: ai sẽ tấn công, đi cửa nào
Có ba kiểu kẻ tấn công mình thấy cần tách bạch, vì mỗi kiểu đi một cửa.
Người dùng của chính bạn. Họ gõ thẳng vào ô chat: jailbreak, moi system prompt, đóng vai, lừa model làm việc ngoài phạm vi. Cửa này dễ thấy nhất, và cũng là cửa mà hầu hết các bài viết về prompt injection nói tới. Tin vui là thiệt hại thường giới hạn trong phạm vi quyền của chính người đó.
Người thứ ba không hề dùng ứng dụng của bạn. Đây mới là kiểu đáng sợ. Họ chỉ cần đặt một đoạn chữ vào nơi model của bạn sẽ đọc: một trang web, một email gửi tới hộp thư mà bot đọc, một file được chia sẻ, một issue trên GitHub, một review sản phẩm. Gọi là indirect prompt injection. Người dùng thật của bạn vô tội, họ chỉ nhờ bot tóm tắt hộp thư, nhưng thứ bot làm lại theo lệnh của một người hoàn toàn khác, và với quyền của nạn nhân.
Chuỗi cung ứng. Model tải từ nguồn lạ (file pickle có thể chạy code khi load), dữ liệu fine-tune bị đầu độc, thư viện hoặc MCP server bên thứ ba có mô tả tool chứa lệnh ẩn. Kiểu cuối gọi là tool poisoning: mô tả tool được đưa thẳng vào context của model, nên một MCP server độc có thể "dặn dò" model ngay cả khi tool của nó chưa từng được gọi.
# bước 3: vẽ ranh giới tin cậy
Giờ vẽ hệ thống ra và tô màu: chỗ nào là dữ liệu không tin cậy, chỗ nào là hành động có hậu quả. Mỗi mũi tên đi từ vùng này sang vùng khác là một chỗ cần soi.
Để ý một chi tiết hay bị bỏ sót: Vector DB không tự động là vùng tin cậy. Nếu tài liệu trong đó đến từ email khách hàng, từ trang wiki ai cũng sửa được, hay từ web crawl, thì nó cũng không đáng tin hơn nguồn gốc của nó. RAG không "rửa sạch" được dữ liệu.
Một chi tiết nữa: output cũng là một cửa ra. Mũi tên từ LLM tới phần render markdown trông vô hại, nhưng như bạn sẽ thấy ở vụ EchoLeak bên dưới, đó lại là con đường dữ liệu chạy ra ngoài.
# lethal trifecta và rule of two
Giữa năm 2025, Simon Willison đúc kết một quy tắc mà theo mình đáng in ra dán cạnh màn hình. Ông gọi là lethal trifecta, bộ ba chết người. Một hệ thống LLM hội đủ cả ba thứ sau gần như chắc chắn có lỗ hổng:
- Truy cập được dữ liệu riêng tư.
- Tiếp xúc với nội dung không tin cậy.
- Có cách gửi dữ liệu ra ngoài.
Logic rất đơn giản. Kẻ tấn công đặt lệnh vào nội dung không tin cậy (chân 2), lệnh đó bảo model lấy dữ liệu riêng tư (chân 1), rồi gửi ra ngoài (chân 3). Thiếu một chân thì chuỗi tấn công đứt.
Cuối năm 2025, nhóm bảo mật của Meta đưa ra một phiên bản tổng quát hơn gọi là Agents Rule of Two: trong một phiên làm việc, agent chỉ nên có tối đa hai trong ba tính chất là xử lý dữ liệu không tin cậy, truy cập hệ thống nhạy cảm hoặc dữ liệu riêng tư, và thay đổi trạng thái hoặc giao tiếp ra ngoài. Muốn có đủ cả ba thì phải có người duyệt chen vào giữa. Cách phát biểu này rộng hơn lethal trifecta ở chỗ nó tính cả những hành động phá hoại (xoá dữ liệu, sửa cấu hình) chứ không chỉ chuyện rò rỉ.
Hai quy tắc này biến một câu hỏi mơ hồ kiểu "agent của mình có an toàn không" thành một câu hỏi kiểm tra được: với mỗi luồng xử lý, nó đang có mấy chân?
# mổ xẻ một vụ thật: EchoLeak
Lý thuyết nghe vậy, xem một vụ thật cho thấm. Tháng 6/2025, công ty bảo mật Aim Security công bố lỗ hổng họ đặt tên là EchoLeak (CVE-2025-32711) trong Microsoft 365 Copilot. Điều làm nó nổi tiếng là đây là một lỗ hổng zero-click: nạn nhân không cần bấm vào gì cả.
Theo báo cáo của Aim Security, chuỗi tấn công đại khái như sau.
Kẻ tấn công gửi một email bình thường tới nạn nhân. Nội dung email được viết khéo để trông như lời dặn dành cho người đọc chứ không phải lệnh cho AI, nhờ vậy lách qua được bộ phân loại chống prompt injection của Microsoft. Nạn nhân không cần mở email đó.
Vài hôm sau, nạn nhân hỏi Copilot một câu bình thường về công việc. Copilot dùng RAG tìm trong hộp thư những thứ liên quan, và kéo luôn email của kẻ tấn công vào context. Đây là chân "nội dung không tin cậy".
Lệnh trong email bảo Copilot lấy những thông tin nhạy cảm nhất trong context (chân "dữ liệu riêng tư") và nhét chúng vào URL của một đường link hoặc một cái ảnh markdown. Copilot có cơ chế lọc link trỏ ra ngoài, nhưng cơ chế đó bỏ sót một kiểu cú pháp markdown ít phổ biến (reference-style). Khi giao diện render câu trả lời, trình duyệt tự tải ảnh, và dữ liệu đi theo URL ra ngoài. Đó là chân thứ ba.
Còn một lớp nữa: Content Security Policy của trang chỉ cho tải ảnh từ một số domain của Microsoft. Nhóm nghiên cứu tìm ra một proxy của Microsoft Teams nằm trong danh sách được phép và có thể chuyển tiếp request ra ngoài, thế là lọt qua cả CSP.
Microsoft đã vá phía server. Nhưng mình thấy vụ này đáng học ở chỗ: mỗi lớp phòng thủ đều có, và mỗi lớp đều bị lách một chút. Classifier bị lách bằng cách viết lệnh như lời người với người. Bộ lọc link bị lách bằng một cú pháp ít gặp. CSP bị lách bằng một domain "nhà mình". Không lớp nào sai hoàn toàn, nhưng chồng lên nhau vẫn hở. Và tất cả bắt nguồn từ một thiết kế có đủ bộ ba của lethal trifecta.
# bước 4: đi qua từng vị trí trên bản đồ
Có bản đồ và có lăng kính lethal trifecta rồi, giờ đi qua từng vị trí. Bảng dưới đây là một checklist gọn để dùng khi review thiết kế một tính năng AI. Nó không đầy đủ, nhưng đủ để không bỏ sót những thứ lớn.
| vị trí | mối đe doạ | hướng xử lý chính |
|---|---|---|
| Ô chat | Jailbreak, moi system prompt | Coi system prompt là công khai: không nhét key, không nhét logic phân quyền vào đó |
| Nội dung model đọc | Indirect prompt injection | Tách luồng đọc nội dung lạ khỏi luồng có quyền hành động, ghi nhận nguồn gốc dữ liệu |
| Vector DB | Đầu độc dữ liệu RAG | Kiểm soát ai được ghi, lưu nguồn của từng chunk, lọc theo quyền của người hỏi |
| Tool call | Lạm quyền | Quyền tối thiểu, người duyệt cho hành động không hoàn tác được |
| Output | Rò rỉ qua ảnh hoặc link, XSS, SQL do model sinh | Allowlist domain, CSP chặt, escape output, không thực thi thẳng thứ model viết |
| MCP, thư viện, model | Tool poisoning, model độc | Nguồn tin cậy, ghim phiên bản, đọc mô tả tool trước khi cài, không load pickle lạ |
| Hoá đơn | Denial of wallet | Giới hạn token theo request, theo user, theo ngày; cảnh báo khi tăng bất thường |
| Log, trace | Lộ dữ liệu người dùng | Che dữ liệu nhạy cảm trước khi ghi, giới hạn người xem |
Riêng dòng "lọc theo quyền của người hỏi" ở Vector DB đáng nói thêm. Rất nhiều hệ thống RAG nội bộ nhét tài liệu của mọi phòng ban vào một index, rồi để model "tự biết" không nói ra tài liệu mật. Model không biết đâu. Lọc quyền phải làm ở tầng truy vấn, trước khi tài liệu đi vào context: người hỏi không có quyền đọc tài liệu nào thì tài liệu đó không bao giờ được lấy ra.
# chặn đường rò rỉ qua ảnh markdown
Vì EchoLeak và hàng loạt vụ tương tự đều đi qua đúng con đường này, đây là đoạn code nên có trong mọi ứng dụng render output của LLM thành markdown. Ý tưởng là xử lý trên cây cú pháp của markdown thay vì dùng regex, để không bỏ sót những cú pháp lạ như reference-style:
import { visit, SKIP } from 'unist-util-visit'
import type { Root } from 'mdast'
const ALLOWED_HOSTS = new Set(['tungdadev.com', 'images.unsplash.com'])
function isAllowed(url: string): boolean {
try {
const { protocol, hostname } = new URL(url)
return protocol === 'https:' && ALLOWED_HOSTS.has(hostname)
} catch {
return false // URL tương đối hoặc sai cú pháp: chặn cho chắc
}
}
// remark plugin: chạy sau khi parse, nên mọi cú pháp (inline, reference, autolink)
// đều đã được quy về cùng một kiểu node, không lọt được như khi dùng regex
export function remarkBlockExternalUrls() {
return (tree: Root) => {
visit(tree, (node, index, parent) => {
if (!parent || index === undefined) return
if (node.type === 'image' && !isAllowed(node.url)) {
parent.children[index] = { type: 'text', value: `[ảnh bị chặn: ${node.alt ?? ''}]` }
}
if (node.type === 'link' && !isAllowed(node.url)) {
parent.children.splice(index, 1, ...node.children)
return [SKIP, index] // duyệt lại từ vị trí cũ để soi cả ảnh nằm trong link
}
if (node.type === 'definition' && !isAllowed(node.url)) {
node.url = '#' // định nghĩa của link/ảnh kiểu reference
}
})
}
}Dòng return [SKIP, index] nhìn thì thừa nhưng bỏ đi là hở: với [](https://evil.com), link bị gỡ ra nhưng cái ảnh bên trong được đưa lên đúng vị trí cũ mà visit đã đi qua, nên nó lọt nguyên vẹn. Một lưu ý nữa: plugin này chỉ thấy cú pháp markdown. Nếu pipeline render cho phép HTML thô (kiểu rehype-raw) thì một thẻ <img src=...> trong output sẽ đi đường khác, nên hoặc tắt HTML thô, hoặc sanitize thêm ở tầng HTML.
Lớp thứ hai là Content Security Policy ở trình duyệt, kiểu img-src 'self' https://images.unsplash.com. Nhưng như EchoLeak đã cho thấy, CSP chỉ chặt khi danh sách domain thật sự hẹp, và mỗi domain trong đó cần được soi xem có thể bị dùng làm trung gian chuyển tiếp hay không.
# bước 5: thiết kế để giới hạn thiệt hại
Tới đây có thể bạn nghĩ: vậy chẳng lẽ cứ thêm người duyệt vào mọi chỗ? Không hẳn. Có một hướng tiếp cận đang được giới nghiên cứu quan tâm là giải quyết bằng kiến trúc thay vì bằng bộ lọc.
Ví dụ rõ nhất là CaMeL, một thiết kế do Google DeepMind công bố năm 2025 trong paper "Defeating Prompt Injections by Design". Ý tưởng chính là chia việc cho hai model với quyền rất khác nhau.
Model "đặc quyền" chỉ được nhìn thấy yêu cầu của người dùng, tuyệt đối không đọc dữ liệu không tin cậy. Từ yêu cầu đó, nó viết ra một kế hoạch dưới dạng code: bước 1 đọc email này, bước 2 trích ra thông tin kia, bước 3 gửi cho người này. Vì nó không bao giờ đọc nội dung độc, kế hoạch không thể bị injection bẻ hướng.
Model "cách ly" thì làm việc với dữ liệu bẩn, như đọc email và trích thông tin, nhưng không có quyền gọi bất kỳ tool nào. Nó chỉ trả về giá trị.
Phần hay nhất là trình thông dịch chạy kế hoạch đó theo dõi nguồn gốc của từng giá trị. Một địa chỉ email được trích ra từ một email lạ sẽ mang nhãn "không tin cậy". Trước khi gọi tool gửi email, trình thông dịch kiểm tra chính sách, ví dụ "người nhận phải đến từ yêu cầu của người dùng, không được đến từ dữ liệu ngoài", và từ chối nếu vi phạm. Injection vẫn có thể làm model cách ly trích sai thông tin, nhưng không thể khiến hệ thống gửi dữ liệu tới một địa chỉ do kẻ tấn công chọn.
Cái giá là hệ thống giải được ít tác vụ hơn một agent tự do, và việc viết chính sách cho từng tool tốn công. Đổi lại, đây là một trong những thiết kế đầu tiên cho ra đảm bảo thay vì chỉ là xác suất, dù đảm bảo đó chỉ mạnh bằng bộ chính sách bạn viết: hành động nào không có chính sách che thì vẫn hở. Trên benchmark AgentDojo, paper báo CaMeL giải được 77% tác vụ với bảo đảm an toàn, so với 84% của hệ thống không phòng thủ.
Bạn không cần dựng nguyên CaMeL mới áp dụng được tinh thần của nó. Ngay trong code agent hiện tại, có thể gắn nhãn nguồn gốc cho dữ liệu và kiểm tra trước khi hành động:
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class Value:
data: str
trusted: bool # True nếu đến từ người dùng hoặc hệ thống của mình
@dataclass
class Tool:
name: str
fn: Callable
sends_data_out: bool
reversible: bool
def run_tool(tool: Tool, args: dict[str, Value], ask_human: Callable[[str], bool]):
untrusted_args = [k for k, v in args.items() if not v.trusted]
# tham số do nội dung lạ quyết định (người nhận, URL, nội dung gửi đi...)
# mà lại đi ra ngoài hệ thống: bắt buộc người duyệt
if tool.sends_data_out and untrusted_args:
if not ask_human(f"{tool.name}: tham số {untrusted_args} đến từ nguồn không tin cậy. Cho phép?"):
raise PermissionError(f"{tool.name} bị từ chối")
if not tool.reversible and not ask_human(f"{tool.name} không hoàn tác được. Tiếp tục?"):
raise PermissionError(f"{tool.name} bị từ chối")
return tool.fn(**{k: v.data for k, v in args.items()})Thay vì hỏi người dùng "cho phép gửi email không?" cho mọi email (và họ sẽ bấm đồng ý theo phản xạ sau lần thứ năm), cách này chỉ hỏi đúng lúc nguy hiểm: khi dữ liệu không tin cậy đang quyết định chỗ dữ liệu đi tới.
# đừng quên mấy thứ nhàm chán
Giữa đủ thứ kỹ thuật tấn công mới lạ, dễ quên rằng phần lớn sự cố trong thực tế vẫn đến từ lỗi rất cũ. API key của nhà cung cấp model nằm trong code frontend. Endpoint gọi model không có rate limit nên bị người ta dùng chùa thành proxy miễn phí. Log ghi nguyên văn câu chat có số căn cước của khách. Tool được cấp quyền admin cho tiện lúc dev rồi quên hạ.
Threat model tốt cho ứng dụng LLM phải có cả danh sách của bảo mật web bình thường. LLM thêm rủi ro mới chứ không xoá rủi ro cũ.
# bắt đầu từ đâu
Lấy một tính năng AI đang chạy, dành một buổi chiều với team đi qua năm bước: liệt kê tài sản, liệt kê kẻ tấn công, vẽ ranh giới tin cậy, soi từng vị trí trên bản đồ, và đếm chân lethal trifecta cho từng luồng. Luồng nào đủ ba chân thì cắt bớt một chân trước khi làm bất kỳ thứ gì khác.
Vẽ xong bản đồ mới là nửa đường. Nửa còn lại là thử tấn công chính hệ thống của mình xem bản đồ có đúng không, và đó là chủ đề của bài tiếp theo: red teaming LLM.
Tài liệu tham khảo:
- Simon Willison: The lethal trifecta for AI agents (2025)
- Simon Willison: Prompt injection attacks against GPT-3 (2022)
- EchoLeak: The First Real-World Zero-Click Prompt Injection Exploit in a Production LLM System (arXiv, 2025)
- Hack The Box: Inside CVE-2025-32711 (EchoLeak)
- Defeating Prompt Injections by Design, CaMeL (Debenedetti và cộng sự, Google DeepMind, 2025)
- Meta: Agents Rule of Two, a practical approach to AI agent security (2025)
- OWASP Top 10 for Large Language Model Applications
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.
On this page
- # cái khó gốc rễ: không có ranh giới giữa lệnh và dữ liệu
- # bước 1: liệt kê thứ đáng bảo vệ
- # bước 2: ai sẽ tấn công, đi cửa nào
- # bước 3: vẽ ranh giới tin cậy
- # lethal trifecta và rule of two
- # mổ xẻ một vụ thật: EchoLeak
- # bước 4: đi qua từng vị trí trên bản đồ
- # chặn đường rò rỉ qua ảnh markdown
- # bước 5: thiết kế để giới hạn thiệt hại
- # đừng quên mấy thứ nhàm chán
- # bắt đầu từ đâu