技術者ブログ

クラウド型WAF「Scutum(スキュータム)」の開発者/エンジニアによるブログです。
金床“Kanatoko”をはじめとする株式会社ビットフォレストの技術チームが、“WAFを支える技術”をテーマに幅広く、不定期に更新中!

2017年10月

1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31        
  • お問い合わせはこちら
Scutum開発者/エンジニアによる技術ブログ WAF Tech Blog

Struts2 S2-052を例とした脆弱性攻撃手法の調査及びそれらを考慮した防御機能の開発

2017年9月27日
野村 真作(Scutum開発者/株式会社ビットフォレスト シニアマネージャー)

はじめに

先日、Struts 2に新たな脆弱性S2-052(CVE-2017-9805)が発見され、修正されました。
これはリモートからの任意のコードの実行(RCE)が可能な脆弱性であり、「またか」と思われた方も多かったのではないかと思います...。
しかし実はこの脆弱性によるRCEは、過去繰り返しStruts2に報告されてきたOGNLインジェクションとは異なるメカニズムで発生するものでした。
ここでは、この脆弱性の原因と、RCEに至るメカニズムを解説してみようと思います。
多少プログラミングの知識のある方向けになってしまいますが、ご容赦ください。

シリアライズ/デシリアライズ

プログラム内で扱うオブジェクトのインスタンスを特定のフォーマットに従ってバイト列に変換する、またその逆操作を行う仕組みをシリアライズ/デシリアライズと呼びます。 これは単なるデータの保存・復元ではなく、オブジェクトのインスタンスの種類や状態の保存・復元なので、バイト列から復元したオブジェクトのメソッドを呼び出すと言うような事も可能です。
さて、仮にこのバイト列の入力元が信頼できないソースであり、その入力値をノーチェックで復元した場合はどうなるでしょうか? 当然、アプリケーションのプログラム内に、プログラマの意図していなかったオブジェクトのインスタンスが生成されてしまう、と言うような事態に繋がってしまいます。
これによって引き起こされる現象はアプリケーション毎にさまざまで、それが現実に問題となるかどうかも含めてアプリケーションのつくりに左右されますが、セキュアコーディング的には、シンプルにやってはいけない事だと言ってしまってもよいでしょう。^注1
この脆弱性を表す一般的な名称と言うのはどうも無いようなのですが、PHP界隈で使われている「オブジェクトインジェクション」と言うのがもっとも実態に近い名称ではないかと思います。

このように、脆弱性としては特に新しいものではなく古くから知られているものではあるのですが、先述した通り脆弱性になるかどうかもそのアプリケーション次第である場合が多く、バッファオーバーランのように存在している事自体でほぼ脆弱性だと認められるものと比べると、いまいちメジャーになりきれない脆弱性です。
しかしJavaの世界では、2015年に比較的大きな話題となりました。^注2 これは、WebSphereやJBossなどの著名なソフトウェアにこの脆弱性が発見され、しかもRCE可能だったと言う事があったからです。^注3 当時公開されたRCEに至る手法としてApache Commons Collectionsの特定クラスが利用されており、これを持ってApache Commons Collectionsの脆弱性だ、などと言われたりもしましたが^注3 ^注4、私の感覚ではあくまでも、信頼できない入力データをデシリアライズしている個々のソフトウェアの脆弱性だろうと感じます。

さて、一時期注目されたもののその後表立って大きな話題にはならなかったこの脆弱性ですが、S2-052として見つかった脆弱性への攻撃手法は、まさにこのデシリアライズで生成されるオブジェクトを巧妙に組み立てる事によってRCEを可能とするものでした。
この仕組みを見て行きましょう。

XStream

Struts 2の開発に使用されている言語はJavaであり、Javaは標準でシリアライズ/デシリアライズの仕組みを持っていますが、それとは別に、パフォーマンスやシリアライズされた際のデータの扱いやすさ等を目的とした、サードパーティ製のライブラリが多数公開されています。
S2-052の脆弱性の箇所で使用されていたのはこれらの内の一つのXStreamと言うライブラリで、標準ではオブジェクトをXMLの形で保存・復元できるようになっています。
例えば、

<map>
  <entry>
    <string>hoge</string>
    <string>fuga</string>
  </entry>
</map>

を標準設定のXStreamでデシリアライズすると、Javaのソースコードで

HashMap<String, String> map = new HashMap<>();
map.put("hoge", "fuga");

とやった場合の、変数map相当のオブジェクトが生成されます。
(組み込み型の詳しい記述法は公式サイト^注5等をご覧ください。)

