文章总结: 本文分析Next.jsSSR中的权限错位漏洞链,涉及Cookie覆盖、Query解析差异和DataRoute绕过。通过利用nxtP前缀处理差异获取miku的ticket_uuid,并利用RequestCookies的覆盖特性与req.cookies的只赋值一次特性绕过中间件鉴权,最终通过DataRoute绕过根路径限制获取完整flag。建议开发者统一解析逻辑,避免依赖特定实现细节。 综合评分: 85 文章分类: 漏洞分析,WEB安全,红队,渗透测试,安全开发
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 fsfrom'node:fs/promises'asyncfunctionreadSecondFlagHalf() { const flag = (await fs.readFile('/flag.txt','utf8')).trim() return flag.slice(Math.ceil(flag.length /2)) }exportasyncfunctiongetServerSideProps() { return { props: { backstageNote:awaitreadSecondFlagHalf(), }, } }exportdefaultfunctionCdnHome({ backstageNote }) { return ( <mainclassName="accepted-background"aria-label="Accepted"> <divclassName="flag-overlay">{backstageNote}</div> </main> ) }
貌似访问首页就可以了,看中间件
import {NextResponse }from'next/server'exportfunctionmiddleware(request) { const remoteAddress = request.headers.get('x-real-migu') ||'' if (remoteAddress !=='1.3.3.7') { const url =newURL('/rejected', request.url) returnNextResponse.redirect(url,302) } returnNextResponse.next() }exportconst 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; } }
可以注意到对于请求头都经过了二次规范,改是没用了
看看中间件
exportconst config = { 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 权限错位链分析》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论