If you have any thoughts on my blog or articles and you want to let me know, you can either post a comment below(public) or tell me via this feedback form

Analysis of CVE-2023-46729: URL Rewrite Vulnerability in Sentry Next.js SDK

On November 9, 2023, Sentry published an article on their blog titled Next.js SDK Security Advisory - CVE-2023-46729. The article discusses the details of the CVE-2023-46729 vulnerability, including its cause, discovery time, and patching time.

Although the vulnerability was officially announced on 11/9, it was actually fixed in version 7.77.0 released on 10/31. Some time was given to developers to patch the vulnerability.

Now let’s briefly discuss the cause and attack method of this vulnerability.

Vulnerability Analysis

There is also a more technical explanation on GitHub: CVE-2023-46729: SSRF via Next.js SDK tunnel endpoint

You can see this paragraph:

An unsanitized input of Next.js SDK tunnel endpoint allows sending HTTP requests to arbitrary URLs and reflecting the response back to the user.

In Sentry, there is a feature called “tunnel,” and this image from the official documentation perfectly explains why tunneling is needed:

tunnel

Without tunneling, requests sent to Sentry would be directly sent through the browser on the frontend. However, these requests sent directly to Sentry may be blocked by ad blockers, preventing Sentry from receiving the data. If tunneling is enabled, the request is first sent to the user’s own server and then forwarded to Sentry. This way, the request becomes a same-origin request and will not be blocked by ad blockers.

In the Sentry SDK specifically designed for Next.js, a feature called rewrite is used. Here is an example from the official documentation:

module.exports = {
  async rewrites() {
    return [
      {
        source: '/blog',
        destination: 'https://example.com/blog',
      },
      {
        source: '/blog/:slug',
        destination: 'https://example.com/blog/:slug', // Matched parameters can be used in the destination
      },
    ]
  },
}

Next.js rewrite can be divided into two types: internal and external. The latter is more like a proxy, as it can directly redirect the request to an external website and display the response.

The implementation of the Next.js Sentry SDK is in sentry-javascript/packages/nextjs/src/config/withSentryConfig.ts:

/**
 * Injects rewrite rules into the Next.js config provided by the user to tunnel
 * requests from the `tunnelPath` to Sentry.
 *
 * See https://nextjs.org/docs/api-reference/next.config.js/rewrites.
 */
function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void {
  const originalRewrites = userNextConfig.rewrites;

  // This function doesn't take any arguments at the time of writing but we future-proof
  // here in case Next.js ever decides to pass some
  userNextConfig.rewrites = async (...args: unknown[]) => {
    const injectedRewrite = {
      // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]`
      // Nextjs will automatically convert `source` into a regex for us
      source: `${tunnelPath}(/?)`,
      has: [
        {
          type: 'query',
          key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
          value: '(?<orgid>.*)',
        },
        {
          type: 'query',
          key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers
          value: '(?<projectid>.*)',
        },
      ],
      destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0',
    };

    if (typeof originalRewrites !== 'function') {
      return [injectedRewrite];
    }

    // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it
    const originalRewritesResult = await originalRewrites(...args);

    if (Array.isArray(originalRewritesResult)) {
      return [injectedRewrite, ...originalRewritesResult];
    } else {
      return {
        ...originalRewritesResult,
        beforeFiles: [injectedRewrite, ...(originalRewritesResult.beforeFiles || [])],
      };
    }
  };
}

The crucial part is this section:

const injectedRewrite = {
  // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]`
  // Nextjs will automatically convert `source` into a regex for us
  source: `${tunnelPath}(/?)`,
  has: [
    {
      type: 'query',
      key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
      value: '(?<orgid>.*)',
    },
    {
      type: 'query',
      key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers
      value: '(?<projectid>.*)',
    },
  ],
  destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0',
};

It determines the final URL to redirect to based on the o and p query string parameters.

The problem here is that both of these parameters use the .* regular expression, which matches any character. In other words, for the following URL:

https://huli.tw/tunnel?o=abc&p=def

It will proxy to:

https://oabc.ingest.sentry.io/api/def/envelope/?hsts=0

It looks fine, but what if it’s like this?

https://huli.tw/tunnel?o=example.com%23&p=def

%23 is the URL-encoded result of #. It will be proxied to:

https://oexample.com#.ingest.sentry.io/api/def/envelope/?hsts=0

We use # to include the original hostname as part of the hash and successfully change the destination of the proxy. However, the leading o is a bit annoying. Let’s get rid of it by adding @ at the beginning:

https://huli.tw/tunnel?o=@example.com%23&p=def

It becomes:

https://[email protected]#.ingest.sentry.io/api/def/envelope/?hsts=0

In this way, an attacker can use the o parameter to change the destination of the proxy and redirect the request anywhere. As mentioned earlier, this rewrite feature directly returns the response. So when a user visits https://huli.tw/[email protected]%23&p=def, they will see the response of example.com.

In other words, if an attacker redirects the request to their own website, they can output <script>alert(document.cookie)</script>, turning it into an XSS vulnerability.

If the attacker redirects the request to other internal web pages like https://localhost:3001, it becomes an SSRF vulnerability (but the target must support HTTPS).

As for the fix, it’s simple. Just add some restrictions to the regex. Finally, Sentry adjusted it to only allow digits:

{
  type: 'query',
  key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
  value: '(?<orgid>\\d*)',
},

This issue has been fixed in version 7.77.0 and later.

Conclusion

This vulnerability is really simple and easy to reproduce. Just find the fix commit and take a look at the code to understand how to exploit it.

In summary, when doing URL rewriting, you really need to be cautious, as it’s easy to encounter issues (especially when you’re not just rewriting the path but the entire URL).

Exploring Various SSR (Server-side rendering) from a Historical Perspective HITCON CTF 2023 and SECCON CTF 2023 Writeup

Comments