Tags: csrf injection redis cache-poisoning xss web

Rating: 5.0

This web challenge was a well made challenge I enjoyed spending time solving! It requires a lot of different steps and interesting interactions between the components of this web application.

When you first visit this website you are greeted with a home page:

As a regular user you can click on the "Create" tab to create your very own ASCII snake (or viper)

When you first create the viper it will get a new UUID4 token as the default name. A user can change this name using the form on the page, which sends a GET request to the /editviper endpoint.

The source code could be downloaded in this challenge, and we see that this web application is written in node js and is using the express framework.
Looking through the source code we can see a lot of different endpoints:

* app.get('/create', function (req, res) (GET)
* Generates a new session and sets viperId and viperName for this session.

* app.get('/viper/:viperId', middleware(20), function (req, res) (GET)
* Used to view a viper. The server fetches the viperName from the user session and renders this page with these values. It also checks that you have a valid viperId for this page, unless you are an admin user.

* app.get('/editviper', function (req, res) (GET)
* Used to change the viperName in the current user session.

* app.get('/logout', function (req, res) (GET)
* Destroys a session and logs you out.

* app.get('/analytics', function (req, res) (GET)
* An interesting endpoint used by /analytics.js. It is used to update the amount of visits from a specific IP address for analytics purposes. These visit-counts are stored in a redis server. The IP address is passed in as a GET query parameter.

* app.get('/admin/generate/:secret_token', function(req, res) (GET)
* This endpoint is only used by the admin bot to generate a token for this bot. It is not really a part of the challenge for us to exploit.

* app.get('/admin', function (req, res) (GET)
* Shows the admin page if the user is a valid admin. (admin is true in the current session)

* app.get('/admin/create', function(req, res) (GET)
* Used to create an admin viper. Here is where the flag.txt file is read from disk and stored in the viperName corresponding to a viperId. The viperId can be passed as a GET query parameter. The endpoint also requires a valid csrf token...

Okay, that is a lot of different endpoints to check out. Let's start by crafting a theory of what the end goal is. We want the content of flag.txt, and the only way to get this is through the /admin/create endpoint (if there aren't any other lfi/rce vulns of course, but I did not immediatly find one). There is a web page where we can give a link to the admin and the admin will visit our page, so we most likely have to find some sort of XSS vulnerability to make the admin create an admin viper that we eventually can visit to get the flag.

To do this we most likely have to leak the CSRF token that is generated when the admin visits the /admin endpoint. The CSRF token is an integer and is generated like this:

js
const randomToken = getRandomInt(10000, 1000000000);
client.set('__csrftoken__' + sess.viperId, randomToken, function(err, reply) {


It is then stored on the redis server with the key "__csrftoken__" + sess.viperId. The admin's initial viperId is admin_account, so the Redis key is actually __csrftoken__admin_account.

The admin will only visit a /viper/<uuid>-page so we will need to find an XSS vulnerability (or something similar) on this page.

Let's take a look at the node server source code and the HTML source code for this page.

**server.js** - /viper/:viperId endpoint
js
app.get('/viper/:viperId', middleware(20), function (req, res) {
let viperId = req.params.viperId;
let sess = req.session;

const sessViperId = sess.viperId;
const sessviperName = sess.viperName;

}

res.render('pages/viper', {
name: sessviperName,
});
}else{
res.redirect('/');
}
});


It uses the _ejs_ template framework and renders the page while passing two variables; name and analyticsUrl.

We can find these values in the HTML template:

**views/pages/viper.js**
html
<div class="col-sm-8">
<h1>This is your snake: <%= name %></h1>

Hopefully you enjoy this very nice ascii art!

(......)

<footer>

<%- analyticsUrl %>

<script src="/analytics.js"></script>
</footer>


According the the _ejs_ documentation, name will get html escaped, but analyticsUrl will not (<%= vs <%-). This means the we might be able to inject some XSS or other things inside of this 

DOM object. We also notice the /analytics.js script, which is pretty small:

js
fetch(document.getElementById("analyticsUrl").innerHTML)


It looks like this script fetches whatever text that is inside of 

. Maby we can use this for something later.

Alright, so how can we change the value of analyticsUrl? The source code tells us that it is created like this:

js


so we need to change our host header or x-real-ip to inject something into this URL. NGINX ( config also given) passes $remote_ip as x-real-ip and our $http_host as host to the node application. I tried to add an x-real-ip or x-forwarded-for header without any luck.. It seems like it's hard to change this IP address value. However, changing the Host header in my request seemed to do the trick.

NOTE that when testing stuff against this page, it has caching enabled. So wait a bit if it is not changing when testing different payloads to inject. The node js application caches the /viper/<uuid> page in memory for 20 seconds (using a middleware), and it uses the Host header as key (+ originalIp).

This means that everyone with the same Host header can visit this page to see its content without any cookie, and the value of viperId and analyticsUrl will not be changed. It uses the cached version's values, and this will be relevant later on. Since the /viper/ page is dynamical (viperName and analyticsUrl is based on the user that is visiting the page) the caching is an essential part if we want another user to visit our page with our injected payload.

We need to make sure the user visiting our page hit the cache (must have same cache-key as us). The user visiting our page will most likely have 2020.redpwnc.tf in the Host header. In the code we notice that it is only the first part of the Host header before : that is used as a key, so it is still possible to cache a page if we inject something after 2020.redpwnc.tf:.

I first tried injecting <script> or  tags, but this did not work because of a Content Security Policy set by the node application: res.setHeader("Content-Security-Policy", "default-src 'self'");. I also had problems using forward slashes (/) in the Host header.

I also tried to include the admin page using an iframe, but this did not work either. This is because the node application sets
res.setHeader("X-Frame-Options", "DENY") which will deny all tries of embedding the page on another page.

We need to find another solution... analytics.js! The CSP will not stop this script from being executed since it's a javascript source hosted on this domain.
This script will do all of the work for us, and force the admin to visit a page of our choice. We just need to change the url to something else.

If we inject your own URL into the Host header, it will try to fetch this URL, but the CSP will stop us from connecting to our own controlled server. However we are only interested in the /admin/create page, so lets try to inject this into the host header!

Host: 2020.redpwnc.tf:31291/admin/create# <- We also use an anchor to make the rest of the URI irrelevant.

As we saw earlier, it does not like forward slashes in the Host header, but I figured out a bypass for this. Just use backslashes instead!

Host: 2020.redpwnc.tf:31291\admin\create#

When testing locally, it will fetch the /admin/create page. But we still need to give it a viperId and a csrfToken parameter.
We can test this against the /editviper page to see if it also works when the admin is visiting our page. The admin will then create a /viper/ page with the viperName we specified in the query parameters:

1. Create user: curl -v 2020.redpwnc.tf:31291/create
2. Inject URL into Host header and at the same time cache the request:

curl -v -H 'Cookie: connect.sid=s%3Ar2rS0fUoonJJzH1NQzdgg20g5QZ9Wrlm.MmuClcI0%2BK2%2BSs6qic0asM%2FaxNohMueSILeOU2RZSOY' -A '' http://2020.redpwnc.tf:31291/viper/571d4567-de04-4f17-a408-0f83e619ac94 -H 'Host: 2020.redpwnc.tf:31291\editviper?viperName=COOLNAME#'

3. Then we give our viper URL (http://2020.redpwnc.tf:31291/viper/571d4567-de04-4f17-a408-0f83e619ac94) to the admin at: https://admin-bot.redpwnc.tf/submit?challenge=viper (Remember that you need to send it to the admin before the cache expires after 20 seconds)
4. Visit admin's viper page using the default viperId  to see that the name is now COOLNAME: http://2020.redpwnc.tf:31291/viper/admin_account

Alright, first step is now done! Now we need to force the admin to make a viper containing the flag, but to do this we need the CSRF token. I spent a lot of time figuring this out. I considered using a CSS side channel attack to bruteforce the token, but this would be hard since the token is on another page. I also considering doing requests directly to the Redis server and change its value to something else.

But then it struck me that I could use the /analytics page to retrieve the CSRF token! This is actually quite obvious if you think about it, but I did not think I could use the /analytics page for something useful at first.

**server.js** - /analytics endpoint
js
app.get('/analytics', function (req, res) {

return;
}

if(err){
res.status(500).send("Something went wrong");
return;
}
res.status(200).send("Success! " + ip_address + " has visited the site " + reply + " times.");
});
} else {
if(err){
res.status(500).send("Something went wrong");
return;
}
res.status(200).send("Success! " + ip_address + " has visited the site 1 time.");
});
}
});
});


