SSTエンジニアブログ

SSTのエンジニアによるWebセキュリティの技術を中心としたエンジニアブログです。

Log4Shellで何が起こっていたのかを追ってみる

はじめに

こんにちは。久々に寝坊やらかして凹んでる、SST研究開発部の小野里です。今年入ってきた新人さんたちは、私のようにならないでほしいと祈るばかりです。

さて、新年度には入ってしまいましたが、つい先日まで2021年度新卒研修最後の延長戦として、以前話題になったLog4Shell脆弱性のPoCを作るという課題に取り組んでいました。やっと動作するところまでいったものの、ここまでの道のりは非常に果てしなく複雑で長く険しいものでした。
セキュリティ業界において、多くの場合脆弱性の詳細な再現手順は伏せられる傾向にあります。それは主に悪用を防ぐためなのですが、セキュリティの初学者には実際の所何をどうするとどう危ないのか、分かりづらい場合も多いのが現状です。
Log4Shell脆弱性は非常に大きな騒ぎになったため、各所の対応も早かったかと思います。そこで、比較的Log4Shellの影響が落ち着いてきたかと思われる今、(全てではありませんが)もうちょっと突っ込んで「中で何が起こってどうなったか」の話を書いてみようと思います。

なお、Log4Shell実行時に攻撃者が用意するサーバにはLDAP,RMI,DNSなどの種類がありますが、この記事ではLDAPに絞って書きます。別のプロトコルについては今回調査していないため、この記事はあくまでLDAPと組み合わせてLog4Shellを行う場合の話という事をご承知おきください。

※注意!!!
本記事の内容は脆弱性の理解・セキュリティ技術の向上のために記載しているものであり、決して悪意ある攻撃の実行を示唆するものではありません。実際に攻撃手法を試してみる場合、必ず自分の管理下にあるサーバとの通信に限ってください。

目次

Log4Shellとは

この記事を読む方には既に周知のことと思いますが、今一度Log4Shellの概要についておさらいします。 Log4Shellは、Log4j/Log4j2(本記事では総称してLog4jと表記)というロギング用Javaライブラリにて発生した脆弱性の通称です。CVEは以下の通りです。

Log4jによりログ内にある形式で書かれたクエリが記録されると、Log4jが自動でクエリにより指定された外部リソースにアクセス、任意コードを実行するファイルをダウンロードしてきて実行してしまうという脆弱性です。ユーザの入力がログに記録されるだけで発生してしまうという実行の簡易さ、Javaで実行可能なものならほぼ自由に任意コード実行が出来てしまうという影響の大きさから、非常に大変な騒ぎになったことは記憶に新しいかと思います。Log4jのアップデートにより既に脆弱性は修正されており、最新版のライブラリに更新することが現在最も簡単で有効な対処法となっています。

結局、何が問題だったの?

Log4Shellの根本的な問題は、ログメッセージに対してLookup機能が働いてしまう事でした。
Log4jには、ある特定のテンプレート文字列を自動的に置き換える機能が搭載されています。これ自体は他の言語やライブラリなどにもよく見られる機能で、例えばPythonでは

a = "hoge"
b = "fuga"
print(f"{a} is (b)")

# 実行結果: hoge is fuga

のような形で、自動的に文字列の中に変数の中身を展開できます。このような感じで、Log4jの中で使う様々な値をテンプレート文字列で置き換えることが出来ました。本来は設定ファイルなどに書いて、ログ出力の形式を指定したりするのに利用されるものなのですが、以前の脆弱なバージョンではログメッセージにもこのテンプレート文字列の展開が働いてしまっていました。
つまり、ユーザがテンプレート文字列を含んだ入力を行い、それをLog4jがログに記録することで、自動的にそのテンプレート文字列が展開されます。ここまでなら精々ログに意図しない入力が残るだけなのですが、このテンプレート文字列から、さらにJNDI Lookupという機能を呼び出すことが出来てしまいました。これは後に軽く解説しますが、この機能を使うことで外部のリソースからJavaのオブジェクトをダウンロードできます。これで攻撃者が用意したJavaオブジェクトを攻撃対象にダウンロードさせ、そのJavaオブジェクトがデシリアライズ、Javaクラスがロードされる時に任意コードが実行されてしまう、というのが問題となっていました。現在ではそもそもログメッセージにはテンプレート文字列の展開が働かないようになっているため、この問題は起こらないようになっています。

この問題については以下の記事が詳しかったので、参考にしてみてください。

atmarkit.itmedia.co.jp

Log4Shell発生の仕組み

Log4jの中で何が起こっているのかについて、もう少し詳しく見てみましょう。Log4Shellは大まかに次のような流れで発生します。

Log4Shell発生の仕組み

