Skip to main content

JavaのsnakeyamlでYAML解析の脆弱性を防ぐ

Blog Design Yaml parsing issues

2021年3月30日

0 分で読めます

YAMLとは?

YAMLは、人が読みやすいデータシリアライズ形式で、設定ファイルによく使われます。YAMLという言葉は「YAML Ain’t a Markup Language」の頭字語で、2001年に初めて公開されました。YAMLは、いずれもテキストベースの構造化形式であるJSONやXMLと比較できます。

YAML、JSON、XMLの違い

これらの言語と同様に、YAMLはJSONより読みやすく、XMLより冗長にならないように設計されています。たとえば、3つの言語は構造やネストを扱う構文がそれぞれ異なりますが、YAMLではインデントと空白を使って表現します。

YAMLファイルは、アプリケーションやアプリケーションサーバー、クラスターの設定によく使われます。Spring Bootアプリケーションで非常によく使われる形式で、もちろんKubernetesの設定にも使われます。また、JSONやXMLと同様に、YAMLを使ってデータをシリアライズおよびデシリアライズすることもできます。

YAMLはXMLやJSONに代わる優れた形式に見えますが、その構造を好まない人も少なくありません。YAMLは行単位で記述し、インデントで構造やネストを表すため、複雑なデータ構造を解析する際にインデントが問題を引き起こすことがよくあります。複雑でデータ量の多い構造では、空白が1つ足りない、あるいは余分にあるだけで、YAMLの解析に失敗します。その結果、予期しない問題が発生し、YAMLファイルのどこに原因があるのかを見つけるのも困難です。

特に注意が必要なのは、古いバージョンのsnakeyamlを使ってJavaアプリケーションにYAMLを手動でインポートすると、問題が起きる可能性があることです。

要約

古いバージョンのsnakeyamlには、サービス拒否(DoS)の脆弱性があります。この問題を防ぐため、snakeyamlをバージョン1.26以降にアップデートすることを強くおすすめします。

snakeyamlを使ってJavaでYAMLファイルを解析する

JavaアプリケーションでYAMLファイルを解析するには、広く知られているライブラリsnakeyamlを使用できます。軽量でシンプルなこのライブラリを使うと、YAMLをオブジェクトに変換したり、オブジェクトをYAMLに変換したりできます。

ここでは、YAMLをJavaプログラムに読み込む方法を見てみましょう。基本的には2つの方法があります。1つ目は、snakeyamlでYAML入力を汎用的に読み込む方法です。以下のコードでは、クラスパス上のresourcesフォルダーからYAMLファイルを読み込みます。

InputStream is = getClass().getClassLoader().getResourceAsStream(filename);
Yaml yaml = new Yaml();
var lhm = (LinkedHashMap) yaml.load(is);

このようにYAMLを読み込むと、結果はYAMLファイルを構造化して表すLinkedHashMapになります。特定の型に縛られない汎用的な構造のため、さまざまな型のデータを含めることができます。

2つ目は、YAMLをより具体的に読み込む方法です。YAML入力を特定のオブジェクトに解析できます。snakeyamlは命名規則に従って、YAMLの変数をオブジェクトのフィールドに割り当てようとします。YAMLファイルの構造がオブジェクトの構造やデシリアライズ先のオブジェクトに合わない場合は、例外が発生します。以下のコードでは、YAML入力をPerson型に解析します。

InputStream is = getClass().getClassLoader().getResourceAsStream("person.yaml");
Yaml yaml = new Yaml(new Constructor(Person.class));
Person person = yaml.load(is);

Person.java

public class Person {
   private String firstname;
   private String lastname;
//getters and setters
}

Person.yaml

firstname: "Matt"
lastname: "Murdock"

YAMLをオブジェクトに解析するどちらの方法も問題なく機能します。入力の内容が確実に分かっている場合は、YAML入力を特定のオブジェクトに変換できます。そうでない場合は、より汎用的な方法を使い、リストを手動で検索するのがおすすめです。

YAMLにおけるBillion Laughs攻撃

YAMLの機能の1つに、アンカーを作成できることがあります。アンカーを別の場所で再利用できるため、同じ記述を繰り返す必要がありません。以下の簡単な例では、var1とvar2という2つの変数を作成しています。アンカーを使うと、var2の値はvar1と同じになります。

var1: &anchor value
var2: *anchor

これを極端なケースに当てはめて、YAMLで有名なBillion Laughs攻撃を作ってみましょう。この仕組みを入れ子状に適用すると、実際にBillion Laughsを作成できます。

