Next.jsのSSRF脆弱性 CVE-2024-34351
Next.jsでSSRF(=Server Side Request Forgery)の脆弱性が発覚したことが社内で話題になったので、まとめておこうと思います。対象の脆弱性は以下です。
脆弱性の概要
SSRF脆弱性は本来到達できないサーバーに対して、公開されてるサーバーを経由してアクセスすることができてしまう脆弱性です。
今回の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
関数に起因しています。
createRedirectRenderResult
は命名の通り、リダイレクト先のレンダリング結果であるRSC Payloadを生成する関数です。この関数の中でHost
ヘッダーの情報を参照し、fetchUrl
を生成しています。
そしてそれをそのまま、fetch
の引数に利用しています。
つまり、Host
を書き換えればこのフォーマットに沿ったURLを生成することができ、任意のサーバーにリクエストを送信することができてしまうのです。
ただし、上記実装からこの脆弱性には以下の条件が伴います。
- HEAD・GETリクエストしか行えない
-
redirect()
に渡したパスにしかリクエストが行えない- azuさんのコメントの通り、こちらは誤りでした。攻撃用のサーバーから302などを返すことでパスも任意のものにすることが可能です。
いわゆるSafeなメソッド、そして自由なパスが設定できるわけではないことがせめてもの救いです。ただし、APIのパスと公開してるURLパスが完全に一致してるようなケースでは情報を抜き取られるようなリクエストが成功する可能性があるので、注意が必要です。
redirect
でfetch
してるのか
なぜそもそもNext.jsはなぜ、redirect
の処理内でfetch
を呼び出しているのでしょうか?
Server Actionsは通常formのaction
propsを経由して呼び出しますが、実際にはこれは自画面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")
が呼び出されると、createRedirectRenderResult
は http://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リクエストを元に、Host
とOrigin
を改ざんしたhttpリクエストです。
POST http:/ 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
も修正したのはHost
とOrigin
が一致してないとエラーになってしまうためです(おそらく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)
...
想定通りfetchUrl
がlocalhost:9999
になっており、localhost:9999
へのHEAD
リクエストが失敗していることがわかります。脆弱性として記述されている内容通りの挙動のようであることが確認できました。
今回筆者が調査した内容は以上です。
まとめ
筆者はSSRF攻撃にあまり馴染みがなかったので、今回の事象はとても興味深かったです。今回は任意のメソッドやパスを指定できないので、実害が想定されるケースはさらに少数になりそうなもののSSRF攻撃が成り立ってしまう場合、ビジネスに重大な被害を及ぼす可能性もあるので、今回のケースは人ごとではなくアプリケーションの実装中にも注意が必要な部分だなと感じました。
また、脆弱性関係なくNext.jsのredirect
処理や挙動についてはここまで詳細に知らなかったので、勉強になりました。一応対応自体されてるとはいえ、この実装自体パフォーマンス的にもネットワーク介して1週してしまうあたりこれでいんだろうか感は正直あります。また、これだとカスタムサーバーについてフォローできてない件含め、App Router以降カスタムサーバーについての言及が少ないことが気になっています。脆弱性対応含めもうコアチームがあまりカスタムサーバーのことを気にかけていないということであれば、Next.js利用者としては採用しづらくなるので知りたいところです。
他にも今後Next.jsで脆弱性が発覚することもあるかもしれません。あまり今までアンテナを貼ってなかったので、今後はNext.jsの脆弱性も注視するようにしようと思います。
Discussion
攻撃用のサーバを用意して、hostで攻撃用のサーバを指定しておいて、
攻撃用のサーバはgetに対して、302 redirectをレスポンスを返せば、
Fetch APIはredirectがfollowがデフォルトなので、任意のウェブサイトのコンテンツを取得して、攻撃者はレスポンスとしてそのコンテンツを取得できると思います。
なので、Metadataを取得するとかの攻撃ができるかもしれないですね(別のヘッダが必要で通らない場合も多そうですが)
なんと、、、不勉強でした。
ご指摘ありがとうございます、修正しました!
この文面だと、Next.jsサーバーから HTTP リクエストが送信できることが悪いことかのような書きぶりになってしまっていますが、そうではなくて、攻撃者が意図した任意の HTTP リクエストが送れてしまうことが悪いと言いたいのかなと想像しています。それがより正確に伝わるよう、「self hostingなNext.jsサーバーから任意のhttpリクエストを送信できてしまう」などと書き換えてはどうかと思ったのですが、いかがでしょうか?
ご指摘ありがとうございます。ごもっともです。
修正しました!
対応ありがとうございます!