以下、図の番号に沿って流れを説明していきます。

  1. 攻撃者は攻撃対象サービスにクエリを送ります。このクエリには実行したいコードが置いてあるサーバへのURLが含まれています。クエリはログに記録されさえすればいいので、ユーザが入力可能な部分なら概ねどこでもOKです。
  2. サービスが受け取ったクエリ文字列はLog4jに渡され、ログに記録されます。ここでサービス側が受け取った文字列をそのままログに書き込まなければLog4Shellは発生しませんが、ユーザの入力をログに記録しないサービスは少ないのではないでしょうか。
  3. Log4jは受け取ったクエリをログに書き込む際、クエリのテンプレート文字列を自動的に解釈して、URLのアドレスにアクセスします。ここで、後に解説するJNDI Lookupという機能が働きます。
  4. Log4jは攻撃者の用意したサーバにアクセスし、そこに配置されている任意コードを実行するクラス・インスタンスのデータをダウンロードしてきます。
  5. Log4jはダウンロードしてきたインスタンスのデータを自動的にデシリアライズします。この時、インスタンスがロード・toString()されるので、インスタンスにロード時やtoString()時に実行される命令がある場合はここで任意のコードを実行することが可能になります。

なお先述の通り、攻撃者が用意するサーバにはLDAP,RMI,DNSなどの種類がありますが、この記事ではLDAPに絞って話を進めます。

LDAPとは

ここで、LDAPについても軽く説明しておきます。 LDAPはLightweight Directory Access Protocolの略で、ディレクトリサービスに接続するために使用されるプロトコルのことです。本記事では付随するLDAPサーバ含むソフトウェアなどの環境も総称してLDAPと呼ぶことにします。
ディレクトリサービスとは何か、といった詳しい話はググれば出てくるのでそちらにお任せしますが、文字列情報を迅速にやりとりするためのシステム、と思っていただければ大体合っているかと思います。MicrosoftのActive Directoryを始めとして、主にネットワーク内におけるアカウント情報の管理に利用されることが多いです。もちろん、それ以外の情報を格納することも可能であり、それが今回の肝となります。

この記事の前に、Log4Shellに関わるLDAPの基礎知識の記事を書いています。よろしければそちらもご一読ください。

https://techblog.securesky-tech.com/entry/2022/04/21/ldap-for-log4shelltechblog.securesky-tech.com

JNDI Lookupとは

JNDI (Java Naming and Directory Interface)とは、Javaから様々なネームサービスやディレクトリサービスに接続するための機能です。この機能を使うことで、接続先がDNSでもLDAPでも同じインターフェースで情報を取得することが出来ます。 このJNDIで実際に各サービスに対して問い合わせを行うのがlookupメソッドです。JNDIの特徴として、「文字列情報だけでなく、Javaのオブジェクトも取得できる」という点があります。つまり、JNDIでLookupすることでシリアライズされたJavaのオブジェクトを取得し、それをデシリアライズしてしまうという流れがLog4ShellによるRCE(Remote Code Exception:リモートコード実行)の実態となります。

解説としては特にこれ以上言う事がないのですが、ちょっと分かりづらかった方もいるかと思います。JNDIとは何か?については以下の記事が参考になりました。

www.ne.jp

Log4jのソースコードを見てみよう

それでは早速、Log4jのソースコードを覗いてみましょう。ここではLog4Shell脆弱性の存在する、Log4j2 2.14.0のソースコードを見てみます。なお、最新版のLog4jでも今回解説する流れ自体は同じですが、先述の通り最新版ではログメッセージに対してJNDI Lookupが働かないため、問題はなくなっています。

これからソースコードを追っていきますが、色々なクラスを行ったり来たりするため少々複雑な話になります。今回扱う範囲を図にしてみましたので、解説と見比べながら参考にしていただければと思います(大きい図になってしまったので拡大しながらご覧ください)。

ソースコードの流れ

さて、JNDIが今回の問題なので、それで検索をかけてみると……ありました。org.apache.logging.log4j.core.lookupパッケージにJndiLookup.javaというファイルがあります。これをざっと眺めてみると、以下のようなメソッドがありました。

@Override
public String lookup(final LogEvent event, final String key) {
    if (key == null) {
        return null;
    }
    final String jndiName = convertJndiName(key);
    try (final JndiManager jndiManager = JndiManager.getDefaultManager()) {
        return Objects.toString(jndiManager.lookup(jndiName), null);
    } catch (final NamingException e) {
        LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", jndiName, e);
        return null;
    }
}

いかにも怪しいメソッドですね。ここで注目したいのは、tryで実行しようとしている次の一文です。

