日々常々

ふつうのプログラマがあたりまえにしたいこと。

3月末のSpringFrameworkのRCEを紐解いてみる

https://spring.io/blog/2022/03/31/spring-framework-rce-early-announcement

Spring Shell がかわいそうなのでSpring4Shellって呼び方はしてほしくないなーと。でもCVEの番号なんて覚えれないので、通称は欲しい。この名前で広まっちゃったから検索に使ってるけど、、、

起こりうる問題とか実務ですべき対応とかはSpringの公式アナウンスを読んでください。 なんかおかわりもあるっぽいので、SpringBoot使ってるなら 2.6.6 -> 2.6.7 と更新してくことになりそ。

中身の話します。発生直後とかは攻撃の助けになっちゃうので言及は控えめにした方がいい気もするけど、そろそろいいかなって……攻撃者はもう情報持ってるだろうし。。

Springの対応内容

コミット で「なるほどねー」と言えるなら、以降は読む必要ありません。

この CachedIntrospectionResultsHistory を見ると、ひとつ前の変更は 2020-08-29 で2年弱触られておらず、過去10年で30コミット程度。そんな変更が活発なクラスでもないです。

動かして確認してみよう

CachedIntrospectionResults の変更箇所は private なコンストラクタで、使用箇所はパッケージプライベートな static メソッド forClass(Class) だけ。このメソッドは BeanUtilsBeanWrapperImpl で使用されます。 使いやすい BeanUtils を使用して試してみましょう。

BeanUtils と言われると「ああ、getter/setter呼んだりBeanのプロパティをがさっとコピーしたりするあれね」とか「 BeanUtils とか BeanUtil とかいろんなパッケージにあるよなー」とか思い当たる方も多いでしょう。

動作確認コード

今回問題になっていたリクエストは class.module.classLoader.resources.context... というものでした。

BeanUtils はJavaBeansを扱うもので、 java.beans.PropertyDescriptor が使われています。 PropertyDescriptor はリフレクションで少し踏み込んだことをしたことがなければ見たこともないかもしれませんが、別に知らなくても問題ありません。「あーJavaBeansのパッケージにプロパティをいじるクラスがあるんだなぁ」くらいで十分すぎると思います。興味あったら触ってみるのもいいと思いますが。

JavaBeansについては以下のエントリを見ておくと心穏やかに生きられると思います。

irof.hateblo.jp

さて、やってみましょう。

  1. BeanUtils を(介して対象の CachedIntrospectionResults を)使ってプロパティ名に対する PropertyDescriptor を取得
  2. PropertyDescriptor#getReadMethod() で getterを取得
  3. getterを使ってインスタンスを取得
  4. インスタンスに対して1に戻って繰り返し
import org.springframework.beans.BeanUtils;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;

public class CVE202222965Test {

    public static void main(String... args) throws Exception {
        Object instance = new CVE202222965Test();

        for (String propertyName : "class.module.classLoader.definedPackages".split("\\.")) {
            System.out.println("-- " + propertyName);
            PropertyDescriptor propertyDescriptor = BeanUtils.getPropertyDescriptor(instance.getClass(), propertyName);
            System.out.printf("%s に対する %s のPropertyDescriptor: %s%n", instance.getClass(), propertyName, propertyDescriptor);
            if (propertyDescriptor == null) {
                System.out.println("とれなかった(セーフ)");
                return;
            }

            Method readMethod = propertyDescriptor.getReadMethod();
            instance = readMethod.invoke(instance);
            System.out.println("PropertyDescriptorで取れた値: <<" + instance + ">>");
            if (instance == null) {
                System.out.println("とれなかった(セーフ)");
                return;
            }
        }
        System.out.println("ClassLoaderから取れちゃった: " + instance);
    }
}

Java9以降で該当するバージョンの spring-beans を使うと最後まで行きます。こんな感じ。

