SORA.SEC WIRED ARCHIVE 网络安全
1148 字
6 分钟
Next.js SSR 权限错位链分析

取材sekaictf 2026

Next.js SSR 权限错位链分析 Cookie 覆盖 + Query 解析差异 + Data Route 绕过#

对手搓很友好的一集

首先看flag位置,可以看出被分为两半了

import fs from 'node:fs/promises'
export async function readFirstFlagHalf() {
const flag = (await fs.readFile('/flag.txt', 'utf8')).trim()
return flag.slice(0, Math.ceil(flag.length / 2))
}

可以追溯其调用的函数

export async function getServerSideProps({ req, res }) {
const ticketUser = await findByTicketUuid(req.cookies.ticket_uuid || '')
if (ticketUser?.id !== 1) {
res.statusCode = 403
return {
props: {
backstageNote: '',
denied: true,
},
}
}
return {
props: {
backstageNote: await readFirstFlagHalf(),
denied: false,
},
}
}

需要ticketUser为1,可以看看原生用户miku

await db.run(
`
INSERT INTO users (id, username, password_hash, tier, ticket_uuid)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
password_hash = excluded.password_hash,
ticket_uuid = excluded.ticket_uuid
`,
[1, 'miku', await bcrypt.hash(crypto.randomUUID(), 12), 'VIP', crypto.randomUUID()]
)

密码的hash是强加密,没法破解,怎么拿到ticket uuid是个问题

在全局查找ticket uudi的时候看到了row函数

function rowToUser(row) {
if (!row) return null
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
tier: row.tier,
ticketUuid: row.ticket_uuid,
}
}

查看其调用位置

其中findusername是在login时鉴权采用,而findById在

export async function getServerSideProps({ query }) {
const user = await findById(query.id)
if (!user) return { notFound: true }
const qrDataUrl = await QRCode.toDataURL(user.ticketUuid, {
errorCorrectionLevel: 'M',
margin: 1,
width: 220,
color: {
dark: '#06111a',
light: '#ffffffff',
},
})

的时候只需要传入query.id就可以row返回所有的信息。这在access-card.js中

因为是nextjs的框架,看看对于这个板块中间件的处理

export async function middleware(request) {
const session = await verifyJwt(request.cookies.get('session')?.value)
if (!session?.sub) {
return deny(request)
}
if (request.nextUrl.pathname === '/access-card') {
const checkedId = request.nextUrl.searchParams.get('id')
if (checkedId !== session.sub) {
return deny(request)
}
}

当然,这里的session最先一定是自身的,所以可以先建立一个用户

接下来就是过中间件。request.nextUrl.searchParams.get(‘id’)需要和session字段相等

但是同时query.id需要等于1

这里可以清晰看见这里是存在解析差异的

query.id是直接取,但是searchParams.get(‘id’)是nextjs库中的一个方法

Nextjs的源码有这个常量

export const NEXT_QUERY_PARAM_PREFIX = ‘nxtP’

以及这个函数

export function normalizeNextQueryParam(key: string): null | string {
const prefixes = [NEXT_QUERY_PARAM_PREFIX, NEXT_INTERCEPTION_MARKER_PREFIX]
for (const prefix of prefixes) {
if (key !== prefix && key.startsWith(prefix)) {
return key.substring(prefix.length)
}
}
return null
}

可以看到会对前缀进行去除

这样先传nxtPid,再传id就可以返回id=1的ticket uuid

再看backroom的鉴权

if (request.nextUrl.pathname === '/backroom') {
const expectedTicket = session.ticketUuid
const middlewareTicket = request.cookies.get('ticket_uuid')?.value || ''
if (!expectedTicket || middlewareTicket !== expectedTicket) {
return deny(request)
}
}

以及

export async function getServerSideProps({ req, res }) {
const ticketUser = await findByTicketUuid(req.cookies.ticket_uuid || '')
if (ticketUser?.id !== 1) {
res.statusCode = 403
return {
props: {
backstageNote: '',
denied: true,
},
}
}
return {
props: {
backstageNote: await readFirstFlagHalf(),
denied: false,
},
}
}

条件有几个

req.cookies.ticket_uuid为miku的ticket

session.ticketUuid和request.cookies.get(‘ticket_uuid’)需要相等(中间件鉴权)

session是没法改得,所以request.cookies.get(‘ticket_uuid’)需要取到和我们用户一样得ticket

但是req.cookies.ticket_uuid为miku的ticket

可以看到这两者用的也是不一样的方法

request.cookie.get()逻辑如下

export class RequestCookies {
_parsed: Map = new Map()
constructor(requestHeaders: Headers) {
const header = requestHeaders.get('cookie')
if (header) {
const parsed = parseCookie(header)
for (const [name, value] of parsed) {
this._parsed.set(name, { name, value })
}
}
}
get(name) {
return this._parsed.get(name)
}
}

可以看到这键值对会进行覆盖。

反复的this._parsed.set(name, { name, value })

会对上一对相同name的进行覆盖

而 req.cookies.ticket_uuid

const obj = {}
const key = ...
// only assign once
if (obj[key] === undefined) {
obj[key] = value
}

源码注释也有 // only assign once

所以并不会被覆盖

所以就可以在对于覆盖的cookie进行操作,如下

Cookie: ticket_uuid=MIKU_TICKET; session=YOUR_SESSION; ticket_uuid=YOUR_TICKET

也就可以拿到第一部分的flag

再者看第二部分

import fs from 'node:fs/promises'
async function readSecondFlagHalf() {
const flag = (await fs.readFile('/flag.txt', 'utf8')).trim()
return flag.slice(Math.ceil(flag.length / 2))
}
export async function getServerSideProps() {
return {
props: {
backstageNote: await readSecondFlagHalf(),
},
}
}
export default function CdnHome({ backstageNote }) {
return (
<main className="accepted-background" aria-label="Accepted">
<div className="flag-overlay">{backstageNote}</div>
</main>
)
}

貌似访问首页就可以了,看中间件

import { NextResponse } from 'next/server'
export function middleware(request) {
const remoteAddress =
request.headers.get('x-real-migu') || ''
if (remoteAddress !== '1.3.3.7') {
const url = new URL('/rejected', request.url)
return NextResponse.redirect(url, 302)
}
return NextResponse.next()
}
export const config = {
matcher: ['/'],
}

也非常短,限定了remoteAddress

刚想着直接改,但是不行,nginx发力了,看看.conf

server {
listen 8081;
server_name _;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Real-Migu $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
location /assets/ {
alias /app/public/;
}
location / {
proxy_pass http://127.0.0.1:3002;
}
}

可以注意到对于请求头都经过了二次规范,改是没用了

看看中间件

export const config = {
matcher: ['/'],
}

这里只对/进行了防护,这里会想到nextjs的特性

在服务build之后,对于函数的请求资源都会去额外构建一个分区, data route

也就是说,可以去哪里拿到json,也就绕过了/的封锁

接口在/_next/data//index.json

访问未受保护的页面拿到,然后去index.json拿flag

并且因为assetPrefix: ‘cdn’

所以

/ -> /cdn/_next/data//index.json

over

Next.js SSR 权限错位链分析
https://ymsora.com/posts/sejai-1/
作者
YMsora~X
发布于
2026-07-04
许可协议
Unlicensed
LAST UPDATE / 最后更新