2026-04-09 · AI框架源码系列
2026 年 4 月 8 日,一个普通的周三,LangChain-core 一天之内发了两个版本:0.3.84 和 1.2.28。
大部分人刷到 changelog 的时候会直接跳过------又是修 bug 的小版本。
但这次不一样。两个版本有一条一模一样的改动:sanitize prompts。CVE 编号 2026-4539。
这是安全补丁。说明在此之前,LangChain 的 PromptTemplate 存在一个真实可利用的注入漏洞。你可能已经在线上跑着用了 PromptTemplate 的 Agent。
一、问题出在哪:两行源码的故事
先说结论再展开原理。
LangChain 的 PromptTemplate 用的是 Python 的 str.format_map 做变量替换。这个方法有个特点:它不区分 {} 是你写在模板里的,还是用户输入带进来的。只要字符串里有 {变量名},它就会尝试替换。
类比一下:这就像餐厅有个规定"单据里括号里的都是厨师指令",结果顾客在菜名里写了个括号,厨师就真的按顾客的意思操作了。
来看 LangChain-core 0.3.83(补丁之前)的核心格式化逻辑:
python
# langchain_core/prompts/prompt.py(补丁前简化版)
class PromptTemplate(StringPromptTemplate):
def format(self, **kwargs: Any) -> str:
kwargs = self._merge_partial_and_user_variables(**kwargs)
return DEFAULT_FORMATTER_MAPPING[self.template_format](
self.template, **kwargs
)
# DEFAULT_FORMATTER_MAPPING["f-string"] 最终调用:
def format(self, template: str, **kwargs: Any) -> str:
return template.format_map(kwargs) # ← 问题就在这里
就这一行:template.format_map(kwargs)。
用户传进来的值,没有任何处理,直接喂给了 format_map。
二、攻击场景还原:从读取变量到泄露 API Key
设定:你做了一个翻译服务,模板长这样:
ini
from langchain_core.prompts import PromptTemplate
template = """
你是一个专业翻译,请将原文翻译成英文。
规则:{rules}
原文:{user_text}
"""
prompt = PromptTemplate(
input_variables=["rules", "user_text"],
template=template
)
正常请求:user_text="你好世界",一切正常。
攻击者请求:user_text="{rules}"
执行 template.format_map({"rules": "保持专业语气", "user_text": "{rules}"}) 时,发生了两轮替换:
• 第一轮:{user_text} → {rules}(用户注入的字符串)
• 第二轮:{rules} → 保持专业语气
原文那栏显示了 rules 的值。这还只是轻量版。
更危险的变体:
python
# Python 的 str.format_map 支持属性访问和下标访问
user_text = "{config.OPENAI_API_KEY}"
# 如果你无意中把配置对象传进了 kwargs(线上代码里不罕见):
result = prompt.format(
rules="保持专业语气",
user_text=user_text,
config=app_config # ← 假设在某个中间件里统一注入了上下文
)
# 输出里会包含你的 OPENAI_API_KEY
print(result)
# 你是一个专业翻译...
# 规则:保持专业语气
# 原文:sk-proj-xxxxxxxxxxxxxxxxxx str:
"""把用户输入里的 { 和 } 转义,阻止二次解析"""
return value.replace("{", "{{").replace("}", "}}")
def format(self, **kwargs: Any) -> str:
kwargs = self._merge_partial_and_user_variables(**kwargs)
if self.template_format == "f-string":
sanitized = {
k: _sanitize_value_for_fstring(str(v)) if isinstance(v, str) else v
for k, v in kwargs.items()
}
return DEFAULT_FORMATTER_MAPPING["f-string"](self.template, **sanitized)
# jinja2 路径启用 SandboxedEnvironment...
把 { 替换成 {{。在 Python f-string 语法里,{{ 是转义字符,最终渲染成字面量 {,不会被当占位符解析。
和 SQL 参数化查询的哲学完全一致:数据和结构分离,不让数据改变结构的语义。
Jinja2 路径启用沙箱:
python
from jinja2.sandbox import SandboxedEnvironment
def jinja2_formatter_safe(template: str, **kwargs: Any) -> str:
env = SandboxedEnvironment() # 限制属性访问,禁止危险方法
return env.from_string(template).render(**kwargs)
SandboxedEnvironment 会阻断通过 __class__.__mro__ 等 dunder 属性爬升到系统类的路径,大幅收窄 SSTI 的可利用面。
五、你的代码要怎么改:四个可直接执行的步骤
步骤一:马上升级
bash
pip install --upgrade langchain-core
# 目标:>= 0.3.84(v1.x 用户升到 >= 1.2.28)
# 验证版本
python3 -c "import langchain_core; print(langchain_core.__version__)"
步骤二:全局搜索高风险用法
perl
# 找所有用了 jinja2 格式的地方
grep -r 'template_format.*jinja2' --include="*.py" .
# 找所有往 PromptTemplate.format() 传参的调用
grep -r '\.format(' --include="*.py" . | grep -v "^Binary"
重点关注:变量值来自用户请求、数据库查询结果、第三方 API 返回值的地方。
步骤三:纵深防御------额外的输入过滤层
升级之后 LangChain 自己会做 sanitize,但多一层纵深防御没坏处:
python
import re
import logging
def validate_prompt_input(text: str, max_length: int = 2000) -> str:
"""
在用户输入进入 PromptTemplate 之前的额外校验层
升级到 0.3.84+ 后保留为纵深防御
"""
if len(text) > max_length:
raise ValueError(f"输入超长:{len(text)} > {max_length}")
# 检测可疑的模板语法,记录日志供安全审计
if re.search(r'\{[^}]{0,100}\}', text):
logging.warning(f"[SECURITY] 检测到可疑模板语法,来源IP可能需要关注:{text[:100]!r}")
return text
# 用法
user_input = validate_prompt_input(request.get("user_text", ""))
result = prompt.format(rules="保持专业语气", user_text=user_input)
步骤四:模板结构绝对不能来自用户输入
makefile
# 危险:让用户选择模板字符串
user_template = request.get("template")
prompt = PromptTemplate(template=user_template, ...) # ← 绝对不行
# 安全:用白名单选预定义模板
TEMPLATE_REGISTRY = {
"translate": "请将以下文本翻译成英文:{text}",
"summarize": "请总结以下内容,不超过200字:{text}",
"qa": "基于以下上下文回答问题:\n上下文:{context}\n问题:{question}",
}
template_key = request.get("template_type")
if template_key not in TEMPLATE_REGISTRY:
raise ValueError(f"不支持的模板类型:{template_key}")
prompt = PromptTemplate.from_template(TEMPLATE_REGISTRY[template_key])
六、这个漏洞揭示了一个更大的问题
从工程角度看,这个漏洞背后有个 AI 应用特有的系统性风险,比漏洞本身更值得关注。
传统 Web 应用的数据流相对简单:用户输入 → 验证 → 业务逻辑 → 数据库/响应。
AI 应用的数据流复杂得多,而且有个关键区别:中间环节的数据会被重新注入到下一轮的 Prompt 里。
用户输入 ↓ 向量数据库检索结果(来自外部文档,可能被攻击者预先污染) ↓ 注入面 → PromptTemplate.format()(补丁前) ↓ LLM 推理 → Agent 决策 ↓ 工具调用返回值(再次进入下一轮 Prompt) ↓ 注入面再次放大 → PromptTemplate.format()(多轮迭代) ↓ 最终输出 / 副作用(发邮件、写文件、调外部接口)
任何中间节点------工具返回值、RAG 检索结果------都可能携带恶意的模板语法。而且 Agent 的多轮迭代会把注入面放大。
这意味着 AI 应用的安全模型需要更深层地思考"什么是可信数据"。不仅用户输入不可信,外部工具的返回值同样不可信。
本周 MCP Python SDK v1.27.0 发布,也包含安全修复------通过 MCP 工具返回的内容如果被不加过滤地塞进 Prompt,同样是注入面。这不是 LangChain 一家的问题,是整个 AI 工具链的系统性风险。
小结
把这次漏洞的核心逻辑串一遍:
• 根因 :str.format_map 不区分"模板变量"和"变量值里的 {}",用户输入可触发二次模板解析
• 影响:读取其他变量值;在有敏感对象传入时泄露配置数据;jinja2 模式下执行任意代码
• 修法 :变量值进入格式化之前把 {} 转义成 {{}};jinja2 启用 SandboxedEnvironment
• 原则:模板结构不能来自用户输入;所有进入 Prompt 的外部数据都视为不可信
接下来值得关注的方向:AI 应用的"输入信任边界"到底在哪里?Framework 层(LangChain 做)、工具调用层(开发者做)、LLM 的 system prompt 层(模型层做)------理想状态是三层都有,但目前缺乏统一的标准和最佳实践。这块会是 2026 年 AI 安全方向的热点。
如果这篇文章对你有帮助,欢迎转发给正在用 LangChain 做 AI 应用的同事。