Log4jの脆弱性、ライブラリとの付き合い方

Log4j2に任意コード実行の脆弱性が見つかった(CVE-2021-44228)。ログに特定の文字列が含まれているとLog4jのフォーマッタが反応し、JNDIを経由してリモートからJava classを動的ロードするという内容で、見た目としてはprintfフォーマット文字列攻撃の発展型のように見える。Log4j自体がJavaでのロギングのデファクトスタンダードになっていることもあり、あちこちのWebサイトでAttack surfaceが発見されていてすごいことになっている。

ログにはユーザ入力がそのまま出力されることも珍しくないのだから、そんな信頼できない入力をフォーマット文字列のように扱うという設計自体が驚きで、Log4jのように広く使われているOSSにそんな実装が入っているということは意外だった。とはいえエンタープライズ用途だと、パッケージ品をコードを変えずに設定ファイルをいじるだけで運用時に挙動を変えたいという欲求がありそう(エンジニアの工数を割きたくない、運用担当のスキルがコードを書けるほど高くない等)なので、そういう汚いハックをしたい企業が影響力を持った結果としてこういう機能が入ったのかなというのが最初の所感だった。

ところが実際のところは考慮不足に近い設計ミスのようで、どうも問題になっているLookupという機能自体は、ログが吐かれるコンテキストに応じて違う値に解決されてほしいプレースホルダをLayoutの一部として埋め込むための仕組みとして作られているように見える(Log4j - Log4j 2 Lookups)。これがテンプレートに含まれるプレースホルダだけを置換するならまだ いいのだが1、実際にはConfigに由来する文字列だけでなく、ロガーに渡されたログ対象の文字列そのものに対しても同じ文字列置換を掛けていたらしい(MessagePatternConverter.java)。で、その文字列置換の方法の一つとしてJNDIを利用したリソースの解決があるため、結果としてログ文字列に ${jndi URI} を潜り込ませることができれば好き勝手にリモートのクラスを読み込ませられるようになっていた。要するにユーザー入力をprintfのフォーマット文字列に使っているようなもので、こんな問題が気づかれずに長期間放置されていたとはにわかには信じがたい。

脆弱性の詳細についてはさておき、この問題に関して、OSSライブラリの機能を完全に理解せずにプロダクトに投入すること自体が無責任ではないかという意見がある。こういった意見に対する反論として、全てのライブラリのコードを読むのは現実的でないとか、複雑な実装について試行錯誤する時間をスキップするために集合知に頼っているのだから本末転倒だといったものをよく見かけるが、今回のように非常に大きく防御の難しい脆弱性を抱え込むというリスクを考えると、やっぱり盲目的にライブラリを信用するのはある種の責任の放棄だと感じる。もちろんこの議論は無限後退を始めるとキリがなく、ライブラリがだめなら言語処理系はどうなんだ、OSはどうなんだ、ファームウェアはどうなんだとなってしまうので、どこかで実際にコードを読まずに信頼するという線引きが必要になるし、人や組織によってどの程度の規模や領域のコードなら読めるのか、また読んで理解するコストがリターンに見合うのかという境界は異なるだろう。自分もLog4jのコードはほとんど読んだことがないので人のことはとやかく言えない。

個人的には、実際にこういった脆弱性が存在することを予見できたかはともかく、単なるログライブラリとして自分が日頃使っている機能に対してlog4jの規模が大きすぎるのではないか、という疑いを持ったことがなかった点は反省するべきだと感じている。直感に対して規模が大きいということはそれだけ使っていない、もしくは理解していない機能が多いということだし、そういった理解していない機能が問題を引き起こす可能性は十分にありえる。これはLog4jに限った話ではなく、一般に単体のライブラリとしてコード量の多いものや、大量のtransitive dependencyを要求するライブラリなどにも当てはまる。

以前から(今回の脆弱性とは無関係に)数百行くらいの実装で済む機能ならそのためだけに既存のライブラリを引っ張ってくるよりも、自前で書いてしまったほうが中長期的にはメリットが大きいのではないかと考えていた。直接的な理由は色々なライブラリが大量にdependencyを引っ張ってくることによってdependency hellが発生したり、ビルド速度が低下したりといった問題(特にWebpackのようなbundlerが絡むビルドでは顕著になる)にうんざりしていたからだが、自前で機能を実装すれば余計な機能を背負い込まずに済むし、自分のプロダクトに特化した改造を加えたいときにも小回りが効きやすくなる。もちろんこの選択にはトレードオフがあり、短期的にはメンテナンスするべきコード量が増えるので開発速度が落ちる可能性があるし、複数人のチームで働くときは他のメンバーがこういった汎用的な関数をメンテナンスできる程度の習熟度があるとは必ずしも仮定できない。それでも、このアプローチは先に挙げたメリットに加えて、必要以上に大きなライブラリをコードやドキュメントすら理解せずに使うことの無責任さへの解答にもなっているので、しばらく試してみる価値があるように思う。

2021/12/23 22:50-06:00 @na4zagin3氏の指摘を受けて最終段落の文章を修正。


  1. 実はそれでも良くなくて、数日後に続いて発見されたCVE-2021-45046とCVE-2021-45105はLookupが再帰的に展開されることを利用して、テンプレートが特定のLookupを含むときにPayloadを埋め込んでいる。 ↩︎