try (final JndiManager jndiManager = JndiManager.getDefaultManager()) {

どうやら、JndiManagerというクラスのgetDefaultManager()メソッドを呼び出しているようです。このメソッドがどんなものかを見てみましょう。

public static JndiManager getDefaultManager() {
    return getManager(JndiManager.class.getName(), FACTORY, null);
}

どうやらgetManager()というメソッドの戻り値を返すだけのごく簡単なメソッドのようです。ここで、FACTORYという定数がありますが、これは次のように定義されています。

private static final JndiManagerFactory FACTORY = new JndiManagerFactory();

この定数がJndiManagerFactory型だという事が分かりました。ここは一旦置いておいて、先ほどのgetManager()メソッドを見てみましょう。このメソッドはorg.apache.logging.log4j.core.appenderパッケージのAbstractManager.javaクラスにあります。

public static <M extends AbstractManager, T> M getManager(final String name, final ManagerFactory<M, T> factory,
                                                          final T data) {
    LOCK.lock();
    try {
        @SuppressWarnings("unchecked")
        M manager = (M) MAP.get(name);
        if (manager == null) {
            manager = factory.createManager(name, data);
            if (manager == null) {
                throw new IllegalStateException("ManagerFactory [" + factory + "] unable to create manager for ["
                        + name + "] with data [" + data + "]");
            }
            MAP.put(name, manager);
        } else {
            manager.updateData(data);
        }
        manager.count++;
        return manager;
    } finally {
        LOCK.unlock();
    }
}

なんだか複雑そうですが、とりあえずManagerを返すメソッドであるという察しは付くかと思います。ここで、M manager = (M) MAP.get(name);としていますが、まだMAPにmanagerは追加されていないので、下のif (manager == null)の中身に入ります。
ここで、次の一文に注目してみましょう。

manager = factory.createManager(name, data);

ここで、getManager(JndiManager.class.getName(), FACTORY, null);という形で呼び出されていたことから、factoryの中身はJndiManagerFactoryであることが分かります。また、datagetManager()の引数3番目なので、data=nullです。では、JndiManagerFactory.createManager()を見てみましょう。

@Override
public JndiManager createManager(final String name, final Properties data) {
    try {
        return new JndiManager(name, new InitialContext(data));
    } catch (final NamingException e) {
        LOGGER.error("Error creating JNDI InitialContext.", e);
        return null;
    }
}

このメソッドはnew JndiManager(name, new InitialContext(data))を返すメソッドであることが分かります。つまり、最初のtry (final JndiManager jndiManager = JndiManager.getDefaultManager()) {は、紆余曲折を経た末にnew JndiManagerするためのものだったという事です。

ここで注目したいのがnew InitialContext(data))の一文です。ざっくり説明すると、普通JavaからJNDIで情報をやり取りするにはContextというものを設定します。これは接続先の認証情報や接続先URL、セキュリティレベルなどの諸々の設定項目をまとめたもので、普通はこれらをHashtableにまとめてからjavax.naming.InitialContext()に渡すことでContextを設定します。しかし、InitialContext()の引数にnullを渡すことで、特に何もせずともデフォルトの設定でJNDI Lookupを行うことが出来ます。LDAPの場合、デフォルト設定では細かい検索条件の指定はできず、認証情報も設定できないため匿名バインドでのアクセスとなるので、通常利用の範疇でセットアップしたLDAPサーバでは有用なレスポンスを返すことはありません。ただし、Log4Shellでは接続先のLDAPサーバ自体を攻撃者が指定できてしまいます。そのため、攻撃者側で匿名バインドのアクセスを許可したLDAPサーバを用意し、それにリクエストさせることで悪用できるのが、Log4Shellの特徴です。

ソースコードの話に戻りましょう。tryでjndiManagerを取得することに成功すると、次の文に進みます。

return Objects.toString(jndiManager.lookup(jndiName), null);

ここではjndiManager.lookup()でLookupした結果をそのままtoString()しています。取ってきた情報がJavaオブジェクトの場合、少なくともここでtoStringされる時に一度JavaオブジェクトのデシリアライズやJavaクラスのロードが発生します。ここでRCEが可能になります。

つまり、Log4jにおけるJNDI Lookupの流れを超簡易的に再現するなら、以下のようなコードになります。

public class JavaLookup{
  public static void main(String[] args){
    final String query = "ldap://(LDAPサーバのドメイン)/cn=javaobject,dc=sample";
    try{
      Context context = new InitialContext(null); //InitialContextにnullを指定
      Object result = (Object) context.lookup(query); //InitialContext(null)のContextでlookup
      System.out.println(result); //結果をtoString()
    }catch (NamingException e){
      e.printStackTrace();
    }
  }
}

まとめ

Log4Shellは

  1. ログメッセージに対してテンプレート文字列の展開が働く
  2. 展開されたテンプレート文字列からJNDI Lookupが働く
  3. JNDI Lookupにより取得されたJavaオブジェクトのデシリアライズ・Javaクラスのロードが発生し、RCEが発生する

という流れで発生します。一見JNDIが悪いように見えますが、このJNDI Lookupの動作自体は想定通りの正常なものであり、問題はこれをユーザ側から自由に呼び出せてしまう事でした。先述の通り、現在は1.の段階でログメッセージに対してはテンプレート文字列の展開が働かないようになっているため、脆弱性は修正されています。また、Java自体も現在のバージョンではLDAPからのオブジェクトのロードは-Dcom.sun.jndi.ldap.object.trustURLCodebase=trueオプションをVMに明示的に与えない限り働かないようになっています。

今回の調査は研修という事もあって、騒ぎが落ち着いて情報も色々出てからの調査でしたが、実際の脆弱性調査ではそうもいきません。今後も継続的に学習を続け、最新の脆弱性にすぐ対応できるよう力をつけていきたい所存です。

参考文献