⚠️

Next.jsのSSRF脆弱性 CVE-2024-34351

2024/05/18に公開
5

Next.jsでSSRF(=Server Side Request Forgery)の脆弱性が発覚したことが社内で話題になったので、まとめておこうと思います。対象の脆弱性は以下です。

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-34351

https://github.com/vercel/next.js/security/advisories/GHSA-fr5h-rqp8-mj6g

脆弱性の概要

SSRF脆弱性は本来到達できないサーバーに対して、公開されてるサーバーを経由してアクセスすることができてしまう脆弱性です。

https://blog.tokumaru.org/2018/12/introduction-to-ssrf-server-side-request-forgery.html

今回のNext.jsの脆弱性はhttpヘッダーのHostを書き換えることで、self hostingなNext.jsサーバーから任意のhttpリクエストを送信できてしまうというものです。これは、外部には公開してない内部APIに対するリクエストも可能になるため、SSRF攻撃になりえます。

今回の脆弱性の対象は、以下の条件を満たしている必要があります。

  • Next.jsをself hostingで運用している
  • Next.jsアプリケーションがServer Actionsを利用している
  • Server Actionがredirect()/から始まるパスで呼び出している
  • Next.jsのバージョンがv14.1.0以下である

最新バージョンでの状況

本脆弱性はv14.1.1ですでに解消されているとされています。ただし、公式には言及されてないもののカスタムサーバー(expressやfastify)を利用している場合この脆弱性が解消してないようです。

脆弱性の原因

脆弱性が解消されたとされるのがv14.1.1なので、v14.1.0時点での実装を元に説明します。今回の脆弱性はcreateRedirectRenderResult関数に起因しています。

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

createRedirectRenderResultは命名の通り、リダイレクト先のレンダリング結果であるRSC Payloadを生成する関数です。この関数の中でHostヘッダーの情報を参照し、fetchUrlを生成しています。

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

そしてそれをそのまま、fetchの引数に利用しています。

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

つまり、Hostを書き換えればこのフォーマットに沿ったURLを生成することができ、任意のサーバーにリクエストを送信することができてしまうのです。

ただし、上記実装からこの脆弱性には以下の条件が伴います。

  • HEAD・GETリクエストしか行えない
  • redirect()に渡したパスにしかリクエストが行えない
    • azuさんのコメントの通り、こちらは誤りでした。攻撃用のサーバーから302などを返すことでパスも任意のものにすることが可能です。

いわゆるSafeなメソッド、そして自由なパスが設定できるわけではないことがせめてもの救いです。ただし、APIのパスと公開してるURLパスが完全に一致してるようなケースでは情報を抜き取られるようなリクエストが成功する可能性があるので、注意が必要です。

なぜredirectfetchしてるのか

そもそもNext.jsはなぜ、redirectの処理内でfetchを呼び出しているのでしょうか?

Server Actionsは通常formのactionpropsを経由して呼び出しますが、実際にはこれは自画面URLに対しPOSTリクエストを送信して呼び出しを行います。このPOSTリクエストこそServer Actionsの実態で、redirectがServer Actions内で呼び出されると、このレスポンスにリダイレクト先のRSC Payloadが含まれるようになります。

Server Actionsを呼び出した時のPOSTのレスポンス

