Indico discloses local files resulting in Remote Code Execution through LaTeX injection
Description
Indico is an event management system that uses Flask-Multipass, a multi-backend authentication system for Flask. In versions prior to 3.3.12, due to vulnerabilities in TeXLive and obscure LaTeX syntax that allowed circumventing Indico's LaTeX sanitizer, it is possible to use specially-crafted LaTeX snippets which can read local files or execute code with the privileges of the user running Indico on the server. Note that if server-side LaTeX rendering is not in use (ie XELATEX_PATH was not set in indico.conf), this vulnerability does not apply. It is recommended to update to Indico 3.3.12 as soon as possible. It is also strongly recommended to enable the containerized LaTeX renderer (using podman), which isolates it from the rest of the system. As a workaround, remove the XELATEX_PATH setting from indico.conf (or comment it out or set it to None) and restart the indico-uwsgi and indico-celery services to disable LaTeX functionality.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
indicoPyPI | < 3.3.12 | 3.3.12 |
Affected products
1Patches
45f24d23ce9c4Use more thorough LaTeX caret parser (#7382)
2 files changed · +86 −36
indico/util/mdx_latex.py+31 −7 modified@@ -161,11 +161,37 @@ def unescape_html_entities(text): def _resolve_latex_carets(text): - while True: - text, n = re.subn(r'\^\^0*([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), text) - if not n: - break - return text.replace('^^\x1c', '\\') + """Resolve LaTeX double-caret escape sequences. + + See this TeX.SE answer for details on how LaTeX handles such sequences: + https://tex.stackexchange.com/a/64848/1651 + """ + done = False + while not done: + done = True + while m := re.search(r'(\^{2,})(?=[a-f0-9])', text): + num = len(m.group(1)) + start = m.start(1) + end = m.end(1) + if not re.match(rf'[a-f0-9]{{{num}}}', text[end : end + num]): + break + ccode = int(text[end : end + num], 16) + char = chr(ccode) if ccode and ccode <= 0x10ffff else '' # avoid NULs and invalid charchodes + text = text[:start] + char + text[end + num :] + done = False + if m := re.search(r'(\^\^)([\x00-\xbf])', text): + start = m.start(1) + end = m.end(2) + ccode = ord(m.group(2)) + # we ignore the -64 case for low char codes since this mostly adds nonsense + # and simply swallowing that character instead of inserting it should be safe. + # this also matches what the latex renderer on overleaf does when there's a + # string with an invalid caret escape sequence, ie the carets disappear and + # the rest remains as-is. + char = chr(ccode + 64) if ccode < 64 else '' + text = text[:start] + char + text[end:] + done = False + return text def latex_escape(text, ignore_math=True, ignore_braces=False): @@ -206,8 +232,6 @@ def math_replace(m): text = re.sub(r'\$[^\$]+\$|\$\$[^\$]+\$\$', math_replace, text) pattern = re.compile('|'.join(re.escape(k) for k in chars)) - # handle encoded backslashes, the `chars` replacement below escapes them - text = re.sub(r'\^{2,}(?:0*5c|\x1c)', r'\\', text) text = pattern.sub(substitute, text) if ignore_math:
indico/util/mdx_latex_test.py+55 −29 modified@@ -10,39 +10,65 @@ import pytest from markdown import Markdown -from indico.util.mdx_latex import LaTeXExtension, latex_escape +from indico.util.mdx_latex import LaTeXExtension, _resolve_latex_carets, latex_escape -def test_escape(): - assert latex_escape(r'\naughty') == r'\textbackslash{}naughty' - assert latex_escape(r'^^5cnaughty') == r'\textbackslash{}naughty' - assert latex_escape('^^\x1cnaughty') == r'\textbackslash{}naughty' - assert latex_escape(r'^^^^^^00005cnaughty') == r'\textbackslash{}naughty' - assert latex_escape(r'^^^^005cnaughty') == r'\textbackslash{}naughty' - assert (latex_escape(r'this\\is\\harmless') == - r'this\textbackslash{}\textbackslash{}is\textbackslash{}\textbackslash{}harmless') - assert latex_escape(r'\\\extranaughty') == r'\textbackslash{}\textbackslash{}\textbackslash{}extranaughty' - assert latex_escape(r'\mbox{\naughty}') == r'\textbackslash{}mbox\{\textbackslash{}naughty\}' - assert latex_escape(r'\mbox{\^^6eaughty}') == r'\textbackslash{}mbox\{\textbackslash{}\^{}\^{}6eaughty\}' +@pytest.mark.parametrize(('input', 'expected'), ( + (r'\naughty', r'\textbackslash{}naughty'), + (r'^^5cnaughty', r'\^{}\^{}5cnaughty'), + ('^^\x1cnaughty', '\\^{}\\^{}\x1cnaughty'), + ('^^\x1c^^.aughty', '\\^{}\\^{}\x1c\\^{}\\^{}.aughty'), + (r'^^^^^^00005cnaughty', r'\^{}\^{}\^{}\^{}\^{}\^{}00005cnaughty'), + (r'^^^^005cnaughty', r'\^{}\^{}\^{}\^{}005cnaughty'), + (r'this\\is\\harmless', r'this\textbackslash{}\textbackslash{}is\textbackslash{}\textbackslash{}harmless'), + (r'\\\extranaughty', r'\textbackslash{}\textbackslash{}\textbackslash{}extranaughty'), + (r'\mbox{\naughty}', r'\textbackslash{}mbox\{\textbackslash{}naughty\}'), + (r'\mbox{\^^6eaughty}', r'\textbackslash{}mbox\{\textbackslash{}\^{}\^{}6eaughty\}'), +)) +def test_escape(input, expected): + assert latex_escape(input) == expected + + +@pytest.mark.parametrize(('input', 'expected'), ( + (r'\naughty', r'\protect $\\naughty$'), + (r'\begin{naughty}', r'\protect $\\begin{naughty}$'), + (r'\begin{equation}', r'\protect $\begin{equation}$'), + (r'^^5cnaughty', r'\protect $\\naughty$'), + ('^^\x1cnaughty', r'\protect $\\naughty$'), + ('^^\x1c^^.aughty', r'\protect $\\naughty$'), + (r'^^^^^^00005cnaughty', r'\protect $\\naughty$'), + (r'^^^^005cnaughty', r'\protect $\\naughty$'), + (r'\\naughty', r'\protect $\\naughty$'), + (r'^^5e^5cnaughty', r'\protect $\\naughty$'), + (r'\to^^64ay', r'\protect $\\today$'), + (r'harm\\less', r'\protect $harm\\less$'), + (r'\\\extranaughty', r'\protect $\\\\extranaughty$'), + (r'\mbox{\naughty}', r'\protect $\mbox{\\naughty}$'), + (r'\mbox{\^^6eaughty}', r'\protect $\mbox{\\naughty}$'), + (r'\mbox{\very^^6eaughty}', r'\protect $\mbox{\\verynaughty}$'), + (r'\epsilon_\psi^\theta', r'\protect $\epsilon_\psi^\theta$'), +)) +def test_escape_math(input, expected): + assert latex_escape(f'${input}$') == expected -def test_escape_math(): - assert latex_escape(r'$\naughty$') == r'\protect $\\naughty$' - assert latex_escape(r'$\begin{naughty}$') == r'\protect $\\begin{naughty}$' - assert latex_escape(r'$\begin{equation}$') == r'\protect $\begin{equation}$' - assert latex_escape(r'$^^5cnaughty$') == r'\protect $\\naughty$' - assert latex_escape('$^^\x1cnaughty$') == r'\protect $\\naughty$' - assert latex_escape(r'$^^^^^^00005cnaughty$') == r'\protect $^^^^\\naughty$' - assert latex_escape(r'$^^^^005cnaughty$') == r'\protect $^^\\naughty$' - assert latex_escape(r'$\\naughty$') == r'\protect $\\naughty$' - assert latex_escape(r'$^^5e^5cnaughty$') == r'\protect $\\naughty$' - assert latex_escape(r'$\to^^64ay$') == r'\protect $\\today$' - assert latex_escape(r'$harm\\less$') == r'\protect $harm\\less$' - assert latex_escape(r'$\\\extranaughty$') == r'\protect $\\\\extranaughty$' - assert latex_escape(r'$\mbox{\naughty}$') == r'\protect $\mbox{\\naughty}$' - assert latex_escape(r'$\mbox{\^^6eaughty}$') == r'\protect $\mbox{\\naughty}$' - assert latex_escape(r'$\mbox{\very^^6eaughty}$') == r'\protect $\mbox{\\verynaughty}$' - assert latex_escape(r'$\epsilon_\psi^\theta$') == r'\protect $\epsilon_\psi^\theta$' +@pytest.mark.parametrize(('input', 'expected'), ( + (r'\^^4oday we are ^^5cnaughty \^^4aday', r'\today we are \naughty \Jday'), + (r'\^^4aday we are ^^5cnaughty \^^4oday', r'\Jday we are \naughty \today'), + (r'\^^4aday we are ^^5cnaughty \^^4aday', r'\Jday we are \naughty \Jday'), + (r'\^^4oday', r'\today'), + (r'^^5e^5ctoday', r'\today'), + (r'^^5cnaughty', r'\naughty'), + ('^^\x1cnaughty', r'\naughty'), + ('^^\x1c^^.aughty', r'\naughty'), + (r'^^^^^^00005cnaughty', r'\naughty'), + (r'^^^^^^00x05cnaughty', r'00x05cnaughty'), + (r'^^^^00x05cnaughty', r'^00x05cnaughty'), + (r'^^^^^^10ffff', '\U0010ffff'), + (r'^^^^^^110000', ''), +)) +def test_resolve_carets(input, expected): + assert _resolve_latex_carets(input) == expected @pytest.mark.parametrize(('input', 'expected'), (
0adb70f0ed66Fix LaTeX regexps (third time's the charm) (#7377)
3 files changed · +56 −8
indico/legacy/pdfinterface/latex.py+4 −0 modified@@ -10,6 +10,7 @@ import os import subprocess import tempfile +import time from importlib.resources import as_file from importlib.resources import files as res_files from io import BytesIO @@ -253,6 +254,7 @@ def run(self, template_name, **kwargs): source_filename, target_filename = self.prepare(template_name, **kwargs) log_filename = os.path.join(self.source_dir, 'output.log') log_file = open(log_filename, 'a+') # noqa: SIM115 + start = time.time() try: self.run_latex(source_filename, log_file) if self.has_toc: @@ -264,6 +266,8 @@ def run(self, template_name, **kwargs): # something went terribly wrong, no LaTeX file was produced raise LaTeXRuntimeException(source_filename, log_filename) + duration = time.time() - start + Logger.get('pdflatex').info('Generated PDF in %.02f seconds', duration) return target_filename
indico/util/mdx_latex.py+38 −6 modified@@ -103,7 +103,7 @@ safe_mathmode_commands = { 'above', 'abovewithdelims', 'acute', 'aleph', 'alpha', 'amalg', 'And', 'angle', 'approx', 'arccos', 'arcsin', 'arctan', 'arg', 'array', 'Arrowvert', 'arrowvert', 'ast', 'asymp', 'atop', 'atopwithdelims', 'backslash', - 'bar', 'Bbb', 'begin', 'beta', 'bf', 'Big', 'big', 'bigcap', 'bigcirc', 'bigcup', 'Bigg', 'bigg', + 'bar', 'Bbb', 'beta', 'bf', 'Big', 'big', 'bigcap', 'bigcirc', 'bigcup', 'Bigg', 'bigg', 'Biggl', 'biggl', 'Biggm', 'biggm', 'Biggr', 'biggr', 'Bigl', 'bigl', 'Bigm', 'bigm', 'bigodot', 'bigoplus', 'bigotimes', 'Bigr', 'bigr', 'bigsqcup', 'bigtriangledown', 'bigtriangleup', 'biguplus', 'bigvee', 'bigwedge', 'bmod', 'bot', 'bowtie', 'brace', 'bracevert', 'brack', 'breve', 'buildrel', 'bullet', 'cap', 'cases', 'cdot', @@ -141,6 +141,13 @@ 'vee', 'Vert', 'vert', 'vphantom', 'wedge', 'widehat', 'widetilde', 'wp', 'wr', 'Xi', 'xi', 'zeta', '\\' } +# XXX not sure if all of them make sense inside math blocks, but they're safe and used by people... +safe_environments = { + 'eqnarray', 'equation', 'center', 'equation*', 'array', 'align', 'figure', 'itemize', 'align*', 'table', 'tabular', + 'aligned', 'eqnarray*', 'enumerate', 'acronym', 'justify', 'gathered', 'pmatrix', 'description', 'multline', + 'cases', 'matrix', +} + class ImageURLException(Exception): pass @@ -153,6 +160,14 @@ def unescape_html_entities(text): return out.replace('"', '"') +def _resolve_latex_carets(text): + while True: + text, n = re.subn(r'\^\^0*([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), text) + if not n: + break + return text.replace('^^\x1c', '\\') + + def latex_escape(text, ignore_math=True, ignore_braces=False): if text is None: return '' @@ -191,7 +206,8 @@ def math_replace(m): text = re.sub(r'\$[^\$]+\$|\$\$[^\$]+\$\$', math_replace, text) pattern = re.compile('|'.join(re.escape(k) for k in chars)) - text = re.sub(r'\^{2,}0*5c', r'\\', text) # handle encoded backslashes, the `chars` replacement below escapes them + # handle encoded backslashes, the `chars` replacement below escapes them + text = re.sub(r'\^{2,}(?:0*5c|\x1c)', r'\\', text) text = pattern.sub(substitute, text) if ignore_math: @@ -204,10 +220,26 @@ def math_replace(m): def sanitize_mathmode(text): def _escape_unsafe_command(m): - command = m.group(1) - return m.group(0) if command in safe_mathmode_commands else r'\\' + command - - return re.sub(r'(?:\\|\^{2,}0*5c)([a-zA-Z]+|(?:\\|\^{2,}0*5c))', _escape_unsafe_command, text) + fullcommand = m.group('fullcmd') # full command w/ args but without leading backslash + command = m.group('cmd1') or m.group('cmd2') # just the command name + arg = m.group('arg') # the arg from inside {...} after the command + if (command == 'begin' and arg in safe_environments) or command in safe_mathmode_commands: + return m.group(0) + else: + return fr'\\{fullcommand}' + + text = _resolve_latex_carets(text) + return re.sub(r''' + \\ + (?P<fullcmd> + (?: + (?P<cmd1>begin) # command name if it's one where we care about the arg + \s*\{(?P<arg>[^}]+)\} # {arg} + ) + | + (?P<cmd2>[a-zA-Z]+|\\) # command name if it's anything else or a backslash + ) + ''', _escape_unsafe_command, text, flags=re.VERBOSE) def escape_latex_entities(text):
indico/util/mdx_latex_test.py+14 −2 modified@@ -16,21 +16,33 @@ def test_escape(): assert latex_escape(r'\naughty') == r'\textbackslash{}naughty' assert latex_escape(r'^^5cnaughty') == r'\textbackslash{}naughty' + assert latex_escape('^^\x1cnaughty') == r'\textbackslash{}naughty' assert latex_escape(r'^^^^^^00005cnaughty') == r'\textbackslash{}naughty' assert latex_escape(r'^^^^005cnaughty') == r'\textbackslash{}naughty' assert (latex_escape(r'this\\is\\harmless') == r'this\textbackslash{}\textbackslash{}is\textbackslash{}\textbackslash{}harmless') assert latex_escape(r'\\\extranaughty') == r'\textbackslash{}\textbackslash{}\textbackslash{}extranaughty' + assert latex_escape(r'\mbox{\naughty}') == r'\textbackslash{}mbox\{\textbackslash{}naughty\}' + assert latex_escape(r'\mbox{\^^6eaughty}') == r'\textbackslash{}mbox\{\textbackslash{}\^{}\^{}6eaughty\}' def test_escape_math(): assert latex_escape(r'$\naughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$\begin{naughty}$') == r'\protect $\\begin{naughty}$' + assert latex_escape(r'$\begin{equation}$') == r'\protect $\begin{equation}$' assert latex_escape(r'$^^5cnaughty$') == r'\protect $\\naughty$' - assert latex_escape(r'$^^^^^^00005cnaughty$') == r'\protect $\\naughty$' - assert latex_escape(r'$^^^^005cnaughty$') == r'\protect $\\naughty$' + assert latex_escape('$^^\x1cnaughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$^^^^^^00005cnaughty$') == r'\protect $^^^^\\naughty$' + assert latex_escape(r'$^^^^005cnaughty$') == r'\protect $^^\\naughty$' assert latex_escape(r'$\\naughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$^^5e^5cnaughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$\to^^64ay$') == r'\protect $\\today$' assert latex_escape(r'$harm\\less$') == r'\protect $harm\\less$' assert latex_escape(r'$\\\extranaughty$') == r'\protect $\\\\extranaughty$' + assert latex_escape(r'$\mbox{\naughty}$') == r'\protect $\mbox{\\naughty}$' + assert latex_escape(r'$\mbox{\^^6eaughty}$') == r'\protect $\mbox{\\naughty}$' + assert latex_escape(r'$\mbox{\very^^6eaughty}$') == r'\protect $\mbox{\\verynaughty}$' + assert latex_escape(r'$\epsilon_\psi^\theta$') == r'\protect $\epsilon_\psi^\theta$' @pytest.mark.parametrize(('input', 'expected'), (
fb169ced710cFix LaTeX regexps even more (#7373)
2 files changed · +9 −5
indico/util/mdx_latex.py+5 −5 modified@@ -164,7 +164,6 @@ def latex_escape(text, ignore_math=True, ignore_braces=False): '&': r'\&', '~': r'\~{}', '_': r'\_', - '^^5c': r'\textbackslash{}', '^': r'\^{}', '\\': r'\textbackslash{}', '\x0c': '', @@ -192,22 +191,23 @@ def math_replace(m): text = re.sub(r'\$[^\$]+\$|\$\$[^\$]+\$\$', math_replace, text) pattern = re.compile('|'.join(re.escape(k) for k in chars)) - res = pattern.sub(substitute, text) + text = re.sub(r'\^{2,}0*5c', r'\\', text) # handle encoded backslashes, the `chars` replacement below escapes them + text = pattern.sub(substitute, text) if ignore_math: # Sanitize math-mode segments and put them back in place math_segments = list(map(sanitize_mathmode, math_segments)) - res = re.sub(re.escape(math_placeholder), lambda _: '\\protect ' + math_segments.pop(0), res) + text = re.sub(re.escape(math_placeholder), lambda _: '\\protect ' + math_segments.pop(0), text) - return res + return text def sanitize_mathmode(text): def _escape_unsafe_command(m): command = m.group(1) return m.group(0) if command in safe_mathmode_commands else r'\\' + command - return re.sub(r'(?:\\|\^\^5c)([a-zA-Z]+|(?:\\|\^\^5c))', _escape_unsafe_command, text) + return re.sub(r'(?:\\|\^{2,}0*5c)([a-zA-Z]+|(?:\\|\^{2,}0*5c))', _escape_unsafe_command, text) def escape_latex_entities(text):
indico/util/mdx_latex_test.py+4 −0 modified@@ -16,6 +16,8 @@ def test_escape(): assert latex_escape(r'\naughty') == r'\textbackslash{}naughty' assert latex_escape(r'^^5cnaughty') == r'\textbackslash{}naughty' + assert latex_escape(r'^^^^^^00005cnaughty') == r'\textbackslash{}naughty' + assert latex_escape(r'^^^^005cnaughty') == r'\textbackslash{}naughty' assert (latex_escape(r'this\\is\\harmless') == r'this\textbackslash{}\textbackslash{}is\textbackslash{}\textbackslash{}harmless') assert latex_escape(r'\\\extranaughty') == r'\textbackslash{}\textbackslash{}\textbackslash{}extranaughty' @@ -24,6 +26,8 @@ def test_escape(): def test_escape_math(): assert latex_escape(r'$\naughty$') == r'\protect $\\naughty$' assert latex_escape(r'$^^5cnaughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$^^^^^^00005cnaughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$^^^^005cnaughty$') == r'\protect $\\naughty$' assert latex_escape(r'$\\naughty$') == r'\protect $\\naughty$' assert latex_escape(r'$harm\\less$') == r'\protect $harm\\less$' assert latex_escape(r'$\\\extranaughty$') == r'\protect $\\\\extranaughty$'
2 files changed · +5 −2
indico/util/mdx_latex.py+3 −2 modified@@ -164,6 +164,7 @@ def latex_escape(text, ignore_math=True, ignore_braces=False): '&': r'\&', '~': r'\~{}', '_': r'\_', + '^^5c': r'\textbackslash{}', '^': r'\^{}', '\\': r'\textbackslash{}', '\x0c': '', @@ -188,7 +189,7 @@ def math_replace(m): if ignore_math: # Extract math-mode segments and replace with placeholder - text = re.sub(r'\$[^\$]+\$|\$\$(^\$)\$\$', math_replace, text) + text = re.sub(r'\$[^\$]+\$|\$\$[^\$]+\$\$', math_replace, text) pattern = re.compile('|'.join(re.escape(k) for k in chars)) res = pattern.sub(substitute, text) @@ -206,7 +207,7 @@ def _escape_unsafe_command(m): command = m.group(1) return m.group(0) if command in safe_mathmode_commands else r'\\' + command - return re.sub(r'\\([a-zA-Z]+|\\)', _escape_unsafe_command, text) + return re.sub(r'(?:\\|\^\^5c)([a-zA-Z]+|(?:\\|\^\^5c))', _escape_unsafe_command, text) def escape_latex_entities(text):
indico/util/mdx_latex_test.py+2 −0 modified@@ -15,13 +15,15 @@ def test_escape(): assert latex_escape(r'\naughty') == r'\textbackslash{}naughty' + assert latex_escape(r'^^5cnaughty') == r'\textbackslash{}naughty' assert (latex_escape(r'this\\is\\harmless') == r'this\textbackslash{}\textbackslash{}is\textbackslash{}\textbackslash{}harmless') assert latex_escape(r'\\\extranaughty') == r'\textbackslash{}\textbackslash{}\textbackslash{}extranaughty' def test_escape_math(): assert latex_escape(r'$\naughty$') == r'\protect $\\naughty$' + assert latex_escape(r'$^^5cnaughty$') == r'\protect $\\naughty$' assert latex_escape(r'$\\naughty$') == r'\protect $\\naughty$' assert latex_escape(r'$harm\\less$') == r'\protect $harm\\less$' assert latex_escape(r'$\\\extranaughty$') == r'\protect $\\\\extranaughty$'
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-rm2q-f7jv-3cfpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33046ghsaADVISORY
- github.com/indico/indico/commit/0adb70f0ed66e129361d447868f5f3eb90dc5e96ghsax_refsource_MISCWEB
- github.com/indico/indico/commit/1dbb12525b3de14229bf4d1ae192988068f975f6ghsax_refsource_MISCWEB
- github.com/indico/indico/commit/5f24d23ce9c4b0e4b68b3d0b58987a948fc57c8aghsax_refsource_MISCWEB
- github.com/indico/indico/commit/fb169ced710c30cf792ce4b9f48688db0633cfd8ghsax_refsource_MISCWEB
- github.com/indico/indico/releases/tag/v3.3.12ghsax_refsource_MISCWEB
- github.com/indico/indico/security/advisories/GHSA-rm2q-f7jv-3cfpghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.