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);