From 42462cdc4c420153fd5d32280c23b92c9742eee3 Mon Sep 17 00:00:00 2001 From: hayageek Date: Sat, 21 Mar 2026 18:39:47 +0530 Subject: [PATCH] Fix for Server-Side Template Injection (SSTI) via #evaluate --- .../google/escapevelocity/MethodFinder.java | 65 +++++++++++ .../google/escapevelocity/TemplateTest.java | 102 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/src/main/java/com/google/escapevelocity/MethodFinder.java b/src/main/java/com/google/escapevelocity/MethodFinder.java index f8f91f5..61e060c 100644 --- a/src/main/java/com/google/escapevelocity/MethodFinder.java +++ b/src/main/java/com/google/escapevelocity/MethodFinder.java @@ -19,6 +19,7 @@ import static java.util.stream.Collectors.toSet; import com.google.common.collect.HashBasedTable; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Table; import java.lang.reflect.Method; @@ -39,6 +40,40 @@ */ class MethodFinder { + /** + * Methods that are blocked from being invoked in templates to prevent reflection-based attacks + * such as Remote Code Execution via {@code getClass().forName(...).getMethod(...).invoke(...)}. + * + *

The key is the fully-qualified class name. If the value is an empty set, all methods + * on that class are blocked. Otherwise only the named methods are blocked. + */ + private static final ImmutableMap> BLOCKED_METHODS = + ImmutableMap.>builder() + // Prevent obtaining Class objects from arbitrary instances. + .put("java.lang.Object", ImmutableSet.of("getClass")) + // Prevent reflective class loading, method/constructor/field lookup, and instantiation. + .put("java.lang.Class", ImmutableSet.of( + "forName", "newInstance", + "getMethod", "getMethods", "getDeclaredMethod", "getDeclaredMethods", + "getConstructor", "getConstructors", + "getDeclaredConstructor", "getDeclaredConstructors", + "getField", "getFields", "getDeclaredField", "getDeclaredFields", + "getClassLoader")) + // Prevent reflective invocation and instantiation. + .put("java.lang.reflect.Method", ImmutableSet.of("invoke")) + .put("java.lang.reflect.Constructor", ImmutableSet.of("newInstance")) + .put("java.lang.reflect.Field", ImmutableSet.of("get", "set")) + // Prevent direct command execution. + .put("java.lang.Runtime", ImmutableSet.of("exec", "getRuntime")) + .put("java.lang.ProcessBuilder", ImmutableSet.of()) + // Prevent class loading. + .put("java.lang.ClassLoader", ImmutableSet.of()) + .put("java.lang.Thread", ImmutableSet.of( + "getContextClassLoader", "setContextClassLoader")) + // Prevent JVM shutdown and environment access. + .put("java.lang.System", ImmutableSet.of("exit", "setSecurityManager")) + .build(); + /** * For a given class and name, returns all public methods of that name in the class, as previously * determined by {@link #publicMethodsWithName}. The set of methods for a given class and name is @@ -76,6 +111,7 @@ private ImmutableSet uncachedPublicMethodsWithName(Class startClass, Set methods = Arrays.stream(startClass.getMethods()) .filter(m -> m.getName().equals(name)) + .filter(m -> !isMethodBlocked(m)) .collect(toSet()); if (!classIsPublic(startClass)) { methods = @@ -89,6 +125,35 @@ private ImmutableSet uncachedPublicMethodsWithName(Class startClass, return ImmutableSet.copyOf(methods); } + /** + * Returns true if the given method is on the blocklist. A method is blocked if the class that + * declares it (or any of its ancestors) appears in {@link #BLOCKED_METHODS} with either an empty + * set (meaning all methods are blocked) or a set containing the method name. + */ + private static boolean isMethodBlocked(Method method) { + Class declaringClass = method.getDeclaringClass(); + return isBlockedInHierarchy(declaringClass, method.getName()); + } + + private static boolean isBlockedInHierarchy(Class clazz, String methodName) { + if (clazz == null) { + return false; + } + ImmutableSet blocked = BLOCKED_METHODS.get(clazz.getName()); + if (blocked != null && (blocked.isEmpty() || blocked.contains(methodName))) { + return true; + } + if (isBlockedInHierarchy(clazz.getSuperclass(), methodName)) { + return true; + } + for (Class iface : clazz.getInterfaces()) { + if (isBlockedInHierarchy(iface, methodName)) { + return true; + } + } + return false; + } + private static final String THIS_PACKAGE = getPackageName(Node.class) + "."; /** diff --git a/src/test/java/com/google/escapevelocity/TemplateTest.java b/src/test/java/com/google/escapevelocity/TemplateTest.java index bce64db..0e14d7b 100644 --- a/src/test/java/com/google/escapevelocity/TemplateTest.java +++ b/src/test/java/com/google/escapevelocity/TemplateTest.java @@ -1525,6 +1525,108 @@ public void evaluate() { expectException("#evaluate(23)", "Argument to #evaluate must be a string: 23"); } + @Test + public void evaluateBlocksReflectionChainViaGetClass() throws IOException { + String template = "#evaluate($payload)"; + String payload = + "#set($clazz = $dummy.getClass())" + + "$clazz.forName('java.lang.Runtime')"; + Map vars = new HashMap<>(); + vars.put("dummy", "anything"); + vars.put("payload", payload); + Template parsed = Template.parseFrom(new StringReader(template)); + EvaluationException e = + assertThrows(EvaluationException.class, () -> parsed.evaluate(vars)); + assertThat(e).hasMessageThat().contains("no method getClass"); + } + + @Test + public void blocksGetClassOnAnyObject() throws IOException { + Template parsed = Template.parseFrom(new StringReader("$x.getClass()")); + EvaluationException e = + assertThrows( + EvaluationException.class, + () -> parsed.evaluate(ImmutableMap.of("x", "hello"))); + assertThat(e).hasMessageThat().contains("no method getClass"); + } + + @Test + public void blocksClassForName() throws IOException { + Template parsed = Template.parseFrom(new StringReader("$C.forName('java.lang.Runtime')")); + EvaluationException e = + assertThrows( + EvaluationException.class, + () -> parsed.evaluate(ImmutableMap.of("C", Class.class))); + assertThat(e).hasMessageThat().contains("no method forName"); + } + + @Test + public void blocksClassGetMethod() throws IOException { + Template parsed = Template.parseFrom(new StringReader("$C.getMethod('getRuntime', $empty)")); + Map vars = new HashMap<>(); + vars.put("C", Runtime.class); + vars.put("empty", new Class[0]); + EvaluationException e = + assertThrows(EvaluationException.class, () -> parsed.evaluate(vars)); + assertThat(e).hasMessageThat().contains("no method getMethod"); + } + + @Test + public void blocksMethodInvoke() throws Exception { + java.lang.reflect.Method method = String.class.getMethod("valueOf", int.class); + Template parsed = Template.parseFrom(new StringReader("$m.invoke(null, 42)")); + Map vars = new HashMap<>(); + vars.put("m", method); + EvaluationException e = + assertThrows(EvaluationException.class, () -> parsed.evaluate(vars)); + assertThat(e).hasMessageThat().contains("no method invoke"); + } + + @Test + public void blocksRuntimeExec() throws IOException { + Template parsed = Template.parseFrom(new StringReader("$rt.exec('id')")); + EvaluationException e = + assertThrows( + EvaluationException.class, + () -> parsed.evaluate(ImmutableMap.of("rt", Runtime.getRuntime()))); + assertThat(e).hasMessageThat().contains("no method exec"); + } + + @Test + public void blocksProcessBuilder() throws IOException { + Template parsed = Template.parseFrom(new StringReader("$pb.start()")); + EvaluationException e = + assertThrows( + EvaluationException.class, + () -> + parsed.evaluate( + ImmutableMap.of("pb", new ProcessBuilder("echo", "pwned")))); + assertThat(e).hasMessageThat().contains("no method start"); + } + + @Test + public void blocksFullRceChainViaEvaluate() throws IOException { + String rcePayload = + "#set($m=$Class.forName('java.lang.Runtime').getMethod('getRuntime', $noParamTypes))" + + "#set($rt=$m.invoke(null, $noArgs))" + + "$rt.exec('touch /tmp/test-pwned')"; + Map vars = new HashMap<>(); + vars.put("Class", Class.class); + vars.put("noParamTypes", new Class[0]); + vars.put("noArgs", new Object[0]); + vars.put("payload", rcePayload); + Template parsed = Template.parseFrom(new StringReader("#evaluate($payload)")); + assertThrows(EvaluationException.class, () -> parsed.evaluate(vars)); + assertThat(new java.io.File("/tmp/test-pwned").exists()).isFalse(); + } + + @Test + public void allowsSafeMethodsOnClass() throws IOException { + Template parsed = Template.parseFrom(new StringReader("$Integer.getName()")); + String result = parsed.evaluate(ImmutableMap.of("Integer", Integer.class)); + assertThat(result).isEqualTo("java.lang.Integer"); + } + @Test public void nullReference() throws IOException { Map vars = Collections.singletonMap("foo", null);