GitHub Security Lab CTF 3: XSS-unsafe jQuery plugins

Language: Javascript - Difficulty level: ***

Do you want to challenge your vulnerability hunting skills and to quickly learn Semmle CodeQL?

Your mission, should you choose to accept it, is to find variants of jQuery plugins that expose their clients to undocumented XSS (cross-site scripting) vulnerabilities. You will do this by utilizing CodeQL, our simple, yet expressive, code query language. To capture the flag, you’ll need to write a query that finds the unsafely implemented jQuery plugins in Bootstrap, using this step by step guide.

Contest

And you can win a prize! Before you complete the challenge, follow the instructions in the contest rules & instructions page for your chance to win a prize. We will select the two best CodeQL queries received before December 31st, 2019 to win the grand prize: a Nintendo Switch! Ten additional CodeQL queries will also be chosen to win other cool prizes. Winners will be contacted by email.

Introduction

jQuery is an extremely popular, but old, open source JavaScript library designed to simplify things like HTML document traversal and manipulation, event handling, animation, and Ajax. As of May 2019, jQuery was used by 73% of the 10 million most popular websites. The jQuery library supports modular plugins, and developers around the world have implemented thousands plugins that seamlessly extend jQuery's capabilities. Bootstrap is a popular library which has used jQuery's plugin mechanism extensively. But the jQuery plugins inside Bootstrap used to be implemented in an unsafe way that could make the users of Bootstrap vulnerable to Cross-site scripting (XSS) attacks. This is when an attacker uses a web application to send malicious code, generally in the form of a browser side script, to a different end user. The malicious script can access any cookies, session tokens, or other sensitive information retained by the browser and used with that site.

Four such vulnerabilities in Bootstrap jQuery plugins were fixed in this pull request. MITRE has issued the following CVEs for the vulnerabilities: CVE-2018-14040, CVE-2018-14042, CVE-2018-20676, and CVE-2018-20677.

The core mistake is the use of the omnipotent jQuery $-function in places where a more specialized, and safer, function would have sufficed. This is particularly problematic in cases where the $-function evaluates its input as JavaScript-code instead of as a CSS-selector.

The bootstrap codebase contains hundreds of calls to the jQuery $-function and a plethora of jQuery plugin options. In this challenge, you will use Semmle CodeQL to find those calls and plugin options. Of course many of those calls and plugin options are safe, so throughout this challenge you will refine your query to reduce the number of false positives.

By the end of this challenge, you will have built a query that is able to find these unsafe plugins that allow for XSS vulnerabilities.

Challenge problem

As an example of the core problem that we want to find, consider the simple jQuery plugin:

(function ( $ ) {
    $.fn.copyText = function( options ) {
        // copy the text from the chosen element.
        let text = $(options.textSrcSelector).text();
        return this.text(text);
    };
}( jQuery ));

The plugin copies the text of one element to another, the element to copy text from is obtained by evaluating options.textSrcSelector as a CSS-selector, or that is the intention at least. The problem in this example is that $(options.textSrcSelector) will execute JavaScript code if the value of options.textSrcSelector is a string like "<img src=x onerror=alert(1)>".

Clients of the plugin may not be aware of this potential XSS vector as the option name ends with Selector, and may not escape user-input for the selector appropriately.

To make the plugin more safe, the XSS vector should be documented or the dedicated $(document).find function should be used instead:

let text = $(document).find(options.textSrcSelector).text();

So in this challenge, we want a query that flags the unsafe version, but not the safe version, of the plugin above.

Setup instructions

The quickest way to get started with CodeQL is to use LGTM's query console.

However, if you prefer, you can also download the VSCode extension and write and run your queries offline. You will need to download the database of the unpatched version of the Bootstrap codebase, and import it into VSCode to run your queries.

If you are new to CodeQL, consider taking a look at the following documentation:

If you find yourself stuck on any step of this challenge, send us an email at ctf@github.com.

Warmup challenge

The standard CodeQL queries leverage the builtin libraries for advanced data flow analysis to get the best results. To get you started with CodeQL, the warmup challenge will guide you in writing a simpler syntactic query that still can find some interesting results.

Warmup step 0: Finding the arguments to potentially unsafe jQuery function calls

As previously shown, programmers may be using the convenient $ function in places where they should have used $(document).find instead. In this step, we will find the arguments to such calls.

Question: Complete the template below to find first argument to $-calls

import javascript

from CallExpr dollarCall, Expr dollarArg
where
  dollarCall.getCalleeName() = _ and // TODO replace `_` with an appropriate string
  dollarArg = dollarCall.getArgument(_) // TODO replace `_` with an appropriate number
select dollarArg, "This looks like the first argument to the jQuery '$'-function."