-- class
class cve.CVE202222965Test に対する class のPropertyDescriptor: org.springframework.beans.GenericTypeAwarePropertyDescriptor[name=class]
PropertyDescriptorで取れた値: <<class cve.CVE202222965Test>>
-- module
class java.lang.Class に対する module のPropertyDescriptor: org.springframework.beans.GenericTypeAwarePropertyDescriptor[name=module]
PropertyDescriptorで取れた値: <<unnamed module @30cb5b99>>
-- classLoader
class java.lang.Module に対する classLoader のPropertyDescriptor: org.springframework.beans.GenericTypeAwarePropertyDescriptor[name=classLoader]
PropertyDescriptorで取れた値: <<jdk.internal.loader.ClassLoaders$AppClassLoader@42110406>>
-- definedPackages
class jdk.internal.loader.ClassLoaders$AppClassLoader に対する definedPackages のPropertyDescriptor: org.springframework.beans.GenericTypeAwarePropertyDescriptor[name=definedPackages]
PropertyDescriptorで取れた値: <<[Ljava.lang.Package;@548e7350>>
ClassLoaderが取れちゃった: [Ljava.lang.Package;@548e7350

Java8以前はgetModuleメソッドがないので取れないし、対応後のspring-beansはmoduleが取れないようになってます。もちろんclassLoaderも取れない。

"class.module.classLoader.definedPackages" の最後の definedPackagesClassLoader の持っている public なgetterならなんでも良いです。 PropertyDescriptor 経由のアクセスなので乱暴なメソッド呼び出しはできませんが、今回の脆弱性DataBinder なのでsetterを呼び出しちゃう。で、setter経由でTomcatのあいつを狙ったらいけちゃう、と言うのが示されちゃった内容。 探し回ったらTomcat以外でもなんやかんや見つかるでしょうね。

こんな感じでTomcatにwar作ってデプロイしたりDataBinderを使ったりしなくても、事象の原因部分だけは動かせます。

直感的にはとにかく ClassLoader に手が届くのが気持ち悪いのですが、これ "class.module.classLoaderclass.classLoader だと だいぶ昔(2010-03)から取れない んですよね。 今回の対応前から ("classLoader".equals(pd.getName())continue してるので、元々 CachedIntrospectionResultsClassLoader を扱わせるつもりはなかったわけで。

動きを変えて遊んでみる

  • プロパティのたどりかたをクラスを読みながら変える
    • "class.module.classLoader.definedPackages" をいじってみてどこまで手が届くんだっけ?とやってみる。
  • 起点のクラスを変える
    • たとえば new CVE202222965Test() としてるのを new Object() とか、クラスローダーが違うクラスを使ってみる。
  • setterを呼んでみる
    • getReadMethod してるとこで getWriteMethod 呼べばsetterがあれば取れます。値をねじ込めます。
    • インスタンスメソッドなら値を突っ込む対象のインスタンスがなかったらあまり役に立たないんですが、手に入ったインスタンス経由で static なsetter風メソッドとかに手が届いちゃったら、ね。

……攻撃者がやってることなんですが、こう言うことしてるとJSLとかフレームワークとかの構造が少し見えるようになってくるんじゃないかなと。遊びで得られる知見ってどこで役に立つかわかりませんが、なんらかの糧にはなります。

思うところ

別段 ClassLoader に手が届いたからと言って、 ClassLoader の機能を使ってるわけではないです。初期情報を見た時は「 ClassLoader に無理矢理クラスを読み込ませてるのかな?」と思ったりしましたが、濡れ衣でした。「任意のコードを実行できるプロパティに手が届いてしまうケースが見つかった」ですかね。

今回の方法はgetterを辿ってsetterを呼び出せるなら何にでも適用できるもので、 ClassLoader は踏み台にされただけ。 ClassLoader は多くのインスタンスに手が届きやすいのでよく狙われるんですけども。

じゃぁ問題はどこにあるんだって言うと、意識的に実装してないプロパティにアクセスできるところなんじゃないかなぁ。 具体的には getClass() メソッドで Classクラスのインスタンスが取れる点。要するにすべてのインスタンスObject を実装するがゆえに Object のメソッドをすべて持っている(けれどそれが意識から漏れがち)というところが。 Object のメソッドって業務コードではだいたい直接は使わないんですよね。 toString equals hashCode あたりは意識するものの、別段 Object のメソッドである必要はないと言うか。他のメソッドはもっと意識されてないでしょうし。そういえば Java9でdeprecatedになっていた finalize がJava18の JEP 421: Deprecate Finalization for Removal で削除に一歩進みましたね。さすがに finalize くらいになると削除に対してもすごく慎重な進め方をしている模様。

話を戻して、 BeanUtils を使う場面で Object クラスのメソッドを呼び出したいと思うことはない、と言うかシンプルに考えれば対象クラスに実装していないsetter/getterを呼び出したいとは思っていなさそうなんですが、現実問題としてBeanと呼ばれるものは共通プロパティを基底Beanと呼ばれるものに定義してたりするので、ユーティリティとしては親クラスも辿って呼び出す必要はあるんですよねぇ。

対応内容を見てると「なんで getClass() の呼び出し自体止めないんだろ」とか思うところですが、 Class クラス自体を使用されたら大穴は空いたままだし、 class プロパティはなんだかんだで使われてるんだろうなぁ。。

そういえば InitBinderのフィールド指定

@InitBinder でdisallowFieldを指定する」がバージョンアップするまでの対応として挙げられています。「そんな機能知らなかった」と言う話を聞いたりしたので、haljikさんのこちらを紹介。

意図しない改竄に対して安全

「動けばいい」なら面倒なだけなんですが、こういうこと起こるとホワイトリスト方式って強いなぁと……

まとめると

と言うことで、変更内容は以下。

  • 変更前
    • Class クラスのプロパティ名 classLoaderprotectionDomain は扱わせない。(ブラックリスト
  • 変更後
    • Class クラスで扱えるプロパティは name で終わるものだけ。 (ホワイトリストに近いルール)
    • すべてのクラスで ClassLoaderProtectionDomainインスタンスは扱わせない。

だいぶ厳しくなってます。 Java9で生えた getModule メソッドが public でなかったり get から始まっていなければ、と言う感じでしょうか。私がメンテナなら「まじかー……」とか言ってると思います。 普段よく触っているコードならともかく、ほとんど更新しないコードですしね。

ともかく、今後は BeanUtilsClassLoader なプロパティと Class クラスのほとんどのプロパティは無視されるようになります。 万一使っていたら「バージョンアップしたら動かなくなった」とかなります。 そんなもの BeanUtils でやるんじゃないよ、って思うけど。踏まないこと祈ってる。

GradleWrapperの追加や更新の手順

  • Gradle 7.3.2

Gradleを使っているプロダクトだと、基本的にGradleWrapperを使うと思います。 プロジェクト作成時に最新のGradleを使うかと思いますが、Gradleの更新でたまにビルド時間短縮とかもあるんで、新機能使わなくてもWrapperも更新していきたいところです。

なお 公式のユーザーガイド に書かれている内容です。

short answer

私がやってるのは、追加も更新も「 gradle wrapper を叩く」だけです。

以下は説明。

前提

SDKMAN! でGradleをインストールしています。

sdk install gradle

なお、手元のGradleのバージョンアップも一緒です。install で新しいのがあったらインストールしてくれる。

sdk install gradle

普通は sdk upgrade gradle なんだろうけど、upgradeを忘れたり「updateとどっちだっけ?」とかなるので、雑にinstallしてたりします。覚えるコマンドが少ないのはいい・・・

windows環境でどうするかは知らない。

なるほど。

GradleWrapperがあたりまえなので手元にGradleをインストールすることも少ないかもしれません。 私は一からプロジェクトを作ることも多いので入れてます。

GradleWrapperの追加

gradle wrapper

GradleWrapperがないGradleプロジェクトではこれで追加。build.gradlewrapper タスクを書いておく必要とかはないです。(昔は要ったんだっけ。忘れた。)

……なんだけど、だいたいプロジェクト作成時に入ってたりする。 すくなくとも Spring InitializrでGradle選んだり、gradle init で作るとGradleWrapperは勝手についてきます。 なので、追加のためにgradle wrapper 叩くことは案外なかったりする。

更新

本題。一番やる頻度は高いんだろうけど、「どうするんだっけ?」ってなるやつです。

先頭で書いたように 公式のユーザーガイドにUpgrading the Gradle Wrapperと言うズバリな項目 があるんですが、この項目に Example: Upgrading the Wrapper version の見出しでこう言うの書いてるんですよね。

$ ./gradlew wrapper --gradle-version 7.3.2

BUILD SUCCESSFUL in 4s
1 actionable task: 1 executed

これが罠。「Example」であって「これ一発で更新できるよ!」って言うものじゃないんです。前段の説明に書いてるんだけど、英語読まずに コマンドだけ実行しちゃう人が結構多いので、このブログ書いてます。

推奨される方法は「追加と同じことをしよう」です。すなわち実行するコマンドは以下になります。

gradle wrapper

……最新のGradleが手元にあるならば。

gradle-wrapper.propertiesdistributionUrl を書き換える」や「 gradlew--gradle-versionを指定してwrapperタスクを実行する」は、この方法を取れないときの次善策です。

動作はしますが、これらの手順の後は ./gradlew wrapper (バージョン指定なしでよい) を実行しましょう。 なんだか面倒臭いですが、wrapper タスクでの gradlewgradlew.batgradle-wrapper.jar の更新は、実行したGradleのバージョンでなされます。なので--gradle-versionを指定しても置き替わらないんですよね。

更新手順まとめ

手元にGradleがある場合は、手元のGradleを最新にして gradle wrapper を叩けばOKです。

手元にGradleがない場合は以下のいずれかになります。

  • ./gradlew wrapper --gradle-version {目的のバージョン} を叩いて、 ./gradlew wrapper を叩く。
  • --gradle-version と言うオプションを忘れてたら gradle-wrapper.properties のURLを書き換えて、 ./gradlew wrapper を叩く。

どの手順でも「wrapper タスクを新しいGradleで叩く」は変わりありません。

更新したのをコミットするときに gradlew, gradlew.bat が更新されてなかったら、「更新できてないんじゃ?」と疑ってみるのがいいと思います。バージョンアップのたびになんか変わってますので。(今後変わらないことあるかもだけど)

Javaで「ライブラリの最新版がある」と言うときの基礎知識

Log4j 2のバージョンアップのやりかた で "「Mavenリポジトリ」の指すもの" を軽く書きましたが、いい機会なのでもう少し書いておきます。

最新版は使える?

f:id:irof:20211214123532p:plain

https://twitter.com/irof/status/1469139048954724354

こういうツイートをしまして。 見てる順番は Log4j 2のトップページMvnRepositoryのlog4j-coreGitHubのLog4j 2のタグ一覧Central Repositoryのlog4j-apiディレクトリです。

ツイートの状態から「Log4j 2はリリース成功してからタグ作るで運用してるんだなぁ」とか、リリース成功したら自動でタグ作ってるわけでもないのかなぁとか思いました。私はタグをトリガーにリリースのパイプライン動かすのが好きです。リリース失敗したら消したくなるけど。

基本的に「最新版が使える」は「公開されているMavenリポジトリから取得できる」であり、狭義では「Central Repositoryに公開されている」を指します。 ツイートの4枚目で 2.15.0 の存在を確認できているので「最新版が使える」状態でした。

現代ではMavenやGradleのようなビルドツールを使わないことは考えづらく、jarを手動でダウンロードすることはほとんどありません。 あるとしても一部のAntでのビルドを保守している文脈か、ライセンス等の問題でCentral Repositoryなどで公開できない一部のライブラリを使用する場合です。

たとえば Log4j 2サイトのダウンロードページから最新版のjarがダウンロードできたとしても、それを使用するようにビルドツールを設定してあげる必要があります。 最近ではほとんど用途のないlibディレクトリに配置してのパス指定や、後述のインハウスリポジトリへの一時的な登録などになります。これらをしなければ「自分の手元ではビルドできてもCIではビルドできない」となったりして、前時代的だし、どちらも面倒。やらずに済むならやりたくないのが今日この頃です。やらずに済んでよかった。

インデックスサイト

リポジトリの階層をたどるのは面倒なので、 https://search.maven.orghttps://mavenrepository.com のようなMavenリポジトリの検索を助けてくれるサービスを使用することが多いでしょう。Googleとかで検索してもこれらが先に出てくるはずです。

私はインデックスサイトとか呼んでますが、共通の呼び名は知りません。インデックスサイトはそれぞれのサイト次第であり、別に mvn コマンドが使用する最新のリポジトリの状態を示すものではありません。

ここになくても、使えるものは使えます。検索を助けてくれるだけ。

Mavenリポジトリ

mvn コマンドはMavenリポジトリを使用します。 Mavenリポジトリはローカルリポジトリとリモートリポジトリに分けられますが、基本的にリモートリポジトリが原本、ローカルはキャッシュくらいの感覚でOKです。

mvn install とか叩くとローカルリポジトリに登録されますが、使えるのは自分だけです。リモートリポジトリへの登録は mvn deploy とかですが、サーバーのURLとか認証情報とかが必要になってきます。

リポジトリの場所は .m2/settings.xml とか pom.xml とかコマンド実行時の引数とかで設定できますが、何も設定しなければローカルは ~/.m2/repository で、リモートリポジトリは https://repo.maven.apache.org/maven2/ です。

プロジェクトで使ってるリモートリポジトリがわからなかったら、存在しないライブラリ落とそうとしたらエラーログに出てきたりします。

f:id:irof:20211214134633p:plain

修飾なしで「Mavenリポジトリ」と言うと、基本的にはCentral Repositoryを指すと思って大丈夫です。 他はSpringのMavenリポジトリとか修飾をつけて呼ぶはずです。Central Repository以外のリポジトリを使用する動機は、そのリポジトリでしか公開されていないものを使用したい場合で、多くはSNAPSHOTやMilestoneなリリースを使いたい場合になります。他は作者が何かしらの理由によりCentral Repositoryで公開していない場合でしょう。(「めんどくさい」って理由で上げないことも多いと思います。)

ライブラリごとのリポジトリの場所

たとえばLog4j 2のリリースリポジトリhttps://repository.apache.org/service/local/staging/deploy/maven2 で、SNAPSHOTリポジトリhttps://repository.apache.org/content/repositories/snapshots のようです。 なのでSNAPSHOTはSNAPSHOTリポジトリorg/apache/logging/log4j/log4j-core にあります。

これはLog4j 2のpom.xml を読めばわかることです。 org.apache.logging.log4j:log4j-core -> org.apache.logging.log4j:log4j -> org.apache.logging:logging-parent -> org.apache:apache とparentを辿れば <distributionManagement>に書かれています。4階層もあって面倒なので、自力でpom読むよりは手元のpomでmvn help:effective-pom する方が確実ですし、結局<property>なのでビルド時に上書きできるんですけども。

と、pom.xmlを見たら書いてたりするという、あまり役に立たない知識を書いておきたかっただけです。 公式サイトのSnapshot buildsに書いてあるので、素直にこっち見た方がいいです。

インハウスリポジトリ

リモートリポジトリのうち、組織内部でのみ使用する目的で作られたリポジトリをインハウスリポジトリと言ったりします。

私が使ったことがあるのはSonatypeのNexus(Central Repositoryが使用している)や、JFrogのArtifactory(Springが使用している)。最近ではGitHub PackagesAWS CodeArtifactなども選択肢に入るかもしれません。

自分達だけが使うライブラリを登録したい場合はもちろん、クローズドなCentral Repositoryにアクセスできない環境や、Central Repositoryが落ちた時に備えるとか、ネットワーク的に近いところにキャッシュしてビルド時間を短縮したいとか、いろんな理由で使われます。

インハウスリポジトリを使用する場合、開発端末の ~/.m2/settings.xml に書くことが多いと思います。

Central Repository

Central Repsitoryは、Sonatypeが運営しており、世界中で使われ、あちこちでミラーリングされてたりするリポジトリです。

「セントラリリポジトリ」の他、「Mavenセントラルリポジトリ」とか、「セントラル」とか、先にも書きましたが単に「Mavenリポジトリ」と呼ばれたりします。いずれもCentral Repositoryのことです。JVM界隈では「ここで公開されているライブラリはだいたいどこでも使える」と言っていいです。

逆にここで公開されていないライブラリを使うには、個別の対応が必要になる認識です。

Central RepositoryのURL

Central RepotiroyのURLは https://repo1.maven.org/maven2https://repo.maven.apache.org/maven2 があり、公式で並列してアナウンスされているもの です。なのでどっちでもいい。

内容微妙に違うし(正確には「違うことがある」かな。この記事書いた時違ったけど、今みたら同じになってた。)、確かrepo1.maven.orgからrepo.maven.apache.orgに変更された経緯があった気がするんだけど、混乱したからどっちでもいいを維持することにしたんでしょうかね。Central Repotitoryを運営しているSonatypeの運営するインデックスサイト https://search.maven.org/ のリンクがrepo1向いてるんで、インターネットで示すのはrepo1がいいかなと思っています。リリースしたら両方に反映されるし。

Central Repositoryへの反映

CentralRepositoryへの反映は(個人の場合は)図のようにOpenSourceSoftwareRepositoryHostingというサービスを使用し、複数ステップで行われます。

f:id:irof:20211214205406p:plain

デプロイとリリースは別ですが、プラグインでの自動化も可能。リリース時にjavadocやsourceがあるかとか署名の検証とかが行われ、ダメだったら跳ねられます。一度リリースしたものは削除できません。分かれてるのは最終確認みたいな感じですね。

Log4j 2はApacheなのでLarge Organization向けのものになるんでしょうかね。同期にどれくらい時間がかかるかはよくわかりません。

Central Repositoryへの反映ですが、少なくともOSSRHでは図で書いているように制御不可です。数時間かかる時もあれば、さきほどは10分くらいで反映されました。この時間が問題になることは少ないですが、JIGのようにGradlePluginPortal(ほぼ即時反映)とCentral Repositoryとの同時リリースすると、「GradlePluginPortalにはあるけどCentral Repositoryにないので使えない時間」があったり、単一リポジトリのマルチモジュールでなく複数リポジトリに分かれているライブラリはCentral Repositoryに反映されてからでないとリリース作業が行えない、とかがあったりします。

Central Repositoryでのライブラリ公開は一度やっておくとこの辺りの事情がイメージしやすくなるのかなと。

Gradleの話

GradleもなんだかんだでCentral Repositoryを使います。いっとき jcenter() を使う流れはありましたが、JCenterのサービス終了に伴い、Central Repositoryに戻ってきた感じ。サービス終了が発表された時は騒ぎになりましたが、さすがにもう大丈夫でしょうか。あと3ヶ月でダウンロードもできなくなるので、そこでガン無視してたところが騒いだりしないか若干心配。

Gradleはbuild.gradleで以下のように書く必要があるので「Mavenセントラル」と言う言葉に馴染みはあるのではないでしょうか。

repositories {
    mavenCentral()
}

逆にデフォルトで使われるMavenの方がCentral Repsitoryは意識しないのかもしれないなぁ、と。

変更メモ

  • 2021-12-15T12:13
    • タイトルを 「Mavenリポジトリに最新版がある」とは から 「Mavenリポジトリに最新版がある」と言うときの基礎知識 に変えました。
  • 2021-12-15T15:50
    • さらにタイトルを Javaで「ライブラリの最新版がある」と言うときの基礎知識 に変えました。