Sentry 在 2023 年 11 月 9 號時,在部落格上發布了這篇文章:Next.js SDK Security Advisory - CVE-2023-46729,內容主要在講述 CVE-2023-46729 這個漏洞的一些細節,包含漏洞成因、發現時間以及修補時間等等。
雖然說是在 11/9 正式對外發布漏洞公告,但漏洞其實在 10/31 發佈的 7.77.0 版本已經修復了,有預留一些時間給開發者們來修補漏洞。
接著就來簡單講一下這個漏洞的成因以及攻擊方式。
漏洞分析
在 GitHub 上面也有一個比較偏技術的說明:CVE-2023-46729: SSRF via Next.js SDK tunnel endpoint
可以看到這一段:
An unsanitized input of Next.js SDK tunnel endpoint allows sending HTTP requests to arbitrary URLs and reflecting the response back to the user.
在 Sentry 裡面,有一個叫做 tunnel 的功能,這張來自於官方文件的圖完美地解釋了為什麼需要 tunnel:
如果沒有 tunnel 的話,送給 Sentry 的請求會在前端直接透過瀏覽器發送,而這些直接發給 Sentry 的請求可能會被一些 ad blocker 擋住,Sentry 就沒辦法接收到數據。若是有開啟 tunnel,就會變成先將請求發送給自己的 server,再從自己的 server 轉發給 Sentry,這樣就變成了 same-origin 的請求,便不會被 ad blocker 擋住。
在專門提供給 Next.js 使用的 Sentry SDK 中,是用了一個叫做 rewrite 的功能,官方範例如下:
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 基本上可以分為兩種,internal 跟 external,後者的話其實更像是 proxy 的感覺,可以直接把請求導向到外部網站,然後顯示出 response。
Next.js Sentry SDK 的實作在 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 || [])],
};
}
};
}
其中的重中之重就是這一段:
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',
};
會根據 query string 中的 o
跟 p
,決定最後要重新導向的 URL。
而這邊的問題是這兩個參數的 regexp 都用了 .*
,也就是說會配對到任何字元,換句話說,底下這個網址:
https://huli.tw/tunnel?o=abc&p=def
會 proxy 到:
https://oabc.ingest.sentry.io/api/def/envelope/?hsts=0
看起來沒什麼問題,但如果是這樣呢?
https://huli.tw/tunnel?o=example.com%23&p=def
%23
是 #
URL encode 後的結果,最後就會 proxy 到:
https://oexample.com#.ingest.sentry.io/api/def/envelope/?hsts=0
我們利用了 #
來把原本的 hostname 都當成 hash 的一部分,並且成功更改了 proxy 的目的地。但最前面那個 o 還是有點煩人,不如把它一起消除掉吧!只要在最前面加個 @
就行了:
https://huli.tw/tunnel?o=@example.com%23&p=def
會變成:
https://[email protected]#.ingest.sentry.io/api/def/envelope/?hsts=0
如此一來,攻擊者就可以利用 o 這個參數更改 proxy 的目的地,將 request 導向至任何地方。剛剛有說過這個 rewrite 功能會將 response 直接回傳,所以當使用者瀏覽:https://huli.tw/[email protected]%23&p=def
的時候,看到的 response 會是 example.com
的結果。
也就是說,如果攻擊者把請求導向至自己的網站,就可以輸出 <script>alert(document.cookie)</script>
,就變成了一個 XSS 漏洞。
若是攻擊者不是導到自己的網站,而是導向到其他內部的網頁如 https://localhost:3001
之類的,就是一個 SSRF 的漏洞(但目標必須支援 HTTPS 就是了)。
至於修復方式的話也很簡單,只要對 regexp 做出一些限制即可,最後 Sentry 是調整成只允許數字:
{
type: 'query',
key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
value: '(?<orgid>\\d*)',
},
7.77.0 版本以及之後的版本都已經修復了這個問題。
總結
這個漏洞真的滿簡單而且滿好重現的,只需要找到修復的 commit,看兩眼程式碼大概就能知道怎麼攻擊。
總之呢,在做 URL rewrite 的時候真的必須謹慎一點,不然還滿容易出問題的(尤其是你不只是 rewrite path,而是 rewrite 整個 URL)。
評論