また、Java標準のシリアライズ/デシリアライズ機構とは全く独立した仕組みなので、Serializableインタフェースを実装していないオブジェクトのシリアライズ/デシリアライズも可能です。 これを踏まえた上で、Metasploit Frameworkに組み込まれたS2-052攻撃モジュールに含まれるXMLを見てみましょう。
...何やら通常のJavaプログラミングではあまり目にする事は無いであろうクラス名が並んでいますがそれはともかく、やっている事は見たままで、HashMapのキーとしてjdk.nashorn.internal.objects.NativeStringオブジェクトを設定し、そのNativeStringオブジェクトのフィールドvalueにcom.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Dataオブジェクトを設定し、そのBase64DataオブジェクトのフィールドdataHandler(型はjavax.activation.DataHandler)のフィールドdataSourceにcom.sun.xml.internal.ws.encoding.xml.XMLMessageのstaticなネストしたクラスであるXmlDataSourceオブジェクトを設定し...と言うような事を粛々と行っているだけです。
これらのオブジェクトのインスタンスは、XStreamのかなり自由なデシリアライズ機能によって、正確に攻撃者の意図した内部状態を持って生成される事になります。
通常、オブジェクトが生成されただけでは、なかなかRCEに繋がったりはしません。 しかしこの攻撃XMLでは、デシリアライズされたそれぞれのオブジェクトの内部実装と、「攻撃者の意図した内部状態」とが組み合わさって、RCEを実現しています。

XStreamのmapタグのデフォルト実装はHashMapとなっています。 HashMapなので、このマップにkeyとvalueの組をセットする際に、keyのハッシュ値が計算されます。 これがRCEへの最初のトリガとなります。 keyとして設定するオブジェクトはjdk.nashorn.internal.objects.NativeStringなので、このhashCode()を見てみましょう。

見ての通りすぐ下のgetStringValue()を呼んでvalueのインスタンスがStringかどうかチェックしていますが、このオブジェクトのvalueのインスタンスはcom.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Dataとしてデシリアライズされているので、Base64Data.toString()が呼び出される事になります。

以下順に処理を追っていくと、まずBase64Data.get()

このインスタンスのdataには何も設定されておらずnullなので、DataHandler.getDataSource()

XMLMessage$XmlDataSource.getInputStream()

と来てCipherInputStreamのインスタンスを取得した上でBase64Data.get()に戻り、それを引数としてByteArrayOutputStreamEx.readFrom(InputStream)を呼び出します。

ByteArrayOutputStreamExはBase64Data.get()内で初期サイズ1024で作成されている為、この時点でのcountは0、buf.lengthは1024なので、そのままCipherInputStream.read(byte[], int, int)へ。

ostartとofinishは共に0として組み立てられているので、CipherInputStream.getMoreData()が呼び出されます。

doneはfalseinputはjava.lang.ProcessBuilder$NullInputStreamibufferはサイズ0の配列なので、input.read(ibuffer)に入り0が返ってきます。 この為、Cipher.update(byte[], int, int)が呼ばれ、

このインスタンスはNullCipherなのでCipher.checkCipherState()では何もせずにただ返って来るだけであり、

Cipher.chooseFirstProvider()へ。

spiはNullCipherインスタンスを普通にnewで作成すれば必ず非nullなのですが、デシリアライズ経由でnullになるように組み立てられており、debugは通常は(システムプロパティjava.security.debugにallかjcaを設定してJVMを起動していない限り)nullです。 firstServiceもnullなので、serviceIteratorからjavax.imageio.spi.FilterIterator.hasNext()へ進み、

serviceIteratorのnextは非nullなので、更にjavax.imageio.spi.FilterIterator.next()へと進みます。
そのまま進んだadvance()では同じくjavax.imageio.spi.FilterIteratorとして設定されたiterのhasNext()を呼び出しますが、こちらのnextはjava.lang.ProcessBuilderです。 つまり、任意のコマンドを設定されたProcessBuilderインスタンスであるeltを引数として、javax.imageio.ImageIO$ContainsFilter.filter(Object)が呼び出されます。

ようやく最後のステップです。
このfilterのmethodはProcessBuilder.start()として設定してある為、攻撃者が任意に設定したコマンドを保持したProcessBuilderインスタンスのstart()が呼び出される事となります。

と言うわけで、RCEが実現しました。

ガジェットチェーン

