取材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 onceif (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/
访问未受保护的页面拿到
并且因为assetPrefix: ‘cdn’
所以
/ -> /cdn/_next/data/
over