This query should give you 737 results (solution).

Tip: Use the autocompletion feature of CodeQL, and the documentation of each predicate, to understand what they do.

Warmup step 1: Finding jQuery plugins options

As seen in the diff of the pull request. The jQuery plugin options tend to be accessed in expressions of the form this.options.NAME. We will therefore write a query that identifies such chained property accesses.

Question: Complete the template below to find some jQuery plugin options

import javascript

from PropAccess optionsObject, PropAccess option
where
  optionsObject.getPropertyName() = _ and // TODO replace `_` with an appropriate string
  option.getBase() = _ // TODO replace `_` with an appropriate variable
select option, "This looks like a jQuery plugin option."

This query should give you 59 results (solution).

Hint: Run the query below to get an idea of what getPropertyName() and getBase() do. Or just hover over these functions to see the help.

from PropAccess pa
select pa.getPropertyName(), pa.getBase()

Warmup step 2: Finding the vulnerable calls

We now have a query for the potentially dangerous arguments to $-calls, and we have a query for the potentially dangerous options, so we should combine the two to get a query that flags actual vulnerabilities.

Question: Combine the 2 previous solutions and complete the template below to find vulnerable calls

import javascript

from CallExpr dollarCall, Expr dollarArg, PropAccess optionsObject, PropAccess option
where dollarCall.getCalleeName() = _ and // TODO reuse the answer from Warmup step 0
  dollarArg = dollarCall.getArgument(_) and // TODO reuse the answer from Warmup step 0
  optionsObject.getPropertyName() = _ and // TODO reuse the answer from Warmup step 1
  option.getBase() = _ and // TODO reuse the answer from Warmup step 1
  dollarArg = _  // TODO replace `_` with an appropriate variable
select dollarArg, "Using a jQuery plugin option as the argument to '$' may result in a cross-site scripting vulnerability."

This query should give you 3 results (solution).

Warmup step 3: Trying out the dataflow library

The solution to step 2 should result in a query with three alerts on the unpatched Bootstrap codebase, two of which are true positives that were fixed in the linked pull request. There are however additional vulnerabilities that are beyond the capabilities of a purely syntactic query. The use of intermediate variables and nested expressions are typical source code examples that require use of the dataflow library.

To find one more variant of this vulnerability, we will try out the dataflow library by adjusting the query to use dataflow library a tiny bit, instead of relying purely on the syntactic structure of the vulnerability.

Read the documentation to understand what the below template does:

Question: Complete the template below according to the instructions

import javascript

from CallExpr dollarCall, Expr dollarArg, PropAccess optionsObject, PropAccess option
where
  dollarCall.getCalleeName() = _ and // TODO reuse the answer from Warmup step 2
  dollarArg = dollarCall.getArgument(_) and // TODO reuse the answer from Warmup step 2
  optionsObject.getPropertyName() = _ and // TODO reuse the answer from Warmup step 2
  option.getBase() = _ and // TODO reuse the answer from Warmup step 2
  dollarArg.flow().getALocalSource().asExpr() = _ // TODO reuse the answer from Warmup step 2 for the right-hand side of the equality
select dollarArg, "Using a jQuery plugin option as the argument to '$' may result in a cross-site scripting vulnerability."

This improved query should result in one more true positive alert, resulting in three true positives and one false positive. And you could not have caught them with a grep.

This concludes the warmup challenge, and you should be ready now to try the real challenge. It will make use of the dataflow library from the start in order to produce as many high quality alerts as possible, a successfully completed query will find all of the four fixed vulnerabilities, and two additional vulnerabilities that were not fixed in the pull request!

Challenge

The challenge is split into several steps, each of which contains multiple questions.

The challenge makes use of the of the dataflow library. To get you started with the library, the table below shows some example dataflow queries that are useful to know about in this challenge. A more thorough cheat-sheet for the dataflow library can be seen here.

DescriptionQuery
Get a call to a functionfrom DataFlow::SourceNode v select v, v.getACall()
Get an argument of a callfrom DataFlow::CallNode c select c, c.getArgument(_)
Get a parameter of a functionfrom DataFlow::FunctionNode f select f, f.getParameter(_)
Get a read of a propertyfrom DataFlow::SourceNode v select v, v.getAPropertyRead(_)
Get a write of a propertyfrom DataFlow::SourceNode v select v, v.getAPropertyWrite(_)
Get the right hand side of a property writefrom DataFlow::PropWrite w select w, w.getRhs()
Get a value written to a propertyfrom DataFlow::SourceNode v select v, v.getAPropertySource(_)
Get a reference to a class instancefrom DataFlow::ClassNode c select c, c.getAnInstanceReference()

Step 0: Finding the arguments to potentially unsafe jQuery function calls