lol1: &lol1 ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
lol2: &lol2 [*lol1,*lol1,*lol1,*lol1,*lol1,*lol1,*lol1,*lol1,*lol1]
lol3: &lol3 [*lol2,*lol2,*lol2,*lol2,*lol2,*lol2,*lol2,*lol2,*lol2]
lol4: &lol4 [*lol3,*lol3,*lol3,*lol3,*lol3,*lol3,*lol3,*lol3,*lol3]
lol5: &lol5 [*lol4,*lol4,*lol4,*lol4,*lol4,*lol4,*lol4,*lol4,*lol4]
lol6: &lol6 [*lol5,*lol5,*lol5,*lol5,*lol5,*lol5,*lol5,*lol5,*lol5]
lol7: &lol7 [*lol6,*lol6,*lol6,*lol6,*lol6,*lol6,*lol6,*lol6,*lol6]
lol8: &lol8 [*lol7,*lol7,*lol7,*lol7,*lol7,*lol7,*lol7,*lol7,*lol7]
lol9: &lol9 [*lol8,*lol8,*lol8,*lol8,*lol8,*lol8,*lol8,*lol8,*lol8]
lolz: &lolz [*lol9]

ご覧のとおり、lol1は"lol"という文字列10個のリストです。変数lol2はlol1を10個含むリストです。この仕組みを何度か繰り返すと、lolzは"lol"を10^9個含むことになります。つまり、Billion Laughsです。

アンカーを使うと、YAML爆弾を作成できます。このようなYAML入力が生成する膨大な量の(入れ子になった)オブジェクトによって、メモリが不足します。

snakeyaml、特にバージョン1.26未満では、これが問題になる可能性があります。最初の例のようにYAMLを汎用的に解析すると、このYAML爆弾によってjava.lang.OutOfMemoryError(Java heap space)が発生します。通常、アプリケーションがクラッシュして利用できなくなるため、サービス拒否(DoS)につながります。

一方、2つ目の例のようにYAMLを特定のオブジェクトに解析すれば、問題は小さそうに見えるかもしれません。snakeyamlライブラリは、変数名をオブジェクトのフィールドに対応させようとします。対応できない場合は、YAML例外が発生します。一見有効な解決策に思えますが、万全ではありません。

先ほどと同じように、firstnameとlastnameを持つperson型があるとします。さらに、person型のコレクションで表される子どもを含めることもできます。

public class Person {
   private String firstname;
   private String lastname;
   private List<Person> children;
   //getters and setters
}

今度は、元のBillion Laughs攻撃と同じ問題が発生します。アンカーを使って何層にもわたる同様のYAMLファイルを作成できます。YAMLファイルは次のようになります。型のすべてのフィールドを埋める必要はないことに注意してください。

firstname: X
lastname: XX
children:
   - children: &a [{firstname: a},{firstname: a ..]
   - children: &b [{children: *a},{children: *a} ..]
   - children: &c [{children: *b},{children: *b} ..]
   - children: &d [{children: *c},{children: *c} ..]
   - children: &e [{children: *d}, {children: *d} ..]
...

ルートの親に複数の子どもを作成し、すべての子どもの子どもが前のアンカーを参照するようにして、雪だるま式に増える構造を作りました。そのため、YAMLファイルを汎用的に解析しても、Personオブジェクトに特化して解析しても、ヒープメモリが不足します。

JavaでYAMLのBillion Laughs攻撃を防ぐ

この問題の解決策は、思っているよりずっと簡単です。YAMLファイルで空白の抜けを探すより、はるかに手間がかかりません。snakeyamlをバージョン1.26以降にアップデートするだけです。snakeyamlの開発者は、スカラー以外のノードで使えるエイリアスの数を最大50個に制限して、この問題を見事に修正しました。新しいバージョンのsnakeyamlで、前述のようなYAML爆弾を解析すると、該当のメッセージを含む例外が発生します。つまり、ヒープメモリが不足せず、アプリケーションも稼働し続けます。

このことからも、依存しているライブラリを常に把握しておくことがいかに重要かが分かります。このケースでは、新しいバージョンにアップデートすることで問題を解決できます。ご存じのとおり、コードリポジトリを接続すればSnykがサポートします。さらに、Snyk Open Sourceで依存ライブラリを継続的にスキャンすれば、このような脆弱性に不意を突かれることはありません。

CTFを始めよう

オンデマンドのバーチャル入門ワークショップを視聴して、CTFの課題の解き方を学びましょう。