はじめに
これまでの記事で、XML要素・テキスト・名前空間宣言など、さまざまなイベントを検知するハンドラ関数を解説してきました。今回取り上げる xml_set_external_entity_ref_handler() は、その中でも特にセキュリティ上の重要性が高い関数です。
XML文書には、DTD(文書型定義)を使って外部のファイルやURLを参照する「外部実体参照(External Entity Reference)」という仕組みがあります。この仕組みは、悪用されるとXXE(XML External Entity)攻撃と呼ばれる深刻な脆弱性につながることで知られています。xml_set_external_entity_ref_handler() は、この外部実体参照が発生した際にPHP側で制御を行うためのハンドラであり、正しく理解し適切に扱うことがセキュアなXML処理の実装において欠かせません。本記事では基本的な使い方から、セキュリティ対策としての活用法まで詳しく解説します。
関数概要
| 項目 | 内容 |
|---|---|
| 関数名 | xml_set_external_entity_ref_handler() |
| 所属拡張 | XML Parser拡張(Expatベース、標準で有効) |
| シグネチャ | xml_set_external_entity_ref_handler(XMLParser $parser, ?callable $handler): true |
| 引数1 | $parser — xml_parser_create()等で生成したパーサーインスタンス |
| 引数2 | $handler — 外部実体参照検知時に呼び出すコールバック(nullで解除) |
| コールバックの引数 | (XMLParser $parser, string $openEntityNames, string $base, string $systemId, string $publicId) |
| コールバックの戻り値 | falseを返すとパーサーが処理を中断する(重要なセキュリティ制御) |
| 対応バージョン | PHP 4以降 |
| 重要度 | セキュリティ(XXE攻撃対策)に直結する重要な関数 |
XXE攻撃とハンドラの役割(イメージ図)
悪意あるXML文書の例
<!DOCTYPE root [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>&xxe;</root>
│
▼
xml_parse()による解析
│
│ "&xxe;" という実体参照を検知
▼
┌───────────────────────────────┐
│ xml_set_external_entity_ref_handler() │ ★この記事の対象
│ → 外部リソース(file:///etc/passwd)を │
│ 読み込もうとする直前にハンドラが発火 │
└───────────┬───────────────────┘
▼
┌────────────────┬────────────────┐
│ ハンドラでfalseを返す │ ハンドラを登録しない │
│ → 読み込みを拒否できる │ → デフォルト動作に依存 │
│ (安全) │ (バージョンにより挙動が異なる)│
└────────────────┴────────────────┘
ポイントは、このハンドラが外部リソースへのアクセスが実際に行われる「前」に介入できるという点です。コールバックの中で明示的に false を返すことで、ファイルシステムやネットワークへの不正なアクセスを未然に防ぐことができます。
実践サンプル7選
例1:基本的な使い方(外部実体参照をすべて拒否する)
<?php
class SecureXmlParser
{
/**
* 外部実体参照を検知した場合、常にfalseを返して
* 一切の外部リソースアクセスを拒否する(最も安全な設定)
*/
public function parseSecurely(string $xml): bool
{
$parser = xml_parser_create();
xml_set_external_entity_ref_handler(
$parser,
function ($p, $openEntityNames, $base, $systemId, $publicId) {
// 常にfalseを返すことで、外部リソースの読み込みを拒否する
return false;
}
);
$result = xml_parse($parser, $xml, true);
xml_parser_free($parser);
return (bool) $result;
}
}
$secureParser = new SecureXmlParser();
$maliciousXml = '<!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><root>&xxe;</root>';
var_dump($secureParser->parseSecurely($maliciousXml)); // 拒否され、解析が失敗する
例2:検知した外部実体参照をログに記録する監視クラス
<?php
class ExternalEntityAuditLogger
{
private array $detectedEntities = [];
/**
* 外部実体参照を検知した際、その詳細情報を記録しつつ
* 常に拒否することで、攻撃の試みを可視化する
*/
public function parseWithAudit(string $xml): array
{
$this->detectedEntities = [];
$parser = xml_parser_create();
xml_set_external_entity_ref_handler(
$parser,
function ($p, $openEntityNames, $base, $systemId, $publicId) {
$this->detectedEntities[] = [
'entity_names' => $openEntityNames,
'system_id' => $systemId,
'public_id' => $publicId,
];
return false; // 検知しつつ拒否する
}
);
xml_parse($parser, $xml, true);
xml_parser_free($parser);
return $this->detectedEntities;
}
}
$logger = new ExternalEntityAuditLogger();
print_r($logger->parseWithAudit(
'<!DOCTYPE root [<!ENTITY x SYSTEM "http://evil.example.com/data">]><root>&x;</root>'
));
例3:信頼できるホワイトリストのURLのみ許可する慎重な実装
<?php
class WhitelistedEntityResolver
{
private array $allowedHosts;
public function __construct(array $allowedHosts)
{
$this->allowedHosts = $allowedHosts;
}
/**
* systemIdのホスト名がホワイトリストに含まれる場合のみ許可する
* (なお、外部実体参照は原則すべて拒否するのが最も安全であり、
* このパターンは十分な検証と理解の上で慎重に採用すべき点に注意)
*/
public function parse(string $xml): bool
{
$parser = xml_parser_create();
xml_set_external_entity_ref_handler(
$parser,
function ($p, $openEntityNames, $base, $systemId, $publicId) {
if ($systemId === '') {
return true; // systemIdがない場合は無害なケースとして許可
}
$host = parse_url($systemId, PHP_URL_HOST);
if (!in_array($host, $this->allowedHosts, true)) {
return false; // ホワイトリスト外は拒否
}
return true;
}
);
$result = xml_parse($parser, $xml, true);
xml_parser_free($parser);
return (bool) $result;
}
}
$resolver = new WhitelistedEntityResolver(['trusted.example.com']);
例4:外部実体参照を検知したら即座に例外をスローする厳格な実装
<?php
class SecurityException extends RuntimeException
{
}
class StrictXmlSecurityParser
{
/**
* 外部実体参照を検知した時点で、
* 拒否するだけでなく即座に例外をスローして処理全体を止める
*/
public function parse(string $xml): void
{
$parser = xml_parser_create();
$detected = false;
xml_set_external_entity_ref_handler(
$parser,
function () use (&$detected) {
$detected = true;
return false;
}
);
xml_parse($parser, $xml, true);
xml_parser_free($parser);
if ($detected) {
throw new SecurityException('外部実体参照を含むXMLは受け付けられません');
}
}
}
$parser = new StrictXmlSecurityParser();
try {
$parser->parse('<!DOCTYPE root [<!ENTITY x SYSTEM "file:///etc/passwd">]><root>&x;</root>');
} catch (SecurityException $e) {
echo 'セキュリティエラー: ' . $e->getMessage() . PHP_EOL;
}
例5:外部から受け取るXMLファイルの一括セキュリティチェックツール
<?php
class XxeVulnerabilityScanner
{
/**
* 複数のXMLファイルに対して、
* XXE攻撃の可能性がある外部実体参照が含まれていないかをスキャンする
*/
public function scanFiles(array $filePaths): array
{
$results = [];
foreach ($filePaths as $path) {
$content = file_get_contents($path);
$parser = xml_parser_create();
$hasExternalEntity = false;
xml_set_external_entity_ref_handler(
$parser,
function () use (&$hasExternalEntity) {
$hasExternalEntity = true;
return false;
}
);
xml_parse($parser, $content, true);
xml_parser_free($parser);
$results[$path] = $hasExternalEntity ? '要注意: 外部実体参照を検出' : '問題なし';
}
return $results;
}
}
// $scanner = new XxeVulnerabilityScanner();
// print_r($scanner->scanFiles(['/tmp/upload1.xml', '/tmp/upload2.xml']));
例6:モダンな代替手段(libxml系)との比較
<?php
class ModernAlternativeComparison
{
/**
* 現代的なXML処理では、DOMDocumentやSimpleXMLと組み合わせて
* libxml_disable_entity_loader()(PHP 8.0未満)や、
* LIBXML_NOENTフラグの活用が一般的な対策として知られている
*/
public function parseWithDom(string $xml): ?DOMDocument
{
$dom = new DOMDocument();
// PHP 8.0以降、外部エンティティの読み込みはデフォルトで無効化されている
$success = $dom->loadXML($xml, LIBXML_NONET);
return $success ? $dom : null;
}
}
$comparison = new ModernAlternativeComparison();
$dom = $comparison->parseWithDom('<root><item>value</item></root>');
echo $dom?->saveXML() . PHP_EOL;
例7:内部実体参照との違いを確認する比較デモ
<?php
class EntityTypeComparator
{
/**
* 内部実体参照(DTD内で値が完結するもの)は
* このハンドラの対象にならないことを確認する
*/
public function compareEntityTypes(): array
{
$results = [];
// 内部実体参照の例(外部リソースを参照しない)
$internalXml = '<!DOCTYPE root [<!ENTITY greeting "こんにちは">]><root>&greeting;</root>';
$parser1 = xml_parser_create();
$externalDetected1 = false;
xml_set_external_entity_ref_handler($parser1, function () use (&$externalDetected1) {
$externalDetected1 = true;
return false;
});
xml_parse($parser1, $internalXml, true);
xml_parser_free($parser1);
$results['internal_entity'] = $externalDetected1 ? '検知された' : '検知されなかった(想定通り)';
// 外部実体参照の例
$externalXml = '<!DOCTYPE root [<!ENTITY ext SYSTEM "http://example.com/data.xml">]><root>&ext;</root>';
$parser2 = xml_parser_create();
$externalDetected2 = false;
xml_set_external_entity_ref_handler($parser2, function () use (&$externalDetected2) {
$externalDetected2 = true;
return false;
});
xml_parse($parser2, $externalXml, true);
xml_parser_free($parser2);
$results['external_entity'] = $externalDetected2 ? '検知された(想定通り)' : '検知されなかった';
return $results;
}
}
$comparator = new EntityTypeComparator();
print_r($comparator->compareEntityTypes());
関連関数との比較
| 関数/機能 | 役割 | xml_set_external_entity_ref_handlerとの違い |
|---|---|---|
xml_set_external_entity_ref_handler() | 外部実体参照を検知・制御する | 本記事の対象。Expat系パーサー専用のセキュリティ制御 |
xml_set_default_handler() | 未処理データ全般を捕捉する | 外部実体参照に特化しておらず、汎用的な捕捉手段 |
DOMDocument::loadXML() の LIBXML_NONET | ネットワークアクセスを無効化 | libxml系のDOM方式における同種のセキュリティ対策 |
libxml_disable_entity_loader()(PHP 8.0未満) | libxml全体で外部エンティティ読み込みを無効化 | Expat系ではなくlibxml系全体に影響する、より広範な設定 |
simplexml_load_string() | XMLをオブジェクトとして読み込む | PHP 8.0以降はデフォルトで外部エンティティが無効化されている |
よくある落とし穴(注意点)
- ハンドラを登録しないと、バージョンによって挙動が異なる可能性がある ハンドラを一切登録しない場合の外部実体参照の扱いは、PHPやExpatライブラリのバージョン、ビルド設定によって異なることがあります。セキュリティを重視するなら、常に明示的にハンドラを登録し、必要のない外部参照は拒否するのが安全な設計です(例1を参照)。
falseを返す意味を正しく理解する コールバックの戻り値でfalseを返すことが、外部リソースの読み込みを拒否する唯一の確実な手段です。この戻り値の重要性を見落とし、ハンドラ内で何も返さない(nullが返る)実装をしてしまうと、意図せず読み込みを許可してしまう可能性があります。- 原則として「すべて拒否」が最も安全な方針である ホワイトリスト方式(例3)は柔軟性がある一方、実装の誤りがそのままセキュリティホールに直結するリスクを伴います。外部実体参照を利用する正当な理由がない限り、無条件に拒否する実装(例1・例4)を基本方針とすることを強く推奨します。
- 内部実体参照とは区別されることを理解する DTD内で完結する単純な内部実体参照(
<!ENTITY greeting "こんにちは">など)は、このハンドラの対象にはなりません。外部リソース(SYSTEMやPUBLICで指定されるファイルやURL)への参照のみが対象です(例7を参照)。 - 現代的な代替手段(DOMDocument/SimpleXML)ではデフォルトで安全になっている点も理解する PHP 8.0以降、
DOMDocumentやSimpleXMLElementを使う場合はデフォルトで外部エンティティの読み込みが無効化されています。新規開発でXXE対策を考える場合、そもそもExpat系のxml_parse()系ではなく、これらのモダンなAPIを使う選択肢も検討する価値があります(例6を参照)。
まとめ
| 観点 | まとめ |
|---|---|
| 何をする関数か | XML文書中の外部実体参照(外部ファイル・URLへの参照)を検知し、その処理を制御する |
| 主な用途 | XXE攻撃対策としてのセキュリティ制御、外部実体参照の監視・ログ記録 |
| 最重要のポイント | コールバックでfalseを返すことで、外部リソースへの不正アクセスを未然に防げる |
| 推奨される方針 | 特別な理由がない限り、すべての外部実体参照を無条件に拒否する |
| 注意点 | ハンドラ未登録時の挙動の不確実性、内部実体参照との違い、モダンAPIでのデフォルト対策との比較 |
xml_set_external_entity_ref_handler() は、単なる利便性のための関数ではなく、XXE攻撃という実害のある脆弱性から自分のアプリケーションを守るための重要な防御線です。外部から受け取るXMLを処理するすべてのシステムにおいて、このハンドラを適切に設定する(あるいはより安全なモダンAPIを選択する)ことを強く推奨します。
