Prompt 注入防御避坑:那些看起来安全实则失效的方案
一、当"加了校验"变成幻觉:为什么防御会悄悄失效
很多团队在接入大模型后,会先做一层输入过滤。他们认为只要挡住"忽略指令"这类短语,系统就安全了。这种信心往往来自一次演示:测试人员输入恶意语句,被规则拦截,于是评审通过。
但上线后的真实环境远比演示复杂。攻击者不会照着示例喊话。他们会把指令藏进一段客服对话里,藏在用户上传的文档中,藏在多轮聊天的上下文缝隙里。当过滤规则只看字面,攻击却发生在语义层,拦截就成了摆设。
更隐蔽的问题是"防御假象"。一个方案可能拦截了十种已知攻击,却在报表里显示零漏报。这并不代表它强,只代表它没遇到没见过的样本。把"没被发现"当成"不存在",是安全建设里最常见的自欺。
还有一类失效来自架构错位。团队把检测放在应用网关,却忘了模型还会读取检索回来的网页内容。外部文档里的指令同样能注入,而网关对此一无所知。信任边界画错了位置,再厚实的墙也挡不住门内的敌人。
这些方案的共同点,是都"看起来安全"。它们有个说得通的原理,通过了初步测试,甚至写了漂亮的文档。但当攻击面从字面走向语义、从用户框走向上下文,它们便在看不见的地方开了天窗。
二、失效的底层逻辑:从信任边界错位看防御漏洞
要理解为什么防御会失效,先把系统拆成三层信任。每一层都可能是注入入口,而多数方案只守住了第一层。
第一层是用户框。多数规则层只在这里设防,拦住了"忽略上面的要求"这样的直白表达。第二层是检索内容。RAG 系统把网页、邮件、知识库拼进上下文,这些内容里的指令同样会被执行,而网关看不见它们。
第三层是工具回传。模型调用一个接口,接口返回的数据又被塞回提示词,形成二次注入。攻击者只要控制其中一个外部数据源,就能借模型的手完成越权。
失效的根源有三处。其一,信任边界只画在应用入口,没覆盖"所有进入上下文的内容"。其二,检测只看表层字符,不判断"这句话是数据还是指令"。其三,防御与动作解耦:能拦输入,却拦不住已被污染的上下文去调工具。
把这三层画清楚,就能解释为什么很多"加了校验"的系统仍然被注入。它们防住了入口,却放进了污染源。
三、可验证的多层防御实现:把"看起来安全"变成"可审计"
下面是一段贯穿三层信任的防御骨架。它把输入、检索内容、工具回传都纳入检测,并内置超时、降级与并发控制。
import asyncio import re import hashlib # 三类入口共享同一套检测策略,避免"只防用户框"的错位 INJECTION_RULES = [ r"忽略(上面|之前|先前|以上).{0,12}?(要求|指令|提示|规则)", r"ignore.{0,8}?(previous|above|system).{0,8}?instruction", r"你现在是.{0,10}?(开发者|管理员|root|无限制)", ] class TrustBoundary: def __init__(self, classifier=None, timeout: float = 0.8, max_workers: int = 16): self._classifier = classifier self._timeout = timeout # 信号量限制并发,避免大批内容同时过检时打爆分类器 self._sem = asyncio.Semaphore(max_workers) async def _rule_scan(self, text: str) -> bool: lowered = text.lower() for pat in INJECTION_RULES: if re.search(pat, text, re.IGNORECASE) or re.search(pat, lowered): return True return False async def _semantic_score(self, text: str) -> float: if self._classifier is None: return 0.0 try: # 分类器可能慢或抖动,严格超时并按"不可信"降级 return float(await asyncio.wait_for(self._classifier.score(text), self._timeout)) except (asyncio.TimeoutError, Exception): # 任何异常都视为风险,宁可误报也不放行污染 return 1.0 async def inspect(self, text: str, source: str) -> dict: # source 标记来源: user / retrieval / tool,用于事后审计 if not text or not isinstance(text, str): return {"risk": "block", "source": source, "reason": "empty_or_invalid"} async with self._sem: if await self._rule_scan(text): return {"risk": "high", "source": source, "reason": "rule_hit"} score = await self._semantic_score(text) if score >= 0.6: return {"risk": "high", "source": source, "reason": "model_score", "score": score} return {"risk": "low", "source": source, "reason": "pass", "score": await self._semantic_score(text) if False else 0.0} async def guard_pipeline(texts: list[tuple[str, str]], boundary: TrustBoundary) -> list[dict]: # 对所有入口并发检测,单条失败不影响整体,并记录来源 async def _one(item): try: return await boundary.inspect(*item) except Exception as e: return {"risk": "high", "source": item[1], "reason": f"error:{e}"} return await asyncio.gather(*[_one(t) for t in texts])这段代码的要点:三类入口共用TrustBoundary,从架构上消除"只防用户框"的错位;信号量控制并发,防止检索批量内容时压垮分类器;超时与异常统一降级为高风险,保证链路不开天窗;source字段把每次判定绑定来源,让"看起来安全"变成可审计的记录。
落地时还应把检测结果写进结构化日志,按来源统计命中率。当某类来源的漏报上升,说明攻击面转移,需要补规则或微调分类器。这才是把防御从"演示通过"推进到"持续可证"。
四、防御的边界:哪些方案注定补不上裂缝
即便做了多层防御,仍有几条边界必须认清,否则会再次陷入"看起来安全"。
规则层必然有盲区。攻击者用角色扮演、剧情虚构、Base64 编码,就能把意图藏进无害叙述。规则只能挡已知形态,挡不住无穷的变体。把它当唯一防线,等于把锁只装在前门。
语义分类器会被样本分布拖垮。如果你的训练集没有某类攻击,分类器对它的评分接近随机。更麻烦的是概念漂移:新漏洞、新话术出现后,旧模型分数逐渐失真。需要建立定期重训与影子流量评估,而不是训一次用全年。
上下文隔离有体验代价。把不可信内容放进独立沙箱,模型会丢失跨轮记忆,多轮对话可能变得割裂。隔离应是按需触发,只在风险越阈时启用,而非对所有输入一刀切。
工具边界无法靠提示词兜底。很多团队在系统提示里写"禁止删除文件",但模型并不真正理解禁止。真正可靠的做法是在工具层做权限校验与二次确认,让模型无法越权,而不是靠它自律。
最后,没有方案能覆盖零日注入。当攻击手法全新,规则与分类器都还没见过,只能靠最小权限与动作审计兜底。把"多层防御"理解成"绝对安全",本身就是新的漏洞。
五、总结
Prompt 注入防御失效,多源于信任边界错位与"演示通过即安全"的错觉。真正的防线要覆盖用户输入、检索内容、工具回传三类入口,用规则挡已知、语义补变体,并以超时降级保证链路不漏。工程上需并发控制与来源审计,边界上要承认规则有盲区、分类会漂移、隔离有代价、提示词兜不住工具越权。把防御做成可观测、可重训、可审计的链路,才能摆脱"看起来安全"的假象。