vite的文件读取漏洞前些天在p牛的知识星球讨论很热烈,当时我就想分析一下,但是好像忘了。然后这几天做了TGCTF的题,没想到已经出来一系列CVE了,所以现在赶紧开始来分析一下。
Vite 是一个现代化的前端构建工具,由 Vue.js 作者尤雨溪开发,旨在提供更快的开发体验和更高效的构建过程。它利用浏览器原生的 ES 模块(ESM)支持,实现了快速的开发服务器启动和即时的模块热更新(HMR)。在开发阶段,Vite 通过按需加载模块和使用 esbuild 预构建依赖,显著提升了启动速度和更新效率;在生产环境中,Vite 使用 Rollup 进行代码打包,生成高度优化的静态资源。此外,Vite 提供开箱即用的配置和丰富的插件生态,支持多种前端框架(如 Vue、React、Svelte 等),是现代前端开发的优选工具。
Vite更适合现代的前端构建
https://mp.weixin.qq.com/s/HMhzXqSplWa-IwpftxwTiA
CVE-2025-30208(6.2.2)
复现
环境:
1 | services: |
1 | http://localhost:5173/@fs/etc/passwd?raw?? |
有后缀名时,会报错403 Restricted
1 | http://localhost:5173/@fs/flag.txt?raw?? |
使用?import&raw??即可
1 | http://localhost:5173/@fs/flag.txt?import&raw?? |
分析
找到漏洞点
先clone下源码,直接比较修复后的差异
可以看出修复是把传入以下验证过程的&和?替换成了空
这个判断的大概意思是先检测url是否匹配正则,如果不匹配直接放走不经过ensureServingAccess的检测,而主要绕过也是在这里,然后更多的细节就需要调试了
调试
经过无数次踩坑,最后:
1 | pnpm install#遇到报错挨个解决,有的报错可以忽略,因为playground里有很多项目容易冲突 |
playground里随便选一个点debug,Debug debug
发现在我们断点处停下了
直接看调用栈,可以看出是一连串的middleware的调用
1 | viteTransformMiddleware(), transform.ts:173 |
直接从viteHostCheckMiddleware开始下断点调试,
viteHostCheckMiddleware检查host是否合法
viteCachedTransformMiddleware检查缓存是否已更新,否则返回304
如果是心跳请求则返回204
主要是处理静态资源请求,满足某些条件的文件会被当成publicFiles直接serve,不经过构建/处理。
!publicFiles.has(...)
- 当前请求的路径不在
publicFiles
中(即不是public/
中的文件)→ 跳过。 isImportRequest(...)
- 类似
/src/main.ts
的模块请求 → 跳过。 isInternalRequest(...)
- 类似
/@vite/client
、/@fs/
这类 Vite 内部路径 → 跳过。 urlRE.test(...)
- 匹配
.js?url
这类带查询参数的模块转换请求(这些是要经过模块处理的)→ 跳过。
这看起来就比较难利用了,我们继续看/@fs/
情况下的next()
这里就比较重要了,可以说上面的分析都没啥用,只是我分析了我就写上去了,hhh,,。
先对url进行处理,只接受GET,同时放走了不需要渲染的/
和/favicon.ico
,
然后就是删去了两个正则匹配到的部分/\bt=\d{13}&?\b/
,/[?&]$/
,替换__x00__
为字符0x00
(不会产生截断~笑)
然后cleanUrl会把/[?#].*$/
替换成空,方便识别文件后缀/类型(一般考虑cleanUrl之后结果的不好利用,因为就是个秃的文件名)
然后检查是否是.map
结尾的soursemap文件,是否以/public/开头但没被静态server捕获
此时的url为/@fs/Users/leon/flag?raw?
然后因为两个正则都没匹配上,这个正则本意是匹配**?raw**
这种恶意参的,但是我们加的**?**
只被删了一次,剩下的**?**
干扰了正则匹配,导致绕过ensureServingAccess对是否是合法请求源码路径的检查,因为没有?raw
的许多正常请求确实不符合ensureServingAccess,所以用&&
来短路
1 | export const urlRE = /(\?|&)url(?:&|$)/ |
然后这个if,只要满足其中一项就能进去
1 | req.headers['sec-fetch-dest'] === 'script' #判断特定header |
然后再判断是不是css,进入处理css的流程,否则直接读取通过transformRequest读取url
transformRequest主要是把request和cacheKey设置进了_pendingRequests,然后他会自动检测size>0进行请求的发送,但是我断点下成这样也没有拦住她
但是我们其实不需要拦住她,因为我们知道,她就是又执行了doTransform,只不过这次environment中有了新东西
所以在doTransform里面下断点
先获取module,跟进一下
这里会把请求过对应关系的装入_unresolvedUrlToModuleMap里,所以每次要重启server。然后_resolveUrl处理一下rawUrl
主要是对后缀的处理
所以前面都是在根据加载过的文件形成的map找modle,没用,最后我们到了loadAndTransform里面
前面有一下对map的处理,进这里的load
遍历plugins,挨个尝试用相应handler去call取得的id(文件路径及参数),最后通过vite:asset获取成功
其实这里面逻辑也比较简单
总结
所以,有后缀的情况下还可以
1 | sec-fetch-dest: script |
CVE-2025-31486(6.2.3/4)
受影响版本范围 | 修复版本 |
---|---|
>=6.2.0, <=6.2.4 | >=6.2.5 |
>=6.1.0, <=6.1.3 | >=6.1.4, <6.2.0 |
>=6.0.0, <=6.0.13 | >=6.0.14, <6.1.0 |
>=5.0.0, <=5.4.16 | >=5.4.17, <6.0.0 |
<=4.5.11 | >=4.5.12, <5.0.0 |
复现
我们把版本换成6.3.3
http://localhost:5173/@fs/Users/leon/flag?.svg?.wasm?init
/@fs/Users/leon/flag.txt?import&.svg?.wasm?init
都成功了
分析
这里的?.svg?.wasm?init
末尾没有引号,不会被urlWithoutTrailingQuerySeparators
影响,而transformRequest也能正常解析,被wasm-helper解析成功了
检测了是否以.wasm?init
结尾
然后fileToUrl把文件内容转换成了data协议的url形式,造成了文件内容泄露
总结
感觉这种用plugin处理url的方法挺容易出问题啊hh
6.3.4增加了对?inline
恶意调用plugin的禁用
CVE-2025-32395(6.2.5)
受影响版本范围 | 修复版本 |
---|---|
>=6.2.0, <=6.2.5 | >=6.2.6 |
>=6.1.0, <=6.1.4 | >=6.1.5, <6.2.0 |
>=6.0.0, <=6.0.14 | >=6.0.15, <6.1.0 |
>=5.0.0, <=5.4.17 | >=5.4.18, <6.0.0 |
<=4.5.12 | >=4.5.13, <5.0.0 |
版本换成6.2.5
这个版本直接把transform.ts检查恶意访问的几个正则匹配封装了,同时增加了对?.svg?.wasm?init
绕过的检查
复现
/@fs/Users/leon/Desktop/vite/#/../../../flag?import
这个回显就很舒服了
但是不能用浏览器发,否则不能正常发送#
分析
HTTP 1.1规范(RFC 9112)规定在请求目标(request-target)中不允许出现#字符。然而,攻击者是有可能发送包含#的请求的。对于那些请求行(包含请求目标)无效的请求,规范建议服务器返回400错误请求)或301永久重定向)状态码来拒绝这些请求。对于HTTP 2协议也是类似的情况。
如果是在Node或Bun运行Vite环境下,当收到包含#的请求时,这些运行时内部不会拒绝该请求,而是将其传递到用户代码层面。在这种情况下,http.IncomingMessage.url的值会包含#字符。而Vite在检查server.fs.deny时,假定req.url不会包含#字符,这就导致了包含#的这类请求能够绕过server.fs.deny的检查。
先打/@fs/Users/leon/Desktop/vite/../../flag
因为没有使用任何插件,所以其实这个检测对这次payload无效
路径最终会被处理,id为/Users/le0n/flag
因为没有使用插件,最终进入了下一个回调,专门处理/@fs/
的
这里检查ensureServingAccess路径是否合法,但是如果此时我们传入#
,url.pathname为/@fs/Users/leon/Desktop/vite/
,直接绕过了检查
后面仍然会提供flag的访问
总结
这次的绕过和前面几次不同,是出在处理#
而不是恶意调用plugin上的
6.2.6增加的对使用#
的拦截