このように、行わせたい挙動(上記例で言えば任意のコマンドを設定したProcessBuilder.start()の呼び出し)の実現の為に、クラスパス上にある各種クラスの都合の良いパーツを選び出して組み合わせ、それらパーツを使って目的地にまで辿り着くようにインスタンスの内部状態を設定しています。
このような「各種クラスの都合の良いパーツ」は「ガジェット」と呼ばれ、ガジェットを組み立てて一連の処理に仕立てあげる事は「ガジェットチェーン」と呼ばれています。 先述した2015年の話での「Apache Commons Collectionsの特定クラス」もまさに有用なガジェットの一つだったと言えます。
これは、ローレイヤーな攻撃手法で言うところのReturn-Oriented Programming(ROP)のような事を、Javaのソースコードレベルで行っているようなものだと言えばわかりやすいかもしれません。 その為か、一部ではProperty-Oriented Programmingと呼ぶ向きもあるようです。

と、言うだけなら簡単なのですが、ガジェットチェーンを探し出すのにはそれなりに手間がかかります。 特に今回の攻撃XMLではすべてがJDKに標準で含まれているクラスのガジェットで実現されており、特定バージョンのApache Commons Collectionsや、他に有用なガジェットが存在する事が知られているSpring、Groovyなどのライブラリも必要ありません。 私は正直、よくもまあこんな都合の良いコードパスを探し出したな...と感心しました。
実はこのMetasploit攻撃モジュールのXMLはもちろん、恐らく脆弱性公表後最初に公開されたPoCの攻撃リクエストも、共通のツールで出力された物である可能性が高いと考えています。 このツールには様々なシリアライズ/デシリアライズライブラリに関してのリサーチペーパーが含まれており、非常に力の入った内容となっているので、この件に興味のある方はぜひご一読する事をお薦めします。

ところで、例え特定のガジェットの挙動を脆弱性だととらえて何らかの方法で使えなくしたとしても、Javaのライブラリ資産は膨大であり、いずれ別のガジェットが探し出される事でしょう。 それは際限の無いモグラ叩きであり、そのような対策は不毛です。 私が、デシリアライズによってコード実行されてしまうと言うのは特定のガジェットを含むライブラリの脆弱性などではなく、あくまでも信頼できない入力データをそのままデシリアライズしたソフトウェアの脆弱性である、と感じるのは主にそれが理由です。

WAFによる緩和策とその実装における考慮事項

私たちScutum開発チームでは、普段から、発見された脆弱性がWAFで緩和できる類のものなのか、緩和できそうならどのような対応を行うのがより良いのかを判断する為に、脆弱性の根本原因や攻撃ベクタの調査を行っています。 もちろん今回の脆弱性に関しても調査した上で、簡単に対策がバイパスされてしまうような事が無いように防御機能を実装しています。 今回の攻撃手法に対しての防御を行う上で、私たちが考慮した点を幾つか挙げてみようと思います。

まずもっとも単純に思いつく防御方法から考えてみます。
攻撃XMLでは任意のコマンドの実行を行う為にProcessBuilderを利用しています。 ではリクエストに「ProcessBuilder」と言う文字列を含んでいた場合に止めるようにすればどうでしょうか?
もちろん攻撃ツールを使うだけの、いわゆるスクリプトキディの人達の攻撃ならば、それでも大体は止まるでしょう。
しかし、もう少しスキルレベルの高い攻撃者なら、こう考えるはずです。

「攻撃はXMLで行えるのだから、各タグ属性値や要素テキストの検知されやすい文字列はランダムにエンコードしておこう」

文字列「ProcessBuilder」を「Pro&#x63;essBu&#x69;lder」、「Pro&#99;essBu&#105;lder」、「Pro&#x0063;essBu&#00000105;lder」等のように適当にエンコードして送信すると、リクエストに「ProcessBuilder」と言う文字列は含んでいませんが攻撃は成功します。
静的な文字列検索による防御はあっさり破綻してしまいました。
このような文字列検索を行うのなら、特定の条件のリクエストの内容をXML文書だと認識してパースし、エンコードされた属性値などは正しくデコードした上で行う必要があります。

では、「特定の条件のリクエスト」とはどう言う意味でしょうか?
S2-052の場合は、リクエストヘッダのContent-Typeが「application/xml」であった場合に脆弱性のある部分に処理が辿り着く事になります。 なのでWAFとしては、このようなリクエストであった場合にXMLとしてパースする処理を行えばよい事になります。
これは誤検知を減らす為にも役立ちます。
WAFとして適切に機能させる為には誤検知は可能な限り無くしたいところであり、その為には脆弱性のある部分に処理が辿り着かない事が明らかであるリクエストに関しては、その脆弱性のチェックは行わないのがもっとも望ましいと考えられます。
しかし、攻撃者はリクエストヘッダにContent-Typeを複数設定し、WAFのContent-Type識別と、そのWAFで守るべきサーバ側でのContent-Type識別とのズレを利用しようとするかもしれません。
Content-Type: application/xmlContent-Type: application/x-www-form-urlencodedを両方ヘッダに持つリクエストが来た際に、Struts 2がapplication/xmlだと認識するのに、WAFがapplication/x-www-form-urlencodedだと判断してしまうと言うような事があってはなりません。

