Resumen
Este fallo es lo que quedó sin arreglar de CVE-2022-23915. La corrección publicada en Weblate 4.11.1 protegió varias llamadas a Mercurial frente a la inyección de argumentos, pero se dejó una: HgRepository.get_file(). Esa función monta la orden hg cat y pone la ruta del fichero justo detrás de la revisión, sin el separador --, que indica a Mercurial que lo que viene después ya no son opciones:
return self.execute(
["cat", "--rev", revision, path],
remote_op="none",
needs_lock=False,
merge_err=False,
)
La ruta sale del campo template del componente, y Weblate acepta ahí nombres relativos que empiezan por -. Si el nombre tiene forma de opción, Mercurial lo interpreta como un parámetro de línea de órdenes y no como un fichero del repositorio. Con la sintaxis --config=alias.cat=!<command> se puede redefinir cat como alias de shell, así que Weblate acaba ejecutando una orden del atacante en lugar de leer un fichero.
La vía para llegar a esa función es el complemento Update RESX files. Después de actualizar el repositorio, este complemento lee la plantilla con:
content = component.repository.get_file(
component.template,
previous_head,
)
Datos del caso:
- Tipo: inyección de comandos del sistema operativo (OS Command Injection), CWE-78.
- Versiones afectadas: de la 4.11.1 (la primera con el parche de CVE-2022-23915) a la 2026.7.1 de PyPI, además del commit
76d311849a56c99cfc68968827a7cf9a13af3551demain. La función vulnerable y la llamada que la alcanza existen desde Weblate 2.19, pero las versiones anteriores a la 4.11.1 ya entran en CVE-2022-23915. - CVE asignado: CVE-2026-86035, según el resumen del investigador.
- Estado: resuelto. El reporte se envió el 19 de julio de 2026 y se hizo público el 15 de septiembre de 2026.
El investigador aclara que no es el mismo problema que CVE-2026-24126. Aquel era una inyección de argumentos en ssh-keyscan, solo accesible para administradores, que permitía leer ficheros a través de /manage/ssh/.
Pasos de reproducción
Para que el fallo se pueda explotar, el componente tiene que cumplir todas estas condiciones:
- Usa Mercurial como sistema de control de versiones.
- Usa el formato RESX.
- Su plantilla tiene un nombre de fichero con forma de opción, que empieza por
-. - Tiene instalado el complemento Update RESX files.
- Después se procesa una actualización del repositorio.
El reporte no publica el nombre de plantilla exacto usado en la prueba, solo la sintaxis --config=alias.cat=!<command>.
El investigador entregó una prueba de integración automatizada formada por dos ficheros adjuntos, run_full_integration_standalone.sh y release_2026_7_1_full_integration_test.py. Hay que descargarlos en el mismo directorio y lanzar:
chmod +x run_full_integration_standalone.sh
./run_full_integration_standalone.sh
El script hace lo siguiente:
- Descarga la distribución oficial de código fuente de Weblate 2026.7.1 desde PyPI.
- Comprueba su SHA-256:
b0bcfea9f9143dc43e652ee317f6dcc85218fa9bc283445ceee98cd2b51462c7. - Crea una base de datos efímera con PostgreSQL 14.
- Arranca el contenedor oficial
weblate/weblate:2026.7.1.1. - Ejecuta cuatro pruebas de integración reales de Weblate.
- Borra los contenedores, la red y los ficheros temporales.
Las pruebas pasan por los componentes reales de Weblate: la API REST de componentes y la de instalación de complementos, el sistema de permisos, los registros en PostgreSQL, Component.do_update(), el despachador de eventos posteriores a la actualización, ResxUpdateAddon.update_translations() y, al final, HgRepository.get_file().
Qué permisos hacen falta
Antes de recibir el permiso component.edit, la cuenta de prueba solo obtiene:
HTTP/1.1 403 Forbidden
Después de concederle únicamente component.edit en el ámbito del proyecto, sin ser superusuario ni staff y sin el permiso management.addons, la cuenta puede cambiar la plantilla y también instalar el complemento a través de la API:
restricted_user_superuser=False
restricted_user_staff=False
component_edit=True
management_addons=False
api_template_patch=200
api_addon_install=201
addon_post_update_event=True
El investigador precisa que lo de los permisos del complemento solo lo incluye para demostrar que el fallo es alcanzable, y que no lo reporta como una vulnerabilidad aparte.
Resultado
En las dos ejecuciones vulnerables, independientes entre sí, la actualización termina con normalidad y aun así se ejecuta la orden inyectada. Esa orden lee un fichero sintético creado por el investigador y muestra con qué identidad se está ejecutando:
normal_do_update=True
synthetic_read='WEBLATE-OWNED-SYNTHETIC-INTEGRATION-PROOF\n'
execution_identity='uid=1000(weblate) gid=1000(weblate) ...\n'
La prueba incluye dos controles:
- Un componente con una plantilla normal,
safe.resx, sigue el mismo camino de actualización sin generar ninguna de las dos salidas. - Una versión parcheada en la que solo se añade
--a la orden actualiza con normalidad, pero la orden inyectada no se ejecuta:
- ["cat", "--rev", revision, path]
+ ["cat", "--rev", revision, "--", path]
patched_normal_do_update=True
patched_synthetic_read=None
patched_execution_identity=None
Resultado final de las pruebas:
Ran 4 tests
OK
Todo se probó en local, con paquetes y contenedores oficiales, repositorios temporales y datos sintéticos. No se tocó ninguna instancia de Hosted Weblate ni datos de terceros.
Impacto
Basta con ser editor de componentes en un proyecto para saltarse los límites de autorización de Weblate y ejecutar órdenes del sistema operativo con los privilegios de la cuenta de servicio compartida de Weblate. Según cómo esté desplegada la instancia, el atacante puede:
- exponer o modificar repositorios y datos de la aplicación;
- acceder a credenciales que el proceso reciba del entorno, a material de firma o a los artefactos de traducción;
- alterar el contenido de los repositorios o las traducciones generadas.
La prueba demuestra que la ejecución se repite de forma fiable como uid=1000(weblate). No demuestra ejecución como root.
Remediación
La solución es colocar el separador -- antes de la ruta en HgRepository.get_file(). Así Mercurial trata siempre el nombre como una ruta literal, aunque empiece por un guion:
return self.execute(
["cat", "--rev", revision, "--", path],
remote_op="none",
needs_lock=False,
merge_err=False,
)
El investigador recomienda además revisar todas las llamadas a Git y Mercurial que reciban rutas procedentes del repositorio, y añadir una prueba de regresión con un fichero versionado cuyo nombre tenga forma de opción.