2:I[1758,["931","static/chunks/app/page-0854063c8a2761bf.js"],""]
4:I[6605,[],""]
5:I[335,[],""]
3:{"id":"2436a10b19c8cb512110aa93eaeb1eedad656714","bound":null}
0:["XPabJU6EC5bmFbyr5UtJF",[[["",{"children":["__PAGE__?{\"search\":\"test\"}",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},["$L1",[["$","h1",null,{"children":"Hello, Next.js!"}],["$","$L2",null,{}],["$","form",null,{"action":"$F3","children":["$","button",null,{"type":"submit","children":"redirect"}]}]],null]]},[null,["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"styles":null}]}]}],null]],[null,"$L6"]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}]]
1:null

このリダイレクト先のRSC Payloadを生成するための処理がcreateRedirectRenderResultで、この関数はリダイレクト先URLにリクエストを投げることでRSC Payloadを取得していました。

例を挙げます。 http://localhost:3000 からServer Actions内でredirect("/hoge")が呼び出されると、createRedirectRenderResulthttp://localhost:3000/hoge へ向けてfetch(前述の通りHEAD+GET)してRSC Payloadを取得します。Next.jsサーバーからNext.jsサーバーへリクエストしているのです。このリクエスト処理が前述のfetchの部分で、前述の通りfetch先のURLの組み立てにユーザーからのリクエストヘッダーのHostを参照していたのです。

実際に攻撃が成り立つのかlocalhostで試してみる

ここまではNext.jsの実装を元に説明してきましたが、実際にこれが攻撃として成立するのかローカル環境で検証してみました。

脆弱性が含まれているとされるNext.jsのv14.1.0のコードをチェックアウトして、createRedirectRenderResult内にdebugコードを追加します。

  const fetchUrl = new URL(`${proto}://${host}${basePath}${redirectUrl}`)
+ console.log(">>> fetchUrl", fetchUrl);
  const headResponse = await fetch(fetchUrl, {
    method: 'HEAD',
    headers: forwardedHeaders,
    next: {
      // @ts-ignore
      internal: 1,
    },
  })
+   .catch((err) => {
+     console.error(">>> HEAD Request error", err);
+     throw err;
+   });

この状態でNext.jsのパッケージをbuildし、これを利用したサンプルアプリケーションに対しHostを改ざんしたリクエストを送信すればfetchUrlが任意のものに書き変わる様子がみて取れるはずです。今回は http://localhost:3000 に対しHost: localhost:9999を付与したリクエストを送ってみます。筆者の検証環境に http://localhost:9999 は存在しないので、HEADリクエストが失敗して上記のcatchに仕込んだエラーログが出力されるはずです。

以下は実際にサンプルアプリケーションからServer Actionsを呼び出した際のPOSTリクエストを元に、HostOriginを改ざんしたhttpリクエストです。

POST http://localhost:3000/ HTTP/1.1
Accept: text/x-component
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: keep-alive
Content-Length: 279
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBxBctGejLCXmkZbu
Host: localhost:9999
Next-Action: 2436a10b19c8cb512110aa93eaeb1eedad656714
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Origin: http://localhost:9999
Referer: http://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

------WebKitFormBoundaryBxBctGejLCXmkZbu
Content-Disposition: form-data; name="1_$ACTION_ID_2436a10b19c8cb512110aa93eaeb1eedad656714"


------WebKitFormBoundaryBxBctGejLCXmkZbu
Content-Disposition: form-data; name="0"

["$K1"]
------WebKitFormBoundaryBxBctGejLCXmkZbu--

Originも修正したのはHostOriginが一致してないとエラーになってしまうためです(おそらくCSRF攻撃対策?)。

実際にこのリクエストを送ると、以下のようなログが出力されました。

>>> fetchUrl URL {
  href: 'http://localhost:9999/?test=1',
  origin: 'http://localhost:9999',
  protocol: 'http:',
  username: '',
  password: '',
  host: 'localhost:9999',
  hostname: 'localhost',
  port: '9999',
  pathname: '/',
  search: '?test=1',
  searchParams: URLSearchParams { 'test' => '1' },
  hash: ''
}
>>> HEAD Request error TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11730:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async globalThis.fetch (/Users/satouakifumi/work/git/sandbox/nextjs-debug/.next/server/chunks/535.js:1:123496)
    ...

想定通りfetchUrllocalhost:9999になっており、localhost:9999へのHEADリクエストが失敗していることがわかります。脆弱性として記述されている内容通りの挙動のようであることが確認できました。

今回筆者が調査した内容は以上です。

まとめ

筆者はSSRF攻撃にあまり馴染みがなかったので、今回の事象はとても興味深かったです。今回は任意のメソッドやパスを指定できないので、実害が想定されるケースはさらに少数になりそうなもののSSRF攻撃が成り立ってしまう場合、ビジネスに重大な被害を及ぼす可能性もあるので、今回のケースは人ごとではなくアプリケーションの実装中にも注意が必要な部分だなと感じました。

また、脆弱性関係なくNext.jsのredirect処理や挙動についてはここまで詳細に知らなかったので、勉強になりました。一応対応自体されてるとはいえ、この実装自体パフォーマンス的にもネットワーク介して1週してしまうあたりこれでいんだろうか感は正直あります。また、これだとカスタムサーバーについてフォローできてない件含め、App Router以降カスタムサーバーについての言及が少ないことが気になっています。脆弱性対応含めもうコアチームがあまりカスタムサーバーのことを気にかけていないということであれば、Next.js利用者としては採用しづらくなるので知りたいところです。

他にも今後Next.jsで脆弱性が発覚することもあるかもしれません。あまり今までアンテナを貼ってなかったので、今後はNext.jsの脆弱性も注視するようにしようと思います。

Discussion

azuazu

redirect()に渡したパスにしかリクエストが行えない

攻撃用のサーバを用意して、hostで攻撃用のサーバを指定しておいて、
攻撃用のサーバはgetに対して、302 redirectをレスポンスを返せば、
Fetch APIはredirectがfollowがデフォルトなので、任意のウェブサイトのコンテンツを取得して、攻撃者はレスポンスとしてそのコンテンツを取得できると思います。

なので、Metadataを取得するとかの攻撃ができるかもしれないですね(別のヘッダが必要で通らない場合も多そうですが)

akfm_satoakfm_sato

なんと、、、不勉強でした。
ご指摘ありがとうございます、修正しました!

mizdramizdra

今回のNext.jsの脆弱性はhttpヘッダーのHostを書き換えることで、self hostingなNext.jsサーバーからhttpリクエストを送信できてしまうというものです。

この文面だと、Next.jsサーバーから HTTP リクエストが送信できることが悪いことかのような書きぶりになってしまっていますが、そうではなくて、攻撃者が意図した任意の HTTP リクエストが送れてしまうことが悪いと言いたいのかなと想像しています。それがより正確に伝わるよう、「self hostingなNext.jsサーバーから任意のhttpリクエストを送信できてしまう」などと書き換えてはどうかと思ったのですが、いかがでしょうか?

akfm_satoakfm_sato

ご指摘ありがとうございます。ごもっともです。
修正しました!