さて、行うべき条件を正しく判定できるようにした上で、XMLのパース処理を行います。
XMLのパース処理と言うのはやはり古くから多くの需要があった為か、これを行うライブラリも多数公開されています。 例えばJavaでの場合は、SAXと呼ばれる実装がJDKに含まれていますが、Struts 2でのXStreamの使われ方では、XmlPull APIの実装の一つであるorg.xmlpull.mxp1.MXParserが使用されます。
ここでも留意すべき点が出てきます。XMLのパース処理と言うのは比較的枯れた処理であるにもかかわらず、パーサの実装毎に特徴のようなものがある為、SAXを使ってパースしようとした場合にはパースエラーになる入力が、MXParserではエラーにならずにパースできてしまう、と言うような場合が存在します。
言い換えると、WAFがXMLのパース処理をMXParser以外のパーサを使用して行っていると、攻撃者は、「WAFのXMLパーサではパースエラーになるけれどMXParserではパースできるリクエスト」を組み立ててWAFのバイパスを試みる、と言う事が可能となる余地があると言う事です。
「ブラウザ毎にHTMLやCSSのパース規則や機能が異なる事から、特定のブラウザでのみ発生するXSS」を攻撃するのと似たようなものでしょうか...。

またそれとは別に、そもそもMXParserを使っていてもパースに失敗する入力の場合にでも、攻撃に成功するパターンも存在します。
XStreamは入力されたXMLのノード構造を頭から順に辿ってオブジェクトをデシリアライズして行きますが、上で解説した例では、RCEへの最初のトリガはデシリアライズされたガジェットオブジェクトのhashCode()呼び出しでした。
攻撃者にしてみれば、ガジェットチェーンのトリガが引かれた後ならば、パースエラーが起きたところでもはや目的は達成しているので何の問題もありません。 むしろ問題が無いだけでなく、そのような入力を意図的に行って、WAFのバイパスを試みる事も有り得ます。

WAFを開発する側としては、自前にしろライブラリを使うにしろ、XMLのパースが失敗しても攻撃が成功する場合がある事を考慮に入れた上で防御機能の実装を行う必要があります。

最後に、先ほどご紹介したツールを確認してみるとわかりますが、上で解説したガジェットチェーン以外にも、リモートに置いたクラスファイルをロードさせてのコード実行のような手法や、ツールでは出力対応していないもののリサーチペーパーには書かれている、任意のクラスファイルのバイナリを送り付けた上でそれを無理矢理インスタンス化させる手法など、ProcessBuilderと言う文字列は含まないけれどもRCEが行えるガジェットチェーンは他にも複数存在します。
意図的なエラーの混入なども考慮した適切なパース処理を行って、入力値を正しくチェックできる状態になったところは、ゴールではなくスタート地点なのです。
ここから、各手法で攻撃を行う為に必要となる要素を抽出し、それらをなるべく誤検知の起こり難い形で防御機能に組み込んではじめて、「それなりに対応できた」と一息ついてよいのでしょう。

終わりに

WAFの開発者としては、特にRCEのような重大な脆弱性への対応は時間との勝負でもあります。攻撃手法の調査を含めた脆弱性の適切な評価はもちろん重要ですが、そのスピードも同じく重要です。
過去にあった事例やその攻撃手法は、ペンテスターを含め脆弱性診断に携わっている方なら積極的に収集しているのではないかと思われますが、知識として持っておく事で似たような脆弱性が見つかった時にどう対応すれば良いのかの判断が行いやすくなる為、WAFやIDS/IPSの開発に携わっている方にも有益だと思います。

そもそも似たような問題にはわざわざ対応せずに済むように準備するのも効果的です。
例えば、SQL injectionの脆弱性はどのようなアプリケーションで起こっても概ね同じような攻撃方法になる為、Scutumではベイジアンネットワークを利用したSQL injectionの防御機能を導入しています。何某のミドルウェアにSQL injectionの脆弱性が見つかりました!と言うような場合にも、特別な対応が必要となる事はほぼ無く済んでいます。
また、S2-052とほぼ同時に公開されたS2-053(CVE-2017-12611)に関しては例によってOGNLインジェクションでしたが、先日実装しておいた対策がさっそく役に立ち、Scutumでは特別対応せずとも防御可能でした。

今後も脆弱性の根本原因やその攻撃手法を調査し、新たに類似の脆弱性が発見されたとしてもそれに対する攻撃は防げると言うような、お客様はもちろん、私たちにとってもより良い形の防御機能を目指して開発して行きたいと思います。