株式会社Flatt SecurityとTokyo HackerOne Clubが共催した脆弱性勉強会「Security․Tokyo #1」より、今回は、LT5「React Hooksに潜む罠」とLT6「一緒にプレイするだけで乗っ取り!? ~任天堂のバッファオーバーフロー脆弱性~」の発表内容をお届けします。
<注意事項>
本記事は技術的知見の共有を目的としています。掲載された発表内容の悪用や曲解、その他社会通念に反する行為を固く禁じます。
▼LT1, 2はこちら▼
▼LT3, 4はこちら▼
React Hooksに潜む罠
スライド
プロフィール
icchy(@icchyr)
とある企業のセキュリティエンジニア。学生時代はCTFに没頭していたが、就職して以来あまり参加していない。最近の趣味は麻雀とピアノ。
今回の発表内容について
今回は、React Hooksの既知の挙動を扱っています。脆弱性ではありますが、0dayではありません。
昨年、この挙動に関してpicoCTFで出題されているのをTwitterでたまたま見つけました。詳しく調べてみたところ、かなり面白いバグだったので、紹介させていただきます。
僕は社内で使うツールを書く必要があり、なんとなくReactを始めた初心者です。フロントエンドの専門家ではありませんし、ソースコードもそこまで深く読んでいません。スライド中、憶測で書いている箇所は斜体かつオレンジ色で示しています。
Reactとは?
Reactは、元FacebookのJordan Walkeが開発した、ユーザーインターフェース構築のためのJavaScriptライブラリです。XMLとJavaScriptを混ぜたような言語であるJSXで書かれた宣言的UIで、一般的にXSSが発生しにくいと言われています。
また、状態管理のための機能が用意されています。React16.8からReact Hooksと呼ばれるシンプルなものが導入されました。
命令的UIと宣言的UI
命令的UIは、DOMを操作してnameの中に値をセットするというやり方ですが、宣言的UIにはプレースホルダーがあり、そこにどのような値を入れていくか記述するという方法を取ります。完成形が見えている形、と言っても良いかもしれません。UIを作る目的においては、宣言的UIの方が都合が良いとされています。
命令的UIと宣言的UIの違いは、「何をやりたいか」と「どうすれば良いのか」のどちらをメインに据えているかの違いだと思います。自動車のマニュアル(MT)とオートマ(AT)の違いのようなものですね。自動車に例えると、命令的UIがマニュアル、宣言的UIがオートマということになります。
Reactって何のためにあるの?
一般に、ブラウザがDOMを描画するコストは高く、不必要な計算はなるべくしないことが重要になってきます。Reactは、差分検出処理(Reconciliation)を行い、必要なDOMだけを計算してくれるので、レンダリングが早くメモリが軽いという特徴があります。なお、Reactに限らず、Vue.jsでもpatchと呼ばれる同様の処理を行っています。
Reactの機能面に関するトピックとしては、他に以下があります。
- Reactのエンジンは、React16からReact Fiberに変わりました。
- 状態管理やメモ化のための機能をReact Hooksとして提供しています。
React Hooks
React Hooksは、コンポーネントが内部で持つ状態を管理することができます。プログラマは状態遷移の処理だけ書けばよく、「処理の結果、何を描画すべきか」ということについてはReactが管理してくれる仕組みです。
例えば、上図のようなコードがあったとします。このコードのcount(赤字)とsetcount(緑字)の部分が、表示する状態と、状態遷移のための処理について記述する箇所で、useStateの()内の数値が初期値です。赤枠で囲った{() => setCount(count +1)}
の部分が、次の状態を決定する処理を書く箇所となっています。
最終的に、赤枠で囲った部分だけ再描画されるので、レンダリングが早く済みます。
React Hooksはどうやって実現しているの?
ReactFiberHooksというReactのソースコードを見るとわかるのですが、React Fiberはコンポーネントの内部状態を管理するためのデータを持っています。Hooksは、React FiberのmemorizedStateに単方向リストとして保存されています。
コンポーネントのHooksを処理する時は、memorizedStateのHooksを辿っていき、順番に処理する流れです。そのため、Hooksは、ある1つのReact Fiberに必ず属するような仕組みになっています。
React Hooksを書く時に必ず守らなければならないこと
React Hooksを書く時には、必ず守らなければいけないことが2つあります。「フックを呼び出すのはトップレベルのみ」と「フックを呼び出すのはReactの関数内のみ」というルールです。
つまり、条件分岐の中で呼び出したり、ネストされた関数の中で呼び出したりするなど、使うHooksが一意ではない呼び出し方をしてはいけないということです。
さて、このコードにある脆弱性を指摘できますか?
この後詳しく見ていきますので、一旦こちらのコードにあるComponent(props)
という書き方について覚えておいてください。
<Component…/> VS Component(props)
コンポーネントに関しては、<Component{...props}>
とComponent (props)
という書き方の差異があります。先ほどのコードではComponent(props)
という関数で記述がされていましたが、一般的にReactでは<Component{...props}>
というJSXの記法で記述します。JSX記法(<Component{...props}>
)は、内部ではReact.createElement(Component , ...)
に変換されています。そのため、Component(props)
とは明確に異なる記法です。
Hooksを管理する上で、この違いは非常に重要です。「今呼び出そうとしている関数にHooksが含まれているかどうか」が重要だからです。関数として呼び出すComponent (props)
は、Reactを書く時に必ず守らなければいけない「フックを呼び出すのはReactの関数内のみ」というルールに違反してしまいます。では、どのような違いがあるのか実際に見てみましょう。
通常の挙動
先ほどのコードではComponent (props)
と記述されていましたが、こちらのコードではJSX記法になっています。
OddComponentとEvenComponentの中身は上図の通りです。順番に見ていきましょう。
まず、useStateが呼ばれて、0がセットされます。
0がセットされているので、countはfalseとなり、EvenComponentに入ります。
EvenComponentの中には、”I’m Even”
というメッセージと初期化された値が入っています。
今はcountが0になっていますが、このボタンをクリックしてcountを進めると、countが1になります。
先程useStateで一回初期化しているので、AppのuseStateのcount:1
は再び初期化することなく再利用させます。
そして、OddComponentに入りました。先程はEvenComponentのuseStateでしたが、今回はOddComponentのuseStateなので、もう一度初期化させます。
中身は”I’m Odd”
となりました。以上が正しい挙動です。
間違った実装
それではここで、最初に見ていただいたComponent(props)
を使ったコードを改めて追ってみましょう。
EvenComponentそのものの呼び出しではなく、関数呼び出しの形になっているので、EvenComponentにたどり着いた時に、Appの中のuseStateが呼び出されます。そのため、Appの中からuseStateが2回連続で呼ばれたと判定されます。
初回は上手くいきますが......
2回目は、useStateが呼び出されるのが2回目という判定になるため、正しい実装を行った場合と異なり、初期化せずに再利用してしまう流れになります。つまり、”I’m Odd”
が初期化されずに”I’m Even”
がそのまま残ってしまいます。
その結果が上図です。”Odd”
が出ているのに、”I’m Even”
が残っています。
これってヤバいんですか?
結論、これは「かなりヤバい」です。例えば、上図にあるようなコードを考えてみるとわかりやすいかと思います。
このようにイメージの高さと幅を持っているオブジェクトを、スプレッド構文で展開するようにする場合、例えば<img src="x" onerror="alert(1);">
を入れるとアラートが鳴ってしまいます。これはXSSですね。
先程「ReactではXSSは発生しにくい」と説明しましたが、このような使い方をすると突然XSSが生まれてきます。
このバグを整理すると
このバグが脆弱性になるには4つ条件があると考えています。
- 攻撃者がある程度データの中身をコントロール可能なStateがある(source)
- Stateの内容によっては予期せぬ挙動を引き起こす箇所がある(sink)
- 以上の2カ所のStateの取り違いが起こる箇所がある(confusion)
- 取り違いが起こっても、呼ばれるHooksの数は変わらない(layout)
下2つのconfusionとlayoutは自分で命名しましたが、上2つの条件(source, sink)に関しては、まさにXSSの考え方と同じです。sourceの例として、クエリ文字列をオブジェクトに変換していること、sinkの例として、先程説明したスプレッド構文でwidth, heightをimgタグに展開していることなどがそれぞれ想定できます。
このバグは検知可能?
このバグを検知することは、一応可能です。ReactのDeveloperモードでは、呼ばれているHooksの種類が変わるとconsoleで警告を出す仕組みになっていますし、ESLintにも教えてくれるプラグインがあります。
しかし、これらで十分かと言われると、全く十分ではないです。まず、ReactのDeveloperモードでは、Hooksの種類が変わらないとエラーが出ません。また、ESLintでは、CRA(create-react-app)やVite(create-vite)がオプショナルなので、自分で追加しないと検知ができません。検知の仕組みが十分でない理由として、ESLintの仕組みが粗いことも一因かと考えています。最初に紹介したComponent(props)
を使ったコードは、すべてのチェックをすり抜けるパターンです。
このバグは検知しにくいため、色々なプロダクトに潜んでいる可能性があります。Reactで書かれたUIは複雑になりがちなので、おそらくOSSにもあるのではと推測しています。このバグを検知するESLintを書くと喜ばれるかもしれません。
問題となりやすい実装
問題となりやすい実装の例としては、以下の3つが考えられます。
- callbackの中で呼んでいる
- 条件分岐の中で呼んでいる
- 関数の中から呼んでいる
それぞれについて、ESLintのreact-hooksと、Reactのdevとprod環境においての実行時チェックによって検知可能かどうかを見ていきます。
一つ目はPromiseなどのcallbackで呼んでいるパターンです。通常Reactでこういうふうに書くことはまずありえないと思いますが、callback関数の呼び出される順番は決定的ではありません。したがって、Hooksを内部で使用するとその順番が逆転するおそれがあり、取り違いが起こります。
なお、ESLintはもちろんReactの実行時においても検出されるため、本番環境になるまでにこの実装パターンは修正されることがほとんどでしょう。
二つ目は条件分岐の中で呼んでいるパターンです。こちらは比較的容易に検知可能なパターンであり、ESLintとdev環境の実行時チェックによって検知されます。しかしdev環境の実行時チェックはHooksの種類が異なる場合のみにしか検知されないため、ESLintによるチェックをしていない場合はすり抜けることになります。prod環境においては検知されず、Hooksの種類が同じ場合は意図しない挙動をすることになります。
三つ目は今回紹介した、関数の中から呼んでいるパターンです。dev環境の実行時チェックでHooksの種類が異なる場合を除いて検知することができません。ESLintが検知できないのには理由があり、Reactの関数コンポーネントの識別方法に由来しています。Reactでは関数コンポーネントの名前をcamel caseにするというルールがあり、かつHooksは関数コンポーネントの中で呼ぶことが許可されています。つまり、ESLintが対象の関数を判別する際に関数名のみでチェックしていると、ルール上は問題ない扱いになります。先ほどの例のように、JSX構文で書くことによってこの問題は解決されますが、ESLintはそれが意図的なものなのか、誤った書き方なのかは判断しようがないということです。
この三つ以外にも、実際にはまだ見落とされているケースがあると思います。React Hooksには守らなければならないルールがあると先に述べましたが、このルールを守らないとアプリが正しく動かない可能性があるだけでなく、危険な動作をするかもしれないということを是非覚えていただけたらと思います。
対策とまとめ
この脆弱性は基本的に発火する条件が厳しいです。source, sink, confusion, layoutがすべて揃わないと発火しません。
source, sink, layoutはアプリケーションの機能や仕様に関わる要素であり、防ぎようがないので、開発者の方はconfusionだけ気をつけておきましょう。このバグは、悪用されるオブジェクトが存在すること、アプリケーションの設計次第では悪用できない場合があるという点についてはmemory corrputionに似ていますが、mitigationは存在せず、実装することもできません。とにかくconfusionを作り込まないようにすることが重要です。
またconfusionのパターンの中でも、関数呼び出しは自動検出できないため気をつけましょう。
本日伝えたかったことは、React Hooksのルールは必ず守りましょうということです。
- フックを呼び出すのはトップレベルのみ
- フックを呼び出すのはReactの関数内のみ
また、Reactで書かれるアプリケーションが増えてきたこともあり、もしかすると世の中のOSSやアプリケーションの中に既にこのバグが潜んでいるかもしれません。まだあまり知られていないと思うので、是非探してみてください。
参考文献
一緒にプレイするだけで乗っ取り!? 〜任天堂のバッファオーバーフロー脆弱性〜
スライド
プロフィール
小笠原啓祐(@yuluhack)
週末せっきゅ!という学生サークルを主催しています。CTFなどの競技系よりむしろ実際的な診断や機密情報の漏洩調査手法などの研究会的サークルです。私個人はCVEを取得すべく奮闘中です!
脆弱性の概要
今回紹介するのは、ENLBufferPwn(CVE-2022-47949)です。これは、Nintendo 3DS以降の任天堂のゲームの共通ネットワークコード(ENL)に存在した脆弱性です。攻撃者がオンラインゲームをしているだけで、被害者のゲーム機でリモートコード実行が可能になるというものです。既にパッチが当てられているため、exploitすることはできなくなっています。
Nintendo 3DS以降の任天堂のゲームで、オンライン通信・対戦可能なものほぼすべてが脆弱性の対象となりました。
こちらが実際にこの脆弱性を悪用された場面と思われる動画です。
こちらはゲーム実況者によるプレイ動画なのですが、実況者の方自身は全く操作していないのにHOMEボタンが押されています。
また、こちらはGitHubでこの脆弱性のPoCについて解説した方によるデモ動画です。
動画の向かって左側は、任意に書き換えた通信ができるようになっている攻撃者側のゲーム機です。ペイロードの送信が終わると、右側のゲーム機の操作が攻撃者側のゲーム機からできるようになります。
バッファオーバーフローについて
この脆弱性は「ENLBufferPwn」という名前の通り、バッファオーバーフローに関する脆弱性です。
バッファオーバーフローとは、バッファの許容量を超えるデータを送信し、システム機能を停止または悪意あるプログラムを実行する攻撃手法のことです。今回の脆弱性では、攻撃リクエストを送信して、ゲームコンソールの異常終了や任意のコードの実行を行うことが可能でした。
ENL
今回の脆弱性の原因となっているのが、ENLプロトコルです。これは、任天堂のゲームにおけるオンライン通信上で用いられるpeer to peerのプライベート通信ライブラリです。
実装はC++で、上図のようになっています。SetメソッドとAddメソッドを用いる際に、入力されたデータサイズがバッファサイズに収まるかチェックしていません。このため、バッファオーバーフローが発生していました。
脆弱性の解説
もう少し詳しく見ていきます。ENLは非同期で、データがバッファに充填される間、ゲームで他の操作を行うことが可能なダブル・バッファ技術が使われています。
上図に示したのは構成例です。network data(usually mii data)とあるのは、任天堂が提供するアバター「mii」に関するデータです。バッファオーバーフローの緩和策であるアドレス空間配置のランダム化(ASLR)が、Nintendo 3DSでは実装されていません。そのため、攻撃者は被害者のゲーム機内のすべてのメモリ配置を知ることができ、バッファを書き換える位置やバッファをコピーする位置を簡単に指定できてしまいます。
通常の処理では、Buffer0が満たされたらBuffer1に移動するという流れになりますが、この脆弱性を悪用した場合、バッファオーバーフローが起きた時に、本来書き換えられるべきではない場所が書き換えられてコピーされてしまいます。具体的には以下の流れです。
- 被害者のゲーム機でバッファオーバーフローを引き起こし、Buffer1のdataptrを上書きするためのデータ(ペイロード)を送信する
- 被害者のゲーム機のBuffer1に設定された任意のデータが、被害者のゲーム機のBuffer1のdataptrが指定する任意のアドレスにコピーされる
- 以上の流れを繰り返す
まとめ
今回紹介した脆弱性のPoCはGitHubに上がっているのですが、パブリックドメインとなっており、二次利用可能です。興味があれば、ぜひ見てみてください。
▼LT1, 2はこちら▼
▼LT3, 4はこちら▼