After some analysis, we can see that it increments the value of a Redis entry with our input (req.query.ip_address) value as key.
We already know the name of the CSRF key: __csrftoken__admin_account. If we use this value for the ip_address parameter,
the CSRF key will be incremented and returned back to us. We can then base64 encode this value and use it in the /admin/create request (since this endpoint requires a base64 encoded version of the token).

Now to the final part of this challenge!

We can force the admin to create a new viper for us, but we need to send two parameters:
1. A UUID4 that is different from our own (We need a different one since our own page is already cached).
2. The CSRF token.

Since we are hitchhiking with analytics.js, we need to bypass an annoying feature with getElementById().innerHTML...
It will HTML escape our & character in our URL, so we can't send two query parameters until we can bypass this.

After a lot of Google searches, a teammate ended up reading a chinese writeup through Google translate: https://blog.zeddyu.info/2020/02/11/xssgame/
He said that when innerHTML sees an HTML comment (

The HTML comment is placed as a query parameter to not destroy the rest of the URL, and the ending is placed behind the anchor. The viperId is just a random ID I chose, and the csrfToken we fetched via /analytics.

Our final plan now is to:
1. Create a user
2. Increment and fetch CSRF token via /analytics
2. Inject host header and cache our viper page
4. Admin visits our URL and will create a new viper with the flag as viperName
5. We visit the cached viper page the admin created to get the flag

Final Python script here:

python
#!/usr/bin/env python3
import requests, socket, re
from urllib.parse import quote
from base64 import b64encode

HOST, PORT = "2020.redpwnc.tf", 31291
#HOST, PORT = "localhost", 31337

# Create new viper and fetch cookie and Viper ID
r = requests.get("http://{}:{}/create".format(HOST,PORT), allow_redirects=False)
viper_id = re.findall("([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})", r.text)[0]

# Get the csrf token
csrftoken = quote(b64encode(r.text.split()[-2].encode()))

s = socket.socket()
s.connect((HOST, PORT))
print(s.recv(32768))
s.close()

# Cache request
print(r.text)

print("Send this URL to the admin")
print("http://{}:{}/viper/{}".format(HOST, PORT, viper_id))

while True:
input("\nClick to continue fetching http://{}:{}/viper{} ... ".format(HOST, PORT, ADMIN_VIPER))
print(r.text)


After running this script, send the admin to our URL and then press enter to get the flag!

                    ---_ ......._-_--.                    (|\ /      / /| \  \                    /  /     .'  -=-'   .                   /  /    .'             )                 _/  /   .'        _.)   /                / o   o        _.-' /  .'                \          _.-'    / .'*|                 \______.-'//    .'.' \*|                  \|  \ | //   .'.' _ |*|                      \|//  .'.'_ _ _|*|                    .  .// .'.' | _ _ \*|                    \-|\_/ /    \ _ _ \*\                     /'\__/      \ _ _ \*\                    /^|            \ _ _ \*                   '               \ _ _ \      ASH (+VK)                                     \_
`