最近在打SCTF的时候遇到一个LangChain反序列化的题目 比较新颖 于是想着学习一下
LangChain介绍
LangChain是一个用于构建大语言模型应用的开源框架 通过这个可以把常用的组件抽象成可串联的链(Chain)
举个例子 假如我们现在要设计一个客服机器人 它的工作是根据用户问题搜索公司文档 然后把结果交给LLM生成回答 这一系列行动是通过LangChain实现的
1 | 搜索链->凭借提示词->调用AI模型->输出回答 |
同时为了提高开发效率 LangChain还提供了很多的辅助功能 其中之一就是保存和加载整个链配置 比如你已经调试好了一条链 想要存成文件下次直接使用 这就需要一个序列化机制
LangChain存在漏洞的设计
在低版本的LangChain之中是存在pickle反序列化漏洞的(LangChain<0.0.233)这是CVE-2023-36258 于是开发者为了替代不安全的Python原生pickle 发明了一种基于JSON/字典的序列化格式 其大致思路是这样的
- 把每一个LangChain对象都表示成一个普通字典
- 在字典之中使用一个特殊字段lc来标记“这是一个LangChain”对象 按照这一设计的协议来将其反序列化
- type表示调用哪个类或函数 id表示要导入的模块路径和对象名称 kwargs表示传什么参数
1 | { |
我们来看到这一段JSON 这就是一个OpenAL的LLM对象序列化之后的内容
1 | "lc": 1 |
这是LangChain协议的身份标识 反序列化器(loads)识别到字典之中存在lc并且其值等用途1的时候就指导需要按照LangChain协议恢复它
1 | "type": "constructor" |
这一段的作用是告诉反序列化器使用那种方式重建对象
这里使用的constructor是最常见的类型 用于动态导入一个可调用对象 然后使用kwargs作为参数调用它 返回结果就是原始对象
1 | "id": ["langchain_community", "llms", "openai", "OpenAI"] |
前面的langchain_community,llms,openal会被使用.号连接起来 写成模块路径langchain_community.llms.openai
最后这一段内容就会被解释成from langchain_community.llms.openai import OpenAI
1 | "kwargs": { |
这一段内容就比较好理解了 这就是传给构造函数的关键字参数 反序列化器拿到OpenAI后 会这样调用
1 | OpenAI(model_name="gpt-3.5-turbo", temperature=0.7) |
从而原封不动的重建出哪个LLM配置对象
这个机制本身没有什么问题 问题出在 它对所有带有lc键的字典一视同仁 并没有严格区分这个字典是LangChain自己生成的还是来自用户输入的普通·数据
LangChain反序列化漏洞如何产生
关于LangChain 目前有两个已知的漏洞
CVE-2025-68664
这是本篇文章主要研究的漏洞 在高版本的LangChain之中 由于修复了前者 我们考虑使用LangChain自己的协议来攻击自己
漏洞点存在于langchain-core的dumps()/dumpd()函数之中 这些函数本来是用来将LangChain对象转换成JSON/字典的 但是当题目处理一个由用户传入的自由格式字典的时候 不会讲字典里可能存在的lc键做任何转义或隔离
又由于我们之前提到 这个机制对所有带有lc的JSON一视同仁 会将这些JSON字典当成合法的LangChain对象来重建 从而执行攻击者指定的任意Python函数或类构造函数 导致远程代码执行或者信息泄露
比如说是这样一个处理结构
1 | user_data = request.get_json() |
这个漏洞的本质原因就是协议控制字段lc和用户数据共用同一个命名空间 并且序列化函数未对用户数据进行净化 导致的信任边界混淆
68664漏洞简单复现
由于我的python版本太高了 直接安装LangChain会出现不兼容的问题
这里我选择手动编写一个和LangChai-core里面的loadd函数相同的函数就行
环境搭建
首先创建文件夹langchain_lab
使用python创建一个虚拟环境
1 | python -m venv venv //这一步创建环境 第二个venv是虚拟环境名称 |
接下来要给虚拟环境安装我们需要的模块
在这里使用request和Flask就行
1 | pip install Flask |
那么现在我们为了模拟LangChain的机制 需要手动编写一个loadd函数
1 | import importlib |
那么漏洞函数和服务端就扣都写好了 直接使用python打开即可
看到Running on http://127.0.0.1:5000就说明启动成功 保持这个终端打开
再开一个终端 同样激活虚拟环境
创建一个攻击脚本
1 | import requests |
我们传入地方payload里面定义了subprocess.check_output(args=["cmd", "/c", "echo ..."]) 在 Windows 上执行命令 命令内容是吧Su@sU写入pwned.txt之内
在终端执行这个攻击脚本就会发现文件pwned.txt生成在目录之中 并且写入了Su@sU了
整个简单模拟完成
读取环境变量
在这道题目之中我们需要读取环境变量之中的SHOP_SUPPORT_SEED
那么我们同样的来模拟一下读取环境变量之中规定内容应该如何操作
将exploit.py之中的内容修改成这样
1 | payload = { |
最核心就是描述导入os模块的getenv函数
等价于
1 | from os import getenv |
由于os.getenv(key, default=None) 所以我们需要在参数之中传入
1 | "kwargs": {"key": "PATH"} |
这样一来就会变成getenv(key="PATH") 于是返回系统环境变量的值
我们再回过头看这道SCTF上的web shop
查看前端js代码可以看到这样的构造逻辑
1 | function ge(e, t) { |
可以了解到这个是LangChain反序列化的形式
这道题中文名可以向机器人发起对话 也符合LangChain这个和AI息息相关的框架
那么我们可以向机器人发出自己构造的带有lc标识的JSON 用以实现我们的目的
在这里我们需要算出staff code 而staff code的计算需要环境变量之中的SHOP_SUPPORT_SEED 那么我们就可以了LangChain反序列化的方式获得环境变量之中的这个值
我们需要知道除了使用os.getnev的方式之外 我们还有别的更简单 更不容易被发现的方式获取环境变量
这里利用secret类型 :在LangChain之中 一些敏感配置不会明文卸载序列化结果里面 而是用一个secret引用 指向环境变量 反序列化的时候 secret类型会自动从os.environ之中读取对应键之中的值
那么我们就可以这么构造
1 | "probe": { |
那么我们就可以先正常登录获取token之后 利用bp发送以下JSON内容
1 | POST /api/chat/messages |
后端处理这个metadata的时候 内部的loadd函数遇到probe字段 并发现lc=1且type=secret的时候 就会执行os.environ["SHOP_SUPPORT_SEED"] 并用返回的字符串替换probe字段的值 此时当再次请求获取聊天记录的时候 返回的metadara.probe
就不再是那个字典 而是变成了实际的种子字符串 我们就获取了环境变量之中的这个值
CVE-2023-36258
这个漏洞是直接使用pickle反序列化来完成 由于早期版本的LangChain.chains.loading模块之中 load_chain()函数直接使用了Python原生的pickle.load()来加载用户提供的文件 就会产生这一漏洞
存在漏洞的设计
LangChain的核心是链 用户可能会话很长时间调试一个复杂的链(搜文档——提取关键信息——调用LLM——格式化输出)那么自然 当用户调试好之后就想将这个链保存到文件 下次就能直接加载使用
于是LangChain设计了一个函数:load_chain(path)其设计初衷就是为了给定一个文件路径 把文件内容反序列化成一个直接可用的Chain对象
那么这时候 Python原生支持 能序列化几乎任何Python对象的pickle就显得很好用了
1 | import pickle |
上述代码定义了一个load_chain函数 这个函数接收一个参数类型提示未字符串的参数path
之后以二进制只读模式打开path指定的文件 并使用pickle.load(f)从文件对象f之中读取二进制字节流 将其还原成原始的Python对象
但是这样设计隐含了一个致命的问题 其假设:被加载的文件是用户自己保存的 安全可信的
然而LangChain作为一个框架 它的load_chain函数很可能被开发者用在服务端 接收来自最终用户上传的文件 攻击者就可以构造恶意的pickle文件让服务器加载
漏洞产生原理
这里运用的漏洞产生原理其实也就是pickle反序列化的原理
pickle反序列化特殊之处在于它保存的是一串操作码 类似于指令 告诉反序列化器一步步一个怎么做
最核心部分在于__reduce__方法
当一个对象定义了reduce方法 pickle会在序列化的时候调用它 这个方法应当返回一个元组(callable,args)当反序列化的时候 pickle.load会执行callable(*args)
那么我们就可以定义一个类 让他的__reduce__返回危险函数和恶意参数 然后讲这一对象序列化成文件
就比如这样
1 | import pickle |
那么当以上序列化数据被反序列化的时候 就会执行os.system('echo Su@sU') 这样一来就会导致恶意代码执行
这个漏洞的利用就是pickle反序列化漏洞的利用逻辑 再次不做过多赘述