● CWE-611 · OWASP A05:2021

XML 外部実体(XXE)インジェクション(CWE-611):仕組みと防ぎ方

文書の DTD に従う XML パーサーは、file:// や http:// の URL から SYSTEM 実体を取得し、その結果を解析したデータに貼り込みます。そのため、アップロードされた文書でサーバーのファイルを読んだり、サーバーに内部アドレスへアクセスさせたりできます。外部実体と実体展開の仕組み、それを招くパーサーの設定、そして defusedxml と forbid_dtd=True で止める方法を Flask の例で解説します。

わかりやすい解説 (ELI5)

印刷所が定型の手紙に文字を差し込んでいます。ある客の手紙には「拝啓[ここに御社の書類棚の 4 番目の引き出しの中身を差し込んでください]様」と書いてあります。注意深い印刷工は客が渡した言葉だけを差し込みます。不注意な印刷工は、ほかの客の契約書が入った 4 番目の引き出しまで行き、それを手紙に刷り込んで郵送してしまいます。XXE はこの不注意な印刷工です。XML 文書がパーサーにファイルや Web アドレスを取ってきて差し込むよう指示し、パーサーはそのとおりにします。対策は「よそから差し込め」という指示を含む手紙をすべて断ることです。

主要な概念と専門用語

DTD と実体
<!DOCTYPE ...> 内の文書型定義では実体を宣言できます。実体は名前の付いた文字列で、パーサーは &名前; が現れる場所にそれを置き換えます。内部実体は文字列そのものを持ち、外部実体は別の場所を指します。
外部実体
<!ENTITY x SYSTEM "file:///etc/hostname"> は、その URL を読んで &x; の位置に中身を入れるようパーサーに指示します。http:// の URL ならリクエストを出すのはサーバーなので、XXE はサーバーサイドリクエストフォージェリになります。
実体展開(Billion Laughs)
ほかの実体を 10 回ずつ参照する実体を重ねると、1 KB の文書がメモリ上で数 GB の文字列に膨らみます(CWE-776)。外部へのアクセスは不要なので、DTD を拒否するか展開に上限を設けるしか止める方法はありません。
パーサーの既定値
挙動はライブラリによって異なります。lxml 5 は既定で内部実体しか展開しませんが、resolve_entities=True と load_dtd=True で穴が再び開きます。Java の DocumentBuilderFactory は disallow-doctype-decl を設定しない限り外部実体を展開し、.NET では DtdProcessing.Prohibit が必要です。
defusedxml
標準のパーサーを包み、危険な構文を処理する代わりに EntitiesForbidden、ExternalReferenceForbidden、DTDForbidden を送出する Python パッケージです。forbid_dtd=True は DOCTYPE を含む文書をすべて拒否します。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

攻撃者が XML 文書をアップロードする

文書は外部実体を宣言し、サーバーが返すフィールドでそれを参照しています。file:///etc/hostname を指す実体は、ファイルを読む場合を示しています。

ステップ 2

パーサーが DTD を処理する

アプリケーションが DTD の読み込みと実体の展開を有効にしているため、パーサーは参照先のファイルや URL を読みます。

ステップ 3

ファイルの中身がデータになる

文字列が customer 要素に入り、エンドポイントはそれを JSON レスポンスで返します。

ステップ 4

攻撃者がファイルを読んだり内部ホストに届いたりする

設定ファイル、鍵、クラウドのメタデータをこの方法で読めます。2014 年、Reginaldo Silva は Facebook の OpenID 処理にあったサーバー上のファイルを読める XXE を報告し、33,500 ドルの報奨金を受け取りました。

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
# invoices.py: アップロードされた DTD の指示にパーサーが従う
from flask import Flask, request
from lxml import etree

app = Flask(__name__)


# 実体を展開し、DTD を読み込み、ネットワークアクセスも許可
@app.post("/invoices/import")
def import_invoice():
    parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)
    root = etree.fromstring(request.get_data(), parser)
    return {"customer": root.findtext("customer"), "total": root.findtext("total")}
堅牢化されたセキュアパッチ
# invoices.py: DTD を含む文書は解析前に拒否する
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from flask import Flask, abort, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1_000_000


# DOCTYPE、実体、外部参照はすべて不可。本文は 1 MB まで
@app.post("/invoices/import")
def import_invoice():
    try:
        root = fromstring(request.get_data(), forbid_dtd=True)
    except (DefusedXmlException, ParseError):
        abort(400)
    return {"customer": root.findtext("customer"), "total": root.findtext("total")}

エンジニアリング&システム堅牢化チェックリスト

参考資料

← セキュリティ目録をすべて見る 脆弱性ガイド一覧 →