Down to the Wire

PlaidCTF 2018: I Heard You Like XSS

Zachary Wade

In preparation for PlaidCTF 2018 I designed a two part web challenge called S-Exploitation (Paren Trap and the Wizard of OSS). Although I intended the first part to be an easier web challenge, and the second to be a tricky follow up, the former had only 16 solves and the latter just 2. Since I had a number of people ask me for clarification after the CTF, and to help other organizers to learn from it, I’ve described below how S-exploitation was designed and meant to be solved.

For those who don’t care about the implementation, you can skip straight ahead to the solution.

Inspiration

A couple of months back I stumbled across this blog post. It’s definitely worth a read, but the short version is that they were able to perform a privelege escalation in CouchDB because two parsers handled duplicate keys differently when decoding JSON strings. One parser (which implemented the spec correctly), could be used for authentication, and the other for privileges. Initially I had planned to make this problem a short recapitulation of that bug. Unfortunately for me (and fortunately for the rest of the world), every reasonable JSON parser I could find for node implemented the spec properly. In a few cases, they had options to deviate from it, but that would have been too obvious to make for an interesting problem.

I considered briefly using the exact stack mentioned in that bug, but then had the idea to implement my own JSON parser. For the life of me, however, I could not think of a way to frame it in a way that would seem “reasonable.” Instead I had the brilliant idea to use an S-expression parser.

S-expressions are sufficiently uncommon that there aren’t many reasonable parsers, but popular enough to not immediately point at the bug. For extra fun, I thought, I could make the parser a native extension and have them pwn it after. With this in mind, I implemented the first part of the problem.

Once the basic problem had been built, I focused my attention on the second part. By this point, I had decided that a pwnable would be too dificult from both a solution and an infrastructure perspective. Besides the pain of doing weird v8 exploitations on an isolated system, giving players the ability to wreak havoc on the main process that is hosting a normal web problem seemed like more headaches than it was worth. At the same time, I wanted to have an element of binary analysis in my web problem. Maybe nothing too insance, but this being PlaidCTF, it seemed criminal to take away that bit of weirdness. Initially I considered making it a run-of-the-mill reversing challenge that players would end up throwing into Angr, but then I had a more devious idea, “what if I could use it to enable an XSS?”.

Solution

Quick note, at the time of writing, the challenge is still being hosted at sexp-updator.chal.pwning.xxx. However, at some point this may go down. If/when that happens, I’ll post a link to the problem source with Dockerfiles for running it locally.

Part 1

The solution to part one is incredibly simple, and for those who were close, probably incredibly frustrating. The first task of the problem was getting source. In one of the sample blog posts a link to “sauce” was available at a weird endpoint: /files/assets/sauce.png. Now normally a static server would not need to have an extra /files/ prefix because they would translate paths on a per directory basis. The fact that they weren’t, plus the clue that it was running in JavaScript, was an indicator to try /files/index.js. Once leaked, this would provide the (almost) full source for the program.

After reading through the source, a conspicuous endpoint is provided for /samoa. Visiting this will redirect the user to a different domain (sexp-samoauth.chal.pwning.xxx). SaMOA, or the Secure and Minimalist OAuth, is as the name suggests. Users are provided with the option to create accounts and then can grant Upd8t0r access to that account. Once they have done so, Upd8t0r will ask users to either create a new account, or sign into an existing one. The important thing to note here is how it knows what account to use. A glance at Upd8t0r's code shows that SaMOA returns a token and a signature separated by a period. Let’s look at a decoded token.

sexp content_copy
((name "@zwad3") (email "[email protected]") (color "Blue") (food "Pizza") (perm (food color basic)))

Interestingly, we see that this is an S-Expression representing all of the data we provided. It also includes the domains that the client requested, namely food, color, and basic. At this point, it might make sense to play around with the various inputs, trying to break out of those strings. Unfortunately, what you would find is that all of the fields are properly sanitized. However, it is slightly odd that the permissions are not quoted. If they are server controlled then this is fine, but do we know that for sure?

If you look at what you’re actually sending to the server during authentication, then you’ll notice that you send 3 items that look like perm[]=food&perm[]=color&perm=basic. This begs the question. What happens if we try to add the permission (foobar) instead of basic.

sexp content_copy
((name "@zwad3") (email "[email protected]") (color "Blue") (food "Pizza") (perm (food color (foobar))))

Ah hah! We have a way of injecting into our signed token. Now let’s see how that helps us.