As shown above, programmers may be using the convenient $ function in places where they should have used $(document).find instead. In this step, we will find the arguments to such calls.

Question 0.0: Can you work out what the below query is doing?

import javascript

from DataFlow::GlobalVarRefNode var, DataFlow::CallNode call
where
  var.getName() = "$" and
  call = var.getACall()
select call.getArgument(0)

Hint: Paste it in the Query Console or in VSCode, and run it.

Question 0.1: Can you work out how the below query, using the builtin model of jQuery, is different from the one above?

import javascript

from DataFlow::SourceNode n, DataFlow::CallNode call
where
  n = jquery() and
  call = n.getACall()
select call.getArgument(0)

Hint: Visit the documentation.

Question 0.2: Finding additional dangerous calls.

The above queries only find a single kind of potentially unsafe $ usage. The CodeQL model of the jQuery library contains JQuery::MethodCall::interpretsArgumentAsHtml, use that to generalize the above query to find additional arguments that may be dangerous.

Step 1: Finding jQuery plugins and their options

As shown above, jQuery plugins are defined by assigning a property to the $.fn object:

$.fn.copyText = function() { ... }

In this step, we will find such plugins, and their options.

Question 1.0: Finding simple plugin definitions.

Write a query that finds plugin definitions as described above. Your query should look for an assignment where the right-hand side is a function, and the left-hand side is a chained property access like $.fn.<property>.

Question 1.1: Finding advanced plugin definitions.

A simple solution to question 1.0 will not handle all cases. How could we also find the case below?

let fn = $.fn;
let f = function() { ... }
fn.copyText = f;

Your query should find 13 results.

Hint: The DataFlow library is very rich. Try to formulate the query as finding a source node whose value is stored in a property of an object from a $.fn property read. Have a look at the documentation.

Question 1.2: Finding plugin options.

Build upon the solution to question 1.0 or 1.1 to find the initial options object of a plugin. Usually, this will be the last parameter of the plugin method.

Hint: Have a look at the documentation

Question 1.3: Finding the individual plugin options.

The solution to question 1.2 gives us the plugin options object, but we care about the individual options. Write a simple query that finds the property reads of the options object, for example options.textSrcSelector from the above example.

If you wrote this query correctly, you should find ... 0 results in the Bootstrap codebase! This is because the plugins transform the options object with default values, and your query is too simple to handle these cases. You'll try to do better in the next step!

Step 2: Taint tracking

To combat the limitations discovered in question 1.3, we will make use of the powerful TaintTracking analysis.

Check out this blog post to get an idea of how it can be used: CVE-2018-4259: MacOS NFS vulnerabilities lead to kernel RCE

Question 2.0: Using TaintTracking.

Implement a standard TaintTracking::Configuration configuration that uses the answers from question 1.3 and 0.2, as sources and sinks. Use the below template to write your query using the predicate hasFlowPath().

/**
 * @name Cross-site scripting vulnerable plugin
 * @kind path-problem
 * @id js/xss-unsafe-plugin
 */

import javascript
import DataFlow::PathGraph

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

  override predicate isSource(DataFlow::Node source) { _ } // TODO: reuse 1.2

  override predicate isSink(DataFlow::Node sink) { _ } // TODO: reuse 0.2
}

from Configuration cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink.getNode(), source, sink, "Potential XSS vulnerability in plugin."

Your query should give you 5 alerts.

Question 2.1: Comparing the patched and unpatched versions

Now that you have a query to find potential XSS vulnerabilities in plugins from the unpatched version of the Bootstrap codebase, let's see how well our query performs on the patched version of the codebase.

Run your query in the query console, or download the snapshot for use in the CodeQL plugin for VSCode. Your query should give you 3 alerts on the patched version of the Bootstrap codebase.

Insert the results, or lack of results (False negatives), in a human-readable table like this:

FileLineClassificationExplanation
js/collapse.jsxxxTrue positive
js/collapse.jsxxxFalse positiveMy query flags all arguments to $, regardless of their origin.
js/tooltip.jsxxxFalse negativeMy query does not handle flow through ternary operators.

Step 3: Triaging the query results

If the query has been implemented as expected until now, two things should be clear from the table of question 2.2:

  1. The query found vulnerability variants that were not patched in the pull request!
  2. The query did not find the vulnerability at js/tooltip:207.

Question 3.0: Unpatched variants

What are the unpatched variants? How could they be exploited? What would the fixes look like?

Question 3.1: False negatives

The false negative for js/tooltip:207 is caused by a lack of taint propagation through the this.options property since taint is not propagated through (pseudo) class fields by default.

