Resumen
translation-finder es la librería de Weblate que recorre un repositorio y detecta qué ficheros de traducción contiene y en qué formato. Para los ficheros de Qt Linguist (.ts), el backend QtDiscovery necesita conocer la versión del formato y la obtiene con _get_qt_ts_version() (en translation_finder/discovery/files.py, línea 80 en la rama principal al commit 671d213, línea 78 en la versión publicada 3.6.1). Esa función la llama QtDiscovery.adjust_format(), una vez por cada resultado de la detección.
El problema es una expansión de entidades XML (ataque conocido como billion laughs) en ese análisis. La función usa xml.parsers.expat con la configuración por defecto, que acepta entidades internas declaradas en el DOCTYPE y las expande. Solo registra un StartElementHandler que lee el atributo version del elemento raíz <TS> y corta el análisis lanzando una excepción propia, _QtRootFoundError. Ese corte no sirve de protección: expat expande las entidades internas mientras normaliza los valores de los atributos, es decir, antes de llamar al manejador de inicio de elemento. Cuando se dispara el aborto, el trabajo de expansión ya está pagado.
No había ningún EntityDeclHandler registrado ni endurecimiento del DTD en ninguna parte del código (el investigador buscó EntityDeclHandler y SetParamEntityParsing en ambas ramas y no encontró ninguna coincidencia). Así, un atacante puede declarar una cadena de entidades internas en el DOCTYPE y referenciarla desde el atributo version.
Este era el cuerpo de la función afectada:
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
Por qué no bastaban las protecciones existentes
expat incluye una protección propia contra billion laughs, pero acota la proporción de amplificación (en torno a 100x), no el trabajo absoluto. Eso se combina mal con el límite de tamaño de la propia librería, FORMAT_SNIFF_MAX_BYTES = 1 MiB, que marca cuánto contenido de cada candidato .ts se entrega a la función:
- Una bomba pequeña no funciona: un fichero de pocos cientos de bytes que pide cien millones de caracteres de expansión excede la proporción y expat lo corta casi al instante (0,051 s medidos).
- Una bomba "gorda" sí funciona: un fichero rellenado hasta quedar justo por debajo del límite de 1 MiB, cuyo atributo
versionencadena suficientes referencias para expandir unos 55 MB, se mantiene dentro de la proporción (~57x) y fuerza esa expansión en un solo análisis. El investigador midió 0,866 s por análisis frente a 0,006 s de un.tsbenigno del mismo tamaño: unas 145 veces el coste de analizar un fichero normal.
El relleno (con comentarios XML) sirve para dar al fichero tamaño "legítimo" suficiente para que la proporción de expansión se quede por debajo del tope de expat. El límite de 1 MiB, en la práctica, le regala al atacante cerca de 1 MB de fichero con el que jugar.
Además, el endurecimiento reciente del mantenedor (commit d69b673, "fix(discovery): bound multi-file format sniffing", ya incluido en 3.6.1) acota bytes y número de ficheros por resultado, no el número de resultados, que es justo el eje por el que escala este fallo. FORMAT_SNIFF_MAX_FILES = 10 nunca se toca aquí, porque cada fichero malicioso genera su propio resultado con un único candidato.
Pasos de reproducción
A alto nivel, la prueba de concepto del investigador (ejecutada en Kali Linux, Python 3.13.9, expat 2.7.3) consistió en:
- Instalar la librería en un entorno virtual (
pip install translation-finder, versión 3.6.1) o instalarla en modo editable desde el commit 671d213 de la rama principal. - Construir un fichero
.tssintácticamente válido que declara en suDOCTYPEuna cadena de entidades internas anidadas (cada nivel multiplica por diez al anterior) y la referencia muchas veces desde el atributoversiondel elemento<TS>, con relleno de comentarios para quedar justo por debajo de 1 MiB. El cuerpo incluye un<context>normal para que sea un.tsválido. - Colocar ese fichero en un repositorio y medir una detección:
discover(ruta). El nombre importa: con el patrón<prefijo>-<idioma>.ts(por ejemploa0-cs.ts) la heurística de nombres genera una máscara comodín distinta por fichero (a0-*.ts,a1-*.ts...), de modo que cada fichero se convierte en su propio resultado y se analiza una vez. - Comprobar el escalado creando N ficheros con nombres de prefijo distintos (
a0-cs.ts,a1-cs.ts, ...). El número de análisis es exactamente N: con N=50 se midieron 50 análisis y 31,68 s de CPU en la rama principal.
Un control importante: si al mismo fichero se le quita el bloque DOCTYPE (dejándolo como un .ts válido de 950 KB), el análisis baja a 0,006 s. Esa diferencia es el fallo; nada más cambia en el fichero.
Mediciones
Resultados del investigador contra 3.6.1 (límite de memoria del hijo de 2 GiB):
| prueba | tamaño fichero | tiempo | RSS pico | |---|---:|---:|---:| | .ts benigno de 950 KB, sin DOCTYPE | 956.969 B | 0,006 s | 20,8 MiB | | bomba pequeña | 599 B | 0,051 s | 26,1 MiB | | bomba gorda (~55 MB de expansión) | 957.496 B | 0,866 s | 127,9 MiB | | misma bomba con EntityDeclHandler que deniega todo | 957.496 B | 0,006 s | 22,7 MiB | | 10 bombas gordas en un repo | 957.496 B c/u | 5,148 s | 129,4 MiB |
Escalado en la rama principal (commit 671d213): N=1 → 0,605 s; N=10 → 5,154 s; N=25 → 16,13 s; N=50 → 31,68 s. El RSS pico se mantiene plano (~130 MiB) en toda la tabla: el recurso que se agota es la CPU, no la memoria. El coste por fichero es de ~0,65 s de CPU por MB de .ts malicioso, idéntico en 3.6.1 y en la rama principal.
Impacto
El impacto es de disponibilidad. discover() se ejecuta sobre repositorios y archivos subidos por usuarios durante la importación o creación de componentes en Weblate, así que cualquier usuario con permiso de escritura en un repositorio puede desencadenarlo.
- El número de ficheros lo elige el atacante y no hay ningún tope en la librería. Con 50 ficheros se queman unos 32 s de CPU por detección, y la detección se repite en cada actualización del repositorio: el coste se mantiene mientras los ficheros sigan ahí.
- El payload comprime extraordinariamente bien: los 957.496 bytes se reducen a unos 4.068 bytes con deflate (~235x). Eso supone unos 163 s de CPU por cada MB comprimido subido. Según el investigador, unas 20 veces más eficiente por byte subido que otro vector que reportó aparte.
- El análisis es CPU-bound dentro del manejo de la petición, así que unas pocas peticiones concurrentes con estas subidas pueden bloquear los procesos web o agotar el pool de trabajadores.
- A diferencia de otros vectores, este sí produce resultados (cada bomba se analiza correctamente tras pagar la expansión), de modo que Weblate recibe además N resultados de detección basura.
Clasificación del investigador: CWE-776 (restricción indebida de referencias recursivas a entidades en DTD, "XML bomb") junto con CWE-400 (consumo de recursos no controlado). Severidad sugerida media, con CVSS:3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (6.5): requiere permiso de escritura en el repositorio y solo afecta a la disponibilidad, pero el coste por subida es extremo.
Remediación
El reporte figura como resuelto. La corrección propuesta y medida por el investigador es registrar un EntityDeclHandler en _get_qt_ts_version() que rechace cualquier declaración de entidad. Los ficheros .ts reales que genera Qt Linguist no usan entidades de DTD, así que denegarlas todas no rompe la detección legítima. El manejador se dispara en el momento de la declaración, antes de que se expanda ninguna referencia:
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
Con esa corrección, la misma bomba de 957 KB pasó de 0,866 s y 127,9 MiB a 0,006 s y 22,7 MiB.
El investigador propone además dos medidas de refuerzo: descartar antes de analizar los candidatos .ts cuyo contenido contenga <!ENTITY, y dar a QtDiscovery.adjust_format() un presupuesto compartido por detección (total de análisis Qt por llamada a discover()), para acotar también el eje del número de resultados.
Datos del programa: reporte a Weblate en HackerOne por el investigador sumit-sabhaya, enviado el 07-oct-2026 y divulgado el 07-nov-2026.
Qué aprender de este caso
- Cuando uses
xml.parsers.expaten Python para leer XML no confiable, registra unEntityDeclHandlerque lance una excepción: la protección interna de expat limita la proporción de amplificación (~100x), no el trabajo total, así que un fichero grande dentro de esa proporción todavía puede forzar decenas de MB de expansión. El límite de ratio no es un límite de coste. - Desconfía de los cortes de análisis "tempranos" basados en
StartElementHandler: la normalización de los valores de atributos (y por tanto la expansión de entidades referenciadas desde un atributo comoversion) ocurre antes de que se invoque ese manejador, así que abortar en el primer elemento no evita el gasto. - Revisa los presupuestos de seguridad fijándote en el eje por el que escala el ataque: aquí los topes "por resultado" (bytes y nº de ficheros) no servían porque el atacante multiplicaba el número de resultados. Un límite agregado por operación completa (total de análisis por
discover()) cierra ese hueco. - Un control útil al revisar un caso así es comparar el mismo fichero con y sin su bloque
DOCTYPE: si el tiempo de análisis se dispara solo por la presencia de declaraciones de entidad, tienes delante un vector de expansión XML.