As alluded to in the motivation, a read through Upd8t0r‘s source shows two different parsings of that S-Expressions. Initially, when you grant access, it asks you to log in. In order to know which user to log in as, it uses the regular expression: /\(name "(([^\\"]*(\\.)*)+)"\)/. This is just a lazy regex to extract the username from the string. However, because of how JS’ regular expression parser works, if there are duplicates, it will return the first one. Then, once you’ve logged in, it parser the full S-Expression, treating it as a key value pair. The function to do that looks like (simplified)

JavaScript content_copy
let decodeSexp (string) => {
    let obj = parser.parse(string);

    let res = {};
    for (let [key, value] of obj) res[key] = value;
    return res;
}

Since this is naive, it will take the second if there are duplicates. Knowing this, we can now craft our exploit by changing the basic permission to be )) (name admin) ((. Once we do this, we can sign in as our other account, and then we will have admin privileges. One such privilege is creating posts, in which we are allowed to use the {{flag}} template to print out the flag for part 1.

Part 2

Now Part 2 is the fun bit. The other feature unlocked by being admin is the /bookmark endpoint, which is effectively an “XSS” endpoint, as long as you provide it with a subresource of Upd8t0r. So, we need to get an XSS, but where do we begin?

Well the first place to start is the native-sexp.node file that is referenced, but not available via our other LFI. However, the posting interface allows us to add a template that gets used for the post. Since this isn’t checked except to make sure its in a subdirectory of /app

Fortunately for us, the native-sexp.node file is in /app. However there is a catch. The blog serves the file as Latin1 because this is the only binary-preserving encoding that can also be used by express for rendering text. However, this causes some problems when it gets served over the wire, as it will be served as utf-8. If you try and open up the resulting binary naively, you will get a whole lotta corruption. Fortunately, a simple script can restore it to its original state. An example decoder might be

JavaScript content_copy
let string = "...";
let decodedString = Buffer.from(string, "utf-8").toString("latin1");

Once you’ve decoded it, you can now open it up in your favorite disassembler, since it’s just a fancy ELF.

As for reversing the binary, I won’t go into much detail on this. It’s relatively straightforward, and since I didn’t remove symbols you don’t have to work too hard. Once you poke around for a little bit, you notice that strings which are prefixed with @ will be eval'd. There’s another catch, though. Most of the useful characters such as [,],(,),... are all disabled. It looks like all it can do is resolve numeric types and variables from the global scope.

Let’s take a step back and remind ourselves of what we want to do. Since our end goal is performing an XSS on the sexp-updator... domain, we need to inject JavaScript somewhere on the page. Yet, the only places where JavaScript is allowed at all are on the home page (for ractive templating), and on the welcome page (for a JS redirect). Since we have no control over the home page, that means we need to inject into welcome. Fortunately for us, it inserts our name directly onto the page. Unfortunately for us, it can only run if it’s using the appropriate nonce. Since the nonce is randomly generated, and it adds entropy on every page access, this seems impossible.

At this point, its worth noting how the randomness works. Specifically, it’s using a package called seedrandom that not only lets you dump entropy into the pool, but as the name suggests, also let’s you seed the random state. Moreover, it positions itself in the global scope, so Math.seedrandom is available from our S-Expression parser. This still seems problematic, since we have no parenthesis and can thus not call functions. Except, that’s not quite true. JavaScript technically has two methods of calling functions. The most practical of these is functionName(arg1, arg2,...). The other, with the addition of template string literals, is to tag a template string as such functionName`stringArg`. This only allows you to pass a single argument as a string array. While this isn’t a tremendous amount of power, it does give us enough to call Math.seedrandom with an argument. With this, we can now predict the nonce after a login.

However, we are not done yet. Even with all of this, we still have no way of forcing the admin to log in using this malicious account. In order to do this, we need another XSS as a launchpad. Looking over at SaMOA for a moment, we notice that on the /auth endpoint the user field is inserted into that page as HTML. This isn’t sufficient, unfortunately. Simply injecting a script tag in here will not cause it to be executed. However, we notice that our content security policy contains strict-dynamic, so if we can convince ractive (the templating engine used by SaMOA) to insert our script into the page, it will be run.

It turns out this is pretty easy. See those flashing flags at the bottom of the page? Well, a quick glance at how that works shows that its requesting the template "firstFlag". Looking at ractive’s source, we see that it does this by doing a getElementById call and then ensuring that the element is a <script>. Well this is easy enough, we’ll just set our user to be

html content_copy
<script id="firstFlag">
  <script>
    alert("PWN");
  </script>
</script>

We’re closer now, but still not able to pull off this crazy exploit. Remember how we can only send Upd8t0r domains to the XSS bot? Well our initial XSS is on SaMOA. However, the /samoa endpoint takes arguments specifying which itms to request. Look at how it does this

JavaScript content_copy
let opts;
if (req.query.opts && Array.isArray(req.query.opts)) {
	opts = req.query.opts;
} else {
	opts = ["color", "food"];
}
optString = (opts.map(s => `${s}=true`)).join("&");
res.redirect(`http://${samoa}/auth?redirect=http://${updator}/login&user=Upd8t0r&${optString}`)

Since it naively joins the requested permissions together, if we set opts[] to be user%3d<exploit>%26f, we can have our redirect set the user parameter.

With this complete, we have everything we need for our exploit chain.

The Exploit (Part 2)

An actual exploit script (guide?) can be found (here)[/upload/fee231f6-exploit2.py]. For convenience, we’ll also step through the process.

The first thing you need to do is to figure out your seed and nonce. In my case, I used the seed "zach" which generated the nonce: "P179M2ydafV7eDiqkc1fvAQfjmCBavE73SYO0Kn0UTjvw2T8lcaKNGAlX5HZZeWkjtxwWf7Z25ZRuZnTKkSjvg==". Once you have that, you need to get a signed token that includes our eval payload. The payload should look like

sexp content_copy
@"Math.seedrandom`zach`" @"foo"

Note that it’s crucial to create a well-formed S-Expression so that the parser gets to the eval phase. However, you also need to have it throw an error so that the admin does not overwrite his cookie. This is what the reference to foo is for.

Now that we have our signed token, we can create our inner XSS. You can look at the exploit script for details, but mine is just a dumb redirect to a domain I control with the cookies as a query parameter. Make sure that the script tag this is enclosed in has our predicted nonce. Otherwise, Chrome will not run it. Since we need this to be injected, register an account on SaMOA using this as the username. You must also create an account on Upd8t0r by hitting the /samoa endpoint once.

We’re now very close. All that remains is to write our outer XSS, which will be wrapped in the ractive impersonation tag. This script first needs to hit the /login endpoint of Upd8t0r using the malicious token we created earlier. Then, it needs to have the client POST to /login with the password you set. It’s probably easier to perform this POST as a redirect by creating a form on the page and submitting it. Then, when Chrome renders the welcome screen, your exploit will execute, giving you the admin’s cookies.

And that’s it, you’ve gotten the flag!

Issues and Lessons Learned

As I mentioned earlier, this problem ended up being significantly harder than I intended. I knew it would be tricky, but I had anticipated solve counts around 40/5 instead of 16/2. So what went wrong?

Well the cop-out answer is that because Plaid was only 36 hours this year, teams didn’t have enough time. Whil this is true, it masks some deeper issues that I could have prevented. Firstly, the source leak at the beginning was a little bit too guessy. I thought it was obvious, and testers found it quickly as well, but I think people are used to seeing LFI as the result of query params, and were confused by this related, but more disguised endpoint.

Beyond that, I think the encoding issue with the LFI was especially problematic. Everyone assumed that it was just a corrupted binary, not realizing that the translation from latin1 to utf-8 was the issue. It might have been a little contrived, but I think casing on whether it was a binary or not and then changing the mime type to something more appropriate would have helped teams along.

Finally, I think this was just too complicated. As for this, I’m divided. On the one hand, I think that this exploit chain was one of the most fun I’ve ever performed. At the same time, seeing first hand the frustration of all those on IRC confused by the different pieces, I’m not sure that it was appropriate for a CTF. For organizers, I’ll leave this decision up to you, and for players, I’ll try to be more considerate next time (perhaps with more intermediate flags).

It’s also worth noting that at least one of the two teams who solved this problem did not use this exact exploit. They instead noted that although you can’t run JS on the post pages, you can embed a meta redirect tag to transfer the bot over to a domain you control. While it’s always annoying as a problem developer to see bypasses to your problem, in this case they only bypassed the XSS on SaMOA, and in a very clever way.

Thanks

Thanks to all who played, and congratulations to TeaDeliverers and GermanysNextRopModel for solving part two. Please feel free to complain or offer suggestions to @zwad3. Thanks!