
Подробный анализ CVE-2025-61686, уязвимости обхода пути в файловом хранилище сессий React Router, включая первопричину, сценарии атак и результаты аудита кода.
CVE ID: CVE-2025-61686
Затронутые версии: @react-router/node с 7.0.0 по 7.9.3
Тип уязвимости: Path Traversal / Directory Traversal
Уязвимость находится в файле packages/react-router-node/sessions/fileStorage.ts в функции getFile() и связанной логике файловых операций.
В файле packages/react-router/lib/server-runtime/sessions.ts, строка 267:
async getSession(cookieHeader, options) {
let id = cookieHeader && (await cookie.parse(cookieHeader, options));
let data = id && (await readData(id));
return createSession(data || {}, id || "");
}
Session ID извлекается из Cookie с помощью метода cookie.parse().
В функции decodeCookieValue() файла packages/react-router/lib/server-runtime/cookies.ts:
async function decodeCookieValue(
value: string,
secrets: string[],
): Promise<any> {
if (secrets.length > 0) {
// 如果配置了 secrets,会验证签名
for (let secret of secrets) {
let unsignedValue = await unsign(value, secret);
if (unsignedValue !== false) {
return decodeData(unsignedValue);
}
}
return null; // 签名验证失败返回 null
}
// 如果没有配置 secrets(未签名),直接返回解码后的值
return decodeData(value);
}
Ключевая проблема: Если cookie не подписана (secrets — пустой массив или не установлен), decodeCookieValue напрямую возвращает декодированное значение cookie, и атакующий может полностью контролировать это значение.
В файле packages/react-router-node/sessions/fileStorage.ts:
export function getFile(dir: string, id: string): string {
// Divide the session id up into a directory (first 2 bytes) and filename
// (remaining 6 bytes) to reduce the chance of having very large directories,
return path.join(dir, id.slice(0, 4), id.slice(4));
}
Эта функция делит session ID на две части:
id.slice(0, 4)id.slice(4)Затем использует path.join() для сборки пути.
Сценарий атаки: При использовании createFileSessionStorage() с cookie без подписи:
../../etc/passwdgetFile():
id.slice(0, 4) = ../.id.slice(4) = /etc/passwdpath.join(dir, ../., /etc/passwd)path.join() нормализует путь, если dir уже является относительным путём или после обработки, всё ещё возможен path traversal.Более точный способ эксплуатации:
....//etc/passwd
id.slice(0, 4) = ....id.slice(4) = //etc/passwd/etc/passwdИли:
../../../etc/passwd (16 символов)
id.slice(0, 4) = ../.id.slice(4) = ./etc/passwdpath.join() может привести к path traversal.Следующие файловые операции могут быть затронуты:
readData(id) — при чтении данных сессии
async readData(id) {
try {
let file = getFile(dir, id);
let content = JSON.parse(await fsp.readFile(file, "utf-8"));
// ...
}
}
updateData(id, data, expires) — при обновлении данных сессии
async updateData(id, data, expires) {
let content = JSON.stringify({ data, expires });
let file = getFile(dir, id);
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, content, "utf-8");
}
deleteData(id) — при удалении данных сессии
async deleteData(id) {
try {
await fsp.unlink(getFile(dir, id));
}
}
Должны одновременно выполняться следующие условия:
createFileSessionStorage()cookie не установлен secrets или secrets — пустой массив)getSession(cookieHeader)
→ cookie.parse(cookieHeader)
→ decodeCookieValue(value, secrets) // при отсутствии подписи возвращает value как есть
→ readData(id)
→ getFile(dir, id) // конкатенация пути, риск path traversal
→ fsp.readFile(file) / fsp.writeFile(file) / fsp.unlink(file)
getFile() не проверяет и не нормализует параметр idpath.join(): хотя path.join() нормализует путь, в определённых случаях (например, когда путь до объединения уже содержит ..) всё ещё возможен path traversal