I Found a Path Traversal in InvenTree’s Report Engine — Here’s How It Works (CVE-2026–33531)

medium.com · Alon Akirav · 16 days ago · research
quality 9/10 · excellent
0 net
I Found a Path Traversal in InvenTree's Report Engine — Here's How It Works (CVE-2026–33531) | by Alon Akirav - Freedium Milestone: 20GB Reached We’ve reached 20GB of stored data — thank you for helping us grow! Patreon Ko-fi Liberapay Close < Go to the original I Found a Path Traversal in InvenTree's Report Engine — Here's How It Works (CVE-2026–33531) How a missing two-line boundary check in a Django template tag turned report generation into arbitrary file read. Alon Akirav Follow ~5 min read · March 27, 2026 (Updated: March 27, 2026) · Free: Yes Background InvenTree is a popular open-source inventory management system built on Django. It's self-hosted, widely deployed in maker spaces, small businesses, and engineering teams — and it ships a powerful report template engine that lets staff users design PDF reports using Django template syntax. That template engine is also where I found CVE-2026–33531. The Vulnerability InvenTree lets staff users upload HTML templates to generate PDF reports. Inside those templates, you can use custom Django template tags — small functions that get called at render time to embed data. Three of those tags — encode_svg_image() , asset() , and uploaded_image() — accept a filename argument, resolve it to a full filesystem path, and read the file. The problem : they never check whether the resolved path is still inside the media directory they're supposed to be scoped to. Supply a filename that walks up the directory tree with ../ sequences, and Python's Path.resolve() will happily hand you a path pointing anywhere on the server filesystem. CVE : CVE-2026–33531 Advisory : GHSA-rhc5–7c3r-c769 Affected : InvenTree < 1.2.6 Patched: 1.2.6, 1.3.0 The Vulnerable Code All three functions follow the same pattern. Here's encode_svg_image() in src/backend/InvenTree/report/templatetags/report.py : def encode_svg_image(filename): full_path = settings.MEDIA_ROOT.joinpath(filename).resolve() # ← No boundary check here with open(full_path, 'rb') as f: data = f.read() return 'data:image/svg+xml;charset=utf-8;base64,' + base64.b64encode(data).decode() And asset() : def asset(filename): full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve() # ← No boundary check here return f'file://{full_path}' The sequence is always: Take user-supplied filename Join it onto a base path ( MEDIA_ROOT or a subdirectory) Call .resolve() — which collapses ../ traversal and follows symlinks Open the file or return a file:// URI for WeasyPrint to fetch Step 4 happens with no check that full_path is still a descendant of MEDIA_ROOT . In the Docker deployment, MEDIA_ROOT is /home/inventree/data/media . That means a filename with enough ../ levels reaches the filesystem root, and from there — any file the application process can read. Attack Flow [Staff user] │ ▼ POST /api/report/template/ Upload a template containing encode_svg_image("") │ ▼ Template stored in database with a pk (e.g. pk=10) │ ▼ [Any authenticated user] │ ▼ POST /api/report/print/ {"template": 10, "items": []} │ ▼ WeasyPrint renders the template server-side Path.resolve() escapes MEDIA_ROOT Target file is read and base64-encoded into the HTML │ ▼ Generated PDF returned to user │ ▼ Decode the base64 string from the PDF → file contents A key detail : template creation requires a staff account, but template triggering does not. Any authenticated user can POST to /api/report/print/ with an existing template pk. This means a single compromised staff account is enough to plant the malicious template — after that, the exfiltration step can be performed by any logged-in user. Reproducing It (Conceptual) The steps below describe the technique at a high level. The full PoC is in the GitHub repository . Step 1 — Staff creates a template using a crafted tag: curl -X POST http://localhost/api/report/template/ \ -H "Authorization: Token " \ -F "name=Demo" \ -F "model_type=part" \ -F "template=@crafted_template.html" \ -F "enabled=true" # Response: {"pk": 10, ...} The template HTML uses one of the affected tags with a path that traverses outside MEDIA_ROOT . The exact traversal depth depends on how many directory levels separate MEDIA_ROOT from the target file. Step 2 — Any authenticated user triggers a print: curl -X POST http://localhost/api/report/print/ \ -H "Authorization: Token " \ -H "Content-Type: application/json" \ -d '{"template": 10, "items": [1]}' # Response: {"pk": 42, ...} Step 3 — Download the generated PDF: curl http://localhost/media/data_output/output.pdf \ -H "Authorization: Token " \ -o output.pdf # HTTP 200 Step 4 — Extract the exfiltrated data: pdftotext output.pdf - | grep -o 'base64,[A-Za-z0-9+/=]*' \ | cut -d, -f2 | base64 -d The PDF body contains the base64-encoded contents of whatever file the tag resolved to. During testing, this successfully read /etc/passwd , the Django secret_key.txt , and config.yaml (database credentials). Why the Django SECRET_KEY Read Matters Most path traversal findings stop at "you can read /etc/passwd ." This one goes further. InvenTree stores its Django SECRET_KEY in /home/inventree/src/backend/InvenTree/secret_key.txt . The Django SECRET_KEY is used to cryptographically sign: Session cookies — an attacker who reads the key can forge a valid session for any user account, including superusers, without knowing any password Password reset tokens — valid reset tokens can be generated for any account without email access CSRF tokens Reading a single file gives an attacker the ability to impersonate every user on the instance. The exploit doesn't just read files, it escapes the web application's security boundary into the OS-level filesystem and enables a full authentication bypass. The vendor scored it lower based on their threat model for staff users, which is a reasonable position given the staff-only prerequisite. The Fix The patch, applied in PR #11579 , adds a single boundary check after Path.resolve() in all three affected functions: full_path = settings.MEDIA_ROOT.joinpath(filename).resolve() if not full_path.is_relative_to(settings.MEDIA_ROOT): raise FileNotFoundError("File Not Found") Path.is_relative_to() (available since Python 3.9) returns False if the resolved path has escaped the expected directory. That's it — two lines close the traversal for all three tags. The lesson is a familiar one: joining user input onto a base path is not the same as scoping user input to that base path. Path.resolve() is doing exactly what it should — canonicalizing the path. The missing step was checking whether the canonical path was still in bounds. CVSS CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N Attack Vector: Network — Exploited via HTTP API Attack Complexity: Low — No race condition or special timing required Privileges Required: High — Staff account required for template creation User Interaction: None — Print trigger requires no victim interaction Scope: Changed — App escapes into OS filesystem (separate security authority) Confidentiality: High — Arbitrary file read; confirmed SECRET_KEY exfiltration Integrity: None — Read-only impact Availability: None — No DoS impact The official advisory was published with a lower score reflecting the vendor's threat model stance on staff-level trust. My proposed vector above reflects the demonstrated impact including the SECRET_KEY read. Affected Versions and Remediation 1.2.6: Vulnerable 1.2.6: Patched 1.3.0: Patched If you run InvenTree in a self-hosted environment, update to 1.2.6 or 1.3.0 immediately. There are no workarounds — the fix is in the application code. Disclosure Timeline 2026–03–19: Vulnerability confirmed with live PoC in local Docker environment 2026–03–19: Private disclosure sent 2026–03–21: CVSS dispute submitted (Scope + Confidentiality) 2026–03–20: Advisory published: GHSA-rhc5–7c3r-c769 2026–03–20: CVE assigned: CVE-2026–33531 Responsible disclosure coordinated through InvenTree's SECURITY.md process. Resources GitHub Advisory : GHSA-rhc5–7c3r-c769 CVE : CVE-2026–33531 Fix PR : #11579 Research repo : github.com/alonaki/InvenTree-Path-Traversal-CVE-2026–33531 InvenTree Threat Model : docs.inventree.org - **InvenTree Threat Model:** [docs.inventree.org]() #cve-2026-33521 #path-traversal #cybersecurity #hacking #bug-bounty Reporting a Problem Sometimes we have problems displaying some Medium posts. If you have a problem that some images aren't loading - try using VPN. Probably you have problem with access to Medium CDN (or fucking Cloudflare's bot detection algorithms are blocking you).