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 を含む文書をすべて拒否します。
ステップ・バイ・ステップの攻撃フロー
攻撃者が XML 文書をアップロードする
文書は外部実体を宣言し、サーバーが返すフィールドでそれを参照しています。file:///etc/hostname を指す実体は、ファイルを読む場合を示しています。
パーサーが DTD を処理する
アプリケーションが DTD の読み込みと実体の展開を有効にしているため、パーサーは参照先のファイルや URL を読みます。
ファイルの中身がデータになる
文字列が customer 要素に入り、エンドポイントはそれを JSON レスポンスで返します。
攻撃者がファイルを読んだり内部ホストに届いたりする
設定ファイル、鍵、クラウドのメタデータをこの方法で読めます。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")}
エンジニアリング&システム堅牢化チェックリスト
- 信頼できない XML は Python では
defusedxmlで解析し、ほかの言語では DTD を無効にする。Java はdisallow-doctype-declを true、.NET はDtdProcessing.Prohibit、libxml2 はXML_PARSE_NOENTとXML_PARSE_DTDLOADを使わない。 - 管理していない入力に対して lxml に
resolve_entities=True、load_dtd=True、no_network=Falseを渡さず、CI で Bandit(B320 がlxml.etreeを検出)を実行する。 - リクエストのサイズを制限し(
MAX_CONTENT_LENGTH)、DOCTYPE を含む文書は拒否する。 - アップロードされた SVG、SAML、DOCX、XLSX ファイルも XML として扱い、同じ強化設定で解析する。
file:///etc/hostnameを指す外部実体を含む文書を送り、400 を期待する CI テストを追加する。