When looking at the vulnerable jQuery plugin implementations, it should be clear that they often are built up as classes that are initialized with the options object. As such, it seems reasonable that options are allowed to taint the class fields, since the tainted write will happen in the constructor and therefore prior to any read of the same field.

To add this taint step, an additional taint step should be added to the Configuration of question 2.1. The taint step should be between property writes and reads on instance references of the same class (see DataFlow::ClassNode::getAnInstanceReference). Modify your query to include this additional taint step.

Question 3.2: Final query results

Redo the table for question 2.2 to based on the solutions to question 3.1.

Step 4: Inspiration for further improvements

The variant analysis of https://github.com/twbs/bootstrap/pull/27047 stops here. But what about other plugins? Is the query precise enough to run on some of the thousands of other jQuery plugins? If the table of question 3.3 contains many "False positive" results, then the manual triaging work for thousands of plugins will be infeasible in practice. This step aims to inspire further query improvements that may reduce the false positives ratio.

Be aware, that some of the suggested improvements can be quite hard to implement. Go as far as you can in this last step, and submit by December 31 to get a chance to win the grand prize!

Question 4.0: Ambiguous sinks

If the solution to question 0.2 only uses JQuery::MethodCall::interpretsArgumentAsHtml, then the argument to the .append() call at js/modal:282 is considered a sink.

However, .append(...) does not interpret its argument as a selector, so it is unlikely to be a mistake by the plugin developer that HTML can be constructed at such calls.

Make the query more precise by requiring that sinks should only be the ambiguous cases where an argument can be interpreted as both HTML and an CSS selector.

Hint: Visit the documentation.

Question 4.1: Deliberate XSS sinks

The solution to question 3.1 will unfortunately result in a new false positive at js/tooltip:432. This result is a false positive since the programmer intends for dynamically constructed HTML to be inserted at this location. This can be seen by looking at the default value for the template option at js/tooltip:37 which clearly is an HTML string and not a CSS selector. It seems reasonable that the clients of the plugin should be aware of this, and this query should therefore not mark that plugin option as unsafe.

Improve the query to filter out false positives for options with a name or a default value that clearly signals that dynamically constructed HTML is expected by the programmer.

Question 4.2: Sanitizers

Some plugins sanitize their option values before using them as potentially dangerous arguments. Here are a few examples of the sanitizers that may be encountered in the wild:

...
let target = options.target;

// string-sanitizer: if `target` is not a string, then it will not be interpreted as HTML
if (typeof target != "string") {
    $(target).append(x);
}

// jquery-sanitizers: if `target` has a `.jquery` property, then it is not a string, and it will not be interpreted as HTML
if (typeof target.jquery !== "undefined") {
    $(target).append(x);
} else {
    target.append(x)
}


if (target.jquery) {
    $(target).append(x);
} else {
    target.append(x)
}
...

Implement support for each of those cases, perhaps by using TaintTracking::Configuration::isSanitizerGuard or AnalyzedNode::getAType.

Question 4.3: Canonical option names

To systematically collect all of the unsafe options for each plugin it could be useful if the query also emits the plugin name and the option name for each alert.

That is, the query message should be something like "The plugin 'copyText' may expose its clients XSS attacks through the option 'textSrcSelector'", when the query is run on the simple plugin example at the beginning of the challenge. Adapt your query to produce this clearer message.

Next steps

We hope you enjoyed this challenge! Don't forget to email us your results at ctf@github.com. Additionally, feel free to send us an email if you have any feedback about this challenge, or if you find yourself stuck and would like some additional hints. If you are interested in continuing to use CodeQL for security research, then we recommend installing the VSCode extension. This will enable you to run your queries offline.

If you are interested in continuing to improve your CodeQL skills, or if you would like to try a challenge in a different language, we have a number of other CTFs to choose from.

Additional hints

Question 3.1

To understand why js/tooltip:207 is a vulnerability that should be flagged. Lets annotate some parts of the that line:

this.options.container ? // ordinary property reads
  $tip.appendTo(this.options.container) : // call to jQuery's appendTo
  $tip.insertAfter(this.$element) // call to jQuery's insertAfter

It can be seen in the diff of the pull request that only one of the calls used a dangerous option as argument, namely the appendTo call. So we would like the query to flag the argument of $tip.appendTo(this.options.container).

To understand why the query did not flag the argument, we should first check that it is considered a sink. This can be done by looking at the results for the solution to question 0.2. Similarly, the results for the solution to question 1.3 should tell us that there exist some options object for the tooltip plugin. When both sources and sinks are recognized, a missing result from the taint tracking query indicates that a taint step is missing somewhere. In this case it is a taint step for the fields of a class that is missing.

As a hint for adding the step through fields, consider this query which finds write/read pairs of class fields named "hide".